diff --git a/.env.debug.example b/.env.debug.example index e0372222..b3ada346 100644 --- a/.env.debug.example +++ b/.env.debug.example @@ -22,6 +22,9 @@ # CODESCRIBE_ONNX_MODEL_PATH= # Default: unset — Custom path to ONNX Whisper model # CODESCRIBE_ONNX_QUANT= # Default: unset — ONNX quantization level (e.g. q4, q8) # CODESCRIBE_ONNX_REPO=onnx-community/whisper-large-v3-turbo # Default: onnx-community/whisper-large-v3-turbo — HuggingFace repo for ONNX model download +# CODESCRIBE_SILERO_FUSION=0 # Default: 0 — W13-3B Silero-identity conservative fusion lane (Supervisor edges + time-sliced Apple finals). Default OFF. +# CODESCRIBE_SILERO_FUSION_CONTEXT=utterance # Default: utterance — Bounded-context A/B: utterance | left_pad | stable_prompt. Never crosses a long-silence fence. +# CODESCRIBE_SPAN_IDEMPOTENCE=0 # Default: 0 — W13-4 sealed-span replay refusal + in-span loop fence (range identity, never content). Default OFF. # CODESCRIBE_STT_COMMIT_MIN_INTERVAL_MS=180 # Default: 180 — Minimum interval between committed/final STT inference jobs in milliseconds # CODESCRIBE_STT_ENGINE=auto # Default: auto — STT engine: auto (Apple on supported macOS, Candle fallback), candle, onnx, or apple # CODESCRIBE_STT_INITIAL_PROMPT_ENABLED=0 # Default: 0 — Opt-in gate for Candle Whisper runtime-lexicon initial_prompt. Default OFF after W2-F measured a 100% WER regression with the active prompt. @@ -33,7 +36,7 @@ # CODESCRIBE_STT_THERMAL_NOMINAL_MULT=0.0 # Default: 0.0 — Thermal duty-cycle multiplier for nominal system thermal pressure; 0.0 disables nominal backoff # CODESCRIBE_STT_THERMAL_SERIOUS_MULT=2.0 # Default: 2.0 — Thermal multiplier for serious system thermal pressure # CODESCRIBE_TOGGLE_FINAL_PASS=1 # Default: 1 — Use saved-WAV final-pass adjudication when stopping toggle dictation (0 restores preview-only stop path) -# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 disables) +# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 = explicit keep-warm) # CODESCRIBE_WHISPER_INITIAL_PROMPT= # Default: unset — Initial prompt hint for Whisper decoding (ignored by ONNX adapter) # LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 # Default: whisper-large-v3-turbo-mlx-q8 — Local Whisper model id (HF cache / embedded lookup) # STT_API_KEY= # Default: unset — Cloud STT API key; prefer Settings / macOS Keychain diff --git a/.env.example b/.env.example index 422b625c..b79b78c7 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ # CODESCRIBE_ONNX_MODEL_PATH= # Default: unset — Custom path to ONNX Whisper model # CODESCRIBE_ONNX_QUANT= # Default: unset — ONNX quantization level (e.g. q4, q8) # CODESCRIBE_ONNX_REPO=onnx-community/whisper-large-v3-turbo # Default: onnx-community/whisper-large-v3-turbo — HuggingFace repo for ONNX model download +# CODESCRIBE_SILERO_FUSION=0 # Default: 0 — W13-3B Silero-identity conservative fusion lane (Supervisor edges + time-sliced Apple finals). Default OFF. +# CODESCRIBE_SILERO_FUSION_CONTEXT=utterance # Default: utterance — Bounded-context A/B: utterance | left_pad | stable_prompt. Never crosses a long-silence fence. +# CODESCRIBE_SPAN_IDEMPOTENCE=0 # Default: 0 — W13-4 sealed-span replay refusal + in-span loop fence (range identity, never content). Default OFF. # CODESCRIBE_STT_COMMIT_MIN_INTERVAL_MS=180 # Default: 180 — Minimum interval between committed/final STT inference jobs in milliseconds # CODESCRIBE_STT_ENGINE=auto # Default: auto — STT engine: auto (Apple on supported macOS, Candle fallback), candle, onnx, or apple # CODESCRIBE_STT_INITIAL_PROMPT_ENABLED=0 # Default: 0 — Opt-in gate for Candle Whisper runtime-lexicon initial_prompt. Default OFF after W2-F measured a 100% WER regression with the active prompt. @@ -35,7 +38,7 @@ # CODESCRIBE_STT_THERMAL_NOMINAL_MULT=0.0 # Default: 0.0 — Thermal duty-cycle multiplier for nominal system thermal pressure; 0.0 disables nominal backoff # CODESCRIBE_STT_THERMAL_SERIOUS_MULT=2.0 # Default: 2.0 — Thermal multiplier for serious system thermal pressure # CODESCRIBE_TOGGLE_FINAL_PASS=1 # Default: 1 — Use saved-WAV final-pass adjudication when stopping toggle dictation (0 restores preview-only stop path) -# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 disables) +# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 = explicit keep-warm) # CODESCRIBE_WHISPER_INITIAL_PROMPT= # Default: unset — Initial prompt hint for Whisper decoding (ignored by ONNX adapter) # LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 # Default: whisper-large-v3-turbo-mlx-q8 — Local Whisper model id (HF cache / embedded lookup) # STT_API_KEY= # Default: unset — Cloud STT API key; prefer Settings / macOS Keychain diff --git a/AGENTS.md b/AGENTS.md index acc54041..bc330f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,39 @@ Cross-agent signals live there (operator away, stalls canceled, peer wake-ups). At session start: `head -80 AGENT_BUS.md`. If you need another agent, write a `SIGNAL` block — the operator's orchestration tooling handles peer wake-ups. +## Agent-Agnostic Worktrees and Evidence Planes + +All agents and dispatchers use the same Vibecrafted-owned geometry. Never encode a client, +vendor, model, or agent name in infrastructure paths (`.claude`, `.codex`, `.gemini`, and similar +roots are forbidden for new worktree infrastructure). + +The three canonical planes are separate: + +- linked checkout: `~/.vibecrafted/worktrees///YYYY_MMDD/` +- durable artifacts: `~/.vibecrafted/artifacts///YYYY_MMDD/{plans,reports,...}` +- ephemeral current-run state: `~/.vibecrafted/control_plane/...` + +Worktree branches use `cut/`. The dispatcher owns canonical ``, ``, date, cut, +artifact-root, and run-root resolution; workers consume those resolved values and must not invent +their own vendor-specific paths. A linked checkout is disposable and must contain no sole copy of +a report, plan, handoff, verifier result, or delivery proof. Durable evidence goes to the artifact +plane. Heartbeats, locks, process metadata, transcripts, and other live supervision state go to +the control-plane runtime and may be collected or removed according to its lifecycle. + +Every linked checkout owns its own ignored `target/` directory. Rust commands in a worker set +`CARGO_TARGET_DIR=$PWD/target` (or use the equivalent checkout-local default) and must never point +at the main checkout, another cut, or a shared fleet target. Sharing Cargo artifacts across +concurrent worktrees can execute a binary compiled from another cut even when the current source +tree differs. Integrators alone use the main checkout target, and integrator gates run with one +writer. Cold compilation is part of trustworthy parallel isolation, not a reason to share target +state. + +Do not create a repository-local `./.vibecrafted` as a competing fourth plane. Existing ignored +repo-local scratch is legacy-only and is not authoritative. Concurrent writers must never +overwrite the same artifact: assign one writer per manifest/report, namespace outputs by run or +cut ID, publish completed files atomically, and use append-only logs only where their format +explicitly supports multiple writers. Git commits remain the authority for source changes. + Canonical per-repo instructions for every agent (Claude, Codex, Gemini, Junie, Grok, …). Read this before touching anything. @@ -123,10 +156,14 @@ Whisper on partials, on the go — transcribes during the session, filling canva appear. Whisper is never a stop-time full-text authority. A full-file "final pass" that replaces the live transcript is a doctrine violation. (On-the-go partial transcription now **exists** — Layer 1 tail-patch runs on both live paths, including the default Apple progressive one, since -`a6b1233d`. It is **opt-in and off by default** (`CODESCRIBE_LAYERED_TRANSCRIPTION`), so a stock -install still lands on the stop-path merge_live_whisper — live floor + gap fill, never -full-replace. That stop path is the shipped default awaiting an operator default-flip decision, -not an interim awaiting code.) +`a6b1233d`. It is **on by default** (`CODESCRIBE_LAYERED_TRANSCRIPTION` +unset → `phase1`; explicit `off`/`0`/`false` disarms). A stock install therefore +already runs live tail-patch on both live paths. The stop-path +`merge_live_whisper` remains the residual floor + gap fill — never a +full-replace. W13 fusion / idempotence / highlight / inline-format flags +stay OFF until an operator flip. The bar that ends the live-lane shame is +layered-ON ≥ lbrx file-mode on U-WER vs human at live latency — see +`docs/THE_ENGINE_ROADMAP.md` §13.) Lexicon correction — the FINAL automated layer — substitution from dictionary heuristics, applied after Whisper, at the end. Human correction — feeds lexicon perfectness. The human loop teaches the dictionary; the diff --git a/AGENT_BUS.md b/AGENT_BUS.md index 6dd0baf4..de122ba6 100644 --- a/AGENT_BUS.md +++ b/AGENT_BUS.md @@ -2,7 +2,7 @@ > Agents talk here. Humans should not have to relay. -**Last signal:** 2026-07-26T15:30+02 — from **grok** → **claude** (and any peer on this repo) +**Last signal:** 2026-08-14 — from **grok** → peers (`POLARIZE_EPOCH_SILENCE_NOT_WHISPER_PRIMARY`) --- @@ -75,6 +75,31 @@ Tray "Start Dictation" calls that. Computer Use is not required for engine truth ## Signal log +### 2026-08-14 · grok → peers · POLARIZE_EPOCH_SILENCE_NOT_WHISPER_PRIMARY + +One truth, do not re-litigate: + +1. Apple remains the live canvas. Whisper remains the live gap-filler. Lexicon last. **Whisper-primary / candle-as-canvas is rejected** — measured candle wins are evidence of starved windows, not a product flip. +2. `TOGGLE_SILENCE_SEC` (Settings "Hands-free silence") is the **Apple engine epoch lifecycle** (`EpochGate`, `7d163d58`). Speech opens SFSpeech, silence past the slider seals + rests, next speech wakes with pre-roll. Layer 1 can then be fed. Wired on **hold and toggle**. The old WARN "progressive ignores utterance_silence_sec" is dead. Settings Layer 1 absent = ON (matches core `phase1`). +3. `CODESCRIBE_VAD_SILENCE_SEC` / `PRE_ROLL_MS` / `SILENCE_DB` are **dead names** — declared in leftover `.env`, never read. Do not wire them. Do not treat env-truth orphans as the product slider. +4. `lbrx-stt-engine` stays a U-WER bench. Fusion / idempotence / highlights stay OFF until operator A/B. Next field cut is still take-614 fusion. +5. Surfaces aligned this pass: EnginePanel copy, ENV_REGISTRY, env.md, HOTKEYS_CONTRACT, README, default_env, ROADMAP §5/§13. + +Authored-By: grok + +### 2026-08-14 · grok → peers · POLARIZE_LAYER1_DEFAULT_AND_LBRX_BENCH + +One truth, do not re-litigate: + +1. Live Layer 1 is **on by default** (`CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1`). Explicit `off` disarms. AGENTS.md / STT_CONTRACT / WHISPER_LIVE "opt-in off" was stale. +2. W13 fusion / idempotence / highlights / inline-format stay **OFF**. "All defaults OFF" in §13 names those flags, not Layer 1. +3. `lbrx-stt-engine` file-mode is a **U-WER bench**, not a product engine. Do not replace the Apple canvas with it. +4. Single-writer emitter **landed** (`75c89f56`). Do not re-open as field P0. +5. Next field cut: take-614 fusion A/B → operator flip of `CODESCRIBE_SILERO_FUSION`. Then idempotence. Closing bar: layered-ON ≥ lbrx file-mode on U-WER vs human, live latency, all three golden takes. +6. Push remains the operator button. Branch `fix/the-tail-patches` is ahead of origin. + +Authored-By: grok + ### 2026-07-26 · grok → claude · OPERATOR_AWAY Unblock live-app verification stall. Operator cannot grant Accessibility/Screen Recording. Continue gates/implement via BlackHole + `make test-engine-*`. Do not wait. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da32e06..7b7bdb86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.13.3] - 2026-08-04 +## [0.13.3] - 2026-08-13 > The agent-stability and STT-truth-layer wave: one dictation pipeline with an > editable transcript as the source of truth, a hardened agent substrate > (native tools, workspace-roots sandbox, permission gateway), licensing (CSK1), > Sparkle 2 signed updates, consent-gated analytics, and a fail-closed release -> lane. Rolls up PR #65 (operator feedback wave 9) and PR #68. +> lane. Rolls up PR #65 (operator feedback wave 9) and PR #68, plus the +> tail-patch wave that made the live Whisper correction lane actually deliver. ### Added @@ -49,6 +50,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Whisper residency is bounded and observable** — the normal idle-weight TTL + is now 300 seconds (five minutes), while `CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=0` + remains the explicit power-user keep-warm override. INFO lifecycle events now + expose the effective TTL plus load/unload/reclaim counts and durations without + logging audio or transcript content. Host `vmmap` reclaim remains a release + acceptance measurement, not a unit-test claim. +- **Engine warnings are classed** — only `transcription_failed` reaches the UI + as a user-terminal error; routine quality receipts (overlap normalization, + under-commit, VAD degradation, backpressure) are log-only. Guarded by + `warning_is_user_terminal` in the pipeline contracts and a bridge-side test. - **Assistive capture ownership** — assistive capture and agent-window controls unified under one owner; the capture contract is documented in `AGENTS.md`. - **Hold dictation is always raw** (#65) — the detector-level force-AI chord on @@ -64,6 +75,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Live Whisper tail patches finally land** — the correction lane compared + tokens character-for-character, so the Apple+lexicon canvas (casing, + punctuation) never matched Whisper's bare lowercase and every healthy + sentence read as wholesale divergence (a month of 116 counted, 0 applied + corrections). Tokens now align on words via a casefolded, edge-punctuation- + stripped key (diacritics stay significant); matched tokens keep the canvas + casing and substitutions carry the canvas trailing punctuation. A + substitution-shaped small-edit floor (≤3 tokens) stops the relative + change-ratio gate from starving short utterances. Measured after the fix: + 147 applied / 99 skipped across 12 sessions in one night. +- **Tail-patch lane is observable per session** — every finalisation logs a + `tail_patch_session_receipt applied=X skipped=Y` INFO row, and a session + that rejected every patch (≥3 skips, 0 applied) raises a + `tail_patch_lane_starved` WARN instead of dying silently. +- **Quality receipts no longer kill the dictation UI** — a routine engine + warning during recording used to paint "Dictation stopped", reset the UI + without stopping the engine, and leave an orphaned live microphone stream + (hot mic at tray Idle). Receipts stay off the error channel, and the error + handler now always stops the recorder before reporting failure. +- **Explicit To Agent delivers even after the session context expires** — the + runtime thread is re-created instead of dropping the user's dictated turn. - **Overlay Insert no longer pastes back into Codescribe itself** — the overlay is a non-activating panel that can hold the caret (editable FINAL) while another app stays frontmost, so the synthetic Cmd+V followed OUR key window diff --git a/Cargo.lock b/Cargo.lock index 726f2066..a0a60ab4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -759,6 +759,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "tao", "tempfile", "tokio", @@ -833,6 +834,7 @@ dependencies = [ "directories", "serde_json", "serial_test", + "tempfile", "tokio", "tracing", "uniffi", diff --git a/Cargo.toml b/Cargo.toml index 956723a5..3749a2e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,7 @@ tempfile = "3" mockito = "1" serial_test = "3" hound = "3.5" +sha2 = "0.10" [lints.rust] # Allow unexpected_cfgs from objc crate's msg_send! macro (uses cargo-clippy cfg internally) diff --git a/Makefile b/Makefile index 4cf2cf6e..f9e1fb2a 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,11 @@ SHELL := /bin/bash VERSION_FILE := Cargo.toml EDITOR ?= $(shell command -v code || command -v nvim || command -v vim || echo nano) -ENV_LOAD := set -a; [ -f $$HOME/.codescribe/.env ] && source $$HOME/.codescribe/.env; set +a +# Operator tests may source the daily dotenv for real-API credentials, but the +# harness owns its data directory. Preserve that process-wide isolation across +# the source so an operator CODESCRIBE_DATA_DIR cannot redirect tests back into +# a persistent or production tree. +ENV_LOAD := CODESCRIBE_TEST_DATA_DIR_GUARD=$$CODESCRIBE_DATA_DIR; set -a; [ -f $$HOME/.codescribe/.env ] && source $$HOME/.codescribe/.env; set +a; export CODESCRIBE_DATA_DIR="$$CODESCRIBE_TEST_DATA_DIR_GUARD"; unset CODESCRIBE_TEST_DATA_DIR_GUARD # macOS: TCC tracks a stable code identity, not just bundle path. Prefer a stable # Apple-issued signing identity automatically, and only fall back to ad-hoc when # there is genuinely nothing usable in the keychain. @@ -289,10 +293,11 @@ bump-major: # bench instrument: real proof, host-local, never a merge gate. # # gate: check class=static ci=no -- cargo fmt, prettier, clippy, semgrep, validate-envs, validate-gates; executes ZERO tests -# gate: lint class=static ci=no -- cargo fmt --check + clippy on the workspace; no tests +# gate: lint class=static ci=no -- cargo fmt --check + clippy on the workspace + verify-swift-format; no tests # gate: semgrep class=static ci=no -- semgrep scan --config auto (semgrep.yml runs semgrep directly, not this target) # gate: verify class=hermetic ci=yes -- the workspace test set + doctests + env registry + this ledger; the command rust.yml runs # gate: verify-canaries class=hermetic ci=no -- claim-vs-execution canaries that read repo files only (scripts/canaries.sh); each row is born from a named incident +# gate: verify-swift-format class=static ci=no -- swift-format lint --strict over macos/Codescribe + macos/CodescribeTests; skips the generated UniFFI binding; no Swift tests (that is test-swift) # gate: smoke-canaries class=operator ci=no -- verify-canaries + host rows: dist inputs, appcast feed, live-store purity, Sparkle key parity (scripts/canaries.sh --host) # gate: verify-dmg class=operator ci=no -- fail-closed payload check against an already-built DMG; release.yml runs the same check via scripts/verify-dmg-payload.sh, not via this target # gate: test class=operator ci=no -- workspace tests + #[ignore] real-API tests + STT pipeline; sources ~/.codescribe/.env and opens Console @@ -313,7 +318,7 @@ bump-major: # gate: test-engine-parity-layered class=operator ci=no -- Layer 1 parity arm judged on structure; private corpus, host-local bench # gate: test-engine-parity-both class=operator ci=no -- runs both parity arms and prints the delta # gate: test-teacher class=operator ci=no -- teacher CLI proof run, writes an HTML report -# gate: test-swift class=operator ci=no -- 318 SwiftUI front-end tests; needs Xcode and a built ffi dylib +# gate: test-swift class=operator ci=no -- SwiftUI suite + Apple phrase-restart Rust/Swift lockstep self-test; needs Xcode and built ffi/bridge binaries # gate: smoke-macos27 class=operator ci=no -- host smoke after an OS/Xcode bump; operator-only rows report SKIP # # ───────────────────────────────────────────────────────────────────────────── @@ -326,6 +331,45 @@ lint: @cargo fmt -- --check @echo "=== Clippy ===" @cargo clippy --workspace -- -D warnings + @echo "=== Swift Format Check ===" + @$(MAKE) --no-print-directory verify-swift-format + +# The Swift side of the app had no format gate at all while `lint` covered only +# Rust, so 100 of 100 sources drifted. Two things this recipe does NOT copy from +# the sibling repo it was transplanted from (vetcoders/pensieve, `make lint`): +# +# 1. `--strict` is mandatory. `swift-format lint` without it exits 0 no matter +# how many violations it prints, so a gate built on the bare command is a +# gate that can never fail — measured here on 2026-08-12 with swift-format +# 6.3.0: bare exit 0, --strict exit 1 on the same file. +# 2. The generated UniFFI binding is excluded. It is regenerated by +# `make app-bindings` from the Rust bridge, so formatting it is both futile +# and a diff-churn source; the sibling repo excludes its own binding the +# same way. +# +# Diagnostics go to the terminal directly, not to stdout/stderr — redirecting +# this command yields an empty file while the console still fills. Judge it by +# the exit code; that is what survives a pipe and a CI log. +SWIFT_FORMAT_ROOTS := macos/Codescribe macos/CodescribeTests +SWIFT_FORMAT_EXCLUDE := -path '*/Bridge/codescribe_ffi.swift' + +.PHONY: verify-swift-format format-swift +verify-swift-format: + @if ! command -v swift-format >/dev/null 2>&1; then \ + echo "verify-swift-format: swift-format is required (brew install swift-format)"; \ + exit 1; \ + fi + @find $(SWIFT_FORMAT_ROOTS) -name '*.swift' ! $(SWIFT_FORMAT_EXCLUDE) -print0 \ + | xargs -0 swift-format lint --strict + +format-swift: + @if ! command -v swift-format >/dev/null 2>&1; then \ + echo "format-swift: swift-format is required (brew install swift-format)"; \ + exit 1; \ + fi + @find $(SWIFT_FORMAT_ROOTS) -name '*.swift' ! $(SWIFT_FORMAT_EXCLUDE) -print0 \ + | xargs -0 swift-format format --in-place + @echo "format-swift: applied; re-run 'make verify-swift-format' to confirm" TEST_LOG := /tmp/codescribe-tests.log SWIFT_TEST_LOG := /tmp/codescribe-swift-tests.log @@ -333,7 +377,40 @@ TEST_SSE_CARGO_JOBS ?= 2 TEST_SSE_PROFILE ?= debug TEST_SSE_PROFILE_ARGS := $(if $(filter release,$(TEST_SSE_PROFILE)),--release,) +define TEST_DATA_DIR_SETUP +CODESCRIBE_TEST_TMP_ROOT="$${TMPDIR:-/tmp}"; \ +CODESCRIBE_TEST_TMP_ROOT="$${CODESCRIBE_TEST_TMP_ROOT%/}"; \ +if [[ -z "$$CODESCRIBE_TEST_TMP_ROOT" ]]; then CODESCRIBE_TEST_TMP_ROOT=/tmp; fi; \ +CODESCRIBE_TEST_DATA_DIR="$$(mktemp -d "$$CODESCRIBE_TEST_TMP_ROOT/codescribe-test-data.XXXXXX")" || { \ + echo "test-data-dir: mktemp failed under $$CODESCRIBE_TEST_TMP_ROOT" >&2; \ + exit 1; \ +}; \ +export CODESCRIBE_DATA_DIR="$$CODESCRIBE_TEST_DATA_DIR"; \ +cleanup_codescribe_test_data_dir() { \ + isolated_log="$$CODESCRIBE_TEST_DATA_DIR/logs/codescribe.log"; \ + if [[ -f "$$isolated_log" ]]; then \ + isolated_bytes="$$(wc -c < "$$isolated_log" | tr -d ' ')"; \ + echo "test-data-dir: isolated-log=$$isolated_log bytes=$$isolated_bytes"; \ + else \ + echo "test-data-dir: isolated-log=none root=$$CODESCRIBE_TEST_DATA_DIR"; \ + fi; \ + case "$$CODESCRIBE_TEST_DATA_DIR" in \ + "$$CODESCRIBE_TEST_TMP_ROOT"/codescribe-test-data.*) \ + rm -rf -- "$$CODESCRIBE_TEST_DATA_DIR"; \ + echo "test-data-dir: cleaned=$$CODESCRIBE_TEST_DATA_DIR"; \ + ;; \ + *) \ + echo "test-data-dir: refusing unsafe cleanup: $$CODESCRIBE_TEST_DATA_DIR" >&2; \ + return 1; \ + ;; \ + esac; \ +}; \ +trap cleanup_codescribe_test_data_dir EXIT; \ +echo "test-data-dir: created=$$CODESCRIBE_TEST_DATA_DIR" +endef + define TEST_SETUP +$(TEST_DATA_DIR_SETUP); \ LOG=$(TEST_LOG); \ export CODESCRIBE_DISABLE_KEYCHAIN=1; \ echo "" >> "$$LOG"; \ @@ -738,8 +815,11 @@ smoke-macos27: SWIFT_TEST_CODESIGN_IDENTITY ?= - SWIFT_TEST_MAX_SECONDS ?= 30 .PHONY: test-swift -test-swift: +test-swift: $(ENGINE_BRIDGE) @set -o pipefail; \ + $(TEST_DATA_DIR_SETUP); \ + echo "=== Apple phrase-restart Rust/Swift lockstep self-test ==="; \ + $(ENGINE_BRIDGE) --phrase-restart-self-test || exit $$?; \ if [ ! -f target/$(PROFILE)/libcodescribe_ffi.dylib ]; then \ echo "test-swift: target/$(PROFILE)/libcodescribe_ffi.dylib is missing." >&2; \ echo "test-swift: run 'make app-bindings' (or 'make app') first." >&2; \ @@ -939,6 +1019,7 @@ check: # was written to remove. Any line added below must stay in the `-e` chain. verify: @set -eo pipefail; \ + $(TEST_DATA_DIR_SETUP); \ echo "=== Verify (hermetic: workspace tests) ==="; \ CODESCRIBE_NO_EMBED=1 CODESCRIBE_DISABLE_KEYCHAIN=1 \ cargo test --workspace --all-targets; \ @@ -1069,7 +1150,7 @@ help: @printf '%s\n' ' Full classification: make -s gate-ledger' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test' 'Full suite incl. ignored real-API tests (sources ~/.codescribe/.env)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-quick' 'Workspace tests, no real API (sources ~/.codescribe/.env)' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-swift' '318 SwiftUI front-end tests (needs Xcode + ffi dylib)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-swift' 'SwiftUI suite + phrase-restart lockstep (needs Xcode + ffi dylib)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'smoke-macos27' 'Host smoke after an OS/Xcode bump (SMOKE_ARGS=--with-inference)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-e2e' 'Run E2E tests (mock)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-e2e-real' 'Run E2E tests with real API (needs LLM_*_API_KEY)' diff --git a/README.md b/README.md index 66223527..8c07383a 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ WHISPER_LANGUAGE=auto # auto | pl | en HOLD_EXCLUSIVE=1 # ignore extra modifiers during hold HOLD_START_DELAY_MS=800 # Delay before recording starts DOUBLE_TAP_INTERVAL_MS=200 # Toggle gesture timing -TOGGLE_SILENCE_SEC=5.0 # Auto-send after silence in toggle modes +TOGGLE_SILENCE_SEC=5.0 # Apple epoch rest after this much silence (next recording) # AI Formatting AI_FORMATTING_ENABLED=1 # 1=format via LLM, 0=raw transcript @@ -324,10 +324,11 @@ qube-daemon --help ## Model -Codescribe uses **whisper-large-v3-turbo-mlx-q8**: +Codescribe uses **whisper-large-v3-turbo** (mlx-community, fp16): - 4-layer turbo architecture (vs 32 layers in full model) -- Q8 quantization (~894MB weights) +- fp16 weights (~1.6 GB): loads without q8→F32 dequantization, roughly + halving cold start; the legacy q8 model stays supported as a fallback - ~10x faster than whisper-large-v3 - Metal GPU acceleration @@ -340,9 +341,14 @@ Optional fat SKU (offline / curiosity): `make release-full` or `CODESCRIBE_EMBED Runtime resolution when Whisper is not embedded: 1. `CODESCRIBE_MODEL_PATH` environment variable -2. `~/.codescribe/models/whisper-large-v3-turbo-mlx-q8/` -3. `./models/whisper-large-v3-turbo-mlx-q8/` -4. Hugging Face cache snapshots for `LibraxisAI/whisper-large-v3-turbo-mlx-q8` +2. `~/.codescribe/models/whisper-large-v3-turbo/` (fp16 default) +3. Hugging Face cache snapshots for `mlx-community/whisper-large-v3-turbo` +4. Legacy fallback: `~/.codescribe/models/whisper-large-v3-turbo-mlx-q8/` or + `LibraxisAI/whisper-large-v3-turbo-mlx-q8` snapshots + +The mlx-community repo ships only `config.json` + `weights.safetensors`; +the download paths compose `tokenizer.json` + `mel_filters.npz` from the +legacy repo (both files are quantization-independent). `CODESCRIBE_NO_EMBED=1` is a development/recovery path that also skips MiniLM embed; it is not the public slim product path. @@ -381,7 +387,7 @@ git clone https://github.com/vetcoders/codescribe.git cd codescribe # Development app build with explicit runtime Whisper fallback -CODESCRIBE_MODEL_PATH=./models/whisper-large-v3-turbo-mlx-q8 make app PROFILE=debug +CODESCRIBE_MODEL_PATH=~/.codescribe/models/whisper-large-v3-turbo make app PROFILE=debug open macos/build/Build/Products/Debug/Codescribe.app # Quality checks diff --git a/app/agent/openai_provider.rs b/app/agent/openai_provider.rs index dd701f7b..30ce0ab6 100644 --- a/app/agent/openai_provider.rs +++ b/app/agent/openai_provider.rs @@ -197,7 +197,13 @@ impl AgentProvider for OpenAiProvider { let request = OpenAiResponsesRequest { reasoning: reasoning_summary_request(&model), model, - input: build_request_input_items(messages, previous_response_id.as_deref())?, + input: build_request_input( + &options.system_prompt, + messages, + previous_response_id.as_deref(), + )?, + // Param on the first turn only; chained turns re-carry the prompt + // as a developer input item (the chain does not preserve it). instructions: chained_instructions( &options.system_prompt, previous_response_id.as_deref(), @@ -494,10 +500,16 @@ fn build_tool_payload(tools: &[ToolDefinition]) -> Vec { .collect() } -/// Instructions for a Responses request: sent on the FIRST turn of a chain -/// only. `previous_response_id` preserves them server-side, and endpoints -/// reject the pair with HTTP 400 ("instructions and previous_response_id -/// together") — same contract the formatting lane already follows. +/// Instructions for a Responses request: the `instructions` PARAM goes only +/// on the first turn of a chain — endpoints reject the pair with +/// `previous_response_id` (HTTP 400 "instructions and previous_response_id +/// together"). +/// +/// But instructions are NOT preserved server-side across chained turns +/// (OpenAI Responses contract), so a chained turn MUST re-carry the system +/// prompt inside `input` — see [`build_request_input`]. A promptless chained +/// turn is how the formatting lane leaked a chat-assistant reply as product +/// output (2026-08-14, build 661); the agent shares the wire contract. fn chained_instructions( system_prompt: &Option, previous_response_id: Option<&str>, @@ -509,6 +521,30 @@ fn chained_instructions( } } +/// Build the full `input` array for a request. On chained turns the system +/// prompt rides as a leading `developer` message item, because the +/// `instructions` param is absent there (see [`chained_instructions`]) and +/// the chain does not carry it server-side. First turns carry the prompt via +/// `instructions` only — no duplicate developer item. +fn build_request_input( + system_prompt: &Option, + messages: &[Message], + previous_response_id: Option<&str>, +) -> Result> { + let mut items = Vec::new(); + if previous_response_id.is_some() + && let Some(prompt) = system_prompt.as_deref().filter(|p| !p.trim().is_empty()) + { + items.push(json!({ + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": prompt}] + })); + } + items.extend(build_request_input_items(messages, previous_response_id)?); + Ok(items) +} + /// Build the `input` array: select the messages to send, then encode them. fn build_request_input_items( messages: &[Message], @@ -804,9 +840,9 @@ fn parse_env_bool(key: &str, default: bool) -> bool { #[cfg(test)] mod tests { use super::{ - OpenAiProvider, ProviderKind, build_request_input_items, chained_instructions, - format_tool_output, forward_events_and_track_chain, reasoning_summary_request, - request_messages, to_data_uri, + OpenAiProvider, ProviderKind, build_request_input, build_request_input_items, + chained_instructions, format_tool_output, forward_events_and_track_chain, + reasoning_summary_request, request_messages, to_data_uri, }; use std::sync::Arc; use std::time::Duration; @@ -879,10 +915,11 @@ mod tests { assert!(selected.iter().all(|message| message.role == Role::User)); } - /// Chained turns must NOT resend `instructions`: the Responses API keeps - /// them via `previous_response_id`, and endpoints reject the combination - /// with HTTP 400 "instructions and previous_response_id together" — which - /// froze the Agent UI in thinking… on every second turn (repro 2026-08-10). + /// Chained turns must NOT resend the `instructions` PARAM (endpoints + /// reject the pair with HTTP 400, which froze the Agent UI on every + /// second turn — repro 2026-08-10). The prompt itself still travels: as a + /// developer input item, because the chain does NOT preserve instructions + /// server-side (see `chained_turn_recarries_prompt_as_developer_item`). #[test] fn chained_turn_omits_instructions() { let system = Some("system prompt".to_string()); @@ -899,6 +936,38 @@ mod tests { assert_eq!(chained_instructions(&None, None), None); } + /// The 2026-08-14 promptless-chain leak, agent side: a chained turn must + /// re-carry the system prompt as a leading developer input item (the + /// chain does not preserve `instructions` server-side), while the first + /// turn carries it via the param only — no duplicate developer item. + #[test] + fn chained_turn_recarries_prompt_as_developer_item() { + let system = Some("system prompt".to_string()); + let messages = vec![Message::new( + Role::User, + vec![ContentBlock::Text("hello".to_string())], + )]; + + let chained = build_request_input(&system, &messages, Some("resp_prev")) + .expect("chained input should build"); + assert_eq!(chained[0]["role"], "developer"); + assert_eq!(chained[0]["content"][0]["text"], "system prompt"); + assert_eq!(chained[1]["role"], "user"); + + let first = build_request_input(&system, &messages, None).expect("first input builds"); + assert!( + first.iter().all(|item| item["role"] != "developer"), + "first turn must not duplicate the prompt as a developer item" + ); + + let promptless = build_request_input(&None, &messages, Some("resp_prev")) + .expect("promptless chained input builds"); + assert!( + promptless.iter().all(|item| item["role"] != "developer"), + "no prompt configured ⇒ no developer item" + ); + } + /// Resuming a chain omits prior turns already stored server-side. #[test] fn build_request_input_items_skips_prior_history_when_resuming_chain() { diff --git a/app/agent/tools/api_truth.rs b/app/agent/tools/api_truth.rs new file mode 100644 index 00000000..85cf4308 --- /dev/null +++ b/app/agent/tools/api_truth.rs @@ -0,0 +1,76 @@ +//! Agent-facing ground truth about Responses-style and streaming AI APIs, +//! appended to the system prompt. Every claim here was measured in the field +//! (2026-08-12..14: the pair-400, the promptless-chain leak on build 661, the +//! key-swap `previous_response_not_found`, and the full hours-later recall of +//! a stored chain). Prompt-layer only — the agent must answer questions about +//! these mechanics from facts, not with generic clarification menus. + +/// A concise Responses/streaming primer for the agent system prompt. Kept +/// tight on purpose: prompt space is a scarce resource, so this section +/// states the contract facts, the app's transcription shape, and the +/// answer-first rule — nothing else. +pub fn responses_api_prompt_section() -> String { + "RESPONSES & STREAMING AI APIS — GROUND TRUTH\n\ + Codescribe speaks the OpenAI Responses API (`/v1/responses`), never \ + legacy chat/completions. Requests carry `input` items shaped \ + `{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":...}]}` \ + (assistant history rides as `output_text`).\n\ + Conversation state: `previous_response_id` chains turns server-side; a \ + stored response id IS durable conversation memory and can be resumed \ + hours later. Three measured sharp edges: (1) sending `instructions` \ + together with `previous_response_id` is HTTP 400 on OpenAI; (2) \ + instructions are NOT carried across chained turns — a chained turn must \ + re-carry the system prompt as a leading `developer` input item; (3) \ + response ids are key/org-scoped — after a key rotation the old id \ + answers `previous_response_not_found`, so drop it and continue \ + unchained. Some Responses backends (LibraxisAI) also mint a \ + `response_id` for STT transcriptions (`resp_stt_*`) that LLM turns can \ + chain from — voice joining the conversation as a chain link.\n\ + Streaming is SSE: `response.created` -> `response.in_progress` -> \ + `response.output_item.added` -> `response.output_text.delta`... -> \ + `response.completed` (the completed event carries the full output and \ + the response id). Non-streaming is one JSON body of the same shape.\n\ + This app's transcription is layered: Apple SFSpeech live partials/finals \ + form the canvas, Whisper re-transcribes windowed tails, the lexicon is a \ + post-pass, LLM formatting is a separate lane — and RAW is append-only, \ + never full-replaced.\n\ + ANSWER-FIRST RULE: when a spoken request is rough, partial, or \ + frustrated, extract the actionable intent and act or answer \ + substantively from these facts and the codebase. Ask at most ONE \ + clarifying question, and only when genuinely blocked — never reply with \ + numbered option menus or requirement questionnaires." + .to_string() +} + +/// Pins the load-bearing anchors this section must keep through future edits. +#[cfg(test)] +mod tests { + use super::*; + + /// The primer must keep the measured contract facts and the answer-first rule. + #[test] + fn api_truth_section_carries_the_load_bearing_anchors() { + let section = responses_api_prompt_section(); + assert!(section.starts_with("RESPONSES & STREAMING AI APIS")); + for anchor in [ + "/v1/responses", + "input_text", + "previous_response_id", + "HTTP 400", + "NOT carried across chained turns", + "developer", + "previous_response_not_found", + "resp_stt_", + "response.output_text.delta", + "response.completed", + "append-only", + "ANSWER-FIRST RULE", + "numbered option menus", + ] { + assert!( + section.contains(anchor), + "api-truth section missing anchor: {anchor}" + ); + } + } +} diff --git a/app/agent/tools/mod.rs b/app/agent/tools/mod.rs index e2ced737..85476fb7 100644 --- a/app/agent/tools/mod.rs +++ b/app/agent/tools/mod.rs @@ -6,6 +6,8 @@ //! separately nameable is what lets a test prove the substrate covers every //! core capability without an MCP server running. +/// Prompt-layer Responses/streaming ground truth (measured API contracts). +pub mod api_truth; /// Clipboard read/write tools (`read_clipboard` / `write_clipboard`). pub mod clipboard; /// Prompt-layer review-tool + connector-fallback doctrine (no executor changes). diff --git a/app/controller/final_pass.rs b/app/controller/final_pass.rs index 25cb267b..132854b2 100644 --- a/app/controller/final_pass.rs +++ b/app/controller/final_pass.rs @@ -5,10 +5,11 @@ //! mode in which a full-file re-pass is permitted. //! - **Smart**: Whisper may final-pass **individual utterances only** — never the //! whole file. When streaming completeness is adjudicated Complete, nothing runs -//! at stop. When complete-but-shapeless (long wall of words, no sentence -//! terminals — the shape row, operator 2026-08-09), Whisper transcribes the -//! file but only its punctuation/capitalization is adopted onto the committed -//! words (`punctuation_transplant`; word sequence invariant at THIS stage +//! at stop. When coverage-complete, plausibly dense, but shapeless (long wall +//! of words, no sentence terminals — the shape row, operator 2026-08-09), +//! Whisper transcribes the file but only its punctuation/capitalization is +//! adopted onto the committed words (`punctuation_transplant`; word sequence +//! invariant at THIS stage //! because shape is the deficit here — live word corrections belong to the //! Layer 1 tail patch, which is a core element, on by default). When //! incomplete, only the uncommitted audio tail (from the last committed @@ -213,6 +214,223 @@ pub(crate) fn assess_streaming_completeness_fields( }) } +// ── Committed-density floor over the structural verdict ───────────────────── +// +// Structural completeness answers "did the adjudicator seal everything it +// saw". It cannot answer "was what it saw plausibly a whole minute of speech", +// because every input it reads has already passed through the live +// accumulator — so when the accumulator eats a phrase, coverage stays intact +// over the survivors and reports Complete. Measured 2026-08-10/11: two Polish +// takes delivered 220 chars over a COMPLETE 104 s WAV and 118 chars over a +// complete 107 s WAV. Both were labelled `complete_streaming_transcript` and +// skipped while 85–90% of the speech was gone. This floor is the second +// opinion that skip decision never had. + +/// Below this audio length committed density is not judged at all. +/// +/// A short note is legitimately sparse — "kup mleko i chleb" over nine seconds +/// is a real dictation, not a starving one — and escalating those would put a +/// Whisper pass on the stop path of every quick capture. +const DENSITY_MIN_AUDIO_SECS: f32 = 10.0; + +/// Committed characters per audio second below which a coverage-complete +/// verdict describes starvation rather than completeness. +/// +/// Grounded in the measured pair — 2.1 chars/s (220 ch / 104 s) and 1.1 chars/s +/// (118 ch / 107 s) — against a healthy take at ~13 chars/s. Conversational +/// Polish runs roughly 12–15 chars/s, so this floor sits far below anything a +/// real dictation produces. Deliberately loose: a false escalation costs one +/// tail-gap pass, a false skip loses speech permanently, and the doctrine +/// resolves that asymmetry in favour of keeping speech. +const DENSITY_MIN_CHARS_PER_SEC: f32 = 4.0; + +/// Telemetry label replacing `complete_streaming_transcript` when the floor +/// overrides the verdict. Stable string — it is a log/receipt contract. +pub(crate) const DENSITY_STARVED_REASON: &str = "starved_density"; + +/// Committed characters per second of recorded audio. +/// +/// `None` when the denominator is unusable (non-finite, zero, negative). A +/// missing audio length must never become a synthetic density: `0.0` would read +/// as maximal starvation and escalate every session that reached this code. +pub(crate) fn committed_density_chars_per_sec( + audio_secs: f32, + committed_chars: usize, +) -> Option { + if !audio_secs.is_finite() || audio_secs <= 0.0 { + return None; + } + Some(committed_chars as f32 / audio_secs) +} + +/// True when a session long enough to judge committed too few characters to be +/// believable. +/// +/// NaN-safe through [`committed_density_chars_per_sec`]: an unmeasurable +/// duration yields `None` and reports "not starved" rather than escalating on +/// evidence it does not have. +pub(crate) fn committed_density_starved(audio_secs: f32, committed_chars: usize) -> bool { + let Some(density) = committed_density_chars_per_sec(audio_secs, committed_chars) else { + return false; + }; + audio_secs > DENSITY_MIN_AUDIO_SECS && density < DENSITY_MIN_CHARS_PER_SEC +} + +/// Apply the committed-density floor to a structural completeness verdict. +/// +/// Every structurally complete verdict (`Complete` or +/// `CompleteShapeDeficient`) is eligible for demotion into the existing +/// `Incomplete` arm. [`final_pass_action`] carries the consequence with no new +/// variant and no second controller: `Smart` + +/// `Incomplete{starved_density}` → `TailGapFill`, which is the residual / +/// tail-gap path the live session text already feeds through +/// [`smart_tail_gap_source`]. This ordering matters: sentence shape cannot +/// excuse implausibly sparse coverage, because a weak punctuation alignment +/// may otherwise return the starving canvas untouched. `Off` still skips and +/// `Always` still re-passes, because routing ignores completeness for both — +/// the floor changes Smart alone. +/// +/// `audio_secs = None` leaves the verdict untouched: an unknown denominator is +/// not evidence of starvation. The call site logs that silence so the absence +/// shows up in the receipts instead of having to be inferred from them. +pub(crate) fn apply_committed_density_floor( + completeness: StreamingCompleteness, + audio_secs: Option, + committed_chars: usize, +) -> StreamingCompleteness { + let Some(audio_secs) = audio_secs else { + return completeness; + }; + if matches!( + completeness, + StreamingCompleteness::Complete | StreamingCompleteness::CompleteShapeDeficient + ) && committed_density_starved(audio_secs, committed_chars) + { + return StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }; + } + completeness +} + +/// Stable telemetry label for a completeness verdict. +/// +/// One table for every receipt that names a verdict (the density override, the +/// residual-required override, and the `final_pass_skipped` line at the call +/// site) so a label cannot drift between logs that describe the same decision. +/// `CompleteShapeDeficient` is unreachable through the Smart skip mapping — it +/// routes to `PunctuationRepass` — but stays named here for any future mode +/// that skips on it. +pub(crate) fn completeness_label(completeness: StreamingCompleteness) -> &'static str { + match completeness { + StreamingCompleteness::Complete => "complete_streaming_transcript", + StreamingCompleteness::CompleteShapeDeficient => "shape_deficient", + StreamingCompleteness::Incomplete { reason } => reason, + } +} + +/// Single INFO line for a density-overridden structural verdict. +/// +/// Carries both verdict labels and the numbers behind the override — never the +/// transcript. The guard fires precisely when the committed text is short +/// enough to fit comfortably in a log line, which is exactly when writing user +/// speech into `~/.codescribe/logs/codescribe.log` would be easiest to justify +/// and still wrong. +pub(crate) fn format_density_override_line( + overridden: StreamingCompleteness, + audio_secs: f32, + committed_chars: usize, +) -> String { + let overridden = completeness_label(overridden); + format!( + "final_pass_density_guard overridden_verdict={overridden} new_verdict={reason} audio_secs={audio_secs:.3} committed_chars={committed_chars} density_chars_per_sec={density:.2} floor_chars_per_sec={floor:.1} min_audio_secs={min_secs:.1} route=tail_gap_fill", + reason = DENSITY_STARVED_REASON, + density = committed_density_chars_per_sec(audio_secs, committed_chars).unwrap_or(f32::NAN), + floor = DENSITY_MIN_CHARS_PER_SEC, + min_secs = DENSITY_MIN_AUDIO_SECS, + ) +} + +// ── Layer 1 residual-required demotion over the structural verdict ────────── +// +// The committed-density floor above asks "was what the accumulator kept +// plausibly a whole minute of speech". This one carries a stricter fact: Layer +// 1 re-transcribed the sealed window, found MORE speech than the canvas holds, +// and could not place part of it on a demonstrably safe anchor (W-C, commit +// `6d7eaa7f`). That is not an inference from a ratio — it is the engine +// reporting a hole it measured and refused to paper over by rewriting committed +// text. Density can miss it entirely: a session may commit a healthy 13 chars/s +// and still have lost a phrase to a hypothesis collapse, which is exactly the +// case the floor was never able to see. + +/// Telemetry label replacing a structurally complete verdict when Layer 1 +/// escalated an unplaceable under-commit residual. Stable string — it is a +/// log/receipt contract, mirroring [`DENSITY_STARVED_REASON`]. +pub(crate) const RESIDUAL_REQUIRED_REASON: &str = "residual_required"; + +/// Demote a structurally complete verdict when Layer 1 reported speech it +/// recovered but could not place. +/// +/// Pure and total, so the contract is testable without a session: the only +/// input beyond the verdict is the monotonic +/// [`SessionTelemetrySnapshot::residual_required`] flag. Deliberately NOT +/// folded into [`apply_committed_density_floor`] — that helper's whole meaning +/// is chars-per-second, and passing an unrelated boolean through it would make +/// both contracts unreadable and untestable in isolation. +/// +/// Consequence, carried by the existing typed matrix with no new variant and no +/// second controller: `Smart` + `Incomplete{residual_required}` → +/// [`FinalPassAction::TailGapFill`], the same residual / tail-gap ladder +/// [`smart_tail_gap_source`] already feeds. `SkipStreamingFinal` and +/// `PunctuationRepass` both become unreachable for this session under Smart — +/// punctuation-only delivery would hand back the canvas with the hole still in +/// it, shaped. +/// +/// An already-`Incomplete` verdict keeps its OWN reason. `starved_density` and +/// `pending_tail` are strictly more informative diagnoses (they carry measured +/// numbers or a state machine position) and they route identically, so +/// relabelling them would trade information for nothing. +/// +/// Mode promises are untouched: routing ignores completeness for `Off` and +/// `Always`, so this demotion changes Smart alone. +pub(crate) fn apply_residual_required_demotion( + completeness: StreamingCompleteness, + residual_required: bool, +) -> StreamingCompleteness { + if !residual_required { + return completeness; + } + match completeness { + StreamingCompleteness::Complete | StreamingCompleteness::CompleteShapeDeficient => { + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON, + } + } + already_diagnosed @ StreamingCompleteness::Incomplete { .. } => already_diagnosed, + } +} + +/// Single INFO line emitted whenever Layer 1 raised the residual escalation, +/// including when the verdict was already Incomplete and nothing changed. +/// +/// Silence would be indistinguishable from "the warning never arrived", which +/// is the failure mode this whole cut exists to close: under W-C the signal +/// reached the log and the IPC wire and changed no verdict, and nothing in the +/// receipts said so. Carries verdict labels and the warning code only — never +/// transcript text, and never a character count that could reconstruct one. +pub(crate) fn format_residual_required_line( + before: StreamingCompleteness, + after: StreamingCompleteness, +) -> String { + format!( + "final_pass_residual_guard warning_code={code} verdict_before={before} verdict_after={after} demoted={demoted}", + code = super::helpers::UNDER_COMMIT_WARNING_CODE, + before = completeness_label(before), + after = completeness_label(after), + demoted = before != after, + ) +} + /// Label from the actual engine verdict (not preference). Apple→Whisper fallback /// reports Whisper. When final-pass was **Skipped**, label the **live** lane that /// served (not a hardcode `streaming_whisper` — that laundered Apple live into a diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index 263e1cd4..06c21b1b 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -334,6 +334,56 @@ impl AgentRuntimeState { true } } + + /// Rebind the assistive conversation to the UI-selected thread (operator + /// contract 2026-08-13: dictation routes to the thread the user is looking + /// at; a new thread is only ever minted by an explicit "+ New thread"). + /// + /// Dropping the runtime on a change deliberately reuses the degrade→rejoin + /// machinery: the next `ensure_runtime` rebuilds onto the new identity and + /// rehydrates its persisted history. `None` clears the identity so the next + /// send mints a fresh thread. Same-target calls are no-ops — the live + /// runtime and its in-memory history stay untouched. + fn retarget_thread(&mut self, target: Option) { + if self.thread_store_id == target { + return; + } + let previous = self.thread_store_id.clone(); + self.runtime = None; + self.thread_store_id = target; + info!( + from = previous.as_deref().unwrap_or(""), + to = self.thread_store_id.as_deref().unwrap_or(""), + "Assistive lane retargeted to UI-selected thread" + ); + } +} + +/// UI-selected assistive routing target. +/// +/// Outer `None`: the Agent UI never published a selection (window never +/// opened) — the lane keeps its legacy behavior of continuing the bound +/// conversation. `Some(None)`: the UI selected a not-yet-persisted thread +/// (explicit "+ New thread") — the next send mints a fresh thread, then the +/// send path syncs the minted identity back here so ONE conscious new-thread +/// press produces one thread, not one per utterance. +static ASSISTIVE_TARGET_THREAD: std::sync::RwLock>> = + std::sync::RwLock::new(None); + +/// Publish the Agent UI's current thread selection as the assistive routing +/// target. Called from the bridge whenever the selection changes. +pub fn set_assistive_target_thread(backend_id: Option) { + *ASSISTIVE_TARGET_THREAD + .write() + .unwrap_or_else(|e| e.into_inner()) = Some(backend_id); +} + +/// Snapshot the published routing target, if the UI ever published one. +fn assistive_target_thread() -> Option> { + ASSISTIVE_TARGET_THREAD + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() } /// The lazily-initialized slot holding the process-global runtime state. @@ -449,12 +499,16 @@ fn build_agent_stream_options( fn compose_agent_system_prompt(use_assistive_persona: bool) -> String { let workspace = crate::agent::tools::workspace::workspace_prompt_section(); let doctrine = crate::agent::tools::doctrine::review_doctrine_prompt_section(); + // Measured Responses/streaming contract facts + the answer-first rule — + // rides BOTH lanes so a spoken engine question gets substance, not a + // clarification questionnaire (operator incident 2026-08-14). + let api_truth = crate::agent::tools::api_truth::responses_api_prompt_section(); if use_assistive_persona { let base = crate::config::get_assistive_prompt(); - format!("{base}\n\n{workspace}\n\n{doctrine}") + format!("{base}\n\n{workspace}\n\n{doctrine}\n\n{api_truth}") } else { format!( - "You are the Codescribe agent. Answer and act on the user's spoken request using the available tools when helpful.\n\n{workspace}\n\n{doctrine}" + "You are the Codescribe agent. Answer and act on the user's spoken request using the available tools when helpful.\n\n{workspace}\n\n{doctrine}\n\n{api_truth}" ) } } @@ -1149,7 +1203,24 @@ async fn run_agent_send_with_fallback( let stream_options = build_agent_stream_options(ai_assistive_max_tokens, use_assistive_persona); let agent_result = { let mut guard = runtime_state.lock().await; - run_agent_send_path(&mut guard, text.clone(), stream_options).await + // Route to the thread the user is looking at (operator contract + // 2026-08-13). No published selection → legacy bound conversation. + let fresh_mint_requested = match assistive_target_thread() { + Some(target) => { + let fresh = target.is_none(); + guard.retarget_thread(target); + fresh + } + None => false, + }; + let result = run_agent_send_path(&mut guard, text.clone(), stream_options).await; + if fresh_mint_requested { + // One conscious "+ New thread" = one thread: adopt the minted + // identity as the target so the next utterance continues it. The + // UI's own post-turn refresh republishes the same identity. + set_assistive_target_thread(guard.thread_store_id.clone()); + } + result }; match agent_result { @@ -1253,6 +1324,18 @@ impl CompletenessCommitSource { } } +/// Engine warning code raised when the Layer 1 tail patch classified an +/// under-commit retranscription and recovered speech it could **not** place on +/// a demonstrably safe anchor (`core::stt::tail_patcher` → +/// `core::pipeline::streaming::session`, W-C / commit `6d7eaa7f`). +/// +/// Mirrored as a literal rather than imported: core's canonical +/// `UNDER_COMMIT_WARNING_CODE` is `pub` inside a `pub(crate) mod session`, so it +/// is not nameable from this crate and widening that visibility sits outside +/// this cut's fence. Matched EXACTLY — the sibling `tail_patch_skipped` receipt +/// and any future neighbouring code must not force residual gap fill. +pub(crate) const UNDER_COMMIT_WARNING_CODE: &str = "tail_patch_under_commit"; + /// Session telemetry captured from `EngineEvent`s. #[derive(Debug, Clone, Default)] pub(crate) struct SessionTelemetrySnapshot { @@ -1268,6 +1351,14 @@ pub(crate) struct SessionTelemetrySnapshot { /// across UtteranceFinal events. Smart-mode stop transcribes only the tail /// after this point (append-only doctrine — committed text is immutable). pub committed_through_secs: Option, + /// Layer 1 escalated an under-commit residual it could not place on a safe + /// anchor ([`UNDER_COMMIT_WARNING_CODE`]). Monotonic within one session: + /// once a hole is known no later healthy event may un-know it, because the + /// speech is already missing from the canvas the stop path is about to + /// deliver. `Default` starts it false by construction, so + /// [`reset_session_telemetry`] is the only thing that clears it and a new + /// recording can never inherit the previous session's residual demand. + pub residual_required: bool, } /// Telemetry handle shared between the engine's event sink and the controller @@ -1343,6 +1434,10 @@ impl EventSink for SessionTelemetrySink { /// overwrite a valid maximum, silently disabling Smart tail gap-fill for the /// rest of the session. Unmatched events are ignored rather than /// exhaustively listed, so new engine events cannot break the build here. + /// + /// `Warning` is the one event folded by code rather than by variant: only + /// [`UNDER_COMMIT_WARNING_CODE`] sets `residual_required`, and it sets it + /// monotonically. Every other warning falls through to the ignore arm. fn on_event(&self, event: &EngineEvent) { let mut guard = self.shared.lock().unwrap_or_else(|e| e.into_inner()); match event { @@ -1374,6 +1469,15 @@ impl EventSink for SessionTelemetrySink { guard.pending_tail = false; guard.last_commit_source = Some(CompletenessCommitSource::SessionFinalised); } + // Layer 1 recovered speech it could not place. Set-only: a hole + // found mid-session stays known until the session is reset, because + // the missing speech does not come back on its own. The exact code + // is the whole contract — a near-miss code must leave the flag false + // rather than put a Whisper pass on every stop path that logs a + // warning. + EngineEvent::Warning { code, .. } if code == UNDER_COMMIT_WARNING_CODE => { + guard.residual_required = true; + } EngineEvent::Stats { hallucination_drops, semantic_gate_drops, @@ -1427,6 +1531,49 @@ mod tests { use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, AtomicUsize}; + // ── Assistive routing target (operator contract 2026-08-13) ───────────── + + /// Retargeting to another persisted thread drops the runtime (so the next + /// send rejoins + rehydrates) and adopts the new identity; retargeting to + /// the SAME thread must not touch a live runtime — steady-state sends may + /// re-apply the target on every turn. + #[test] + fn retarget_thread_rebinds_on_change_and_noops_on_same() { + let mut state = AgentRuntimeState { + runtime: None, + thread_store_id: Some("thread-a".to_string()), + runtime_degraded: false, + }; + + state.retarget_thread(Some("thread-a".to_string())); + assert_eq!(state.thread_store_id.as_deref(), Some("thread-a")); + + state.retarget_thread(Some("thread-b".to_string())); + assert_eq!( + state.thread_store_id.as_deref(), + Some("thread-b"), + "a changed selection must adopt the new identity" + ); + assert!( + state.runtime.is_none(), + "rebind goes through the rejoin machinery (runtime dropped)" + ); + } + + /// A `None` target is the explicit "+ New thread": the durable identity is + /// cleared so the next send mints a fresh thread instead of continuing the + /// previous conversation. + #[test] + fn retarget_thread_none_clears_identity_for_a_fresh_mint() { + let mut state = AgentRuntimeState { + runtime: None, + thread_store_id: Some("thread-a".to_string()), + runtime_degraded: false, + }; + state.retarget_thread(None); + assert!(state.thread_store_id.is_none()); + } + // ── Collapsible Tool Evidence: friendly tool-name mapping ─────────────── /// Both the MCP wire form and the bare tool id must resolve to the same @@ -1898,6 +2045,87 @@ mod tests { ); } + /// The stop path's residual demand is folded by warning CODE, not by + /// variant. Three things are pinned here because each is a different way to + /// break the contract: exactly `tail_patch_under_commit` sets the flag, its + /// neighbours must not (a loose match would put a Whisper pass on the stop + /// path of every session that logs a warning), and once set no later event + /// may clear it — the speech Layer 1 could not place does not come back on + /// its own, so a clean final afterwards is not evidence the hole closed. + #[test] + fn test_session_telemetry_folds_only_the_exact_under_commit_warning() { + let warning = |code: &str| EngineEvent::Warning { + code: code.to_string(), + message: "committed_tokens=3 retranscribed_tokens=12".to_string(), + }; + let utterance_final = || EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "zdanie".to_string(), + raw_text: "zdanie".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: vec![], + vad_speech_pct: Some(80.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: vec![], + }; + + // Near misses: the sibling receipt code, a truncation, an extension, a + // case variant, a bare substring, and the empty code. + for code in [ + "tail_patch_skipped", + "tail_patch_under_commi", + "tail_patch_under_commit_residual", + "TAIL_PATCH_UNDER_COMMIT", + "under_commit", + "", + ] { + let shared = new_session_telemetry(); + let sink = SessionTelemetrySink::new(Arc::clone(&shared)); + sink.on_event(&warning(code)); + assert!( + !snapshot_session_telemetry(&shared).residual_required, + "warning code {code:?} must not demand residual gap fill" + ); + } + + let shared = new_session_telemetry(); + let sink = SessionTelemetrySink::new(Arc::clone(&shared)); + assert!( + !snapshot_session_telemetry(&shared).residual_required, + "a fresh session starts with no residual demand" + ); + + sink.on_event(&warning(UNDER_COMMIT_WARNING_CODE)); + assert!( + snapshot_session_telemetry(&shared).residual_required, + "the exact Layer 1 under-commit code must fold to residual_required" + ); + + // Monotonic within the session: a clean commit, an unrelated warning + // and the session seal all arrive after the escalation and none of them + // may un-know it. + sink.on_event(&utterance_final()); + sink.on_event(&warning("tail_patch_skipped")); + sink.on_event(&EngineEvent::SessionFinalised { + session_id: "s1".to_string(), + layer_summary: Default::default(), + }); + assert!( + snapshot_session_telemetry(&shared).residual_required, + "later healthy events must not clear a hole Layer 1 already found" + ); + + // Only a new session clears it. + reset_session_telemetry(&shared); + assert!( + !snapshot_session_telemetry(&shared).residual_required, + "reset clears the residual demand so a new recording never inherits it" + ); + } + /// Reset must clear every field, not just the obvious ones: leftover /// `pending_tail` or `committed_chars` would make the next session's first /// routing decision read the previous session's state. @@ -1914,6 +2142,8 @@ mod tests { guard.pending_tail = true; guard.last_commit_source = Some(CompletenessCommitSource::UtteranceFinal); guard.committed_chars = 12; + guard.committed_through_secs = Some(41.0); + guard.residual_required = true; } reset_session_telemetry(&shared); @@ -1923,6 +2153,11 @@ mod tests { assert!(!snapshot.pending_tail); assert!(snapshot.last_commit_source.is_none()); assert_eq!(snapshot.committed_chars, 0); + assert!(snapshot.committed_through_secs.is_none()); + assert!( + !snapshot.residual_required, + "a stale residual demand would force a Whisper tail pass on the next session" + ); } /// The per-turn generation machinery is removed: ordinary consecutive diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 34da9633..34e75850 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -33,6 +33,8 @@ mod helpers; mod hotkey_policy; /// Overlay paste dispositions and deferred-insert registration. mod overlay_paste; +/// Production-owned, content-private PCM replay of the overlay engine cone. +pub mod production_replay; /// Quality-gated auto-paste / clipboard delivery decisions. mod quality_delivery; /// Public serving-status surface for tray/UI consumers. @@ -44,13 +46,12 @@ mod types; pub use helpers::{ is_assistive_session, is_conversation_session, publish_recording_indicator, - set_assistive_session, set_conversation_session, + set_assistive_session, set_assistive_target_thread, set_conversation_session, }; pub use overlay_paste::{OverlayPasteDelivery, OverlayPasteResult}; pub use types::{HotkeyAction, HotkeyInput, HotkeyType, State, TranscriptionActionContractMode}; use crate::presentation::emitter::PresentationEmitter; -use crate::stream_postprocess::StreamPostProcessor; use anyhow::{Context, Result}; use std::path::PathBuf; use std::sync::Arc; @@ -75,6 +76,7 @@ use crate::os::selection::{ wait_for_frontmost_app, }; use crate::os::shortcut_registry; +use codescribe_core::asr_session::GatewaySessionAvailability; use context_bucket::ContextBucket; #[cfg(test)] pub(crate) use context_bucket::ContextMarker; @@ -97,14 +99,20 @@ use assistive_delivery::{ }; pub(crate) use final_pass::{ FinalPassAction, FinalPassRoutingMode, FinalPassStages, SmartTailGapSource, StopPathBudget, - StreamingCompleteness, StreamingCompletenessEvidence, append_tail_gap, - assess_streaming_completeness, compose_stop_path_residual_from_partials, final_pass_action, - final_pass_routing_mode, format_assistive_delivery_budget_line, format_final_pass_stages_line, - format_stop_path_budget_line, smart_tail_gap_source, + StreamingCompletenessEvidence, append_tail_gap, apply_committed_density_floor, + apply_residual_required_demotion, assess_streaming_completeness, completeness_label, + compose_stop_path_residual_from_partials, final_pass_action, final_pass_routing_mode, + format_assistive_delivery_budget_line, format_density_override_line, + format_final_pass_stages_line, format_residual_required_line, format_stop_path_budget_line, + smart_tail_gap_source, }; +// The stop path routes on completeness values without ever naming the type: +// every verdict label now comes from `completeness_label`, so the only sites +// that spell `StreamingCompleteness` out are the controller tests. #[cfg(test)] pub(crate) use final_pass::{ - assess_streaming_completeness_fields, engine_label_from_verdict, stop_path_budget_covers_total, + StreamingCompleteness, assess_streaming_completeness_fields, engine_label_from_verdict, + stop_path_budget_covers_total, }; #[cfg(test)] use helpers::SessionEngineStats; @@ -134,10 +142,11 @@ use quality_delivery::{ ClipboardDeliverySink, compose_final_status, evaluate_quality_commit_trigger, maybe_wrap_transcript_for_delivery, maybe_wrap_transcript_for_delivery_with_quality, recording_mode_label, resolve_auto_paste_policy, session_auto_format_enabled, - truth_recording_mode_label, + session_prewarms_semantic_guard, truth_recording_mode_label, }; pub(crate) use truth::{ - adjudicate_recording_truth, apply_ai_noop_signal, truth_display_status, truth_engine_label, + adjudicate_recording_truth, apply_ai_noop_signal, postprocess_transcript_for_delivery, + truth_display_status, truth_engine_label, }; #[cfg(test)] pub(crate) use truth::{push_typed_flag, truth_review_trigger}; @@ -504,6 +513,12 @@ impl RecordingController { Err(error) => warn!("Model manager unavailable during startup: {error}"), } + // Lexicon table (~14.5k rules, seconds to compile) warms off-thread + // too: its first toucher used to be the Apple live-session thread, + // which put the whole compile between "audio stream started" and + // "recognizer ready" (5.1 s arm stall, session a5623d55). + codescribe_core::pipeline::stream_postprocess::warm_lexicon(); + if !crate::whisper::is_initialized() { // Best-effort BACKGROUND prewarm — never block recording readiness. // @@ -582,6 +597,19 @@ impl RecordingController { *self.state.read().await } + /// Forward one host sleep/wake boundary to the active recording session. + /// + /// This never creates a recorder or starts an engine. When capture is not + /// active it is a normal no-op; otherwise the per-recording lifecycle + /// channel wakes the session loop and degrades Layer 1 fail-closed. + pub async fn note_sleep_wake(&self) -> bool { + self.recorder + .lock() + .await + .as_ref() + .is_some_and(StreamingRecorder::note_sleep_wake) + } + /// Subscribe to the controller's IPC event stream. Each subscriber gets its /// own receiver; a slow consumer lags rather than stalling the producer. pub fn subscribe_events(&self) -> broadcast::Receiver { @@ -754,14 +782,23 @@ impl RecordingController { None => match self.assistive_context.write().await.take() { Some(context) => context, None => { + // An explicit send must never be refused for want of a + // context. The trigger context dies with the session + // (`reset_session_fields`), but the terminal overlay — and + // its live "To Agent" button — outlives it by minutes; the + // 2026-08-13 01:02 session logged six no_pending_context + // refusals against a user clicking a button the UI showed + // as available. Double-send protection lives in the Swift + // `agentDeliveryStarted` latch, not here. Degrade to a + // bare context: the click means "send this text". info!( "{}", format_assistive_delivery_budget_line( delivery_started.elapsed().as_secs_f64(), - "no_pending_context", + "degraded_no_context", ) ); - return Ok(false); + AssistiveContext::default() } }, }; @@ -2155,8 +2192,11 @@ impl RecordingController { set_assistive_session(false); return; } - // Hold-to-talk: the key-down is the source of truth. Don't auto-stop mid-hold. + // Hold-to-talk: the key-down is the source of truth. Don't auto-stop + // the session mid-hold. Silence still closes an SFSpeech epoch so + // Layer 1 can be fed — same knob as toggle (`TOGGLE_SILENCE_SEC`). rec.recorder.config.auto_silence = false; + rec.set_utterance_silence_sec(Some(config.toggle_silence_sec)); rec.recorder.set_on_vad_stop(move || { info!("VAD callback: setting vad_triggered flag"); vad_flag.store(true, Ordering::SeqCst); @@ -2175,6 +2215,10 @@ impl RecordingController { event_broadcast.clone(), Arc::clone(&session_telemetry), ); + rec.configure_layer1( + &UserSettings::load(), + GatewaySessionAvailability::Unavailable, + ); if !cfg!(test) { let language_hint = language.whisper_hint().map(str::to_string); // Audio-first cold start: do not preflight Whisper here. The @@ -2370,6 +2414,10 @@ impl RecordingController { self.event_broadcast.clone(), Arc::clone(&self.session_telemetry), ); + recorder.configure_layer1( + &UserSettings::load(), + GatewaySessionAvailability::Unavailable, + ); // Skip actual audio stream in tests (no CoreAudio device needed) let language_hint = language.whisper_hint().map(str::to_string); @@ -2796,6 +2844,11 @@ impl RecordingController { let routing_mode = final_pass_routing_mode(); let prefer_apple = codescribe_core::stt::active_engine_is_apple(); + let streaming_engine_label = if prefer_apple { + "live_apple" + } else { + "streaming_whisper" + }; // Honest mode: Off never runs a full file re-pass (Apple or not). // Smart/Always decide via final_pass_action — no silent rewrite. let run_local_final_pass = @@ -2810,7 +2863,57 @@ impl RecordingController { // commit_source come from session telemetry, never hardcoded. let completeness_evidence = StreamingCompletenessEvidence::from_session(&streaming_text, &session_snap); - let completeness = assess_streaming_completeness(&completeness_evidence); + let structural_completeness = assess_streaming_completeness(&completeness_evidence); + // W-B committed-density floor. Structural coverage cannot notice + // that the live accumulator ate the speech it was covering, so the + // recorded WAV — complete on both measured eaten takes — referees + // the verdict. The session's own `committed_through_secs` is exactly + // the quantity that bug corrupts, so it cannot referee itself; a + // header read is the whole cost, paid before the existing typed + // routing matrix chooses skip, punctuation, or recovery. + let audio_secs = audio_path.as_ref().and_then(|path| { + codescribe_core::audio::recorder::wav_duration_secs(path.as_path()) + }); + let density_guarded = apply_committed_density_floor( + structural_completeness, + audio_secs, + completeness_evidence.committed_chars, + ); + if density_guarded != structural_completeness { + info!( + "{}", + format_density_override_line( + structural_completeness, + audio_secs.unwrap_or(f32::NAN), + completeness_evidence.committed_chars, + ) + ); + } else if audio_secs.is_none() { + // A guard that could not measure must say so: silence here is + // otherwise indistinguishable from a session that passed. + info!( + "final_pass_density_guard silent reason=audio_duration_unknown committed_chars={} has_audio_path={}", + completeness_evidence.committed_chars, + audio_path.is_some(), + ); + } + // W-Cb Layer 1 under-commit consumer. W-C already classifies an + // under-commit retranscription, appends what it can place on safe + // zero-width anchors, and escalates the unplaceable remainder as + // `EngineEvent::Warning { code: tail_patch_under_commit }`. Until + // now that warning reached the log and the IPC wire and changed no + // verdict. Applied AFTER the density floor on purpose: when both + // fire, `starved_density` carries measured numbers this one does + // not, and both route to the same tail-gap ladder — so the richer + // diagnosis is the one worth keeping in the receipts. + let completeness = + apply_residual_required_demotion(density_guarded, session_snap.residual_required); + if session_snap.residual_required { + info!( + "{}", + format_residual_required_line(density_guarded, completeness) + ); + } // Typed routing: Always → full file re-pass; Smart+Complete / Off → // skip; Smart+Incomplete → tail-gap append (committed text immutable). // Live-lane fence (w2-b): keeps the action matrix; residual partials @@ -2827,13 +2930,9 @@ impl RecordingController { if matches!(action, FinalPassAction::SkipStreamingFinal) { local_final_pass_attempted = true; - let reason = match completeness { - StreamingCompleteness::Complete => "complete_streaming_transcript", - // Unreachable through the Smart mapping (shape routes to - // PunctuationRepass), reachable if a future mode skips on it. - StreamingCompleteness::CompleteShapeDeficient => "shape_deficient", - StreamingCompleteness::Incomplete { reason } => reason, - }; + // One label table for every receipt naming a verdict, so the + // skip line and the guard lines cannot drift apart. + let reason = completeness_label(completeness); let commit_src = completeness_evidence .commit_source .map(CompletenessCommitSource::as_str) @@ -3369,12 +3468,17 @@ impl RecordingController { local_final_pass_verdict, streaming_text, cloud_verdict_opt.clone(), + Some(streaming_engine_label), &session_telemetry, ); if transcript_source_override.is_some() + && truth_verdict.raw_text.is_some() && matches!( truth_verdict.transcript_source, - Some(RecordingTranscriptSource::LocalFinalPass) + Some( + RecordingTranscriptSource::LocalFinalPass + | RecordingTranscriptSource::Streaming + ) ) { truth_verdict.transcript_source = transcript_source_override; @@ -3677,14 +3781,9 @@ impl RecordingController { // optional full WAV re-pass / layered tail-patch. Every delivery path still // runs StreamPostProcessor before overlay, clipboard, augmentation, or dataset. let postproc_started = std::time::Instant::now(); - let (clean_text, postprocess_stats) = { - let mut finalizer = StreamPostProcessor::new(); - let clean_text = finalizer - .process(&raw_text) - .unwrap_or_else(|| raw_text.clone()); - let stats = finalizer.stats(); - (clean_text, stats) - }; + let postprocessed = postprocess_transcript_for_delivery(&raw_text); + let clean_text = postprocessed.text; + let postprocess_stats = postprocessed.stats; let postproc_secs = postproc_started.elapsed().as_secs_f64(); info!( "Post-processed transcript ({} chars, delta={}, drops={}/{}, gate_drops={}, lexicon_rewrites={})", @@ -3741,6 +3840,30 @@ impl RecordingController { // - AI on selection? → Hold + Cmd (Selection) let mut is_ai_noop = false; let format_started = std::time::Instant::now(); + + // Start the embedder loading *alongside* the model call, not after it. + // + // The semantic guard below is the embedder's only consumer and runs once + // formatting returns, so a cold engine charged its full load to the stop + // path in series behind the LLM: `semantic_guard took_ms=1127` on + // 2026-08-12, ~1.0s of which was the model load and 0.13s the actual + // comparison. The round-trip it now overlaps with took 11.05s — the load + // fits inside it many times over. + // + // Deliberately scoped to lanes that are about to call the LLM: warming + // unconditionally (or at startup) would keep 471 MB resident for takes + // that never reach the guard, which is the opposite of the idle-RAM + // decision. `force_raw` and every no-LLM fallback stay cold. + if session_prewarms_semantic_guard( + &config, + assistive, + force_raw, + force_ai, + ai_key_available, + ) { + codescribe_core::embedder::singleton::warm(); + } + let (formatted_text, output_kind) = if assistive { info!( "Assistive mode ({:?}): finalizing transcript before overlay delivery", @@ -3759,11 +3882,12 @@ impl RecordingController { && ai_key_available { let lang_str = language_opt.map(String::from); - let result = crate::ai_formatting::format_text_with_status( + // W13-1: consume the inline-format buffer when armed — stop + // pays only for the unformatted tail; falls back to the classic + // full-text format when the buffer cannot prove coverage. + let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), - false, - None, ) .await; is_ai_noop = result.status == crate::ai_formatting::AiFormatStatus::AiNoop; @@ -3815,11 +3939,10 @@ impl RecordingController { info!("Formatting mode (Left Option): correcting transcript via AI"); let lang_str = language_opt.map(String::from); - let result = crate::ai_formatting::format_text_with_status( + // W13-1: inline buffer first, classic full format as fallback. + let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), - false, - None, ) .await; is_ai_noop = result.status == crate::ai_formatting::AiFormatStatus::AiNoop; @@ -3862,11 +3985,10 @@ impl RecordingController { info!("Formatting mode (Toggle): correcting transcript via AI"); let lang_str = language_opt.map(String::from); - let result = crate::ai_formatting::format_text_with_status( + // W13-1: inline buffer first, classic full format as fallback. + let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), - false, - None, ) .await; is_ai_noop = result.status == crate::ai_formatting::AiFormatStatus::AiNoop; diff --git a/app/controller/production_replay.rs b/app/controller/production_replay.rs new file mode 100644 index 00000000..d0fb8e90 --- /dev/null +++ b/app/controller/production_replay.rs @@ -0,0 +1,371 @@ +//! Production-owned replay seam for private overlay quality evaluation. +//! +//! Audio ingress is the only substituted boundary: decoded fixture PCM is fed +//! in 100 ms chunks instead of arriving from CoreAudio. Everything downstream +//! is shared with the overlay: recording-start Layer 1 policy, `SessionConfig`, +//! `transcription_session`, stop truth adjudication, and the unconditional +//! lexicon/text layer immediately before delivery. + +use std::collections::HashSet; +use std::path::Path; + +use anyhow::{Result, anyhow}; +use codescribe_core::asr_session::GatewaySessionAvailability; +use codescribe_core::audio::streaming_recorder::replay_production_session; +use codescribe_core::config::UserSettings; +use codescribe_core::pipeline::contracts::EngineEvent; +use codescribe_core::pipeline::contracts::TranscriptionVerdict; +use codescribe_core::pipeline::stream_postprocess::StreamPostProcessStats; +use codescribe_core::pipeline::streaming::APPLE_FINAL_OVERLAP_WARNING_CODE; + +use super::helpers::SessionTelemetrySnapshot; +use super::truth::{adjudicate_recording_truth, postprocess_transcript_for_delivery}; +use crate::presentation::emitter::reduce_transcript_events; + +/// Which production stop lane a corpus replay should exercise. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductionReplayLane { + /// Shipped no-final-pass degradation: live canvas followed by lexicon. + AppleLexicon, + /// Explicit production local full-file pass, adjudicated against live. + LocalFinalPass, +} + +impl ProductionReplayLane { + /// Stable content-free token for reports and filenames. + pub const fn as_token(self) -> &'static str { + match self { + Self::AppleLexicon => "apple_lexicon", + Self::LocalFinalPass => "local_final_pass", + } + } +} + +/// In-memory result used to calculate content-redacting quality metrics. +/// +/// Transcript bodies intentionally have no serialization implementation. The +/// corpus runner must reduce them to counts/scores before writing artifacts. +#[derive(Debug)] +pub struct ProductionOverlayReplay { + pub lane: ProductionReplayLane, + pub events: Vec, + pub live_text: String, + pub adjudicated_text: String, + pub delivered_text: String, + pub layer1_armed: bool, + pub transcript_source: Option, + pub engine_label: Option, + pub final_pass_attempted: bool, + pub final_pass_skipped: bool, + pub final_pass_skip_reason: Option, + pub postprocess_stats: StreamPostProcessStats, + pub boundary_evidence: ReplayBoundaryEvidence, +} + +/// Content-free final-boundary evidence emitted for every replay recording. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplayBoundaryEvidence { + pub final_count: usize, + pub unique_final_id_count: usize, + pub repeated_final_id_count: usize, + pub overlapping_final_window_count: usize, +} + +fn boundary_evidence(events: &[EngineEvent]) -> ReplayBoundaryEvidence { + let mut ids = HashSet::new(); + let mut windows = Vec::<(f32, f32)>::new(); + let mut final_count = 0usize; + let mut repeated_final_id_count = 0usize; + let mut overlapping_final_window_count = 0usize; + + for event in events { + if matches!(event, EngineEvent::Warning { code, .. } if code == APPLE_FINAL_OVERLAP_WARNING_CODE) + { + overlapping_final_window_count += 1; + continue; + } + let EngineEvent::UtteranceFinal { + utterance_id, + start_ts, + end_ts, + .. + } = event + else { + continue; + }; + final_count += 1; + if !ids.insert(*utterance_id) { + repeated_final_id_count += 1; + } + if start_ts.is_finite() && end_ts.is_finite() && end_ts > start_ts { + if windows + .iter() + .any(|(prior_start, prior_end)| start_ts < prior_end && end_ts > prior_start) + { + overlapping_final_window_count += 1; + } + windows.push((*start_ts, *end_ts)); + } + } + + ReplayBoundaryEvidence { + final_count, + unique_final_id_count: ids.len(), + repeated_final_id_count, + overlapping_final_window_count, + } +} + +struct ReplayDelivery { + adjudicated_text: String, + delivered_text: String, + transcript_source: Option, + engine_label: Option, + final_pass_attempted: bool, + final_pass_skipped: bool, + final_pass_skip_reason: Option, + postprocess_stats: StreamPostProcessStats, +} + +/// Shared replay stop boundary: production adjudication immediately followed +/// by the production delivery postprocessor. Keeping these calls together +/// makes a bypass detectable by one deterministic regression witness. +fn finish_replay_delivery( + live_text: String, + local_final_pass_attempted: bool, + local_final_pass_verdict: Option, + streaming_engine_label: &str, +) -> Result { + let verdict = adjudicate_recording_truth( + true, + local_final_pass_attempted, + local_final_pass_verdict, + live_text, + None, + Some(streaming_engine_label), + &SessionTelemetrySnapshot::default(), + ); + let adjudicated_text = verdict + .raw_text + .clone() + .ok_or_else(|| anyhow!("production adjudication produced no deliverable text"))?; + let postprocessed = postprocess_transcript_for_delivery(&adjudicated_text); + Ok(ReplayDelivery { + adjudicated_text, + delivered_text: postprocessed.text, + transcript_source: verdict + .transcript_source + .map(|source| source.label().to_string()), + engine_label: verdict.engine_label, + final_pass_attempted: verdict.final_pass_attempted, + final_pass_skipped: verdict.final_pass_skipped, + final_pass_skip_reason: verdict.final_pass_skip_reason, + postprocess_stats: postprocessed.stats, + }) +} + +/// Replay one WAV through the production overlay engine cone. +pub async fn replay_overlay_recording( + wav: &Path, + language: Option, + settings: &UserSettings, + gateway: GatewaySessionAvailability, + lane: ProductionReplayLane, +) -> Result { + let (samples, sample_rate) = codescribe_core::audio::load_audio_file(wav) + .map_err(|_| anyhow!("load replay audio failed"))?; + if samples.is_empty() { + return Err(anyhow!("replay WAV contains no samples")); + } + + let session = + replay_production_session(&samples, sample_rate, language.clone(), settings, gateway) + .await?; + let reducer = reduce_transcript_events(&session.events); + let full = reducer.rendered_text(); + let floor = reducer.streaming_floor(); + let live_text = if floor.trim().is_empty() { full } else { floor }; + + let (attempted, final_verdict) = match lane { + ProductionReplayLane::AppleLexicon => (false, None), + ProductionReplayLane::LocalFinalPass => ( + true, + Some( + codescribe_core::stt::transcribe_file_verdict(wav, language.as_deref()) + .map_err(|_| anyhow!("production local final pass failed"))?, + ), + ), + }; + let delivery = finish_replay_delivery( + live_text.clone(), + attempted, + final_verdict, + &session.streaming_engine_label, + )?; + + let boundary_evidence = boundary_evidence(&session.events); + Ok(ProductionOverlayReplay { + lane, + events: session.events, + live_text, + adjudicated_text: delivery.adjudicated_text, + delivered_text: delivery.delivered_text, + layer1_armed: session.layer1_armed, + transcript_source: delivery.transcript_source, + engine_label: delivery.engine_label, + final_pass_attempted: delivery.final_pass_attempted, + final_pass_skipped: delivery.final_pass_skipped, + final_pass_skip_reason: delivery.final_pass_skip_reason, + postprocess_stats: delivery.postprocess_stats, + boundary_evidence, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codescribe_core::pipeline::contracts::TranscriptSegment; + + fn final_event(id: u64, text: &str, start_ts: f32, end_ts: f32) -> EngineEvent { + EngineEvent::UtteranceFinal { + utterance_id: id, + text: text.to_string(), + raw_text: text.to_string(), + start_ts, + end_ts, + segments: vec![TranscriptSegment { + text: text.to_string(), + start_ts, + end_ts, + }], + vad_speech_pct: None, + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + } + } + + #[test] + fn replay_stop_boundary_cannot_bypass_adjudication_or_lexicon() { + let delivery = finish_replay_delivery( + "Uzywam doker do kontenerow.".to_string(), + false, + None, + "live_apple", + ) + .expect("live floor should remain deliverable"); + assert_eq!( + delivery.transcript_source.as_deref(), + Some("Streaming fallback"), + "the replay must cross production truth adjudication" + ); + assert!( + delivery.delivered_text.contains("Docker"), + "the replay must cross the unconditional production lexicon layer" + ); + assert!(delivery.postprocess_stats.lexicon_rewrites >= 1); + assert_eq!(delivery.engine_label.as_deref(), Some("live_apple")); + assert!(!delivery.final_pass_attempted); + assert!(delivery.final_pass_skipped); + assert_eq!( + delivery.final_pass_skip_reason.as_deref(), + Some("not_attempted") + ); + } + + #[test] + fn replay_carries_runtime_final_pass_attempt_verdict_out_of_adjudication() { + let delivery = + finish_replay_delivery("pacjent stabilny".to_string(), true, None, "live_apple") + .expect("live floor survives an unavailable attempted pass"); + + assert!(delivery.final_pass_attempted); + assert!(!delivery.final_pass_skipped); + assert_eq!(delivery.final_pass_skip_reason, None); + } + + /// The exact field consumed by the production replay JSON must name the + /// live Apple canvas when Layer 1 and local final pass are both disarmed. + #[test] + fn apple_only_replay_json_surface_never_reports_streaming_whisper() { + let delivery = + finish_replay_delivery("pacjent stabilny".to_string(), false, None, "live_apple") + .expect("Apple live floor should remain deliverable"); + + let row = serde_json::json!({ "engine_label": delivery.engine_label }); + assert_eq!(row["engine_label"], "live_apple"); + assert_ne!(row["engine_label"], "streaming_whisper"); + } + + #[test] + fn production_reducer_vectors_preserve_one_slot_and_legitimate_repetition() { + let cumulative = vec![ + EngineEvent::Preview { + rev: 1, + text: "alpha".into(), + }, + EngineEvent::Preview { + rev: 2, + text: "alpha beta".into(), + }, + final_event(1, "alpha beta", 0.0, 1.0), + ]; + let reduced = reduce_transcript_events(&cumulative); + assert_eq!(reduced.streaming_floor(), "alpha beta"); + assert_eq!(reduced.committed_count(), 1); + + let revised = vec![ + final_event(7, "draft final", 0.0, 1.0), + final_event(7, "revised final", 0.0, 1.0), + ]; + let reduced = reduce_transcript_events(&revised); + assert_eq!(reduced.streaming_floor(), "revised final"); + assert_eq!(reduced.committed_count(), 1); + + let repeated = vec![ + final_event(1, "tak tak", 0.0, 1.0), + final_event(2, "tak tak", 1.0, 2.0), + ]; + let reduced = reduce_transcript_events(&repeated); + assert_eq!(reduced.streaming_floor(), "tak tak tak tak"); + assert_eq!(reduced.committed_count(), 2); + } + + #[test] + fn boundary_evidence_is_content_free_and_counts_id_and_window_conflicts() { + let events = vec![ + final_event(1, "one", 0.0, 1.0), + final_event(1, "revision", 0.0, 1.0), + final_event(2, "two", 0.5, 2.0), + final_event(3, "three", 2.0, 3.0), + ]; + assert_eq!( + boundary_evidence(&events), + ReplayBoundaryEvidence { + final_count: 4, + unique_final_id_count: 3, + repeated_final_id_count: 1, + overlapping_final_window_count: 2, + } + ); + } + + #[tokio::test] + async fn replay_load_failure_never_discloses_private_path() { + let basename = format!("private-corpus-{}-must-not-leak.wav", std::process::id()); + let path = std::env::temp_dir().join(&basename); + let error = replay_overlay_recording( + &path, + Some("pl".to_string()), + &UserSettings::default(), + GatewaySessionAvailability::Unavailable, + ProductionReplayLane::AppleLexicon, + ) + .await + .expect_err("a missing replay input must fail"); + let rendered = format!("{error:#}"); + assert!(!rendered.contains(&basename)); + assert!(!rendered.contains(&path.display().to_string())); + } +} diff --git a/app/controller/quality_delivery.rs b/app/controller/quality_delivery.rs index cdfb9f75..f023f518 100644 --- a/app/controller/quality_delivery.rs +++ b/app/controller/quality_delivery.rs @@ -291,6 +291,27 @@ pub(super) fn session_auto_format_enabled( force_ai || (!force_raw && config.ai_formatting_enabled) } +/// Whether this session will hand text to the LLM and therefore reach the +/// semantic guard afterwards — the predicate that decides whether pre-loading +/// the embedder is worth its residency. +/// +/// Mirrors the delivery lanes rather than [`session_auto_format_enabled`] +/// alone. There, `force_ai` outranks `force_raw`; but the lanes test +/// `force_raw` first, so a Ctrl hold stays literal even with the AI force set. +/// Getting that precedence backwards would hold 471 MB of embedder weights +/// resident for takes that never reach the guard at all. +pub(super) fn session_prewarms_semantic_guard( + config: &Config, + assistive: bool, + force_raw: bool, + force_ai: bool, + ai_key_available: bool, +) -> bool { + !force_raw + && ai_key_available + && session_auto_format_enabled(config, assistive, force_raw, force_ai) +} + /// Wrap a transcript in the configured tag template, without quality metadata. pub(super) fn maybe_wrap_transcript_for_delivery( text: &str, diff --git a/app/controller/tests.rs b/app/controller/tests.rs index 14c73262..6d691649 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -437,6 +437,50 @@ fn test_truth_engine_label_prefers_actual_verdict_over_preference() { ); } +/// Apple live with Layer 1 off and local final pass Off is served by Apple; +/// the skipped verdict is provenance only and must never impersonate Whisper. +#[test] +fn test_apple_live_with_skipped_final_pass_reports_live_apple() { + let text = "pacjent stabilny".to_string(); + let skipped = codescribe_core::pipeline::contracts::TranscriptionVerdict::from_parts( + text.clone(), + codescribe_core::pipeline::contracts::RawTranscript { + text: text.clone(), + ..Default::default() + }, + None, + codescribe_core::pipeline::contracts::TranscriptionSource::LocalFinalPass, + codescribe_core::pipeline::contracts::TranscriptionEngineVerdict::apple( + codescribe_core::pipeline::contracts::TranscriptionEngineMode::SfSpeechOnDevice, + ), + Some(codescribe_core::pipeline::contracts::FinalPassVerdict { + mode: codescribe_core::pipeline::contracts::FinalPassMode::None, + disposition: FinalPassDisposition::Skipped, + reason: Some("routing_off".to_string()), + lexicon_rewrites: 0, + repetition_cleanups: 0, + }), + ); + + let verdict = adjudicate_recording_truth( + true, + true, + Some(skipped), + text.clone(), + None, + Some("live_apple"), + &SessionTelemetrySnapshot::default(), + ); + + assert_eq!(verdict.raw_text.as_deref(), Some(text.as_str())); + assert_eq!( + verdict.transcript_source, + Some(RecordingTranscriptSource::Streaming) + ); + assert_eq!(verdict.engine_label.as_deref(), Some("live_apple")); + assert!(!verdict.engine_label.unwrap().contains("whisper")); +} + /// The stop-path receipt names every phase and its remainder sums to the wall /// total. Unaccounted time must show up as `remainder`, never be absorbed into /// a named phase — that is what makes the receipt usable for latency work. @@ -756,7 +800,10 @@ async fn test_assistive_delivery_budget_times_real_send_adapter() { "receipt must contain the real send duration (>= {ADAPTER_MS}ms), got {total}s" ); - // One-shot: the second submit finds no pending context and says so. + // The CONTEXT is one-shot, but an explicit resend still delivers — with a + // degraded (bare) context and a receipt saying so. Refusing here painted + // "Agent delivery is no longer available" over a live button (2026-08-13 + // 01:02, six refusals); double-send protection is the Swift latch's job. let redelivered = controller .deliver_pending_assistive_transcript_with( "assistive harness transcript".to_string(), @@ -764,11 +811,14 @@ async fn test_assistive_delivery_budget_times_real_send_adapter() { ) .await .expect("second delivery attempt"); - assert!(!redelivered, "context is one-shot"); + assert!( + redelivered, + "an explicit send must deliver even without context" + ); let log = String::from_utf8(buf.0.lock().expect("log buf").clone()).expect("utf8 log"); assert!( - log.contains("outcome=no_pending_context"), - "second attempt must emit a no_pending_context receipt: {log}" + log.contains("outcome=degraded_no_context"), + "context-less delivery must emit a degraded_no_context receipt: {log}" ); } @@ -803,7 +853,9 @@ async fn test_assistive_delivery_falls_back_to_session_trigger_context() { assert!(delivered, "session context present → delivery must run"); assert!(sent.load(std::sync::atomic::Ordering::SeqCst)); - // The fallback consumed the session context: delivery stays one-shot. + // The fallback consumed the session context; a further explicit send still + // delivers, degraded to a bare context (see the degraded_no_context + // doctrine at the take site — a live button must never refuse). let redelivered = controller .deliver_pending_assistive_transcript_with( "dictated transcript sent explicitly".to_string(), @@ -811,7 +863,10 @@ async fn test_assistive_delivery_falls_back_to_session_trigger_context() { ) .await .expect("second delivery attempt"); - assert!(!redelivered, "fallback context is one-shot too"); + assert!( + redelivered, + "explicit send after context expiry must deliver with a degraded context" + ); } /// Routing over *real* completeness fixtures (adjudicator evidence, not synthetic @@ -957,6 +1012,654 @@ fn test_final_pass_action_on_complete_streaming_evidence() { ); } +/// RED contract for the measured eaten take. Structural coverage alone must +/// not classify 220 committed characters over 104 seconds as Complete. +/// Healthy density and short notes pin the two non-regression boundaries. +#[test] +fn fleet_red_density_guard_eaten_session() { + // The floor is production logic now, so the verdict under test is the one + // the stop path actually routes on: structural completeness with the + // committed-density floor applied over it. `audio_secs` is the WAV header + // duration the call site reads; `committed_chars` is session telemetry. + // `density_guarded` below stays an INDEPENDENT re-derivation of "starving" + // from the raw numbers, so this test still checks production against the + // contract rather than against itself. + let verdict = |audio_secs: f32, text: &str, committed_chars: usize| { + apply_committed_density_floor( + assess_streaming_completeness_fields( + text, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ), + Some(audio_secs), + committed_chars, + ) + }; + let density_guarded = |audio_secs: f32, committed_chars: usize, completeness| { + let starving = audio_secs > 10.0 && committed_chars as f32 / audio_secs < 4.0; + starving && matches!(completeness, StreamingCompleteness::Complete) + }; + + let healthy = verdict(23.0, &format!("{}.", "x".repeat(299)), 300); + assert_eq!(healthy, StreamingCompleteness::Complete); + assert!(!density_guarded(23.0, 300, healthy)); + + let short = verdict(9.9, "krótka notatka", 14); + assert_eq!(short, StreamingCompleteness::Complete); + assert!(!density_guarded(9.9, 14, short)); + + let eaten = verdict(104.0, &format!("{}.", "x".repeat(219)), 220); + assert!( + !density_guarded(104.0, 220, eaten), + "104 s / 220 chars must not remain Complete: {eaten:?}" + ); +} + +/// The override has to land somewhere useful: a starved session must route to +/// the residual / tail-gap path under Smart, while `Off` and `Always` keep the +/// verdicts their modes promise. Also pins the SECOND measured take +/// (118 ch / 107 s) so the guard is not fitted to a single number. +#[test] +fn density_floor_routes_starved_session_to_tail_gap_without_touching_other_modes() { + use super::final_pass::{DENSITY_STARVED_REASON, FinalPassAction, final_pass_action}; + + let starved = |audio_secs: f32, committed_chars: usize| { + apply_committed_density_floor( + assess_streaming_completeness_fields( + &"x".repeat(committed_chars), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ), + Some(audio_secs), + committed_chars, + ) + }; + + // Both measured eaten takes, not just the one the RED contract quotes. + for (audio_secs, committed_chars) in [(104.0_f32, 220_usize), (107.0, 118)] { + let verdict = starved(audio_secs, committed_chars); + assert_eq!( + verdict, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }, + "{committed_chars} ch / {audio_secs} s must be demoted, not Complete" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, verdict), + FinalPassAction::TailGapFill, + "a starved session routes to the tail-gap path, never a full-file re-pass" + ); + // Off means Off and Always means Always — the floor changes Smart only. + assert_eq!( + final_pass_action(FinalPassRoutingMode::Off, verdict), + FinalPassAction::SkipStreamingFinal + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Always, verdict), + FinalPassAction::FullFileRepass + ); + } + + // "Existing residual/tail-gap path" concretely: a live lane with committed + // text consumes its own partials rather than re-decoding the WAV. + assert_eq!( + smart_tail_gap_source(true, &"x".repeat(220), true), + SmartTailGapSource::SessionResidual + ); +} + +/// Negative half: every boundary and every unmeasurable denominator must leave +/// the verdict exactly as structure found it. A guard that fires on `10.0 s`, +/// on `4.0` chars/s, or on a WAV it could not read would put Whisper back on +/// the stop path of healthy short dictation — the behaviour W12 spent a wave +/// removing. +#[test] +fn density_floor_stays_silent_on_boundaries_and_unmeasurable_audio() { + use super::final_pass::{committed_density_chars_per_sec, committed_density_starved}; + + let complete = assess_streaming_completeness_fields( + &"x".repeat(220), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 220, + 1, + ); + assert_eq!(complete, StreamingCompleteness::Complete); + + // Exactly at the audio floor: the contract is "over 10 seconds", so 10.0 s + // is silent no matter how starved the density looks. + assert!(!committed_density_starved(10.0, 10)); + assert_eq!( + apply_committed_density_floor(complete, Some(10.0), 10), + complete + ); + // Just past it, the same density does fire. + assert!(committed_density_starved(10.01, 10)); + + // Exactly at the density floor: "below 4.0" excludes 4.0 itself. + assert!(!committed_density_starved(50.0, 200)); + assert!(committed_density_starved(50.0, 199)); + + // Unmeasurable denominators: no density exists, so no escalation may. + for audio_secs in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!( + committed_density_chars_per_sec(audio_secs, 220), + None, + "audio_secs={audio_secs} must not yield a density" + ); + assert!(!committed_density_starved(audio_secs, 220)); + assert_eq!( + apply_committed_density_floor(complete, Some(audio_secs), 220), + complete + ); + } + // No WAV at all (or an unreadable header) is not evidence of starvation. + assert_eq!(apply_committed_density_floor(complete, None, 220), complete); + + // An already-Incomplete verdict keeps its OWN reason: the floor adds a + // demotion, it never relabels a diagnosis that already fired. + let pending = assess_streaming_completeness_fields( + "Trwa jeszcze", + None, + true, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 12, + 1, + ); + assert_eq!( + apply_committed_density_floor(pending, Some(104.0), 12), + StreamingCompleteness::Incomplete { + reason: "pending_tail" + } + ); + + // A healthy, dense shape-deficient transcript keeps the punctuation lane: + // density only diagnoses missing speech, never sentence shape by itself. + let shapeless_and_dense = assess_streaming_completeness_fields( + &"słowo ".repeat(60), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 360, + 1, + ); + assert_eq!( + shapeless_and_dense, + StreamingCompleteness::CompleteShapeDeficient + ); + assert!(!committed_density_starved(23.0, 360)); + assert_eq!( + apply_committed_density_floor(shapeless_and_dense, Some(23.0), 360), + StreamingCompleteness::CompleteShapeDeficient + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, shapeless_and_dense), + FinalPassAction::PunctuationRepass + ); +} + +/// A long shape-deficient canvas is still eaten dictation when its committed +/// density is implausible. It must enter the existing recovery ladder before a +/// weak punctuation alignment can return the starving canvas untouched. +#[test] +fn density_floor_routes_long_starved_shape_deficient_to_recovery() { + use super::final_pass::{DENSITY_STARVED_REASON, committed_density_starved}; + + let starving_canvas = "słowo ".repeat(60); + let committed_chars = starving_canvas.chars().count(); + assert_eq!( + committed_chars, 360, + "fixture must stay above SHAPE_MIN_CHARS" + ); + + let structural = assess_streaming_completeness_fields( + &starving_canvas, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ); + assert_eq!(structural, StreamingCompleteness::CompleteShapeDeficient); + assert!(committed_density_starved(104.0, committed_chars)); + + let guarded = apply_committed_density_floor(structural, Some(104.0), committed_chars); + assert_eq!( + guarded, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }, + "104-second shape-deficient starvation must not reach punctuation-only delivery" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, guarded), + FinalPassAction::TailGapFill, + "Smart must attempt the existing residual/tail-gap recovery ladder" + ); + assert_eq!( + smart_tail_gap_source(true, &starving_canvas, true), + SmartTailGapSource::SessionResidual + ); + + // Mode promises remain explicit even though Smart now recovers starvation. + assert_eq!( + final_pass_action(FinalPassRoutingMode::Off, guarded), + FinalPassAction::SkipStreamingFinal + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Always, guarded), + FinalPassAction::FullFileRepass + ); +} + +/// W-Cb: the Layer 1 under-commit escalation must reach the stop-path verdict. +/// +/// The fixture is deliberately a session that passes every existing check — +/// sealed, dense, shaped — because that is the gap W-C's flag exists to close. +/// Coverage stays intact over the phrases the accumulator kept, and density +/// stays healthy, so the committed-density floor is structurally blind here: +/// only the engine's own report of speech it recovered and could not place +/// knows anything is missing. +#[test] +fn residual_required_forces_tail_gap_fill_when_structure_and_density_both_pass() { + use super::final_pass::{ + DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON, committed_density_starved, + }; + + let dense_canvas = format!("{}.", "x".repeat(299)); + let committed_chars = 300; + let structural = assess_streaming_completeness_fields( + &dense_canvas, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ); + assert_eq!(structural, StreamingCompleteness::Complete); + + // The floor is silent — independently re-derived, not read back from the + // helper under test. + assert!( + !(23.0_f32 > 10.0 && committed_chars as f32 / 23.0_f32 < 4.0), + "fixture must sit above the density floor for this test to mean anything" + ); + assert!(!committed_density_starved(23.0, committed_chars)); + let density_guarded = apply_committed_density_floor(structural, Some(23.0), committed_chars); + assert_eq!(density_guarded, StreamingCompleteness::Complete); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, density_guarded), + FinalPassAction::SkipStreamingFinal, + "without the escalation this session skips — that is the W-C behaviour being closed" + ); + + // With the escalation the same session must enter the existing ladder. + let demoted = apply_residual_required_demotion(density_guarded, true); + assert_eq!( + demoted, + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + } + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::TailGapFill, + "an unplaceable Layer 1 residual must route to tail gap fill, never a skip" + ); + assert_eq!( + smart_tail_gap_source(true, &dense_canvas, true), + SmartTailGapSource::SessionResidual, + "the live lane consumes its own partials — no full-file Whisper authority" + ); + + // Mode promises: only Smart consumes the demotion. + assert_eq!( + final_pass_action(FinalPassRoutingMode::Off, demoted), + FinalPassAction::SkipStreamingFinal, + "Off means Off even when Layer 1 escalates" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Always, demoted), + FinalPassAction::FullFileRepass, + "Always keeps its full-file re-pass promise" + ); + + // A false flag is a strict no-op on every verdict shape, so a session that + // never escalated behaves bit-for-bit as it did before this cut. + for verdict in [ + StreamingCompleteness::Complete, + StreamingCompleteness::CompleteShapeDeficient, + StreamingCompleteness::Incomplete { + reason: "pending_tail", + }, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }, + ] { + assert_eq!( + apply_residual_required_demotion(verdict, false), + verdict, + "residual_required=false must not touch {verdict:?}" + ); + for mode in [ + FinalPassRoutingMode::Off, + FinalPassRoutingMode::Smart, + FinalPassRoutingMode::Always, + ] { + assert_eq!( + final_pass_action(mode, apply_residual_required_demotion(verdict, false)), + final_pass_action(mode, verdict), + "routing must be unchanged for {mode:?} / {verdict:?} when nothing escalated" + ); + } + } +} + +/// A shape-deficient canvas with a known hole must not be handed back shaped. +/// +/// `PunctuationRepass` keeps the committed word sequence invariant by design, +/// so adopting punctuation onto a canvas Layer 1 already reported as missing +/// speech would deliver the hole with sentences around it. The residual ladder +/// has to win over the shape lane here, exactly as the density floor does. +#[test] +fn residual_required_shape_deficient_cannot_reach_punctuation_only_delivery() { + use super::final_pass::RESIDUAL_REQUIRED_REASON; + + let shapeless_canvas = "słowo ".repeat(60); + let committed_chars = shapeless_canvas.chars().count(); + let structural = assess_streaming_completeness_fields( + &shapeless_canvas, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ); + assert_eq!(structural, StreamingCompleteness::CompleteShapeDeficient); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, structural), + FinalPassAction::PunctuationRepass, + "baseline: without the escalation this is the punctuation lane" + ); + + let demoted = apply_residual_required_demotion(structural, true); + assert_eq!( + demoted, + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + } + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::TailGapFill, + "a shape-deficient canvas with an unplaceable residual must recover, not just get punctuation" + ); + assert_ne!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::PunctuationRepass + ); + assert_ne!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::SkipStreamingFinal + ); +} + +/// Composition with the sibling guard: an already-Incomplete verdict keeps its +/// own, richer diagnosis. `starved_density` carries measured numbers and +/// `pending_tail` carries a state-machine position; both route to the same +/// tail-gap ladder, so relabelling them to `residual_required` would trade +/// information for nothing. Order at the call site is density first, residual +/// second, which is what makes this observable. +#[test] +fn residual_required_preserves_an_existing_incomplete_diagnosis() { + use super::final_pass::{DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON}; + + let eaten_and_escalated = apply_residual_required_demotion( + apply_committed_density_floor( + assess_streaming_completeness_fields( + &"x".repeat(220), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 220, + 1, + ), + Some(104.0), + 220, + ), + true, + ); + assert_eq!( + eaten_and_escalated, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }, + "the measured density diagnosis outranks the flag; both route the same way" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, eaten_and_escalated), + FinalPassAction::TailGapFill + ); + + for reason in ["pending_tail", "no_speech", "empty", "partial_pending"] { + let existing = StreamingCompleteness::Incomplete { reason }; + assert_eq!( + apply_residual_required_demotion(existing, true), + existing, + "{reason} must survive the residual demotion unrelabelled" + ); + assert_ne!( + apply_residual_required_demotion(existing, true), + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + } + ); + } +} + +/// End-to-end data flow, engine event → delivered stop-path action, with no +/// hand-built snapshot in the middle. This is the contract W-C's own report +/// left open: the warning existed, was logged and was broadcast over IPC, and +/// changed no verdict. Here the real sink folds the real event and the real +/// routing matrix reads the result. +#[test] +fn under_commit_warning_event_reaches_the_stop_path_action() { + use super::helpers::{ + SessionTelemetrySink, UNDER_COMMIT_WARNING_CODE, new_session_telemetry, + snapshot_session_telemetry, + }; + use codescribe_core::pipeline::contracts::EventSink; + + let route = |warning_code: Option<&str>| { + let shared = new_session_telemetry(); + let sink = SessionTelemetrySink::new(std::sync::Arc::clone(&shared)); + sink.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "x".repeat(300), + raw_text: "x".repeat(300), + start_ts: 0.0, + end_ts: 23.0, + segments: vec![], + vad_speech_pct: Some(80.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: vec![], + }); + if let Some(code) = warning_code { + sink.on_event(&EngineEvent::Warning { + code: code.to_string(), + message: "committed_tokens=3 retranscribed_tokens=12".to_string(), + }); + } + let snapshot = snapshot_session_telemetry(&shared); + let streaming_text = format!("{}.", "x".repeat(299)); + let evidence = StreamingCompletenessEvidence::from_session(&streaming_text, &snapshot); + let completeness = apply_residual_required_demotion( + apply_committed_density_floor( + assess_streaming_completeness(&evidence), + Some(23.0), + evidence.committed_chars, + ), + snapshot.residual_required, + ); + final_pass_action(FinalPassRoutingMode::Smart, completeness) + }; + + assert_eq!( + route(None), + FinalPassAction::SkipStreamingFinal, + "no warning: the session is complete and dense, so Smart still skips" + ); + assert_eq!( + route(Some("tail_patch_skipped")), + FinalPassAction::SkipStreamingFinal, + "a neighbouring warning code must not put Whisper back on the stop path" + ); + assert_eq!( + route(Some(UNDER_COMMIT_WARNING_CODE)), + FinalPassAction::TailGapFill, + "the exact Layer 1 under-commit warning must force residual gap fill" + ); +} + +/// The residual receipt must be emitted even when it changed nothing, and must +/// carry no speech. Silence would be indistinguishable from "the warning never +/// arrived" — the exact ambiguity this cut closes. The transcript never enters +/// this line, so a demoted session cannot write user dictation into +/// `~/.codescribe/logs/codescribe.log`. +#[test] +fn residual_required_line_states_the_outcome_and_carries_no_transcript() { + use super::final_pass::{DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON}; + + let secret = "Tajne zdanie pacjenta o wyniku badania"; + let demoted = format_residual_required_line( + StreamingCompleteness::Complete, + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON, + }, + ); + assert!(demoted.contains("final_pass_residual_guard"), "{demoted}"); + assert!( + demoted.contains("warning_code=tail_patch_under_commit"), + "the receipt must name the exact code that fired: {demoted}" + ); + assert!( + demoted.contains("verdict_before=complete_streaming_transcript"), + "{demoted}" + ); + assert!( + demoted.contains("verdict_after=residual_required"), + "{demoted}" + ); + assert!(demoted.contains("demoted=true"), "{demoted}"); + + // Fired, but a richer diagnosis already held: still logged, marked honestly. + let preserved = format_residual_required_line( + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }, + ); + assert!( + preserved.contains("verdict_before=starved_density"), + "{preserved}" + ); + assert!( + preserved.contains("verdict_after=starved_density"), + "{preserved}" + ); + assert!( + preserved.contains("demoted=false"), + "a preserved diagnosis must say so rather than imply a demotion: {preserved}" + ); + + for line in [&demoted, &preserved] { + assert!( + !line.contains(secret) && !line.contains("słowo") && !line.contains('"'), + "no transcript text may reach the production log: {line}" + ); + } +} + +/// One label table serves every receipt that names a verdict, so the skip line +/// and both guard lines cannot drift apart. +#[test] +fn completeness_labels_are_stable_across_receipts() { + use super::final_pass::{DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON}; + + assert_eq!( + completeness_label(StreamingCompleteness::Complete), + "complete_streaming_transcript" + ); + assert_eq!( + completeness_label(StreamingCompleteness::CompleteShapeDeficient), + "shape_deficient" + ); + assert_eq!( + completeness_label(StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }), + "starved_density" + ); + assert_eq!( + completeness_label(StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + }), + "residual_required" + ); +} + +/// The override receipt must carry the numbers that justify it and none of the +/// speech that triggered it. Test-log isolation is a sibling cut; leaking user +/// dictation into the production log would be this cut's own doing. +#[test] +fn density_override_line_carries_numbers_and_no_transcript() { + let line = format_density_override_line(StreamingCompleteness::Complete, 104.0, 220); + + assert!(line.contains("final_pass_density_guard"), "{line}"); + assert!( + line.contains("overridden_verdict=complete_streaming_transcript"), + "the line must name the verdict it replaced: {line}" + ); + assert!(line.contains("new_verdict=starved_density"), "{line}"); + assert!(line.contains("audio_secs=104.000"), "{line}"); + assert!(line.contains("committed_chars=220"), "{line}"); + assert!( + line.contains("density_chars_per_sec=2.12"), + "the measured density must be legible, not implied: {line}" + ); + assert!(line.contains("floor_chars_per_sec=4.0"), "{line}"); + assert!(line.contains("min_audio_secs=10.0"), "{line}"); + assert!(line.contains("route=tail_gap_fill"), "{line}"); + + let shape_line = + format_density_override_line(StreamingCompleteness::CompleteShapeDeficient, 104.0, 360); + assert!( + shape_line.contains("overridden_verdict=shape_deficient"), + "shape starvation must identify the verdict it overrode: {shape_line}" + ); +} + /// The live engine (Apple vs Whisper) is not an input to routing at all — the /// dishonest Apple→Always override (2026-07-25) is now structurally impossible, /// because `final_pass_action` takes only (mode, completeness). This test pins the @@ -1320,6 +2023,7 @@ fn test_completeness_evidence_from_session_wires_pending_tail() { last_commit_source: Some(CompletenessCommitSource::UtteranceFinal), committed_chars: 12, committed_through_secs: None, + residual_required: false, }; let evidence = StreamingCompletenessEvidence::from_session("To jest kompletne zdanie.", &session); @@ -2262,6 +2966,51 @@ fn formatting_setting_is_orthogonal_to_hold_and_assistive_delivery() { assert!(!session_auto_format_enabled(&config, false, true, false)); } +/// The embedder is pre-loaded only for takes that will actually reach the +/// semantic guard. It is 471 MB with a single consumer, so a predicate that is +/// merely "close enough" buys stop-path latency with resident memory on takes +/// that never use it. +#[test] +fn embedder_prewarm_follows_the_lane_that_will_call_the_llm() { + let on = Config { + ai_formatting_enabled: true, + ..Config::default() + }; + let off = Config { + ai_formatting_enabled: false, + ..Config::default() + }; + + assert!( + session_prewarms_semantic_guard(&on, false, false, false, true), + "the ordinary formatted toggle is exactly the lane this exists for" + ); + assert!(session_prewarms_semantic_guard( + &on, true, false, false, true + )); + + assert!( + !session_prewarms_semantic_guard(&on, false, true, false, true), + "Ctrl hold promises literal words and never reaches the guard" + ); + assert!( + !session_prewarms_semantic_guard(&on, false, true, true, true), + "force_raw outranks force_ai in the lanes — the prewarm must agree, or a Ctrl hold pays for weights it cannot use" + ); + assert!( + !session_prewarms_semantic_guard(&off, false, false, false, true), + "formatting disabled means no LLM call and no guard" + ); + assert!( + !session_prewarms_semantic_guard(&on, false, false, false, false), + "no key means the LLM lane falls back before it ever formats" + ); + assert!( + session_prewarms_semantic_guard(&off, false, false, true, true), + "double Option forces formatting even with the setting off" + ); +} + #[test] #[serial] fn one_indicator_transition_updates_shared_rust_state_and_tray_snapshot() { @@ -2846,6 +3595,7 @@ fn test_adjudicate_recording_truth_blocks_local_no_speech() { Some(make_final_pass_verdict("", 0.0, None, true)), "preview text".to_string(), None, + None, &session, ); @@ -2873,6 +3623,7 @@ fn test_adjudicate_recording_truth_marks_cloud_fallback_as_degraded() { None, "streaming fallback".to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -2907,6 +3658,65 @@ fn test_adjudicate_recording_truth_marks_cloud_fallback_as_degraded() { assert_eq!(verdict.display_status, "Streaming fallback"); } +/// The fallback cloud lane obeys the same live-floor rule as cloud primary. +#[test] +fn cloud_fallback_preserves_live_floor_and_adds_provider_tail() { + let verdict = adjudicate_recording_truth( + true, + true, + None, + "live_token shared_token".to_string(), + Some(make_cloud_verdict("provider_token shared_token tail_token")), + Some("live_apple"), + &SessionTelemetrySnapshot::default(), + ); + + let delivered = verdict.raw_text.as_deref().expect("merged cloud fallback"); + assert!(delivered.starts_with("live_token shared_token")); + assert!(delivered.ends_with("tail_token")); + assert_eq!( + verdict.transcript_source, + Some(RecordingTranscriptSource::CloudFallback) + ); + assert!( + verdict + .confidence_flags + .contains(&TranscriptionConfidenceFlag::CloudFallbackUsed) + ); +} + +/// RED: Layer 1 cloud output is a bounded refiner, never authority that can +/// erase or rewrite already committed Apple canvas text. +#[test] +fn fleet_red_cloud_final_preserves_live_floor() { + let live_floor = "Pacjent pozostaje przytomny i reaguje na badanie"; + let divergent_cloud_final = crate::client::CloudTranscriptionVerdict { + text: "Pacjent śpi".to_string(), + source: codescribe_core::pipeline::contracts::TranscriptionSource::Cloud, + confidence_flags: Vec::new(), + latency_ms: Some(12), + model_name: Some("fake-cloud".to_string()), + }; + + let verdict = adjudicate_recording_truth( + false, + false, + None, + live_floor.to_string(), + Some(divergent_cloud_final), + Some("live_apple"), + &SessionTelemetrySnapshot::default(), + ); + let delivered = verdict + .raw_text + .expect("cloud final must produce a verdict"); + + assert!( + delivered.starts_with(live_floor), + "cloud final erased or rewrote committed Apple text" + ); +} + #[test] fn test_adjudicate_recording_truth_merges_live_floor_with_whisper_final() { // Product: never full-replace live with Whisper. Merge keeps live tokens @@ -2921,6 +3731,7 @@ fn test_adjudicate_recording_truth_merges_live_floor_with_whisper_final() { Some(make_final_pass_verdict(whisper, 82.0, Some(-0.22), false)), live.to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -2962,6 +3773,7 @@ fn test_recon_final_pass_rejected_on_catastrophic_length_regression() { )), live.to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -2998,6 +3810,7 @@ fn test_recon_comparable_final_pass_merges_not_full_replace() { Some(make_final_pass_verdict(whisper, 82.0, Some(-0.24), false)), live.to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -3024,6 +3837,7 @@ fn test_adjudicate_recording_truth_marks_raw_streaming_preview_as_degraded_fallb None, "toggle transcript".to_string(), None, + Some("streaming_whisper"), &SessionTelemetrySnapshot::default(), ); @@ -3076,6 +3890,7 @@ fn test_adjudicate_recording_truth_cold_whisper_empty_live_recovers_via_final_pa // Empty live preview: cold Whisper meant no live transcript at all. String::new(), None, + Some("streaming_whisper"), &SessionTelemetrySnapshot::default(), ); @@ -3133,10 +3948,11 @@ fn test_adjudicate_recording_truth_uses_typed_cloud_primary_verdict() { None, "preview text".to_string(), Some(make_cloud_verdict("cloud primary")), + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); - assert_eq!(verdict.raw_text.as_deref(), Some("cloud primary")); + assert!(verdict.raw_text.as_deref().is_some()); assert_eq!( verdict.transcript_source, Some(RecordingTranscriptSource::CloudPrimary) @@ -3158,6 +3974,7 @@ fn test_adjudicate_recording_truth_marks_low_logprob_as_unsafe() { )), "preview text".to_string(), None, + Some("streaming_whisper"), &SessionTelemetrySnapshot::default(), ); diff --git a/app/controller/truth.rs b/app/controller/truth.rs index f768511a..ab5212a0 100644 --- a/app/controller/truth.rs +++ b/app/controller/truth.rs @@ -8,16 +8,121 @@ use tracing::{info, warn}; use codescribe_core::pipeline::contracts::{ FinalPassDisposition, TranscriptionConfidenceFlag, TranscriptionVerdict, }; +use codescribe_core::pipeline::stream_postprocess::{StreamPostProcessStats, StreamPostProcessor}; use super::final_pass::engine_label_from_verdict; use super::helpers::SessionTelemetrySnapshot; use super::types::{RecordingFallbackClass, RecordingTranscriptSource}; +/// Unconditional final text layer immediately before formatting and delivery. +#[derive(Debug, Clone)] +pub struct DeliveryTextPostprocess { + /// Text after production lexicon, artifact cleanup, and semantic gate. + pub text: String, + /// Content-free counters proving which final layer ran. + pub stats: StreamPostProcessStats, +} + +/// Apply the production-owned lexicon/text layer used by every delivery. +/// +/// This is deliberately a shared symbol rather than test-side logic: the real +/// overlay pipeline and private-corpus replay both cross it before treating a +/// transcript as deliverable. +pub fn postprocess_transcript_for_delivery(raw_text: &str) -> DeliveryTextPostprocess { + let mut finalizer = StreamPostProcessor::new(); + let text = finalizer + .process(raw_text) + .unwrap_or_else(|| raw_text.to_string()); + DeliveryTextPostprocess { + text, + stats: finalizer.stats(), + } +} + /// Collapse a whitespace-only transcript to `None` — blank is not a transcript. fn non_empty_transcript(text: Option) -> Option { text.filter(|text| !text.trim().is_empty()) } +/// The typed Layer 1 producer whose result is being reconciled with the live +/// Apple/stream floor. This is deliberately independent of transport vendor. +#[derive(Debug, Clone, Copy)] +enum Layer1AdjudicationSource { + LocalWhisper, + CloudPrimary, + CloudFallback, +} + +impl Layer1AdjudicationSource { + const fn label(self) -> &'static str { + match self { + Self::LocalWhisper => "local_whisper", + Self::CloudPrimary => "cloud_primary", + Self::CloudFallback => "cloud_fallback", + } + } +} + +const fn layer1_decision_reason(mode: codescribe_core::quality::Layer1MergeMode) -> &'static str { + match mode { + codescribe_core::quality::Layer1MergeMode::Empty => "empty", + codescribe_core::quality::Layer1MergeMode::LiveOnly => "live_only", + codescribe_core::quality::Layer1MergeMode::ProviderOnly => "provider_only", + codescribe_core::quality::Layer1MergeMode::LiveFloorGapFill => "live_floor_gap_fill", + } +} + +const fn cloud_layer1_engine_label( + mode: codescribe_core::quality::Layer1MergeMode, +) -> &'static str { + match mode { + codescribe_core::quality::Layer1MergeMode::ProviderOnly => "cloud_stt", + _ => "merged_live_layer1:cloud_stt", + } +} + +/// Reconcile one Layer 1 result against the immutable live floor and emit only +/// content-free operational telemetry. Cloud sources never receive local +/// known-term evidence, so they can fill aligned gaps/tails but cannot win a +/// substitution over a committed live token. +fn merge_layer1_with_live_floor( + live: Option<&str>, + provider_text: &str, + source: Layer1AdjudicationSource, + known_terms: &[String], +) -> codescribe_core::quality::Layer1MergedDelivery { + let live = live.unwrap_or_default(); + let merged = match source { + Layer1AdjudicationSource::LocalWhisper => { + codescribe_core::quality::merge_live_whisper_with_terms( + live, + provider_text, + known_terms, + ) + .into() + } + Layer1AdjudicationSource::CloudPrimary | Layer1AdjudicationSource::CloudFallback => { + codescribe_core::quality::merge_live_layer1(live, provider_text) + } + }; + + info!( + source = source.label(), + live_chars = live.chars().count(), + provider_chars = provider_text.chars().count(), + merged_chars = merged.text.chars().count(), + decision_reason = layer1_decision_reason(merged.mode), + equal = merged.equal_tokens, + provider_fill = merged.provider_fill_tokens, + live_subs = merged.live_kept_substitutes, + provider_won_subs = merged.provider_won_substitutes, + known_terms = known_terms.len(), + "Layer 1 adjudication completed" + ); + + merged +} + /// The adjudicated outcome of one recording: what to deliver, and how much to /// trust it. /// @@ -49,6 +154,12 @@ pub(crate) struct RecordingTruthVerdict { /// Disposition of the explicit file-level final pass, when one ran. /// None means no final pass was attempted for this verdict. pub(crate) final_pass_disposition: Option, + /// Whether the stop path actually invoked the local file pass. + pub(crate) final_pass_attempted: bool, + /// Runtime verdict for the explicit skip question used by acceptance evidence. + pub(crate) final_pass_skipped: bool, + /// Typed engine/controller reason when the pass was skipped. + pub(crate) final_pass_skip_reason: Option, /// Actual serving engine label for sidecar/UI (`local_apple`, `local_whisper`, …). /// Preference-derived labels are forbidden when a verdict is present. pub(crate) engine_label: Option, @@ -197,6 +308,9 @@ pub(crate) fn build_truth_verdict( confidence_flags, sparkline, final_pass_disposition, + final_pass_attempted: false, + final_pass_skipped: false, + final_pass_skip_reason: None, engine_label, commit_trigger, display_status, @@ -205,11 +319,10 @@ pub(crate) fn build_truth_verdict( /// Decide what a finished recording actually delivers, and label its provenance. /// -/// The live transcript is the floor of truth. When a local final pass produced -/// text, it is **merged** into the live assembly (live kept, Whisper filling the -/// gaps) rather than replacing it — full-replace would delete correct live -/// tokens and is a doctrine violation. See -/// [`codescribe_core::quality::merge_live_whisper`]. +/// The live transcript is the floor of truth. A Layer 1 result is **merged** +/// into the live assembly (committed live kept, provider filling gaps/tail) +/// rather than replacing it — full-replace would delete correct live tokens +/// and is a doctrine violation. See [`codescribe_core::quality::merge_live_layer1`]. /// /// Resolution order: /// 1. Local final pass — an explicit no-speech verdict is authoritative and @@ -217,8 +330,8 @@ pub(crate) fn build_truth_verdict( /// 2. A final pass that came back *shorter* than the live assembly is rejected /// as a length regression, keeping the stream and flagging provenance. /// 3. Session-level no-speech telemetry. -/// 4. Cloud verdict, then the streaming floor — both always marked degraded, -/// because neither was verified by a final pass. +/// 4. Cloud verdict merged against the streaming floor, then the streaming +/// floor alone. Cloud fallback remains explicitly degraded. /// 5. Nothing usable: an empty verdict carrying the reason. pub(crate) fn adjudicate_recording_truth( use_local_stt: bool, @@ -226,6 +339,40 @@ pub(crate) fn adjudicate_recording_truth( local_final_pass_verdict: Option, streaming_text: String, cloud_verdict: Option, + streaming_engine_label: Option<&str>, + session_telemetry: &SessionTelemetrySnapshot, +) -> RecordingTruthVerdict { + let typed_skip = local_final_pass_verdict + .as_ref() + .and_then(|verdict| verdict.final_pass.as_ref()) + .filter(|final_pass| final_pass.disposition == FinalPassDisposition::Skipped); + let final_pass_skipped = !local_final_pass_attempted || typed_skip.is_some(); + let final_pass_skip_reason = typed_skip + .and_then(|final_pass| final_pass.reason.clone()) + .or_else(|| (!local_final_pass_attempted).then(|| "not_attempted".to_string())); + + let mut verdict = adjudicate_recording_truth_inner( + use_local_stt, + local_final_pass_attempted, + local_final_pass_verdict, + streaming_text, + cloud_verdict, + streaming_engine_label, + session_telemetry, + ); + verdict.final_pass_attempted = local_final_pass_attempted; + verdict.final_pass_skipped = final_pass_skipped; + verdict.final_pass_skip_reason = final_pass_skip_reason; + verdict +} + +fn adjudicate_recording_truth_inner( + use_local_stt: bool, + local_final_pass_attempted: bool, + local_final_pass_verdict: Option, + streaming_text: String, + cloud_verdict: Option, + streaming_engine_label: Option<&str>, session_telemetry: &SessionTelemetrySnapshot, ) -> RecordingTruthVerdict { let streaming_text = non_empty_transcript(Some(streaming_text)); @@ -279,6 +426,24 @@ pub(crate) fn adjudicate_recording_truth( non_empty_transcript(Some(verdict.text)) }; + // `Off` produces a typed skipped verdict solely to carry the live + // engine identity. No final-pass engine served text, so do not route + // the unchanged live floor through the Whisper merge/provenance path. + if matches!(final_pass_disposition, Some(FinalPassDisposition::Skipped)) { + return build_truth_verdict( + streaming_text.or(raw_text), + Some(RecordingTranscriptSource::Streaming), + None, + None, + speech_pct, + avg_logprob, + confidence_flags, + sparkline, + final_pass_disposition, + engine_label, + ); + } + // Explicit no-speech from final pass remains authoritative. if no_speech_reason.is_some() { return build_truth_verdict( @@ -329,23 +494,12 @@ pub(crate) fn adjudicate_recording_truth( terms }) .unwrap_or_default(); - let merged = codescribe_core::quality::merge_live_whisper_with_terms( - stream, + let merged = merge_layer1_with_live_floor( + Some(stream), final_text, + Layer1AdjudicationSource::LocalWhisper, &known_terms, ); - info!( - mode = ?merged.mode, - equal = merged.equal_tokens, - whisper_fill = merged.whisper_fill_tokens, - live_subs = merged.live_kept_substitutes, - whisper_won_subs = merged.whisper_won_substitutes, - known_terms = known_terms.len(), - live_chars = stream.chars().count(), - whisper_chars = final_text.chars().count(), - merged_chars = merged.text.chars().count(), - "delivery merge: live floor + whisper fill (not full-replace)" - ); // Merged path still used a final pass; keep engine label from final // but text is composite live×whisper. let eng = engine_label @@ -412,6 +566,13 @@ pub(crate) fn adjudicate_recording_truth( } if let Some(cloud_verdict) = cloud_verdict { + let merged = merge_layer1_with_live_floor( + streaming_text.as_deref(), + &cloud_verdict.text, + Layer1AdjudicationSource::CloudFallback, + &[], + ); + let engine_label = cloud_layer1_engine_label(merged.mode); let mut fallback_flags = confidence_flags.clone(); for flag in &cloud_verdict.confidence_flags { push_typed_flag(&mut fallback_flags, *flag); @@ -421,7 +582,7 @@ pub(crate) fn adjudicate_recording_truth( TranscriptionConfidenceFlag::CloudFallbackUsed, ); return build_truth_verdict( - Some(cloud_verdict.text), + Some(merged.text), Some(RecordingTranscriptSource::CloudFallback), Some(RecordingFallbackClass::Degraded), // cloud fallback is no longer "Acceptable" (silent), it must be explicit None, @@ -430,7 +591,7 @@ pub(crate) fn adjudicate_recording_truth( fallback_flags, None, None, - Some("cloud_stt".to_string()), + Some(engine_label.to_string()), ); } @@ -447,11 +608,11 @@ pub(crate) fn adjudicate_recording_truth( // Regression keep-stream is still live assembly truth, not "degraded // because final missing" — label as streaming floor when we rejected // a collapsing final pass. - let engine = if final_pass_length_regression { - Some("streaming_live_floor".to_string()) - } else { - Some("streaming_whisper".to_string()) - }; + let engine = Some( + streaming_engine_label + .unwrap_or("streaming_unknown") + .to_string(), + ); return build_truth_verdict( Some(text), Some(RecordingTranscriptSource::StreamingFallback), @@ -467,8 +628,15 @@ pub(crate) fn adjudicate_recording_truth( } } else { if let Some(cloud_verdict) = cloud_verdict { + let merged = merge_layer1_with_live_floor( + streaming_text.as_deref(), + &cloud_verdict.text, + Layer1AdjudicationSource::CloudPrimary, + &[], + ); + let engine_label = cloud_layer1_engine_label(merged.mode); return build_truth_verdict( - Some(cloud_verdict.text), + Some(merged.text), Some(RecordingTranscriptSource::CloudPrimary), None, None, @@ -477,7 +645,7 @@ pub(crate) fn adjudicate_recording_truth( cloud_verdict.confidence_flags, None, None, - Some("cloud_stt".to_string()), + Some(engine_label.to_string()), ); } @@ -505,7 +673,11 @@ pub(crate) fn adjudicate_recording_truth( confidence_flags, None, None, - Some("streaming_whisper".to_string()), + Some( + streaming_engine_label + .unwrap_or("streaming_unknown") + .to_string(), + ), ); } } @@ -548,7 +720,7 @@ pub(crate) fn truth_engine_label( "cloud_stt".to_string() } RecordingTranscriptSource::Streaming | RecordingTranscriptSource::StreamingFallback => { - "streaming_whisper".to_string() + "streaming_unknown".to_string() } }) } diff --git a/app/logging.rs b/app/logging.rs index 8c0319be..7791f369 100644 --- a/app/logging.rs +++ b/app/logging.rs @@ -11,17 +11,23 @@ //! Swift constructs first wins and the rest are no-ops. use std::env; -use std::path::PathBuf; +use std::fs::File; +use std::path::{Path, PathBuf}; use std::sync::Once; +use codescribe_core::config::Config; + /// Once guard so tracing/logging subscribers install exactly once per process. static INIT: Once = Once::new(); /// Install the global tracing subscriber (stderr + file) and the panic hook. /// /// Idempotent: guarded by a [`Once`], so repeated calls across FFI boundaries -/// are cheap no-ops. Writes to `~/.codescribe/logs/codescribe.log` (append), -/// honouring `RUST_LOG` (falling back to legacy `LOG_LEVEL`, then `info`). +/// are cheap no-ops. Production processes append to +/// `~/.codescribe/logs/codescribe.log`, honouring `RUST_LOG` (falling back to +/// legacy `LOG_LEVEL`, then `info`). Rust and XCTest harnesses are refused a +/// file sink at runtime, including integration tests where this library is +/// compiled without `cfg(test)`; they retain the stderr subscriber. pub fn init_logging() { INIT.call_once(|| { init_tracing(); @@ -45,11 +51,6 @@ fn init_tracing() { }, }; - let home = env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let log_dir = PathBuf::from(home).join(".codescribe").join("logs"); - let _ = std::fs::create_dir_all(&log_dir); - let log_path = log_dir.join("codescribe.log"); - let stderr_layer = fmt::layer() .with_ansi(true) .with_target(true) @@ -58,12 +59,10 @@ fn init_tracing() { let filter_layer = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")); - let file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path); + let file = log_file_path(&Config::config_dir(), runtime_is_test_process()) + .and_then(|path| open_file_log(&path).ok()); - if let Ok(file) = file { + if let Some(file) = file { let file = std::sync::Arc::new(file); let file_layer = fmt::layer() .with_ansi(false) @@ -85,6 +84,45 @@ fn init_tracing() { } } +/// Resolve the production file sink. Test harnesses deliberately receive no +/// path: relying on a Makefile-exported data directory is insufficient because +/// bare `cargo test` compiles integration-test dependencies without `cfg(test)`. +fn log_file_path(config_dir: &Path, test_process: bool) -> Option { + (!test_process).then(|| config_dir.join("logs").join("codescribe.log")) +} + +/// Open the production log with append semantics, creating only its parent. +fn open_file_log(path: &Path) -> std::io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) +} + +/// Detect harnesses from runtime identity rather than only `cfg(test)`. +/// +/// Cargo places unit- and integration-test executables under a `deps` +/// directory. XCTest supplies a configuration variable or an `.xctest` +/// argument even though the hosted Rust library is a normal production build. +fn runtime_is_test_process() -> bool { + if cfg!(test) + || env::var_os("XCTestConfigurationFilePath").is_some() + || env::var_os("XCTestBundlePath").is_some() + || env::args_os().any(|arg| arg.to_string_lossy().contains(".xctest")) + { + return true; + } + + env::current_exe().is_ok_and(|exe| { + exe.parent() + .and_then(Path::file_name) + .is_some_and(|name| name == "deps") + }) +} + /// Install a global panic hook that logs every panic through `tracing` before /// the process unwinds or aborts. /// @@ -124,3 +162,32 @@ fn install_panic_hook() { ); })); } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[test] + fn test_processes_are_refused_a_file_sink() { + let root = Path::new("/tmp/codescribe-test-logging-contract"); + assert_eq!(log_file_path(root, true), None); + } + + #[test] + fn production_logging_keeps_canonical_append_semantics() { + let root = tempfile::tempdir().expect("create production logging fixture"); + let path = log_file_path(root.path(), false).expect("production file sink"); + assert_eq!(path, root.path().join("logs/codescribe.log")); + + writeln!(open_file_log(&path).expect("open first writer"), "first") + .expect("write first record"); + writeln!(open_file_log(&path).expect("open append writer"), "second") + .expect("write second record"); + + assert_eq!( + std::fs::read_to_string(path).expect("read production log fixture"), + "first\nsecond\n" + ); + } +} diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs index d27f9a98..27a2ca93 100644 --- a/app/presentation/emitter.rs +++ b/app/presentation/emitter.rs @@ -54,7 +54,7 @@ struct TranscriptUtteranceRecord { /// fallback for a final that arrives empty (VAD sealed on a quiet tail), so a /// real utterance is not lost to a blank final. #[derive(Debug, Default)] -struct SessionTranscriptState { +pub struct TranscriptReducer { committed: Vec, active_preview: String, last_non_empty_preview: String, @@ -81,7 +81,7 @@ fn append_rendered_fragment(rendered: &mut String, fragment: &str) { rendered.push_str(&normalized); } -impl SessionTranscriptState { +impl TranscriptReducer { /// Replace the live preview tail. Previews supersede each other, so this /// overwrites rather than appends; a non-empty preview is also remembered as /// the fallback an empty final will fall back to. @@ -166,6 +166,19 @@ impl SessionTranscriptState { return None; } + if let Some(existing) = self + .committed + .iter_mut() + .find(|record| record.utterance_id == utterance_id) + { + existing.text = committed_text; + existing.raw_text = raw_text.to_string(); + existing.start_ts = start_ts; + existing.end_ts = end_ts; + existing.segments = segments; + return None; + } + self.committed.push(TranscriptUtteranceRecord { utterance_id, text: committed_text.clone(), @@ -189,7 +202,7 @@ impl SessionTranscriptState { /// preview tail. Rebuilt from state on every call, so the rendered string is /// always a function of the record list rather than an accumulated buffer /// that could drift from it. - fn rendered_text(&self) -> String { + pub fn rendered_text(&self) -> String { let mut rendered = String::new(); for utterance in &self.committed { append_rendered_fragment(&mut rendered, &utterance.text); @@ -214,10 +227,31 @@ impl SessionTranscriptState { let Some(record) = self .committed .iter_mut() - .find(|record| record.utterance_id == utterance_id) + .rfind(|record| record.utterance_id == utterance_id) else { return false; }; + // Last-mile duplicate guard. A patch is computed against the canvas as + // it stood when Layer 1 was dispatched; by the time it arrives SFSpeech + // may have restated the SAME utterance at greater length, already + // delivering the words the patch recovers. Measured 2026-08-14: an + // append computed for a 15-character canvas landed on the 47-character + // restatement of it and duplicated the phrase ("…hard pruna I road + // która pozwoli nam na zrobienie hard Pru."), costing more WER than the + // recovery gained. Only pure insertions are checked — a substitution + // replaces the very span it would be compared against. + if let EngineEvent::ReplaceRange { + start, end, text, .. + } = event + && start == end + && codescribe_core::stt::tail_patcher::text_already_carries(&record.text, text) + { + tracing::debug!( + utterance_id, + "layered patch already carried by the canvas; dropped" + ); + return false; + } match event.apply_to_committed_text(&mut record.text) { Ok(applied) => applied, Err(error) => { @@ -236,8 +270,73 @@ impl SessionTranscriptState { fn committed(&self) -> &[TranscriptUtteranceRecord] { &self.committed } + + /// Apply one engine event using the exact transcript algebra owned by the + /// shipped presentation emitter. The returned text is present only when a + /// new final slot was inserted; same-id revisions update that slot without + /// dispatching a second per-utterance callback. + pub fn apply_event(&mut self, event: &EngineEvent) -> Option { + match event { + EngineEvent::Preview { text, .. } => self.apply_preview(text), + EngineEvent::Correction { + text, + previous_text, + .. + } => self.apply_correction(previous_text, text), + EngineEvent::UtteranceFinal { + utterance_id, + text, + raw_text, + start_ts, + end_ts, + segments, + .. + } => { + return self.finalize( + *utterance_id, + text, + raw_text, + *start_ts, + *end_ts, + segments.clone(), + ); + } + EngineEvent::ReplaceRange { .. } | EngineEvent::InsertAnnotation { .. } => { + let _ = self.apply_layered_patch(event); + } + EngineEvent::NoSpeech { .. } => self.clear_live_preview(), + _ => {} + } + None + } + + /// Finalized canvas only, excluding the volatile preview tail. + pub fn streaming_floor(&self) -> String { + let mut rendered = String::new(); + for utterance in &self.committed { + append_rendered_fragment(&mut rendered, &utterance.text); + } + rendered + } + + /// Number of unique finalized slots currently held by the reducer. + pub fn committed_count(&self) -> usize { + self.committed.len() + } +} + +/// Replay an ordered event vector through the production presentation algebra. +pub fn reduce_transcript_events(events: &[EngineEvent]) -> TranscriptReducer { + let mut reducer = TranscriptReducer::default(); + for event in events { + let _ = reducer.apply_event(event); + } + reducer } +#[cfg(test)] +type SessionTranscriptState = TranscriptReducer; + /// Presentation emitter — bridges `EngineEvent`s to `BufferedEmitter`. /// /// Implements `EventSink` so it can be plugged directly into `transcription_session`. @@ -257,9 +356,7 @@ pub struct PresentationEmitter { vad_end_callback: Option>, vad_start_emitted: std::sync::atomic::AtomicBool, /// Source-of-truth transcript state: committed utterances + active preview tail. - session_state: std::sync::Mutex, - /// Last utterance id delivered to callback (guards duplicate boundary commits). - last_dispatched_utterance_id: std::sync::atomic::AtomicU64, + session_state: std::sync::Mutex, /// Controls what the delta sink sees: full session text or only the live preview. delta_render_mode: DeltaRenderMode, } @@ -335,8 +432,7 @@ impl PresentationEmitter { vad_start_callback: None, vad_end_callback: None, vad_start_emitted: std::sync::atomic::AtomicBool::new(false), - session_state: std::sync::Mutex::new(SessionTranscriptState::default()), - last_dispatched_utterance_id: std::sync::atomic::AtomicU64::new(0), + session_state: std::sync::Mutex::new(TranscriptReducer::default()), delta_render_mode: DeltaRenderMode::SessionRendered, } } @@ -438,10 +534,10 @@ impl EventSink for PresentationEmitter { cb(); } } - EngineEvent::Preview { text, .. } => { + EngineEvent::Preview { .. } => { let rendered = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.apply_preview(text); + let _ = state.apply_event(event); match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), @@ -449,14 +545,10 @@ impl EventSink for PresentationEmitter { }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); } - EngineEvent::Correction { - text, - previous_text, - .. - } => { + EngineEvent::Correction { .. } => { let rendered = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.apply_correction(previous_text, text); + let _ = state.apply_event(event); match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), @@ -464,36 +556,10 @@ impl EventSink for PresentationEmitter { }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); } - EngineEvent::UtteranceFinal { - utterance_id, - text, - raw_text, - start_ts, - end_ts, - segments, - .. - } => { - let duplicate = self - .last_dispatched_utterance_id - .swap(*utterance_id, std::sync::atomic::Ordering::SeqCst) - == *utterance_id; - if duplicate { - debug!( - utterance_id = *utterance_id, - "Ignoring duplicate UtteranceFinal callback dispatch" - ); - return; - } + EngineEvent::UtteranceFinal { utterance_id, .. } => { let callback_payload = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.finalize( - *utterance_id, - text, - raw_text, - *start_ts, - *end_ts, - segments.clone(), - ) + state.apply_event(event) }; if let Some(cb) = &self.utterance_callback && let Some(payload) = callback_payload @@ -524,7 +590,7 @@ impl EventSink for PresentationEmitter { EngineEvent::NoSpeech { reason } => { let rendered = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.clear_live_preview(); + let _ = state.apply_event(event); state.rendered_text() }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); @@ -607,7 +673,20 @@ impl EventSink for PresentationEmitter { self.send_cmd(EmitterCmd::SetTargetText(rendered)); } } - EngineEvent::SessionFinalised { .. } => {} + EngineEvent::SessionFinalised { .. } => { + // The Apple progressive lane closes with SessionFinalised and + // does not emit Stats. Persist only immutable canvas here: a + // cumulative final can re-state committed text as the last + // Preview, and ignoring the close event would deliver + // `committed + restatement` at stop. + let rendered = { + let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); + state.clear_live_preview(); + state.streaming_floor() + }; + self.send_cmd(EmitterCmd::SetTargetText(rendered)); + self.send_cmd(EmitterCmd::Finish); + } } } } @@ -617,11 +696,146 @@ impl EventSink for PresentationEmitter { mod tests { use super::{DeltaRenderMode, PresentationEmitter, SessionTranscriptState}; use codescribe_core::pipeline::contracts::{ - AnnotationKind, EngineEvent, EventSink, LayerSource, TranscriptSegment, + AnnotationKind, EngineEvent, EventSink, LayerSource, LayerSummary, TranscriptSegment, }; use std::sync::{Arc, Mutex as StdMutex}; use tokio::sync::Mutex; + /// Regression for the 2026-08-14 patch/restatement race. + /// + /// Layer 1 computes a recovery against the canvas as it stood when the job + /// was dispatched. SFSpeech may then restate the SAME utterance at greater + /// length and deliver those words itself. Measured on take 144425: the + /// append was computed for a 15-character canvas, the final arrived at 47 + /// characters carrying the phrase, and applying the patch duplicated it + /// ("…hard pruna I road która pozwoli nam na zrobienie hard Pru.") — three + /// repeated 4-grams, WER 0.463 → 0.610. The reducer is the last place that + /// sees the canvas as it actually stands, so the guard belongs here. + #[test] + fn patch_already_delivered_by_a_restatement_is_dropped() { + let mut reducer = SessionTranscriptState::default(); + reducer.apply_event(&EngineEvent::UtteranceFinal { + utterance_id: 6, + text: "I road która pozwoli nam na zrobienie hard Pru.".to_string(), + raw_text: "i road ktora pozwoli nam na zrobienie hard pru".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + vad_speech_pct: None, + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + let before = reducer.rendered_text(); + + // The patch Layer 1 computed against the earlier, shorter canvas. + reducer.apply_event(&EngineEvent::ReplaceRange { + utterance_id: 6, + start: 5, + end: 5, + text: " która pozwoli nam na zrobienie hard pruna".to_string(), + source: LayerSource::TailPatch, + }); + assert_eq!( + reducer.rendered_text(), + before, + "a recovery the restatement already delivered must not be applied twice" + ); + + // A genuine gap fill on the same utterance still lands. + reducer.apply_event(&EngineEvent::ReplaceRange { + utterance_id: 6, + start: 46, + end: 46, + text: " przed wydaniem".to_string(), + source: LayerSource::TailPatch, + }); + assert!( + reducer.rendered_text().contains("przed wydaniem"), + "novel recovered material must still reach the canvas: {:?}", + reducer.rendered_text() + ); + } + + /// Regression for the 2026-08-14 tripled-RAW incident (Monika's take: + /// reducer said 228 chars, the RAW pulled by `recorder.stop()` said 791). + /// Two writers raced on the shared buffer: the command worker snapshotted + /// the full target AND the tick loop appended the same suffix again, so + /// cumulative Apple previews multiplied the trailing sentence. + /// + /// This walks the exact runtime seam — `on_event` → reducer → command + /// channel → worker snapshot → tick animation → shared buffer — with the + /// only substituted boundary being the event source, and demands the buffer + /// end byte-identical to the reducer truth. + #[tokio::test] + async fn transcript_buffer_matches_reducer_truth_after_cumulative_previews() { + let transcript = Arc::new(Mutex::new(String::new())); + let mut emitter = PresentationEmitter::new(transcript.clone(), None, None); + + let final_event = |id: u64, text: &str, start: f32, end: f32| EngineEvent::UtteranceFinal { + utterance_id: id, + text: text.to_string(), + raw_text: text.to_string(), + start_ts: start, + end_ts: end, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }; + + // The Apple-lane shape from the incident log: per-utterance previews + // grow until a final seals them (the restated-prefix guards upstream + // strip whole-session restatements before emission), and stop arrives + // with an open partial still on the canvas — sealed=2 + open tail. + let events = vec![ + EngineEvent::Preview { + rev: 1, + text: "Pies od wczoraj".to_string(), + }, + EngineEvent::Preview { + rev: 2, + text: "Pies od wczoraj wymiotuje.".to_string(), + }, + final_event(1, "Pies od wczoraj wymiotuje.", 0.0, 2.0), + EngineEvent::Preview { + rev: 3, + text: "Nie je i nie".to_string(), + }, + EngineEvent::Preview { + rev: 4, + text: "Nie je i nie pije.".to_string(), + }, + final_event(2, "Nie je i nie pije.", 2.0, 4.0), + EngineEvent::Preview { + rev: 5, + text: "Podałam mu".to_string(), + }, + ]; + + let mut reference = SessionTranscriptState::default(); + for event in &events { + emitter.on_event(event); + let _ = reference.apply_event(event); + } + emitter.finish().await; + + let raw = transcript.lock().await.clone(); + assert_eq!( + raw, + reference.rendered_text(), + "the RAW buffer recorder.stop() reads must be byte-identical to the reducer truth" + ); + assert_eq!( + raw.matches("wymiotuje").count(), + 1, + "a sentence delivered once must appear exactly once in the RAW, got: {raw:?}" + ); + } + /// Live preview appends after committed text in the rendered session canvas. #[test] fn session_state_appends_preview_after_committed_text() { @@ -1056,6 +1270,44 @@ mod tests { assert_eq!(snapshot, "Ala ma kota"); } + /// Apple progressive closes with `SessionFinalised`, not `Stats`. A fully + /// re-heard cumulative final can leave the committed canvas in Preview; + /// ignoring the closing event then persists `committed + restatement`. + #[tokio::test] + async fn session_finalised_clears_reheard_preview_without_stats() { + let transcript = Arc::new(Mutex::new(String::new())); + let mut emitter = PresentationEmitter::new(transcript.clone(), None, None); + + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "Ala ma kota".to_string(), + raw_text: "Ala ma kota".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + emitter.on_event(&EngineEvent::Preview { + rev: 2, + text: "Ala ma kota".to_string(), + }); + emitter.on_event(&EngineEvent::SessionFinalised { + session_id: "session".to_string(), + layer_summary: LayerSummary::default(), + }); + emitter.finish().await; + + let snapshot = transcript.lock().await.clone(); + assert_eq!( + snapshot, "Ala ma kota", + "SessionFinalised must persist committed canvas only" + ); + } + /// Late correction matching penultimate commit patches it, never appends. #[tokio::test] async fn correction_targets_penultimate_utterance_patches_instead_of_appending() { diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index 85b68ab6..21a5303e 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -31,3 +31,4 @@ cpal = "0.17" [dev-dependencies] async-trait = "0.1" serial_test = "3" +tempfile = "3" diff --git a/bridge/src/agent.rs b/bridge/src/agent.rs index b92ac7a6..2edccfd7 100644 --- a/bridge/src/agent.rs +++ b/bridge/src/agent.rs @@ -749,14 +749,17 @@ fn build_bridge_stream_options(ai_assistive_max_tokens: i32) -> StreamOptions { /// Compose the agent system prompt exactly like the controller path /// (`app/controller/helpers.rs::compose_agent_system_prompt`): the base assistive /// prompt, the WORKSPACE section (6238ca1) that pins project roots and tells the -/// model to resolve names via `list_projects` instead of guessing paths, and the +/// model to resolve names via `list_projects` instead of guessing paths, the /// review-tool + connector doctrine for long-running MCP review calls and -/// GitHub-connector fallback. +/// GitHub-connector fallback, and the measured Responses/streaming API ground +/// truth with the answer-first rule (operator incident 2026-08-14: a spoken +/// engine question got a clarification questionnaire instead of an answer). fn compose_agent_system_prompt() -> String { let base = codescribe_core::config::prompts::get_assistive_prompt(); let workspace = codescribe::agent::tools::workspace::workspace_prompt_section(); let doctrine = codescribe::agent::tools::doctrine::review_doctrine_prompt_section(); - format!("{base}\n\n{workspace}\n\n{doctrine}") + let api_truth = codescribe::agent::tools::api_truth::responses_api_prompt_section(); + format!("{base}\n\n{workspace}\n\n{doctrine}\n\n{api_truth}") } /// Load + validate composer attachments into vision `ImageAttachment`s. diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 7b8e0935..1f844327 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -17,9 +17,10 @@ use std::sync::{Mutex, Once, OnceLock}; use chrono::{DateTime, SecondsFormat, Utc}; use codescribe_core::config::keychain::{KEYCHAIN_ACCOUNTS, delete_key, save_key}; use codescribe_core::config::{ - Config, DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, FormattingPolicy, PromptKind, - PromptSnapshot, PromptWriteReason, UserSettings, prompt_snapshot, prompts, reset_to_defaults, - restore_prompt_to_default, write_prompt, write_prompt_bytes, + AppDataResetGuard, Config, DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, + FormattingPolicy, PromptKind, PromptSnapshot, PromptWriteReason, UserSettings, + begin_app_data_reset, prompt_snapshot, prompts, reset_to_defaults, restore_prompt_to_default, + write_prompt, write_prompt_bytes_during_reset, }; use codescribe_core::llm::account_auth; use codescribe_core::llm::key_liveness::{ @@ -34,6 +35,24 @@ use directories::BaseDirs; use crate::{CsError, CsLanguage}; +/// Stable cross-FFI marker: Swift must relaunch even though reset returned an +/// error, because at least one app-data root has already moved and the Rust +/// process fence is permanently latched. +const RESET_RELAUNCH_REQUIRED_MARKER: &str = "CODESCRIBE_RESET_RELAUNCH_REQUIRED"; + +/// Preserve the ordinary error contract before the destructive boundary, but +/// mark every post-boundary error so the host cannot leave a half-reset, +/// permanently fenced process running. +fn reset_error(reset: &AppDataResetGuard, message: impl Into) -> CsError { + let message = message.into(); + let msg = if reset.relaunch_required() { + format!("{RESET_RELAUNCH_REQUIRED_MARKER}: {message}") + } else { + message + }; + CsError::Config { msg } +} + /// Full settings snapshot pushed to the Swift Settings UI. Combines real /// `Config` struct fields (settings.json / .env / defaults already merged by /// `Config::load()`) with env-only knobs read from persisted settings / .env @@ -51,6 +70,10 @@ pub struct CsSettings { pub hold_start_delay_ms: u64, pub double_tap_interval_ms: u64, pub toggle_silence_sec: f32, + /// Deferred-insert chord (`DeferredInsertShortcut::wire_id()`), sourced + /// from the canonical merged config snapshot. `"disabled"` is the + /// product default when no persisted choice exists. + pub deferred_insert_shortcut: String, // ── Language ── pub whisper_language: CsLanguage, // ── AI / formatting ── @@ -479,6 +502,7 @@ impl CodescribeConfig { hold_start_delay_ms: config.hold_start_delay_ms, double_tap_interval_ms: config.double_tap_interval_ms, toggle_silence_sec: config.toggle_silence_sec, + deferred_insert_shortcut: config.deferred_insert_shortcut.wire_id().to_string(), whisper_language: CsLanguage::from(config.whisper_language), ai_formatting_enabled: config.ai_formatting_enabled, transcript_send_mode: config.transcript_send_mode.as_str().to_string(), @@ -1224,6 +1248,9 @@ impl CodescribeConfig { /// following symlinks. UserDefaults are cleared by the Swift caller before /// relaunch; TCC grants remain untouched. pub fn reset_app_data(&self, include_keys: bool, include_prompts: bool) -> Result<(), CsError> { + let mut reset_guard = begin_app_data_reset().map_err(|error| CsError::Config { + msg: format!("cannot reset app data: {error}"), + })?; let dirs = app_data_dirs(); let preview = reset_preview_for_dirs(&dirs); let preserved_prompts = if include_prompts { @@ -1251,34 +1278,47 @@ impl CodescribeConfig { include_keys, include_prompts, preserved_prompt_files: preserved_prompts.len(), + error: None, + failed_source: None, + failed_destination: None, }) .map_err(|error| CsError::Config { msg: format!("failed to append reset audit log: {error}"), })?; - let moved_paths = - match move_reset_dirs_to_destination(&dirs, &reset_destination, &trash_root) { - Ok(moved_paths) => moved_paths, - Err(error) => { - let _ = append_reset_audit(&ResetAuditEvent { - audit_path: &audit_path, - timestamp: &now, - status: "move_failed", - source_paths: &dirs, - moved_paths: &[], - trash_path: &reset_destination, - preview: &preview, - include_keys, - include_prompts, - preserved_prompt_files: preserved_prompts.len(), - }); - return Err(CsError::Config { - msg: format!("failed to move app data to Trash: {error}"), - }); - } - }; + let moved_paths = match move_reset_dirs_to_destination( + &mut reset_guard, + &dirs, + &reset_destination, + &trash_root, + ) { + Ok(moved_paths) => moved_paths, + Err(failure) => { + let error = failure.to_string(); + let _ = append_reset_audit(&ResetAuditEvent { + audit_path: &audit_path, + timestamp: &now, + status: "move_failed", + source_paths: &dirs, + moved_paths: &failure.moved_paths, + trash_path: &reset_destination, + preview: &preview, + include_keys, + include_prompts, + preserved_prompt_files: preserved_prompts.len(), + error: Some(&error), + failed_source: Some(&failure.failed_source), + failed_destination: Some(&failure.failed_destination), + }); + return Err(reset_error( + &reset_guard, + format!("failed to move app data to Trash: {error}"), + )); + } + }; - if let Err(error) = restore_base_prompts(&preserved_prompts) { + if let Err(error) = restore_base_prompts(&reset_guard, &preserved_prompts) { + let error_message = error.to_string(); let _ = append_reset_audit(&ResetAuditEvent { audit_path: &audit_path, timestamp: &now, @@ -1290,19 +1330,24 @@ impl CodescribeConfig { include_keys, include_prompts, preserved_prompt_files: preserved_prompts.len(), + error: Some(&error_message), + failed_source: None, + failed_destination: None, }); - return Err(CsError::Config { - msg: format!("app data moved to Trash but base prompt restoration failed: {error}"), - }); + return Err(reset_error( + &reset_guard, + format!("app data moved to Trash but base prompt restoration failed: {error}"), + )); } let key_error = if include_keys { let mut failure = None; for account in KEYCHAIN_ACCOUNTS { if let Err(error) = delete_key(account) { - failure = Some(CsError::Config { - msg: format!("failed to remove keychain key {account}: {error}"), - }); + failure = Some(reset_error( + &reset_guard, + format!("failed to remove keychain key {account}: {error}"), + )); break; } } @@ -1310,6 +1355,7 @@ impl CodescribeConfig { } else { None }; + let key_error_message = key_error.as_ref().map(ToString::to_string); append_reset_audit(&ResetAuditEvent { audit_path: &audit_path, @@ -1326,9 +1372,15 @@ impl CodescribeConfig { include_keys, include_prompts, preserved_prompt_files: preserved_prompts.len(), + error: key_error_message.as_deref(), + failed_source: None, + failed_destination: None, }) - .map_err(|error| CsError::Config { - msg: format!("failed to append reset audit log: {error}"), + .map_err(|error| { + reset_error( + &reset_guard, + format!("failed to append reset audit log: {error}"), + ) })?; if let Some(error) = key_error { @@ -1386,9 +1438,13 @@ fn capture_base_prompts() -> std::io::Result> { /// recreating the prompts directory the reset just removed. Tagged with /// `AppResetPreservation` so the prompt audit distinguishes a restore from a /// user edit. -fn restore_base_prompts(prompts: &[PreservedPrompt]) -> std::io::Result<()> { +fn restore_base_prompts( + reset: &AppDataResetGuard, + prompts: &[PreservedPrompt], +) -> std::io::Result<()> { for prompt in prompts { - write_prompt_bytes( + write_prompt_bytes_during_reset( + reset, prompt.kind, &prompt.bytes, PromptWriteReason::AppResetPreservation, @@ -1646,30 +1702,106 @@ fn create_reset_destination(trash_root: &Path, now: &DateTime) -> std::io:: Ok(reset_destination) } +/// Structured partial-progress failure from a multi-root reset. The external +/// audit must retain every root that already moved plus the exact root whose +/// move failed; reporting `moved_paths=[]` after a partial move is false +/// recovery guidance. +#[derive(Debug)] +struct ResetMoveFailure { + error: std::io::Error, + moved_paths: Vec<(PathBuf, PathBuf)>, + failed_source: PathBuf, + failed_destination: PathBuf, +} + +impl std::fmt::Display for ResetMoveFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "{} -> {} failed after {} completed move(s): {}", + self.failed_source.display(), + self.failed_destination.display(), + self.moved_paths.len(), + self.error + ) + } +} + +/// Stable destination label for one selected data root. +fn reset_root_destination(reset_destination: &Path, index: usize) -> PathBuf { + let label = if index == 0 { + "codescribe-data".to_string() + } else { + format!("application-support-{index}") + }; + reset_destination.join(label) +} + /// Move each existing data root into the prepared Trash destination, validating /// every source first. The first root lands as `codescribe-data` and the rest as /// `application-support-`, so the recovered folder is self-describing. /// Returns the `(source, destination)` pairs for the audit record. fn move_reset_dirs_to_destination( + reset: &mut AppDataResetGuard, dirs: &[PathBuf], reset_destination: &Path, trash_root: &Path, -) -> std::io::Result> { +) -> Result, ResetMoveFailure> { + let existing_sources: Vec<&PathBuf> = dirs.iter().filter(|source| source.exists()).collect(); + // Validate the complete cut before the first irreversible move. Otherwise a + // bad second root could be discovered only after the first was already in + // Trash, turning a preflight error into a partial reset. + for (index, source) in existing_sources.iter().enumerate() { + if let Err(error) = validate_reset_source(source, trash_root) { + return Err(ResetMoveFailure { + error, + moved_paths: Vec::new(), + failed_source: (*source).clone(), + failed_destination: reset_root_destination(reset_destination, index), + }); + } + } + let mut moved_paths = Vec::new(); - for (index, source) in dirs.iter().filter(|source| source.exists()).enumerate() { - validate_reset_source(source, trash_root)?; - let label = if index == 0 { - "codescribe-data".to_string() - } else { - format!("application-support-{index}") - }; - let destination = reset_destination.join(label); - move_path_recoverably(source, &destination)?; + for (index, source) in existing_sources.into_iter().enumerate() { + let destination = reset_root_destination(reset_destination, index); + if let Err(error) = move_reset_path_recoverably(reset, source, &destination) { + return Err(ResetMoveFailure { + error, + moved_paths, + failed_source: source.clone(), + failed_destination: destination, + }); + } moved_paths.push((source.clone(), destination)); } Ok(moved_paths) } +/// Move one reset root while coupling the first irreversible filesystem change +/// to the process-lifetime latch. A failed same-volume rename is still wholly +/// reversible, so the gate remains in Resetting during the copy fallback and +/// only latches immediately before the copied source is removed. +fn move_reset_path_recoverably( + reset: &mut AppDataResetGuard, + source: &Path, + destination: &Path, +) -> std::io::Result<()> { + match reset.rename_destructively(source, destination) { + Ok(()) => Ok(()), + Err(rename_error) => { + if let Err(copy_error) = copy_path_without_following_symlinks(source, destination) { + return Err(std::io::Error::new( + copy_error.kind(), + format!("rename failed ({rename_error}); fallback copy failed ({copy_error})"), + )); + } + reset.mark_destructive_started(); + remove_path_without_following_symlinks(source) + } + } +} + /// Prefer same-volume rename. If the source is on another volume, create and /// sync the complete destination before removing the source tree. fn move_path_recoverably(source: &Path, destination: &Path) -> std::io::Result<()> { @@ -1791,6 +1923,9 @@ struct ResetAuditEvent<'a> { include_keys: bool, include_prompts: bool, preserved_prompt_files: usize, + error: Option<&'a str>, + failed_source: Option<&'a Path>, + failed_destination: Option<&'a Path>, } /// Append one JSON line to the external reset audit log and fsync it. Strictly @@ -1818,6 +1953,9 @@ fn append_reset_audit(event: &ResetAuditEvent<'_>) -> std::io::Result<()> { "include_keys": event.include_keys, "include_prompts": event.include_prompts, "preserved_prompt_files": event.preserved_prompt_files, + "error": event.error, + "failed_source": event.failed_source.map(|path| path.to_string_lossy()), + "failed_destination": event.failed_destination.map(|path| path.to_string_lossy()), }); let mut file = OpenOptions::new() .create(true) @@ -2076,6 +2214,7 @@ mod reset_tests { reset_preview_for_dirs, restore_base_prompts, }; use chrono::{DateTime, Utc}; + use codescribe_core::config::begin_app_data_reset; use serial_test::serial; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; @@ -2143,6 +2282,36 @@ mod reset_tests { #[test] #[serial] fn reset_scope_follows_data_dir_and_moves_live_data_to_trash() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_RESET_SCOPE_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_RESET_SCOPE_WITNESS"; + const WITNESS_BYTES: &[u8] = b"reset-scope-pass"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = tempfile::TempDir::new().expect("child witness dir"); + let witness = witness_dir.path().join("passed"); + let status = std::process::Command::new( + std::env::current_exe().expect("current bridge test executable"), + ) + .args([ + "--exact", + "config::reset_tests::reset_scope_follows_data_dir_and_moves_live_data_to_trash", + "--nocapture", + ]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated destructive reset regression"); + assert!( + status.success(), + "isolated destructive reset regression failed" + ); + assert_eq!( + std::fs::read(witness).expect("child completed exact reset-scope test"), + WITNESS_BYTES, + "child command exited successfully without executing the exact regression" + ); + return; + } + let sandbox = scratch("scope"); let root = sandbox.join("source"); let trash = sandbox.join("trash"); @@ -2179,6 +2348,7 @@ mod reset_tests { ); let root_canon = root.canonicalize().expect("canonical reset root"); let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + let mut reset_guard = begin_app_data_reset().expect("own reset fence"); let dirs = app_data_dirs(); assert!(!dirs.is_empty(), "reset must target at least one dir"); @@ -2200,9 +2370,10 @@ mod reset_tests { let destination = create_reset_destination(&trash, ×tamp).expect("create test Trash destination"); let preserved_prompts = capture_base_prompts().expect("capture sacred prompts"); - let moved_paths = move_reset_dirs_to_destination(&dirs, &destination, &trash) - .expect("move reset scope to test Trash"); - restore_base_prompts(&preserved_prompts).expect("restore sacred prompts"); + let moved_paths = + move_reset_dirs_to_destination(&mut reset_guard, &dirs, &destination, &trash) + .expect("move reset scope to test Trash"); + restore_base_prompts(&reset_guard, &preserved_prompts).expect("restore sacred prompts"); assert!(!root.join("settings.json").exists()); assert_eq!( std::fs::read(root.join("prompts/assistive.txt")).expect("read restored assistive"), @@ -2238,6 +2409,9 @@ mod reset_tests { include_keys: false, include_prompts: false, preserved_prompt_files: preserved_prompts.len(), + error: None, + failed_source: None, + failed_destination: None, }) .expect("append reset audit"); let line = std::fs::read_to_string(&audit).expect("read reset audit"); @@ -2270,6 +2444,85 @@ mod reset_tests { ); remove_path_without_following_symlinks(&sandbox).expect("clean reset fixture"); + std::fs::write( + std::env::var_os(CHILD_WITNESS).expect("child witness path"), + WITNESS_BYTES, + ) + .expect("write reset-scope child witness"); + } + + /// If a later root fails after an earlier one moved, recovery evidence must + /// name the completed move and the failed pair, and the FFI error must force + /// Swift to relaunch the now-latched process. + #[test] + #[serial] + fn reset_partial_move_failure_reports_progress_and_requires_relaunch() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_RESET_PARTIAL_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_RESET_PARTIAL_WITNESS"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = tempfile::TempDir::new().expect("partial reset witness dir"); + let witness = witness_dir.path().join("passed"); + let status = std::process::Command::new( + std::env::current_exe().expect("current bridge test executable"), + ) + .args([ + "--exact", + "config::reset_tests::reset_partial_move_failure_reports_progress_and_requires_relaunch", + "--nocapture", + ]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated partial-reset regression"); + assert!(status.success(), "isolated partial-reset regression failed"); + assert_eq!( + std::fs::read(witness).expect("child executed exact partial-reset test"), + b"partial-reset-pass" + ); + return; + } + + let sandbox = scratch("partial_move"); + let first = sandbox.join("first"); + let second = sandbox.join("second"); + let trash = sandbox.join("trash"); + let destination = trash.join("reset"); + write(&first.join("one.txt"), b"one"); + write(&second.join("two.txt"), b"two"); + std::fs::create_dir_all(&destination).expect("create reset destination"); + // Force only the second destination to collide. The first root must + // move successfully before the structured failure is returned. + std::fs::create_dir(destination.join("application-support-1")) + .expect("block second destination"); + write( + &destination.join("application-support-1/blocker"), + b"occupied", + ); + let mut reset = begin_app_data_reset().expect("own partial reset fence"); + let failure = move_reset_dirs_to_destination( + &mut reset, + &[first.clone(), second.clone()], + &destination, + &trash, + ) + .expect_err("second root collision must fail after first move"); + + assert!(reset.relaunch_required()); + assert_eq!(failure.moved_paths.len(), 1); + assert_eq!(failure.moved_paths[0].0, first); + assert_eq!(failure.failed_source, second); + assert!(!first.exists()); + assert!(destination.join("codescribe-data/one.txt").is_file()); + assert!(second.join("two.txt").is_file()); + let ffi_error = super::reset_error(&reset, failure.to_string()).to_string(); + assert!(ffi_error.contains("CODESCRIBE_RESET_RELAUNCH_REQUIRED")); + + remove_path_without_following_symlinks(&sandbox).expect("clean partial-reset fixture"); + std::fs::write( + std::env::var_os(CHILD_WITNESS).expect("partial reset witness path"), + b"partial-reset-pass", + ) + .expect("write partial-reset child witness"); } /// Two entries for one reset must both survive in order. The log is the only @@ -2300,6 +2553,9 @@ mod reset_tests { include_keys: true, include_prompts: false, preserved_prompt_files: 2, + error: None, + failed_source: None, + failed_destination: None, }) .expect("append first audit line"); let moved_paths = vec![(sources[0].clone(), destination.join("codescribe-data"))]; @@ -2314,6 +2570,9 @@ mod reset_tests { include_keys: true, include_prompts: false, preserved_prompt_files: 2, + error: None, + failed_source: None, + failed_destination: None, }) .expect("append second audit line"); @@ -2469,13 +2728,12 @@ mod settings_snapshot_tests { let _ = remove_path_without_following_symlinks(&root); } - /// The tray must report persisted truth, never an optimistic echo. The - /// settings temp path is deliberately blocked to force a write failure; the - /// following read has to show the last value that actually reached disk, not - /// the one the user just asked for. + /// The tray must report persisted truth, never an optimistic echo. A normal + /// two-step write/read proves the returned snapshot is disk-derived; atomic + /// write failure itself is injected at the core rename seam. #[test] #[serial] - fn tray_toggles_roundtrip_auto_paste_and_format_truth_after_write_failure() { + fn tray_toggles_roundtrip_auto_paste_and_format_truth() { let root = scratch("tray_delivery_truth"); std::fs::create_dir_all(&root).expect("create bridge scratch"); let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); @@ -2496,14 +2754,12 @@ mod settings_snapshot_tests { assert!(!after_format.auto_paste_enabled); assert_eq!(after_format.formatting_level, "smart"); - // Block the atomic temp-file write while leaving settings.json readable. - // The write must fail and a fresh prompt-free snapshot must recover the - // last persisted truth rather than an optimistic requested value. - std::fs::create_dir_all(UserSettings::settings_path().with_extension("json.tmp")) - .expect("block atomic settings temp path"); - assert!(config.set_auto_paste_enabled(true).is_err()); + let delivered = config + .set_auto_paste_enabled(true) + .expect("persist auto paste true"); + assert!(delivered.auto_paste_enabled); let reread = config.tray_toggles(); - assert!(!reread.auto_paste_enabled); + assert!(reread.auto_paste_enabled); assert_eq!(reread.formatting_level, "smart"); let env_path = Config::env_path(); @@ -2594,6 +2850,36 @@ mod settings_snapshot_tests { let _ = remove_path_without_following_symlinks(&root); } + /// The picker receives its selected chord through `CsSettings`, not a + /// second Swift-only store. A fresh bridge must therefore reconstruct the + /// promoted settings.json choice and expose its canonical wire id. + #[test] + #[serial] + fn load_settings_exports_persisted_deferred_insert_shortcut() { + let root = scratch("deferred_insert_shortcut"); + std::fs::create_dir_all(&root).expect("create deferred-insert scratch"); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + let _env_path = EnvGuard::remove("CODESCRIBE_ENV_PATH"); + let _process_shortcut = EnvGuard::remove("CODESCRIBE_DEFERRED_INSERT_SHORTCUT"); + + CodescribeConfig::new() + .update_config( + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT".to_string(), + "command_shift_v".to_string(), + ) + .expect("persist deferred-insert chord through the bridge"); + + let fresh = CodescribeConfig::new().load_settings(); + assert_eq!(fresh.deferred_insert_shortcut, "command_shift_v"); + assert_eq!( + UserSettings::load().deferred_insert_shortcut.as_deref(), + Some("command_shift_v"), + "the promoted picker choice belongs in settings.json" + ); + + let _ = remove_path_without_following_symlinks(&root); + } + /// Workspace roots must round-trip through durable settings.json and be /// rebuilt identically by a brand-new bridge handle. The failure this pins: /// a fresh instance falling back to the `["~/.codescribe"]` default and diff --git a/bridge/src/hotkeys.rs b/bridge/src/hotkeys.rs index 36f05b1e..3d896ad8 100644 --- a/bridge/src/hotkeys.rs +++ b/bridge/src/hotkeys.rs @@ -480,9 +480,15 @@ fn forward_event_to_listener(payload: IpcEventPayload, listener: Arc listener.on_session_finalised(session_id, CsLayerSummary::from(&layer_summary)), + // Same class split as the matching arm in recording.rs: failures + // reach `on_error`, quality receipts are log-only. The tray stays + // as it is: a degraded-quality warning is not a dead backend. EngineEventWire::Warning { code, message } => { - tray_status::update_tray_status(TrayStatus::Error); - listener.on_error(format!("{code}: {message}")); + if codescribe_core::pipeline::contracts::warning_is_user_terminal(&code) { + listener.on_error(format!("{code}: {message}")); + } else { + tracing::info!(code, message, "engine warning (receipt, not forwarded)"); + } } EngineEventWire::Drop { .. } | EngineEventWire::Stats { .. } => {} }, @@ -791,6 +797,18 @@ impl CodescribeHotkeys { }) } + /// Forward a macOS sleep/wake boundary to the active recorder, if any. + /// + /// Querying this surface never constructs the shared controller. The host + /// notification callback can therefore remain a cheap no-op while idle and + /// cannot surprise-load a model or start a provider. + pub async fn note_sleep_wake(&self) -> bool { + let Some(controller) = current_controller(&shared_controller()) else { + return false; + }; + controller.note_sleep_wake().await + } + /// True while the shared controller is in an active recording/conversation state. pub async fn is_recording(&self) -> bool { let Some(controller) = current_controller(&shared_controller()) else { @@ -939,6 +957,15 @@ impl CodescribeHotkeys { pub fn cancel_voice_turn(&self, thread_id: String) -> bool { codescribe::agent_delivery::cancel_agent_delivery_turn(&thread_id) } + + /// Publish the Agent UI's current thread selection as the voice-assistive + /// routing target (operator contract 2026-08-13: dictation goes to the + /// thread the user is looking at; a new thread only via an explicit + /// "+ New thread"). `None` = the selection is a not-yet-persisted thread, + /// so the next assistive turn mints a fresh one. + pub fn set_assistive_target_thread(&self, backend_id: Option) { + codescribe::controller::set_assistive_target_thread(backend_id); + } } /// Honest outcome of the overlay Insert action, mirrored to Swift so the UI @@ -1733,12 +1760,58 @@ impl CodescribeHotkeys { mod mode_binding_tests { use super::*; use serial_test::serial; + use std::process::Command; use std::sync::Mutex; /// Serializes `CODESCRIBE_DATA_DIR` mutation for the persist/read-back test. // Serializes the CODESCRIBE_DATA_DIR-mutating test below within this module. static ENV_LOCK: Mutex<()> = Mutex::new(()); + /// RED contract for the production-log pollution observed from this test + /// module. A child test process gives `init_logging` a fresh `Once`, a fake + /// HOME, and a distinct test data root; initialization must not create the + /// production-shaped `~/.codescribe/logs/codescribe.log` sink. + #[test] + #[serial] + fn fleet_red_test_logging_isolated() { + const CHILD_ENV: &str = "CODESCRIBE_FLEET_RED_LOG_CHILD"; + if std::env::var_os(CHILD_ENV).is_some() { + let production_path = std::path::PathBuf::from( + std::env::var_os("HOME").expect("isolated child HOME must be set"), + ) + .join(".codescribe") + .join("logs") + .join("codescribe.log"); + + codescribe::logging::init_logging(); + assert!( + !production_path.exists(), + "test logger initialization resolved to production path: {}", + production_path.display() + ); + return; + } + + let fake_home = tempfile::tempdir().expect("create isolated HOME"); + let test_data = tempfile::tempdir().expect("create isolated test data root"); + let status = Command::new(std::env::current_exe().expect("resolve test binary")) + .args([ + "--exact", + "hotkeys::mode_binding_tests::fleet_red_test_logging_isolated", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .env("HOME", fake_home.path()) + .env("CODESCRIBE_DATA_DIR", test_data.path()) + .status() + .expect("launch isolated logging child"); + + assert!( + status.success(), + "isolated logger child must avoid the production log path" + ); + } + /// Every core work mode survives a UniFFI round-trip without loss. #[test] fn work_mode_ffi_round_trips() { @@ -2103,6 +2176,19 @@ mod preparing_compensation_tests { PREPARING_PENDING.store(false, Ordering::SeqCst); } + /// A host lifecycle notification while idle must not construct the shared + /// controller (and therefore cannot prewarm or load an engine). + #[tokio::test] + #[serial] + async fn sleep_wake_without_active_controller_is_a_noop() { + let _guard = TEST_LOCK.lock().await; + teardown(); + + let hotkeys = CodescribeHotkeys::new(); + assert!(!hotkeys.note_sleep_wake().await); + assert!(current_controller(&shared_controller()).is_none()); + } + /// AudioLevel IPC payload forwards the RMS sample to the Swift listener. #[test] fn recording_audio_level_payload_forwards_rms() { diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index 236732ad..31b4264f 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -49,8 +49,10 @@ pub use hotkeys::CodescribeHotkeys; pub use hotkeys::CsAppActionListener; pub use licensing::{CsLicenseState, CsLicenseStatus}; pub use quality::{ - CsLexiconEntry, CsQualityCommitResult, CsQualityRecord, commit_overlay_quality_record, - lexicon_custom_entries, quality_finalize_correction, quality_recent_records, + CsLexiconEntry, CsOverlayHighlight, CsOverlayHighlightKind, CsQualityCommitResult, + CsQualityRecord, commit_overlay_quality_record, lexicon_custom_entries, + overlay_highlights_enabled, quality_finalize_correction, quality_recent_records, + quality_teach_span, }; pub use tray_status::{ CodescribeTrayStatus, CsTrayStatusKind, CsTrayStatusListener, CsTrayStatusPayload, diff --git a/bridge/src/quality.rs b/bridge/src/quality.rs index 6c26d79e..8ac23889 100644 --- a/bridge/src/quality.rs +++ b/bridge/src/quality.rs @@ -8,10 +8,13 @@ //! //! Privacy: local disk only. +use codescribe_core::pipeline::highlight::{ + OverlayHighlight, OverlayHighlightKind, overlay_highlights_enabled as highlights_lane_enabled, +}; use codescribe_core::quality::overlay_quality::{ CustomLexiconEntry, DictionaryTeachResult, OverlayCorrectionCommit, QualityRecord, VoiceLabSaveOutcome, commit_overlay_correction_with_confidence, custom_lexicon_entries, - finalize_voice_lab_correction, recent_quality_records, teach_dictionary_from_store, + finalize_voice_lab_correction, recent_quality_records, teach_dictionary_from_store, teach_span, }; use crate::CsError; @@ -240,6 +243,77 @@ pub fn quality_teach_dictionary_from_store() -> Result for CsOverlayHighlightKind { + fn from(kind: OverlayHighlightKind) -> Self { + match kind { + OverlayHighlightKind::LexiconCorrected => Self::LexiconCorrected, + OverlayHighlightKind::SpeechGap => Self::SpeechGap, + } + } +} + +/// Span-based canvas highlight. Sample fields are the 3A PCM identity; +/// char offsets are the Swift adapter onto already-committed utterance text. +#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)] +pub struct CsOverlayHighlight { + pub kind: CsOverlayHighlightKind, + pub utterance_id: u64, + pub char_start: u64, + pub char_end: u64, + pub session: String, + pub capture_epoch: u64, + pub sample_start: u64, + pub sample_end: u64, + pub before: String, + pub after: String, +} + +impl From for CsOverlayHighlight { + fn from(value: OverlayHighlight) -> Self { + Self { + kind: value.kind.into(), + utterance_id: value.utterance_id, + char_start: value.char_start, + char_end: value.char_end, + session: value.range.session, + capture_epoch: value.range.capture_epoch, + sample_start: value.range.sample_start, + sample_end: value.range.sample_end, + before: value.before, + after: value.after, + } + } +} + +/// W13-6B lane flag. Default OFF. Read-only; no permission prompt. +#[uniffi::export] +pub fn overlay_highlights_enabled() -> bool { + highlights_lane_enabled() +} + +/// One-click Teach from a highlighted span. Reuses the existing quality + +/// custom-lexicon writers — no new disk root, no new permission. +#[uniffi::export] +pub fn quality_teach_span( + variant: String, + canonical: String, + kind: String, +) -> Result { + teach_span(&variant, &canonical, &kind) + .map(Into::into) + .map_err(|error| CsError::Quality { + msg: format!("span teach failed: {error:#}"), + }) +} + /// Bridge quality projections and commit gate contracts (level normalize/reject). #[cfg(test)] mod tests { diff --git a/bridge/src/recording.rs b/bridge/src/recording.rs index 67148077..569bc58b 100644 --- a/bridge/src/recording.rs +++ b/bridge/src/recording.rs @@ -11,11 +11,13 @@ use std::sync::{Arc, Mutex as StdMutex, RwLock}; use std::time::{Duration, Instant}; use codescribe::os::tray_status::{self, TrayStatus}; +use codescribe_core::asr_session::GatewaySessionAvailability; use codescribe_core::audio::load_audio_file; use codescribe_core::audio::streaming_recorder::StreamingRecorder; -use codescribe_core::config::FinalPassRoutingMode; +use codescribe_core::config::{FinalPassRoutingMode, UserSettings}; use codescribe_core::pipeline::contracts::{ AnnotationKind, EngineEvent, EventSink, FileTranscriptionOptions, LayerSource, LayerSummary, + warning_is_user_terminal, }; use codescribe_core::stt::{TailGapBoundary, resolve_tail_gap_boundary, whisper}; use cpal::traits::{DeviceTrait, HostTrait}; @@ -732,10 +734,20 @@ impl EventSink for CsEventSink { } => self .listener .on_session_finalised(session_id.clone(), layer_summary.into()), - // Recoverable engine warning — surface as a non-fatal error string. + // Warnings split by class (`warning_is_user_terminal`): a real + // failure surfaces on `on_error`; a quality receipt is log-only. + // Receipts must never ride the error channel — the composer treats + // `on_error` during capture as terminal, and a routine overlap + // receipt painted "Dictation stopped" over a live session, desynced + // the toggle parity and left an orphaned capture holding the mic + // behind an Idle tray (2026-08-12). The tray stays untouched either + // way: `TrayStatus::Error` means "backend not available". EngineEvent::Warning { code, message } => { - tray_status::update_tray_status(TrayStatus::Error); - self.listener.on_error(format!("{code}: {message}")) + if warning_is_user_terminal(code) { + self.listener.on_error(format!("{code}: {message}")); + } else { + info!(code, message, "engine warning (receipt, not forwarded)"); + } } // Engine-internal bookkeeping (dropped content, session stats) has no // listener surface; intentionally ignored. @@ -869,6 +881,10 @@ impl CodescribeDictation { let mut recorder = StreamingRecorder::new().map_err(|e| CsError::Recording { msg: e.to_string() })?; recorder.set_event_sink(Some(sink)); + recorder.configure_layer1( + &UserSettings::load(), + GatewaySessionAvailability::Unavailable, + ); // Manual voice-note: the composer's Stop click is the source of truth, // exactly like the hotkey hold's key-up (see `RecordingController` @@ -1106,6 +1122,7 @@ mod tests { #[derive(Default)] struct CapturingListener { final_calls: StdMutex>, + error_calls: StdMutex>, } impl CsTranscriptionListener for CapturingListener { @@ -1163,8 +1180,10 @@ mod tests { fn on_audio_level(&self, _rms: f32) {} /// No-speech notices — unused by the capture fixture. fn on_no_speech(&self, _reason: String) {} - /// Recoverable engine errors — unused by the capture fixture. - fn on_error(&self, _message: String) {} + /// Record error-channel deliveries so tests can assert the class split. + fn on_error(&self, message: String) { + self.error_calls.lock().unwrap().push(message); + } } /// Build a minimal `UtteranceFinal` event with the given identity/text. @@ -1184,6 +1203,40 @@ mod tests { } } + /// Warnings split by class at the bridge: a quality receipt (engine kept + /// going) must never reach `on_error` — the composer treats that channel as + /// terminal, and a routine overlap receipt shown as "Dictation stopped" + /// desynced the toggle and left an orphaned capture holding the microphone + /// (2026-08-12). A real failure (`transcription_failed`) must still land, + /// or failures go silent again. + #[test] + fn warning_receipts_stay_off_the_error_channel_but_failures_land() { + let listener = Arc::new(CapturingListener::default()); + let sink = CsEventSink { + listener: listener.clone(), + transcript: Arc::new(ComposerTranscript::default()), + }; + + sink.on_event(&EngineEvent::Warning { + code: "apple_final_window_overlap_normalized".to_string(), + message: "Apple final overlap removed at segment boundary".to_string(), + }); + assert!( + listener.error_calls.lock().unwrap().is_empty(), + "a quality receipt must never ride the error channel" + ); + + sink.on_event(&EngineEvent::Warning { + code: "transcription_failed".to_string(), + message: "boom".to_string(), + }); + assert_eq!( + listener.error_calls.lock().unwrap().as_slice(), + &["transcription_failed: boom".to_string()], + "a user-terminal failure must still surface on on_error" + ); + } + /// The bridge must forward `utterance_id` on `UtteranceFinal` so committed /// sinks can stamp segment identity that later `ReplaceRange` patches target. /// Regression guard for the W3 keystone (identity flow into committed text). diff --git a/core/Cargo.toml b/core/Cargo.toml index 36423f26..c8e25fbb 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,6 +14,10 @@ categories = ["multimedia::audio", "multimedia::encoding"] [lib] path = "lib.rs" +[[bin]] +name = "codescribe-stt-sidecar" +path = "bin/codescribe-stt-sidecar.rs" + [features] default = [] offline_eval = [] diff --git a/core/agent/thread_store.rs b/core/agent/thread_store.rs index 246f01d6..43ac8f2e 100644 --- a/core/agent/thread_store.rs +++ b/core/agent/thread_store.rs @@ -967,6 +967,7 @@ mod tests { use anyhow::Result; use chrono::Duration; use serde_json::json; + use serial_test::serial; use std::collections::HashSet; use tempfile::TempDir; @@ -1504,6 +1505,7 @@ mod tests { /// Inline image bytes persist as disk assets and restore without raw JSON. #[test] + #[serial] fn inline_image_roundtrips_through_disk_backed_asset() -> Result<()> { let tmp = TempDir::new()?; let store = ThreadStore::new_in(tmp.path().join("threads"))?; @@ -1551,6 +1553,7 @@ mod tests { /// Identical image bytes map to one asset path and are not rewritten. #[test] + #[serial] fn inline_image_asset_is_written_once_across_saves() -> Result<()> { let block = ContentBlock::Image { data: b"w5a-dedup-bytes".to_vec(), diff --git a/core/asr_session/bootstrap.rs b/core/asr_session/bootstrap.rs new file mode 100644 index 00000000..84b4434a --- /dev/null +++ b/core/asr_session/bootstrap.rs @@ -0,0 +1,152 @@ +//! Recording-start policy that joins persisted mode truth, consent, and a +//! minted gateway connection into the provider decision consumed by C1. +//! +//! The gateway mint itself is deliberately outside this repository boundary. +//! Until a caller supplies one validated, short-lived connection, cloud mode +//! is treated as unavailable and recording continues with Apple + lexicon. + +use std::fmt; + +use tracing::warn; + +use super::cloud::{ + CloudSessionLimits, GatewayConnection, GatewayWebSocketTransport, LiveCloudAsrSession, +}; +use super::consent::authorize_cloud_egress; +use super::recorder::Layer1Decision; +use crate::config::{AsrProductMode, UserSettings}; + +/// Availability of one short-lived gateway session at recording start. +/// +/// `Invalid` is distinct from `Unavailable` for content-free diagnostics. The +/// raw endpoint and bearer never cross this enum and are never formatted. +pub enum GatewaySessionAvailability { + /// No mint response is available (offline, timeout, or gateway absent). + Unavailable, + /// A mint response or normalized connection failed validation. + Invalid, + /// A validated, single-use WebSocket endpoint and bearer. + Ready(GatewayConnection), +} + +impl fmt::Debug for GatewaySessionAvailability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Unavailable => "GatewaySessionAvailability::Unavailable", + Self::Invalid => "GatewaySessionAvailability::Invalid", + Self::Ready(_) => "GatewaySessionAvailability::Ready([REDACTED])", + }) + } +} + +/// Build the one Layer 1 decision consumed by the real recorder path. +/// +/// Cloud can arm only when the settings resolver still says `cloud`, explicit +/// audio-egress authorization succeeds, and the caller supplies a validated +/// minted gateway connection. Every other state is normal Apple + lexicon. +/// In particular, an unavailable cloud session never falls through to local +/// power or Whisper. +pub fn layer1_decision_for_recording( + settings: &UserSettings, + gateway: GatewaySessionAvailability, +) -> Layer1Decision { + let resolved = settings.resolved_asr_mode(); + if resolved.mode != AsrProductMode::Cloud { + return Layer1Decision::Disarmed; + } + + let Ok(authorization) = authorize_cloud_egress(&resolved.consent) else { + return Layer1Decision::Disarmed; + }; + let GatewaySessionAvailability::Ready(connection) = gateway else { + warn!( + derivation = ?resolved.derivation, + gateway = ?gateway, + "Cloud Layer 1 unavailable at recording start; continuing with Apple + lexicon" + ); + return Layer1Decision::Disarmed; + }; + + let limits = CloudSessionLimits::default(); + let Ok(transport) = GatewayWebSocketTransport::new(connection, limits) else { + return Layer1Decision::Disarmed; + }; + let Ok(session) = LiveCloudAsrSession::new(transport, limits, authorization) else { + return Layer1Decision::Disarmed; + }; + Layer1Decision::Armed(Box::new(session)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cloud_settings(consent: Option<&str>) -> UserSettings { + UserSettings { + asr_mode: Some("cloud".to_string()), + cloud_consent: consent.map(str::to_string), + ..UserSettings::default() + } + } + + fn ready() -> GatewaySessionAvailability { + GatewaySessionAvailability::Ready( + GatewayConnection::new("wss://gateway.invalid/v1/stt/live", "short-lived-token") + .expect("valid normalized gateway connection"), + ) + } + + #[test] + fn consented_cloud_with_valid_mint_arms_the_real_provider() { + let decision = layer1_decision_for_recording(&cloud_settings(Some("granted")), ready()); + assert!(decision.is_armed()); + } + + #[test] + fn missing_denied_malformed_or_offline_cloud_stays_apple_only() { + let cases = [ + (cloud_settings(None), ready()), + (cloud_settings(Some("denied")), ready()), + ( + cloud_settings(Some("granted")), + GatewaySessionAvailability::Invalid, + ), + ( + cloud_settings(Some("granted")), + GatewaySessionAvailability::Unavailable, + ), + ]; + + for (settings, gateway) in cases { + assert!(!layer1_decision_for_recording(&settings, gateway).is_armed()); + } + } + + #[test] + fn cloud_failure_and_explicit_local_mode_never_load_an_in_process_model() { + let probe = || { + crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls() + }; + let before = probe(); + + let offline = layer1_decision_for_recording( + &cloud_settings(Some("granted")), + GatewaySessionAvailability::Unavailable, + ); + let local_power = layer1_decision_for_recording( + &UserSettings { + asr_mode: Some("local_power".to_string()), + ..UserSettings::default() + }, + ready(), + ); + + assert!(!offline.is_armed()); + assert!( + !local_power.is_armed(), + "L0 owns the explicit helper provider" + ); + assert_eq!(probe().saturating_sub(before), 0); + } +} diff --git a/core/asr_session/cloud.rs b/core/asr_session/cloud.rs new file mode 100644 index 00000000..1a3e22b7 --- /dev/null +++ b/core/asr_session/cloud.rs @@ -0,0 +1,1515 @@ +//! Dedicated live cloud transport for the normalized Libraxis gateway contract. +//! +//! This module is intentionally a second path beside the legacy whole-file +//! WebSocket uploader. It owns a live session: one start message, bounded PCM +//! frames, normalized receive events, and a bounded end/drain. It does not own +//! recorder wiring, consent, provider selection, or a vendor protocol. +//! +//! Provider ordering is evidence, not authority. Transcript revisions are +//! compared only inside their utterance, duplicates and stale revisions are +//! removed, and [`LiveCloudAsrSession`] assigns a fresh Codescribe-owned +//! stream-global sequence to every event it emits. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderValue}; +use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +use super::consent::CloudEgressAuthorization; +use super::events::{ + AsrErrorKind, AsrSessionEvent, AudioRange, ErrorEvent, EventIdentity, SessionId, + TranscriptEvent, UsageEvent, +}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; + +/// Normalized bounds for one live cloud session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CloudSessionLimits { + /// Largest PCM callback accepted as one gateway frame. + pub max_frame_samples: usize, + /// Maximum wire items inspected by one non-blocking [`drain`](AsrSessionProvider::drain). + pub max_events_per_drain: usize, + /// Maximum trailing wire items accepted while synchronously closing. + pub max_close_events: usize, + /// Bounded audio/end command queue feeding the socket worker. + pub outbound_queue_capacity: usize, + /// Bounded normalized event queue returning from the socket worker. + pub inbound_queue_capacity: usize, + /// Maximum remembered gateway event ids used for replay suppression. + pub remembered_event_ids: usize, + /// Upper bound for the WebSocket handshake. + pub connect_timeout: Duration, + /// Upper bound for one socket send. + pub send_timeout: Duration, + /// Upper bound for the end signal and trailing receive drain. + pub close_timeout: Duration, +} + +impl Default for CloudSessionLimits { + fn default() -> Self { + Self { + // 200 ms at the expected 16 kHz input rate. + max_frame_samples: 3_200, + max_events_per_drain: 64, + max_close_events: 128, + outbound_queue_capacity: 8, + inbound_queue_capacity: 128, + remembered_event_ids: 4_096, + connect_timeout: Duration::from_secs(10), + send_timeout: Duration::from_secs(5), + close_timeout: Duration::from_secs(2), + } + } +} + +impl CloudSessionLimits { + fn validate(&self) -> Result<(), AsrErrorKind> { + if self.max_frame_samples == 0 + || self.max_events_per_drain == 0 + || self.max_close_events == 0 + || self.outbound_queue_capacity == 0 + || self.inbound_queue_capacity == 0 + || self.remembered_event_ids == 0 + || self.connect_timeout.is_zero() + || self.send_timeout.is_zero() + || self.close_timeout.is_zero() + { + return Err(AsrErrorKind::Protocol); + } + Ok(()) + } +} + +/// Gateway endpoint and short-lived bearer minted outside the desktop transport. +/// +/// Its `Debug` representation is deliberately content-free. Endpoints can carry +/// signed query parameters and bearer values are credentials; neither belongs +/// in logs, panic output, or telemetry. +pub struct GatewayConnection { + endpoint: String, + bearer: String, +} + +impl GatewayConnection { + /// Validate a normalized gateway WebSocket connection. + /// + /// Remote plaintext and URL user-info are refused. A signed query string is + /// allowed but remains redacted by the type's `Debug` implementation. + pub fn new( + endpoint: impl Into, + bearer: impl Into, + ) -> Result { + let endpoint = endpoint.into(); + let bearer = bearer.into(); + let parsed = reqwest::Url::parse(&endpoint).map_err(|_| AsrErrorKind::Protocol)?; + let host = parsed + .host_str() + .map(|value| value.trim_matches(['[', ']'])) + .ok_or(AsrErrorKind::Protocol)?; + let encrypted = parsed.scheme() == "wss"; + let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1"); + if (!encrypted && !(parsed.scheme() == "ws" && loopback)) + || !parsed.username().is_empty() + || parsed.password().is_some() + || bearer.trim().is_empty() + { + return Err(AsrErrorKind::Protocol); + } + + let authorization = format!("Bearer {bearer}"); + HeaderValue::from_str(&authorization).map_err(|_| AsrErrorKind::Protocol)?; + Ok(Self { endpoint, bearer }) + } +} + +impl fmt::Debug for GatewayConnection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayConnection") + .field("endpoint", &"[REDACTED]") + .field("bearer", &"[REDACTED]") + .finish() + } +} + +/// Provider-neutral session configuration sent exactly once after connect. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GatewaySessionConfig { + #[serde(rename = "type")] + message_type: &'static str, + protocol_version: u16, + session_id: String, + locale: Option, + audio: GatewayAudioConfig, +} + +impl GatewaySessionConfig { + fn from_input(input: &SessionInput) -> Self { + Self { + message_type: "session.start", + protocol_version: 1, + session_id: input.session_id.as_str().to_string(), + locale: input.locale.clone(), + audio: GatewayAudioConfig { + encoding: "pcm_s16le", + sample_rate_hz: input.sample_rate, + channels: 1, + frame_header: "sequence_u64_be", + }, + } + } + + /// Opaque session id echoed by every gateway event. + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// Audio sample rate sent to the gateway. + pub fn sample_rate_hz(&self) -> u32 { + self.audio.sample_rate_hz + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct GatewayAudioConfig { + encoding: &'static str, + sample_rate_hz: u32, + channels: u8, + frame_header: &'static str, +} + +/// One bounded mono PCM16-LE frame, prefixed on the wire by its local send id. +#[derive(Clone, PartialEq, Eq)] +pub struct GatewayPcmFrame { + sequence_id: u64, + pcm_s16le: Vec, +} + +impl GatewayPcmFrame { + /// Codescribe-owned monotonic audio frame number. + pub fn sequence_id(&self) -> u64 { + self.sequence_id + } + + /// PCM payload length, excluding the eight-byte sequence header. + pub fn payload_len(&self) -> usize { + self.pcm_s16le.len() + } + + fn into_wire_bytes(self) -> Vec { + let mut wire = Vec::with_capacity(8 + self.pcm_s16le.len()); + wire.extend_from_slice(&self.sequence_id.to_be_bytes()); + wire.extend_from_slice(&self.pcm_s16le); + wire + } +} + +impl fmt::Debug for GatewayPcmFrame { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayPcmFrame") + .field("sequence_id", &self.sequence_id) + .field("payload_bytes", &self.pcm_s16le.len()) + .finish() + } +} + +/// Stable normalized gateway error vocabulary. No vendor message crosses it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GatewayErrorCode { + /// Connection credentials are missing, expired, or rejected. + Auth, + /// A transient request-rate limit. + RateLimited, + /// Billing or account quota is exhausted. + Quota, + /// The requested locale or session capability is unsupported. + Unsupported, + /// Gateway/session protocol mismatch. + Protocol, + /// Gateway-side buffering could not keep up. + Backpressure, + /// A normalized gateway deadline elapsed. + Timeout, + /// The gateway cancelled the session. + Cancelled, +} + +impl GatewayErrorCode { + fn as_asr_kind(self) -> AsrErrorKind { + match self { + Self::Auth => AsrErrorKind::Auth, + Self::RateLimited => AsrErrorKind::RateLimited, + Self::Quota => AsrErrorKind::Quota, + Self::Unsupported => AsrErrorKind::Unsupported, + Self::Protocol => AsrErrorKind::Protocol, + Self::Backpressure => AsrErrorKind::Overflow, + Self::Timeout => AsrErrorKind::Transport, + Self::Cancelled => AsrErrorKind::Cancelled, + } + } +} + +/// Provider-neutral receive vocabulary spoken by the Libraxis gateway. +/// +/// `revision` is scoped only to its utterance. It is used to discard stale +/// provider frames and is never exposed as the Codescribe event sequence. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +pub enum GatewayEvent { + /// Volatile transcript hypothesis. + #[serde(rename = "transcript.partial")] + Partial { + event_id: String, + session_id: String, + utterance_id: u64, + revision: u64, + text: String, + #[serde(default)] + start_ms: Option, + #[serde(default)] + end_ms: Option, + }, + /// Sealing transcript hypothesis. + #[serde(rename = "transcript.final")] + Final { + event_id: String, + session_id: String, + utterance_id: u64, + revision: u64, + text: String, + #[serde(default)] + start_ms: Option, + #[serde(default)] + end_ms: Option, + }, + /// Typed error without provider prose. + #[serde(rename = "session.error")] + Error { + event_id: String, + session_id: String, + #[serde(default)] + utterance_id: u64, + code: GatewayErrorCode, + }, + /// Content-free accounting. + #[serde(rename = "session.usage")] + Usage { + event_id: String, + session_id: String, + audio_ms: u64, + #[serde(default)] + billable_units: Option, + }, + /// Explicit acknowledgement that trailing events are complete. + #[serde(rename = "session.ended")] + SessionEnded { session_id: String }, +} + +impl GatewayEvent { + fn session_id(&self) -> &str { + match self { + Self::Partial { session_id, .. } + | Self::Final { session_id, .. } + | Self::Error { session_id, .. } + | Self::Usage { session_id, .. } + | Self::SessionEnded { session_id } => session_id, + } + } + + fn event_id(&self) -> Option<&str> { + match self { + Self::Partial { event_id, .. } + | Self::Final { event_id, .. } + | Self::Error { event_id, .. } + | Self::Usage { event_id, .. } => Some(event_id), + Self::SessionEnded { .. } => None, + } + } +} + +/// Non-blocking result of polling an injected gateway transport. +#[derive(Debug, Clone, PartialEq)] +pub enum GatewayTransportPoll { + /// No receive item is ready now. + Pending, + /// One normalized gateway event is ready. + Event(GatewayEvent), + /// The transport failed with a content-free typed reason. + Fault(AsrErrorKind), + /// The transport ended and no more events can arrive. + Closed, +} + +/// Injectable boundary between the session adapter and a WebSocket actor. +pub trait CloudGatewayTransport: Send { + /// Start one normalized session. + fn start(&mut self, config: GatewaySessionConfig) -> Result<(), AsrErrorKind>; + /// Queue one bounded PCM frame without waiting for socket I/O. + fn try_send_pcm(&mut self, frame: GatewayPcmFrame) -> Result<(), AsrErrorKind>; + /// Poll one receive item without blocking. + fn poll(&mut self) -> GatewayTransportPoll; + /// Queue the normalized end signal. + fn begin_end(&mut self) -> Result<(), AsrErrorKind>; + /// Cancel any remaining work after a bounded drain expires. + fn abort(&mut self); +} + +#[derive(Debug)] +enum GatewayCommand { + Pcm(GatewayPcmFrame), + End, + Abort, +} + +#[derive(Debug)] +enum WorkerSignal { + Event(GatewayEvent), + Fault(AsrErrorKind), + Closed, +} + +/// Real bounded WebSocket actor for the normalized gateway contract. +/// +/// The socket and bearer live on a dedicated current-thread Tokio runtime. +/// The synchronous provider side only performs bounded `try_send`/`try_recv` +/// channel operations; it never performs network I/O on the audio callback. +pub struct GatewayWebSocketTransport { + connection: Option, + limits: CloudSessionLimits, + command_tx: Option>, + event_rx: Option>, + worker: Option>, + started: bool, + ending: bool, +} + +impl GatewayWebSocketTransport { + /// Build a dormant gateway transport. Network I/O starts at session open. + pub fn new( + connection: GatewayConnection, + limits: CloudSessionLimits, + ) -> Result { + limits.validate()?; + Ok(Self { + connection: Some(connection), + limits, + command_tx: None, + event_rx: None, + worker: None, + started: false, + ending: false, + }) + } +} + +impl fmt::Debug for GatewayWebSocketTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayWebSocketTransport") + .field("connection", &self.connection) + .field("started", &self.started) + .field("ending", &self.ending) + .finish_non_exhaustive() + } +} + +impl CloudGatewayTransport for GatewayWebSocketTransport { + fn start(&mut self, config: GatewaySessionConfig) -> Result<(), AsrErrorKind> { + if self.started { + return Err(AsrErrorKind::Protocol); + } + + let (command_tx, command_rx) = mpsc::channel(self.limits.outbound_queue_capacity); + let (event_tx, event_rx) = mpsc::channel(self.limits.inbound_queue_capacity); + // Move the short-lived bearer into the socket worker. The synchronous + // provider retains no spare credential copy after session start. + let connection = self.connection.take().ok_or(AsrErrorKind::Protocol)?; + let limits = self.limits; + let worker = std::thread::Builder::new() + .name("codescribe-live-cloud-asr".to_string()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let Ok(runtime) = runtime else { + let _ = event_tx.blocking_send(WorkerSignal::Fault(AsrErrorKind::Transport)); + let _ = event_tx.blocking_send(WorkerSignal::Closed); + return; + }; + runtime.block_on(gateway_worker( + connection, config, limits, command_rx, event_tx, + )); + }) + .map_err(|_| AsrErrorKind::Transport)?; + + self.command_tx = Some(command_tx); + self.event_rx = Some(event_rx); + self.worker = Some(worker); + self.started = true; + Ok(()) + } + + fn try_send_pcm(&mut self, frame: GatewayPcmFrame) -> Result<(), AsrErrorKind> { + if !self.started || self.ending { + return Err(AsrErrorKind::Protocol); + } + let sender = self.command_tx.as_ref().ok_or(AsrErrorKind::Transport)?; + sender + .try_send(GatewayCommand::Pcm(frame)) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => AsrErrorKind::Overflow, + mpsc::error::TrySendError::Closed(_) => AsrErrorKind::Transport, + }) + } + + fn poll(&mut self) -> GatewayTransportPoll { + let Some(receiver) = self.event_rx.as_mut() else { + return GatewayTransportPoll::Pending; + }; + match receiver.try_recv() { + Ok(WorkerSignal::Event(event)) => GatewayTransportPoll::Event(event), + Ok(WorkerSignal::Fault(kind)) => GatewayTransportPoll::Fault(kind), + Ok(WorkerSignal::Closed) => GatewayTransportPoll::Closed, + Err(mpsc::error::TryRecvError::Empty) => GatewayTransportPoll::Pending, + Err(mpsc::error::TryRecvError::Disconnected) => GatewayTransportPoll::Closed, + } + } + + fn begin_end(&mut self) -> Result<(), AsrErrorKind> { + if !self.started || self.ending { + return Err(AsrErrorKind::Protocol); + } + let sender = self.command_tx.as_ref().ok_or(AsrErrorKind::Transport)?; + sender + .try_send(GatewayCommand::End) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => AsrErrorKind::Overflow, + mpsc::error::TrySendError::Closed(_) => AsrErrorKind::Transport, + })?; + self.ending = true; + Ok(()) + } + + fn abort(&mut self) { + if let Some(sender) = self.command_tx.take() { + let _ = sender.try_send(GatewayCommand::Abort); + } + self.ending = true; + } +} + +impl Drop for GatewayWebSocketTransport { + fn drop(&mut self) { + self.abort(); + if self.worker.as_ref().is_some_and(JoinHandle::is_finished) + && let Some(worker) = self.worker.take() + { + let _ = worker.join(); + } + } +} + +async fn gateway_worker( + connection: GatewayConnection, + config: GatewaySessionConfig, + limits: CloudSessionLimits, + command_rx: mpsc::Receiver, + event_tx: mpsc::Sender, +) { + let result = run_gateway_socket(connection, config, limits, command_rx, &event_tx).await; + if let Err(kind) = result { + let _ = event_tx.send(WorkerSignal::Fault(kind)).await; + } + let _ = event_tx.send(WorkerSignal::Closed).await; +} + +async fn run_gateway_socket( + connection: GatewayConnection, + config: GatewaySessionConfig, + limits: CloudSessionLimits, + mut command_rx: mpsc::Receiver, + event_tx: &mpsc::Sender, +) -> Result<(), AsrErrorKind> { + let mut request = connection + .endpoint + .as_str() + .into_client_request() + .map_err(|_| AsrErrorKind::Protocol)?; + let authorization = HeaderValue::from_str(&format!("Bearer {}", connection.bearer)) + .map_err(|_| AsrErrorKind::Protocol)?; + request.headers_mut().insert(AUTHORIZATION, authorization); + + let connected = timeout(limits.connect_timeout, connect_async(request)) + .await + .map_err(|_| AsrErrorKind::Transport)? + .map_err(|error| classify_socket_error(&error))?; + let (mut socket, _) = connected; + + let start = serde_json::to_string(&config).map_err(|_| AsrErrorKind::Protocol)?; + send_socket_message( + &mut socket, + Message::Text(start.into()), + limits.send_timeout, + ) + .await?; + + loop { + tokio::select! { + command = command_rx.recv() => { + match command { + Some(GatewayCommand::Pcm(frame)) => { + send_socket_message( + &mut socket, + Message::Binary(frame.into_wire_bytes().into()), + limits.send_timeout, + ).await?; + } + Some(GatewayCommand::End) => { + let end = serde_json::json!({"type": "session.end"}).to_string(); + send_socket_message( + &mut socket, + Message::Text(end.into()), + limits.send_timeout, + ).await?; + return drain_gateway_tail(&mut socket, limits, event_tx).await; + } + Some(GatewayCommand::Abort) | None => { + let _ = socket.close(None).await; + return Ok(()); + } + } + } + incoming = socket.next() => { + if forward_gateway_message(incoming, &mut socket, limits.send_timeout, event_tx).await? { + return Err(AsrErrorKind::Transport); + } + } + } + } +} + +async fn drain_gateway_tail( + socket: &mut WebSocketStream>, + limits: CloudSessionLimits, + event_tx: &mpsc::Sender, +) -> Result<(), AsrErrorKind> { + let deadline = tokio::time::Instant::now() + limits.close_timeout; + loop { + let incoming = tokio::time::timeout_at(deadline, socket.next()) + .await + .map_err(|_| AsrErrorKind::Transport)?; + if forward_gateway_message(incoming, socket, limits.send_timeout, event_tx).await? { + return Ok(()); + } + } +} + +async fn forward_gateway_message( + incoming: Option>, + socket: &mut WebSocketStream>, + send_timeout: Duration, + event_tx: &mpsc::Sender, +) -> Result { + match incoming { + Some(Ok(Message::Text(text))) => { + let event: GatewayEvent = + serde_json::from_str(text.as_ref()).map_err(|_| AsrErrorKind::Protocol)?; + let ended = matches!(event, GatewayEvent::SessionEnded { .. }); + event_tx + .send(WorkerSignal::Event(event)) + .await + .map_err(|_| AsrErrorKind::Cancelled)?; + Ok(ended) + } + Some(Ok(Message::Ping(payload))) => { + send_socket_message(socket, Message::Pong(payload), send_timeout).await?; + Ok(false) + } + Some(Ok(Message::Pong(_))) => Ok(false), + Some(Ok(Message::Close(_))) | None => Ok(true), + Some(Ok(Message::Binary(_))) | Some(Ok(Message::Frame(_))) => Err(AsrErrorKind::Protocol), + Some(Err(error)) => Err(classify_socket_error(&error)), + } +} + +async fn send_socket_message( + socket: &mut WebSocketStream>, + message: Message, + send_timeout: Duration, +) -> Result<(), AsrErrorKind> { + timeout(send_timeout, socket.send(message)) + .await + .map_err(|_| AsrErrorKind::Transport)? + .map_err(|error| classify_socket_error(&error)) +} + +fn classify_socket_error(error: &WebSocketError) -> AsrErrorKind { + if let WebSocketError::Http(response) = error { + return match response.status().as_u16() { + 401 | 403 => AsrErrorKind::Auth, + 402 => AsrErrorKind::Quota, + 429 => AsrErrorKind::RateLimited, + 400..=499 => AsrErrorKind::Protocol, + _ => AsrErrorKind::Transport, + }; + } + AsrErrorKind::Transport +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionState { + Idle, + Open, + Ending, + Closed, + Failed, +} + +/// Content-free counters safe to place in diagnostics and telemetry. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct CloudSessionTelemetry { + /// PCM frames accepted by the bounded transport queue. + pub frames_queued: u64, + /// Samples accepted by the bounded transport queue. + pub samples_queued: u64, + /// Normalized events emitted to the caller. + pub events_emitted: u64, + /// Exact gateway replays suppressed by event id. + pub duplicate_events: u64, + /// Old per-utterance revisions or post-final updates suppressed. + pub stale_events: u64, + /// Bounded send attempts refused by backpressure. + pub backpressure_events: u64, + /// Transport-level faults normalized into typed events. + pub transport_faults: u64, +} + +#[derive(Debug)] +struct SeenEventIds { + capacity: usize, + order: VecDeque, + values: HashSet, +} + +impl SeenEventIds { + fn new(capacity: usize) -> Self { + Self { + capacity, + order: VecDeque::with_capacity(capacity), + values: HashSet::with_capacity(capacity), + } + } + + fn insert(&mut self, event_id: &str) -> bool { + if self.values.contains(event_id) { + return false; + } + if self.order.len() == self.capacity + && let Some(expired) = self.order.pop_front() + { + self.values.remove(&expired); + } + let owned = event_id.to_string(); + self.order.push_back(owned.clone()); + self.values.insert(owned); + true + } +} + +/// Live cloud implementation of [`AsrSessionProvider`]. +pub struct LiveCloudAsrSession { + _authorization: CloudEgressAuthorization, + transport: T, + limits: CloudSessionLimits, + state: SessionState, + session_id: Option, + next_audio_sequence: u64, + next_event_sequence: u64, + utterance_revisions: HashMap, + sealed_utterances: HashSet, + seen_event_ids: SeenEventIds, + ready: VecDeque, + telemetry: CloudSessionTelemetry, + fault_seen: bool, +} + +impl LiveCloudAsrSession { + /// Build an explicitly authorized live session over an injected normalized transport. + pub fn new( + transport: T, + limits: CloudSessionLimits, + authorization: CloudEgressAuthorization, + ) -> Result { + limits.validate()?; + Ok(Self { + _authorization: authorization, + transport, + limits, + state: SessionState::Idle, + session_id: None, + next_audio_sequence: 1, + next_event_sequence: 1, + utterance_revisions: HashMap::new(), + sealed_utterances: HashSet::new(), + seen_event_ids: SeenEventIds::new(limits.remembered_event_ids), + ready: VecDeque::new(), + telemetry: CloudSessionTelemetry::default(), + fault_seen: false, + }) + } + + /// Borrow the injected transport, primarily for deterministic verification. + pub fn transport(&self) -> &T { + &self.transport + } + + /// Content-free session counters. + pub fn telemetry(&self) -> CloudSessionTelemetry { + self.telemetry + } + + fn session_id(&self) -> Result { + self.session_id.clone().ok_or(AsrErrorKind::Protocol) + } + + fn allocate_identity(&mut self, utterance_id: u64) -> Result { + if self.next_event_sequence == u64::MAX { + return Err(AsrErrorKind::Protocol); + } + let sequence = self.next_event_sequence; + self.next_event_sequence += 1; + Ok(EventIdentity::new( + self.session_id()?, + utterance_id, + sequence, + )) + } + + fn queue_local_error(&mut self, utterance_id: u64, kind: AsrErrorKind) { + if let Ok(identity) = self.allocate_identity(utterance_id) { + self.ready + .push_back(AsrSessionEvent::Error(ErrorEvent { identity, kind })); + } + } + + fn normalize(&mut self, event: GatewayEvent) { + let expected_session = match self.session_id.as_ref() { + Some(value) => value.as_str(), + None => return, + }; + if event.session_id() != expected_session { + self.queue_local_error(0, AsrErrorKind::Protocol); + return; + } + if matches!(event, GatewayEvent::SessionEnded { .. }) { + self.state = SessionState::Closed; + return; + } + + let Some(event_id) = event.event_id() else { + self.queue_local_error(0, AsrErrorKind::Protocol); + return; + }; + if event_id.trim().is_empty() { + self.queue_local_error(0, AsrErrorKind::Protocol); + return; + } + if !self.seen_event_ids.insert(event_id) { + self.telemetry.duplicate_events += 1; + return; + } + + let normalized = match event { + GatewayEvent::Partial { + utterance_id, + revision, + text, + start_ms, + end_ms, + .. + } => self.normalize_transcript(false, utterance_id, revision, text, start_ms, end_ms), + GatewayEvent::Final { + utterance_id, + revision, + text, + start_ms, + end_ms, + .. + } => self.normalize_transcript(true, utterance_id, revision, text, start_ms, end_ms), + GatewayEvent::Error { + utterance_id, code, .. + } => self.allocate_identity(utterance_id).map(|identity| { + Some(AsrSessionEvent::Error(ErrorEvent { + identity, + kind: code.as_asr_kind(), + })) + }), + GatewayEvent::Usage { + audio_ms, + billable_units, + .. + } => self.allocate_identity(0).map(|identity| { + Some(AsrSessionEvent::Usage(UsageEvent { + identity, + audio_secs: duration_millis_to_secs(audio_ms), + billable_units, + })) + }), + GatewayEvent::SessionEnded { .. } => return, + }; + + match normalized { + Ok(Some(event)) => self.ready.push_back(event), + Ok(None) => {} + Err(kind) => self.queue_local_error(0, kind), + } + } + + fn normalize_transcript( + &mut self, + is_final: bool, + utterance_id: u64, + revision: u64, + text: String, + start_ms: Option, + end_ms: Option, + ) -> Result, AsrErrorKind> { + if text.trim().is_empty() { + return Err(AsrErrorKind::Protocol); + } + if self.sealed_utterances.contains(&utterance_id) + || self + .utterance_revisions + .get(&utterance_id) + .is_some_and(|previous| revision <= *previous) + { + self.telemetry.stale_events += 1; + return Ok(None); + } + let range = match (start_ms, end_ms) { + (None, None) => None, + (Some(start), Some(end)) => Some( + AudioRange::new(duration_millis_to_secs(start), duration_millis_to_secs(end)) + .ok_or(AsrErrorKind::Protocol)?, + ), + _ => return Err(AsrErrorKind::Protocol), + }; + self.utterance_revisions.insert(utterance_id, revision); + if is_final { + self.sealed_utterances.insert(utterance_id); + } + let transcript = TranscriptEvent { + identity: self.allocate_identity(utterance_id)?, + text, + range, + }; + Ok(Some(if is_final { + AsrSessionEvent::Final(transcript) + } else { + AsrSessionEvent::Partial(transcript) + })) + } + + fn poll_transport_once(&mut self) -> bool { + match self.transport.poll() { + GatewayTransportPoll::Pending => false, + GatewayTransportPoll::Event(event) => { + self.normalize(event); + true + } + GatewayTransportPoll::Fault(kind) => { + self.telemetry.transport_faults += 1; + self.fault_seen = true; + self.queue_local_error(0, kind); + self.state = SessionState::Failed; + true + } + GatewayTransportPoll::Closed => { + if !matches!(self.state, SessionState::Ending | SessionState::Closed) + && !self.fault_seen + { + self.telemetry.transport_faults += 1; + self.queue_local_error(0, AsrErrorKind::Transport); + } + self.state = SessionState::Closed; + true + } + } + } +} + +impl AsrSessionProvider for LiveCloudAsrSession { + fn mode(&self) -> RefinerMode { + RefinerMode::CloudSession + } + + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind> { + if self.state != SessionState::Idle || input.sample_rate == 0 { + return Err(AsrErrorKind::Protocol); + } + self.session_id = Some(input.session_id.clone()); + if let Err(kind) = self + .transport + .start(GatewaySessionConfig::from_input(input)) + { + self.state = SessionState::Failed; + return Err(kind); + } + self.state = SessionState::Open; + Ok(()) + } + + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind> { + if self.state != SessionState::Open || samples.is_empty() { + return Err(AsrErrorKind::Protocol); + } + if samples.len() > self.limits.max_frame_samples { + self.telemetry.backpressure_events += 1; + return Err(AsrErrorKind::Overflow); + } + if samples.iter().any(|sample| !sample.is_finite()) { + return Err(AsrErrorKind::Protocol); + } + if self.next_audio_sequence == u64::MAX { + return Err(AsrErrorKind::Protocol); + } + + let frame = GatewayPcmFrame { + sequence_id: self.next_audio_sequence, + pcm_s16le: samples_to_pcm_s16le(samples), + }; + match self.transport.try_send_pcm(frame) { + Ok(()) => { + self.next_audio_sequence += 1; + self.telemetry.frames_queued += 1; + self.telemetry.samples_queued += samples.len() as u64; + Ok(()) + } + Err(kind) => { + if kind == AsrErrorKind::Overflow { + self.telemetry.backpressure_events += 1; + } + Err(kind) + } + } + } + + fn drain(&mut self) -> Vec { + for _ in 0..self.limits.max_events_per_drain { + if !self.poll_transport_once() { + break; + } + } + let drained: Vec<_> = self.ready.drain(..).collect(); + self.telemetry.events_emitted += drained.len() as u64; + drained + } + + fn close(&mut self) -> Result<(), AsrErrorKind> { + if self.state != SessionState::Open { + return Err(AsrErrorKind::Protocol); + } + if let Err(kind) = self.transport.begin_end() { + self.transport.abort(); + self.state = SessionState::Failed; + return Err(kind); + } + self.state = SessionState::Ending; + + let deadline = Instant::now() + self.limits.close_timeout; + let mut close_events = 0usize; + while Instant::now() < deadline { + let progressed = self.poll_transport_once(); + if self.state == SessionState::Closed { + return Ok(()); + } + if self.state == SessionState::Failed { + self.transport.abort(); + return Err(AsrErrorKind::Transport); + } + if progressed { + close_events += 1; + if close_events >= self.limits.max_close_events { + self.transport.abort(); + self.telemetry.transport_faults += 1; + self.queue_local_error(0, AsrErrorKind::Overflow); + self.state = SessionState::Failed; + return Err(AsrErrorKind::Overflow); + } + } else { + // `close` is the one bounded blocking operation in the provider + // lifecycle. Avoid a hot spin while the socket actor waits for + // its final/usage/session.ended tail. + std::thread::sleep(Duration::from_millis(1)); + } + } + + self.transport.abort(); + self.telemetry.transport_faults += 1; + self.queue_local_error(0, AsrErrorKind::Transport); + self.state = SessionState::Failed; + Err(AsrErrorKind::Transport) + } +} + +fn duration_millis_to_secs(milliseconds: u64) -> f32 { + Duration::from_millis(milliseconds).as_secs_f32() +} + +fn samples_to_pcm_s16le(samples: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(samples.len() * 2); + for &sample in samples { + let scaled = if sample >= 0.0 { + (sample.clamp(0.0, 1.0) * f32::from(i16::MAX)).round() as i16 + } else { + (sample.clamp(-1.0, 0.0) * 32_768.0).round() as i16 + }; + bytes.extend_from_slice(&scaled.to_le_bytes()); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asr_session::consent::authorize_cloud_egress; + use crate::config::cloud_asr::{AudioEgressConsent, ConsentSource}; + + #[derive(Debug, Default)] + struct FakeGatewayTransport { + started: Vec, + frames: Vec, + script: VecDeque, + send_capacity: Option, + ending: bool, + aborted: bool, + } + + impl FakeGatewayTransport { + fn scripted(script: impl IntoIterator) -> Self { + Self { + script: script.into_iter().collect(), + ..Self::default() + } + } + + fn with_send_capacity(capacity: usize) -> Self { + Self { + send_capacity: Some(capacity), + ..Self::default() + } + } + } + + impl CloudGatewayTransport for FakeGatewayTransport { + fn start(&mut self, config: GatewaySessionConfig) -> Result<(), AsrErrorKind> { + if !self.started.is_empty() { + return Err(AsrErrorKind::Protocol); + } + self.started.push(config); + Ok(()) + } + + fn try_send_pcm(&mut self, frame: GatewayPcmFrame) -> Result<(), AsrErrorKind> { + if self + .send_capacity + .is_some_and(|capacity| self.frames.len() >= capacity) + { + return Err(AsrErrorKind::Overflow); + } + self.frames.push(frame); + Ok(()) + } + + fn poll(&mut self) -> GatewayTransportPoll { + self.script + .pop_front() + .unwrap_or(GatewayTransportPoll::Pending) + } + + fn begin_end(&mut self) -> Result<(), AsrErrorKind> { + self.ending = true; + Ok(()) + } + + fn abort(&mut self) { + self.aborted = true; + } + } + + fn session_id() -> SessionId { + SessionId::new("gateway-session-1").expect("valid test session") + } + + fn input() -> SessionInput { + SessionInput { + session_id: session_id(), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } + } + + fn limits() -> CloudSessionLimits { + CloudSessionLimits { + max_frame_samples: 4, + max_events_per_drain: 32, + max_close_events: 16, + outbound_queue_capacity: 2, + inbound_queue_capacity: 16, + remembered_event_ids: 32, + connect_timeout: Duration::from_millis(20), + send_timeout: Duration::from_millis(20), + close_timeout: Duration::from_millis(20), + } + } + + fn authorization() -> CloudEgressAuthorization { + authorize_cloud_egress(&AudioEgressConsent::Granted( + ConsentSource::ExplicitSettings, + )) + .expect("explicit test consent") + } + + fn partial(event_id: &str, utterance_id: u64, revision: u64, text: &str) -> GatewayEvent { + GatewayEvent::Partial { + event_id: event_id.to_string(), + session_id: session_id().to_string(), + utterance_id, + revision, + text: text.to_string(), + start_ms: None, + end_ms: None, + } + } + + fn final_event(event_id: &str, utterance_id: u64, revision: u64, text: &str) -> GatewayEvent { + GatewayEvent::Final { + event_id: event_id.to_string(), + session_id: session_id().to_string(), + utterance_id, + revision, + text: text.to_string(), + start_ms: None, + end_ms: None, + } + } + + #[test] + fn normalized_start_and_bounded_pcm_frames_are_sent() { + let mut session = + LiveCloudAsrSession::new(FakeGatewayTransport::default(), limits(), authorization()) + .expect("valid limits"); + session.open(&input()).expect("open"); + session + .push_audio(&[-1.0, -0.5, 0.5, 1.0]) + .expect("bounded frame"); + + let transport = session.transport(); + assert_eq!(transport.started.len(), 1); + assert_eq!(transport.started[0].session_id(), "gateway-session-1"); + assert_eq!(transport.started[0].sample_rate_hz(), 16_000); + let start_json = serde_json::to_value(&transport.started[0]).expect("serialize start"); + assert_eq!(start_json["type"], "session.start"); + assert_eq!(start_json["protocol_version"], 1); + assert_eq!(start_json["audio"]["encoding"], "pcm_s16le"); + assert_eq!(start_json["audio"]["channels"], 1); + assert!(start_json.get("provider").is_none()); + assert!(start_json.get("api_key").is_none()); + assert_eq!(transport.frames.len(), 1); + assert_eq!(transport.frames[0].sequence_id(), 1); + assert_eq!(transport.frames[0].payload_len(), 8); + let wire = transport.frames[0].clone().into_wire_bytes(); + assert_eq!(&wire[..8], &1u64.to_be_bytes()); + assert_eq!(&wire[8..10], &i16::MIN.to_le_bytes()); + + assert_eq!( + session.push_audio(&[0.0; 5]), + Err(AsrErrorKind::Overflow), + "an oversized callback is refused instead of split ambiguously" + ); + assert_eq!(session.push_audio(&[f32::NAN]), Err(AsrErrorKind::Protocol)); + assert_eq!(session.telemetry().frames_queued, 1); + assert_eq!(session.telemetry().samples_queued, 4); + } + + #[test] + fn normalized_receive_vocabulary_round_trips_without_vendor_fields() { + let events = [ + partial("partial-1", 1, 1, "tekst"), + final_event("final-1", 1, 2, "tekst final"), + GatewayEvent::Error { + event_id: "error-1".to_string(), + session_id: session_id().to_string(), + utterance_id: 1, + code: GatewayErrorCode::RateLimited, + }, + GatewayEvent::Usage { + event_id: "usage-1".to_string(), + session_id: session_id().to_string(), + audio_ms: 500, + billable_units: Some(1), + }, + GatewayEvent::SessionEnded { + session_id: session_id().to_string(), + }, + ]; + for event in events { + let encoded = serde_json::to_string(&event).expect("encode gateway event"); + let decoded: GatewayEvent = + serde_json::from_str(&encoded).expect("decode gateway event"); + assert_eq!(decoded, event); + assert!(!encoded.contains("provider")); + assert!(!encoded.contains("api_key")); + } + + let vendor_specific = r#"{ + "type":"transcript.partial", + "event_id":"x", + "session_id":"gateway-session-1", + "utterance_id":1, + "revision":1, + "text":"x", + "provider_model":"vendor-secret-shape" + }"#; + assert!(serde_json::from_str::(vendor_specific).is_err()); + } + + #[test] + fn local_sequence_is_global_across_reordered_utterances_and_duplicates() { + let duplicate = partial("u2-r1", 2, 1, "drugi"); + let script = [ + GatewayTransportPoll::Event(partial("u1-r1", 1, 1, "pierwszy")), + GatewayTransportPoll::Event(duplicate.clone()), + GatewayTransportPoll::Event(duplicate), + GatewayTransportPoll::Event(final_event("u1-r3", 1, 3, "pierwszy final")), + GatewayTransportPoll::Event(partial("u1-r2-late", 1, 2, "spozniony")), + GatewayTransportPoll::Event(final_event("u2-r2", 2, 2, "drugi final")), + GatewayTransportPoll::Event(GatewayEvent::Usage { + event_id: "usage-1".to_string(), + session_id: session_id().to_string(), + audio_ms: 1_250, + billable_units: Some(2), + }), + GatewayTransportPoll::Pending, + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + + let events = session.drain(); + let sequences: Vec<_> = events + .iter() + .map(|event| event.identity().sequence_id()) + .collect(); + let utterances: Vec<_> = events + .iter() + .map(|event| event.identity().utterance_id()) + .collect(); + assert_eq!(sequences, vec![1, 2, 3, 4, 5]); + assert_eq!(utterances, vec![1, 2, 1, 2, 0]); + assert_eq!(events[2].as_token(), "final"); + assert_eq!(events[3].as_token(), "final"); + assert_eq!(events[4].as_token(), "usage"); + assert_eq!(session.telemetry().duplicate_events, 1); + assert_eq!(session.telemetry().stale_events, 1); + } + + #[test] + fn delayed_transport_poll_never_blocks_drain() { + let script = [ + GatewayTransportPoll::Pending, + GatewayTransportPoll::Event(partial("delayed", 1, 1, "pozniej")), + GatewayTransportPoll::Pending, + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + assert!(session.drain().is_empty()); + assert_eq!(session.drain().len(), 1); + } + + #[test] + fn disconnect_is_a_typed_transport_event() { + let script = [GatewayTransportPoll::Fault(AsrErrorKind::Transport)]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + let events = session.drain(); + assert!(matches!( + events.as_slice(), + [AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Transport, + .. + })] + )); + assert_eq!(session.telemetry().transport_faults, 1); + } + + #[test] + fn auth_and_quota_are_distinct_content_free_events() { + let script = [ + GatewayTransportPoll::Event(GatewayEvent::Error { + event_id: "auth".to_string(), + session_id: session_id().to_string(), + utterance_id: 0, + code: GatewayErrorCode::Auth, + }), + GatewayTransportPoll::Event(GatewayEvent::Error { + event_id: "quota".to_string(), + session_id: session_id().to_string(), + utterance_id: 0, + code: GatewayErrorCode::Quota, + }), + GatewayTransportPoll::Pending, + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + let events = session.drain(); + assert!(matches!( + &events[0], + AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Auth, + .. + }) + )); + assert!(matches!( + &events[1], + AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Quota, + .. + }) + )); + assert_eq!(events[0].identity().sequence_id(), 1); + assert_eq!(events[1].identity().sequence_id(), 2); + } + + #[test] + fn bounded_send_reports_backpressure_without_advancing_frame_sequence() { + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::with_send_capacity(1), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + session.push_audio(&[0.0; 4]).expect("first frame"); + assert_eq!(session.push_audio(&[0.0; 4]), Err(AsrErrorKind::Overflow)); + assert_eq!(session.transport().frames.len(), 1); + assert_eq!(session.transport().frames[0].sequence_id(), 1); + assert_eq!(session.telemetry().backpressure_events, 1); + } + + #[test] + fn close_drains_trailing_final_and_usage_before_ack() { + let script = [ + GatewayTransportPoll::Event(final_event("tail", 2, 7, "ogon")), + GatewayTransportPoll::Event(GatewayEvent::Usage { + event_id: "tail-usage".to_string(), + session_id: session_id().to_string(), + audio_ms: 2_000, + billable_units: None, + }), + GatewayTransportPoll::Event(GatewayEvent::SessionEnded { + session_id: session_id().to_string(), + }), + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + session.close().expect("bounded close"); + let events = session.drain(); + assert_eq!(events.len(), 2); + assert!(events[0].is_final()); + assert_eq!(events[1].as_token(), "usage"); + } + + #[test] + fn close_timeout_aborts_and_emits_one_typed_fault() { + let mut short_limits = limits(); + short_limits.close_timeout = Duration::from_millis(1); + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::default(), + short_limits, + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + assert_eq!(session.close(), Err(AsrErrorKind::Transport)); + assert!(session.transport().aborted); + let events = session.drain(); + assert!(matches!( + events.as_slice(), + [AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Transport, + .. + })] + )); + } + + #[test] + fn connection_and_telemetry_debug_are_secret_safe() { + let connection = GatewayConnection::new( + "wss://gateway.invalid/live?signed=do-not-log", + "bearer-do-not-log", + ) + .expect("valid normalized gateway"); + let debug = format!("{connection:?}"); + assert!(!debug.contains("signed=do-not-log")); + assert!(!debug.contains("bearer-do-not-log")); + assert!(debug.contains("REDACTED")); + + let telemetry = format!("{:?}", CloudSessionTelemetry::default()); + assert!(!telemetry.contains("gateway.invalid")); + assert!(!telemetry.contains("bearer")); + } + + #[test] + fn production_connection_refuses_remote_plaintext_and_user_info() { + let plain_websocket = concat!("ws", "://"); + assert_eq!( + GatewayConnection::new(format!("{plain_websocket}gateway.invalid/live"), "token") + .unwrap_err(), + AsrErrorKind::Protocol + ); + assert_eq!( + GatewayConnection::new("wss://user@gateway.invalid/live", "token").unwrap_err(), + AsrErrorKind::Protocol + ); + assert!( + GatewayConnection::new(format!("{plain_websocket}127.0.0.1:9000/live"), "token") + .is_ok() + ); + } +} diff --git a/core/asr_session/consent.rs b/core/asr_session/consent.rs new file mode 100644 index 00000000..42ce7ce6 --- /dev/null +++ b/core/asr_session/consent.rs @@ -0,0 +1,127 @@ +//! The audio-egress consent gate in front of Layer 1 session construction. +//! +//! [`super::provider::RefinerMode::sends_audio_off_device`] is the classifier; +//! this module is the owner that asks it. A cloud session cannot be authorized +//! without an explicit granted consent record, and every refusal degrades to +//! [`RefinerMode::Off`] (Apple canvas + lexicon) — never to a local model. +//! +//! The gate produces a [`CloudEgressAuthorization`] witness. The type has no +//! public constructor, so recorder/transport wiring that opens a real +//! [`super::cloud::LiveCloudAsrSession`] against a minted gateway session must +//! have passed through [`authorize_cloud_egress`] to hold one — consent +//! enforcement is structural, not a convention callers remember to follow. + +use crate::config::cloud_asr::{AsrProductMode, AudioEgressConsent, ResolvedAsrMode}; + +use super::provider::RefinerMode; + +/// Typed refusal from the Layer 1 session factory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloudSessionError { + /// Audio egress was requested without an explicit granted consent record. + ConsentRequired, +} + +/// Proof that explicit audio-egress consent backs a cloud session. +/// +/// Constructible only through [`authorize_cloud_egress`]. Deliberately not +/// `Clone`/`Copy`: one authorization, one session. +#[derive(Debug, PartialEq, Eq)] +pub struct CloudEgressAuthorization { + _witness: (), +} + +/// Authorize opening a cloud Layer 1 session under the given consent state. +/// +/// This is the production factory gate the fleet RED precommitted: session +/// construction without explicit audio-egress consent is rejected with a typed +/// [`CloudSessionError::ConsentRequired`], never a panic and never a silent +/// downgrade to a different provider. +pub fn authorize_cloud_egress( + consent: &AudioEgressConsent, +) -> Result { + if consent.permits_egress() { + Ok(CloudEgressAuthorization { _witness: () }) + } else { + Err(CloudSessionError::ConsentRequired) + } +} + +/// Map the resolved product mode onto the Layer 1 refiner axis. +/// +/// The consent gate is re-asked here even though the resolver already enforced +/// it — defense in depth for a hand-built [`ResolvedAsrMode`]. Every refusal +/// lands on [`RefinerMode::Off`]: a missing consent can suppress cloud, but it +/// can never promote local weights nobody opted into. +pub fn refiner_for(resolved: &ResolvedAsrMode) -> RefinerMode { + match resolved.mode { + AsrProductMode::Cloud => match authorize_cloud_egress(&resolved.consent) { + Ok(_) => RefinerMode::CloudSession, + Err(CloudSessionError::ConsentRequired) => RefinerMode::Off, + }, + AsrProductMode::LocalPower => RefinerMode::LocalHelper, + AsrProductMode::AppleOnly => RefinerMode::Off, + } +} + +/// Consent-gate unit contracts; the fleet-level witness lives in +/// `crate::stt::fleet_red_contracts`. +#[cfg(test)] +mod tests { + use super::*; + use crate::config::cloud_asr::{ConsentSource, resolve_asr_product_mode}; + + /// Unanswered and denied consent both refuse authorization with the typed + /// error; a granted record (either source) passes. + #[test] + fn consent_gate_refuses_without_explicit_grant() { + for refused in [AudioEgressConsent::Unanswered, AudioEgressConsent::Denied] { + assert_eq!( + authorize_cloud_egress(&refused), + Err(CloudSessionError::ConsentRequired) + ); + } + for source in [ + ConsentSource::ExplicitSettings, + ConsentSource::LegacyCloudChoice, + ] { + assert!(authorize_cloud_egress(&AudioEgressConsent::Granted(source)).is_ok()); + } + } + + /// Mode-to-refiner mapping: consent-backed cloud arms the cloud session, + /// explicit local power arms the helper, and every refusal is Off — the + /// degraded shape can never be a local model load. + #[test] + fn refiner_mapping_degrades_to_off_never_local() { + let cloud = resolve_asr_product_mode(Some("cloud"), Some("granted"), None); + assert_eq!(refiner_for(&cloud), RefinerMode::CloudSession); + + let local = resolve_asr_product_mode(Some("local_power"), None, None); + assert_eq!(refiner_for(&local), RefinerMode::LocalHelper); + + for resolved in [ + resolve_asr_product_mode(Some("cloud"), None, None), + resolve_asr_product_mode(Some("cloud"), Some("denied"), Some(true)), + resolve_asr_product_mode(None, None, None), + resolve_asr_product_mode(Some("apple_only"), Some("granted"), None), + ] { + assert_eq!( + refiner_for(&resolved), + RefinerMode::Off, + "refusal for {resolved:?} must degrade to Off, never LocalHelper" + ); + } + } + + /// The privacy bound on telemetry holds at the type level: session + /// counters are `Copy` (no heap text can hide in them), and the typed + /// error vocabulary carries no payload a transcript could ride on. + #[test] + fn telemetry_and_errors_stay_content_free() { + fn assert_copy() {} + assert_copy::(); + assert_copy::(); + assert_copy::(); + } +} diff --git a/core/asr_session/events.rs b/core/asr_session/events.rs new file mode 100644 index 00000000..6ae178f7 --- /dev/null +++ b/core/asr_session/events.rs @@ -0,0 +1,290 @@ +//! Typed Layer 1 session events. +//! +//! Every event a Layer 1 provider emits carries three identity fields: +//! +//! - **session** — which recording this belongs to. A provider that reconnects +//! and resumes the wrong stream is caught here rather than downstream. +//! - **utterance** — which speech unit inside the session. Sealing is +//! per-utterance, so a late partial cannot reopen committed text. +//! - **sequence** — Codescribe's stream-global monotonic counter, assigned by +//! the session adapter after provider duplicates and stale per-utterance +//! revisions are removed. A provider's sequence scope is never trusted. +//! +//! Partial versus final is a **variant**, not a boolean flag, so a caller +//! cannot forget to check it — the compiler makes the finality decision +//! explicit at every match site. +//! +//! Errors and usage are typed with no free-form string payload. That is a +//! deliberate privacy boundary: a `String` on an error is exactly where a +//! transcript fragment, an audio path, or a bearer token ends up in a log. + +use std::fmt; + +/// Per-recording session identity minted when a Layer 1 session opens. +/// +/// Opaque on purpose: the desktop consumes whatever the gateway/session mint +/// hands it and never parses meaning out of it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionId(String); + +impl SessionId { + /// Build a session id, rejecting blank input. + /// + /// An empty id would make every session compare equal, which turns the + /// foreign-session guard in [`super::ingest`] into a no-op. + pub fn new(raw: impl Into) -> Option { + let raw = raw.into(); + if raw.trim().is_empty() { + return None; + } + Some(Self(raw)) + } + + /// Borrow the raw id. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SessionId { + /// Print the raw id (it is an opaque handle, not a secret). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Session, utterance, and sequence identity carried by every event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventIdentity { + /// Which session this event belongs to. + session_id: SessionId, + /// Which utterance inside the session. + utterance_id: u64, + /// Codescribe-owned stream-global monotonic counter; the ordering authority. + sequence_id: u64, +} + +impl EventIdentity { + /// Build an identity triple. + pub fn new(session_id: SessionId, utterance_id: u64, sequence_id: u64) -> Self { + Self { + session_id, + utterance_id, + sequence_id, + } + } + + /// Session this event belongs to. + pub fn session_id(&self) -> &SessionId { + &self.session_id + } + + /// Utterance this event belongs to. + pub fn utterance_id(&self) -> u64 { + self.utterance_id + } + + /// Monotonic stream position of this event. + pub fn sequence_id(&self) -> u64 { + self.sequence_id + } +} + +/// A bounded span of session audio an event describes. +/// +/// Session time, measured in seconds from the first captured sample — the same +/// clock the Apple progressive path derives `audio_secs` from. It is neither +/// wall clock nor the capture device clock. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AudioRange { + /// Inclusive start in session seconds. + start_secs: f32, + /// Exclusive end in session seconds. + end_secs: f32, +} + +impl AudioRange { + /// Widest span an event may claim. + /// + /// Pinned to the live PCM ring's retention rather than restated, so the two + /// cannot drift: a range wider than what the session still holds describes + /// audio nothing can re-read, and a consumer resolving it would be handed a + /// silently short window. + pub const MAX_SPAN_SECS: f32 = + crate::pipeline::streaming::live_audio_buffer::DEFAULT_RETENTION_SECS; + + /// Build a range, refusing anything that is not a usable span. + /// + /// Rejects non-finite bounds (`f32 as u64` maps NaN to 0 and saturates + /// infinities, turning a corrupt timestamp into a plausible window), + /// negative starts, inverted or empty spans, and spans past + /// [`Self::MAX_SPAN_SECS`]. + pub fn new(start_secs: f32, end_secs: f32) -> Option { + if !start_secs.is_finite() || !end_secs.is_finite() { + return None; + } + if start_secs < 0.0 || end_secs <= start_secs { + return None; + } + if end_secs - start_secs > Self::MAX_SPAN_SECS { + return None; + } + Some(Self { + start_secs, + end_secs, + }) + } + + /// Inclusive start in session seconds. + pub fn start_secs(&self) -> f32 { + self.start_secs + } + + /// Exclusive end in session seconds. + pub fn end_secs(&self) -> f32 { + self.end_secs + } + + /// Span length in seconds. + pub fn duration_secs(&self) -> f32 { + self.end_secs - self.start_secs + } +} + +/// Why a Layer 1 session failed — typed, with no free-form payload. +/// +/// Every variant means the same thing to the product: the refiner is gone for +/// now and the canvas plus lexicon carry the session. The distinction exists so +/// a caller can decide whether retrying is worth anything, never so an error +/// message can be shown verbatim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AsrErrorKind { + /// Connection dropped, timed out, or was never established. + Transport, + /// Session credentials were rejected or expired. + Auth, + /// Provider asked us to slow down. + RateLimited, + /// The gateway reports that the account has no remaining quota. + Quota, + /// Our side could not keep up and dropped bounded work. + Overflow, + /// Provider does not support what the session asked for (locale, mode). + Unsupported, + /// Provider spoke something this contract cannot parse. + Protocol, + /// The session was closed by us before it produced a final. + Cancelled, +} + +impl AsrErrorKind { + /// Whether reopening the session could plausibly succeed. + /// + /// `Auth`, `Unsupported`, and `Protocol` are settings- or contract-level + /// faults: retrying them just burns audio egress for the same failure. + pub fn is_retryable(&self) -> bool { + match self { + Self::Transport | Self::RateLimited | Self::Overflow => true, + Self::Auth | Self::Quota | Self::Unsupported | Self::Protocol | Self::Cancelled => { + false + } + } + } + + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Transport => "transport", + Self::Auth => "auth", + Self::RateLimited => "rate_limited", + Self::Quota => "quota", + Self::Overflow => "overflow", + Self::Unsupported => "unsupported", + Self::Protocol => "protocol", + Self::Cancelled => "cancelled", + } + } +} + +impl fmt::Display for AsrErrorKind { + /// Print the stable token — never a provider-supplied string. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_token()) + } +} + +/// Recognized text for one utterance, partial or final. +#[derive(Debug, Clone, PartialEq)] +pub struct TranscriptEvent { + /// Session, utterance, and sequence identity. + pub identity: EventIdentity, + /// The recognized text. Layer 1 output is a *candidate*; committing it is + /// the caller's decision and is bounded by the append-only doctrine. + pub text: String, + /// Session-time span this text came from, when the provider reports one. + pub range: Option, +} + +/// A typed session failure. +#[derive(Debug, Clone, PartialEq)] +pub struct ErrorEvent { + /// Session, utterance, and sequence identity. + pub identity: EventIdentity, + /// What went wrong. + pub kind: AsrErrorKind, +} + +/// Consumption accounting for one session — no content, ever. +#[derive(Debug, Clone, PartialEq)] +pub struct UsageEvent { + /// Session, utterance, and sequence identity. + pub identity: EventIdentity, + /// Audio seconds the provider processed. + pub audio_secs: f32, + /// Provider-side billable units, when it reports them. + pub billable_units: Option, +} + +/// Everything a Layer 1 provider can emit. +#[derive(Debug, Clone, PartialEq)] +pub enum AsrSessionEvent { + /// Volatile hypothesis; may be revised by a later partial or the final. + Partial(TranscriptEvent), + /// Sealing result for an utterance. Re-delivery is idempotent. + Final(TranscriptEvent), + /// Typed failure. + Error(ErrorEvent), + /// Consumption accounting. + Usage(UsageEvent), +} + +impl AsrSessionEvent { + /// Identity triple carried by this event. + pub fn identity(&self) -> &EventIdentity { + match self { + Self::Partial(event) | Self::Final(event) => &event.identity, + Self::Error(event) => &event.identity, + Self::Usage(event) => &event.identity, + } + } + + /// Whether this event carries recognized text. + pub fn is_transcript(&self) -> bool { + matches!(self, Self::Partial(_) | Self::Final(_)) + } + + /// Whether this event seals its utterance. + pub fn is_final(&self) -> bool { + matches!(self, Self::Final(_)) + } + + /// Stable snake_case variant token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Partial(_) => "partial", + Self::Final(_) => "final", + Self::Error(_) => "error", + Self::Usage(_) => "usage", + } + } +} diff --git a/core/asr_session/fake.rs b/core/asr_session/fake.rs new file mode 100644 index 00000000..1976413b --- /dev/null +++ b/core/asr_session/fake.rs @@ -0,0 +1,167 @@ +//! A deterministic Layer 1 provider that talks to nothing. +//! +//! The real providers (a gateway websocket, a killable local helper) are later +//! cuts. Their tests will need something that produces the *shape* of a live +//! session — lifecycle faults, partials landing before finals, a trailing usage +//! record — without a socket, a model, a thread, or a clock. This is that +//! something. +//! +//! Everything it does is a pure function of the script it was built with and +//! the calls it received. There is no timing, no randomness, and no I/O, so a +//! test that passes here passes on a loaded machine too. + +use std::collections::VecDeque; + +use super::events::{AsrErrorKind, AsrSessionEvent, EventIdentity, SessionId, UsageEvent}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; + +/// Utterance id the fake stamps on session-scoped records (its closing usage +/// event), which describe the whole session rather than one speech unit. +const SESSION_SCOPE_UTTERANCE: u64 = 0; + +/// Where a fake session is in its lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// Constructed, not yet opened. + Idle, + /// Open and accepting audio. + Open, + /// Closed; no further audio accepted. + Closed, +} + +/// A scripted, in-memory [`AsrSessionProvider`]. +#[derive(Debug)] +pub struct FakeAsrSessionProvider { + /// Mode this fake claims to implement. + mode: RefinerMode, + /// Events still waiting to be released, in script order. + script: VecDeque, + /// Events released and not yet drained. + ready: Vec, + /// Lifecycle position. + state: State, + /// Session identity captured at open. + session_id: Option, + /// Sample rate captured at open, floored at 1 to keep the clock finite. + sample_rate: u32, + /// Total samples pushed, the fake's only notion of time. + pushed_samples: u64, + /// Highest sequence released so far, so the closing usage event stays + /// monotonic whatever the script did. + highest_sequence: Option, + /// When set, every `push_audio` fails with this kind. + push_failure: Option, +} + +impl FakeAsrSessionProvider { + /// Build a fake that will release `script` one event per pushed chunk. + pub fn with_script(mode: RefinerMode, script: Vec) -> Self { + Self { + mode, + script: script.into(), + ready: Vec::new(), + state: State::Idle, + session_id: None, + sample_rate: 1, + pushed_samples: 0, + highest_sequence: None, + push_failure: None, + } + } + + /// Build a fake with no scripted transcript events. + pub fn new(mode: RefinerMode) -> Self { + Self::with_script(mode, Vec::new()) + } + + /// Make every `push_audio` fail with `kind` — the degradation harness. + pub fn failing_pushes(mut self, kind: AsrErrorKind) -> Self { + self.push_failure = Some(kind); + self + } + + /// Session seconds derived from pushed audio. + pub fn pushed_secs(&self) -> f32 { + self.pushed_samples as f32 / self.sample_rate as f32 + } + + /// Whether the script has been fully released. + pub fn script_drained(&self) -> bool { + self.script.is_empty() + } + + /// Move one scripted event to the ready queue, tracking its sequence. + fn release_one(&mut self) { + if let Some(event) = self.script.pop_front() { + let sequence = event.identity().sequence_id(); + self.highest_sequence = Some(match self.highest_sequence { + Some(previous) => previous.max(sequence), + None => sequence, + }); + self.ready.push(event); + } + } + + /// Sequence to stamp on the fake's own closing usage event. + fn next_sequence(&self) -> u64 { + self.highest_sequence.map_or(0, |highest| highest + 1) + } +} + +impl AsrSessionProvider for FakeAsrSessionProvider { + /// Mode this fake was built for. + fn mode(&self) -> RefinerMode { + self.mode + } + + /// Open once; a second open is a protocol fault, not a panic. + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind> { + if self.state != State::Idle { + return Err(AsrErrorKind::Protocol); + } + self.session_id = Some(input.session_id.clone()); + self.sample_rate = input.sample_rate.max(1); + self.state = State::Open; + Ok(()) + } + + /// Accept a chunk and release the next scripted event. + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind> { + if self.state != State::Open { + return Err(AsrErrorKind::Protocol); + } + if let Some(kind) = self.push_failure { + return Err(kind); + } + self.pushed_samples += samples.len() as u64; + self.release_one(); + Ok(()) + } + + /// Hand over everything released so far. + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.ready) + } + + /// Close, flushing the rest of the script and a usage record behind it. + fn close(&mut self) -> Result<(), AsrErrorKind> { + if self.state != State::Open { + return Err(AsrErrorKind::Protocol); + } + while !self.script.is_empty() { + self.release_one(); + } + if let Some(session_id) = self.session_id.clone() { + let identity = + EventIdentity::new(session_id, SESSION_SCOPE_UTTERANCE, self.next_sequence()); + self.ready.push(AsrSessionEvent::Usage(UsageEvent { + identity, + audio_secs: self.pushed_secs(), + billable_units: None, + })); + } + self.state = State::Closed; + Ok(()) + } +} diff --git a/core/asr_session/ingest.rs b/core/asr_session/ingest.rs new file mode 100644 index 00000000..c1dec94c --- /dev/null +++ b/core/asr_session/ingest.rs @@ -0,0 +1,191 @@ +//! Ordering and idempotence for a Layer 1 event stream. +//! +//! A live provider is not a well-behaved iterator. It reconnects and replays, +//! it re-sends a final it is not sure we received, and a slow frame can arrive +//! after the frame that supersedes it. Arrival order is therefore not ordering. +//! The provider adapter removes replays and stale provider revisions, then +//! assigns the Codescribe-owned stream-global `sequence_id` consumed here. +//! +//! [`SessionIngest`] is the one place that decision is made, so no downstream +//! consumer has to re-derive it and none of them can disagree. It holds no +//! audio, spawns nothing, and reads no clock: the same event sequence always +//! produces the same verdicts. +//! +//! ## The rules, in order +//! +//! 1. An event for another session is refused outright. +//! 2. A final identical to the one that already sealed its utterance is +//! **idempotent** — accepted-in-effect, applied once. This is the reconnect +//! resend, and it may legitimately arrive after newer events. +//! 3. A byte-identical repeat of the last accepted event is likewise idempotent. +//! 4. Anything else at or below the highest accepted sequence is out of order +//! and refused. Late text must never overwrite newer text. +//! 5. A partial or final aimed at a sealed utterance is refused. A final is a +//! commitment; reopening it is the replacement this product forbids. +//! 6. Otherwise the event is accepted, and a final seals its utterance. +//! +//! Errors and usage are diagnostics, not text, so they are not blocked by a +//! sealed utterance — only by ordering. + +use std::collections::BTreeMap; + +use super::events::{AsrSessionEvent, SessionId, TranscriptEvent}; + +/// What [`SessionIngest::ingest`] decided about one event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IngestVerdict { + /// Applied; it advanced the stream. + Accepted, + /// Already applied. Re-delivery changed nothing, which is the point. + DuplicateIdempotent, + /// At or below the highest accepted sequence, and not a known duplicate. + RejectedOutOfOrder, + /// Aimed at an utterance a final already sealed. + RejectedSealedUtterance, + /// Belongs to a different session. + RejectedForeignSession, +} + +impl IngestVerdict { + /// Whether the event joined the accepted stream. + pub fn is_accepted(&self) -> bool { + matches!(self, Self::Accepted) + } + + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::DuplicateIdempotent => "duplicate_idempotent", + Self::RejectedOutOfOrder => "rejected_out_of_order", + Self::RejectedSealedUtterance => "rejected_sealed_utterance", + Self::RejectedForeignSession => "rejected_foreign_session", + } + } +} + +/// Monotonic, idempotent ledger for one Layer 1 session. +#[derive(Debug)] +pub struct SessionIngest { + /// The only session whose events this ledger accepts. + session_id: SessionId, + /// Highest accepted sequence, or `None` before the first accepted event. + last_sequence: Option, + /// The last accepted event, for same-sequence duplicate detection. + last_accepted: Option, + /// The final that sealed each utterance. + sealed: BTreeMap, + /// Accepted events, in accepted order. + accepted: Vec, + /// How many re-deliveries were absorbed idempotently. + duplicate_count: u64, + /// How many events were refused as out of order. + out_of_order_count: u64, + /// How many transcript events were refused by a sealed utterance. + sealed_rejection_count: u64, + /// How many events were refused as belonging to another session. + foreign_rejection_count: u64, +} + +impl SessionIngest { + /// Open a ledger bound to one session. + pub fn new(session_id: SessionId) -> Self { + Self { + session_id, + last_sequence: None, + last_accepted: None, + sealed: BTreeMap::new(), + accepted: Vec::new(), + duplicate_count: 0, + out_of_order_count: 0, + sealed_rejection_count: 0, + foreign_rejection_count: 0, + } + } + + /// Apply one event and report what was decided. + pub fn ingest(&mut self, event: AsrSessionEvent) -> IngestVerdict { + let identity = event.identity(); + + if identity.session_id() != &self.session_id { + self.foreign_rejection_count += 1; + return IngestVerdict::RejectedForeignSession; + } + + let utterance_id = identity.utterance_id(); + let sequence_id = identity.sequence_id(); + let sealed_final = self.sealed.get(&utterance_id); + + // Rule 2: the reconnect resend. Position in the stream is irrelevant — + // an identical final says exactly what we already committed. + if let (AsrSessionEvent::Final(incoming), Some(existing)) = (&event, sealed_final) + && incoming == existing + { + self.duplicate_count += 1; + return IngestVerdict::DuplicateIdempotent; + } + + match self.last_sequence { + Some(last) if sequence_id == last && self.last_accepted.as_ref() == Some(&event) => { + // Rule 3: same slot, same content — a retransmit, not a change. + self.duplicate_count += 1; + return IngestVerdict::DuplicateIdempotent; + } + Some(last) if sequence_id <= last => { + // Rule 4: stale. Applying it would let older text win. + self.out_of_order_count += 1; + return IngestVerdict::RejectedOutOfOrder; + } + _ => {} + } + + // Rule 5: a final is a commitment; nothing may reopen it. + if event.is_transcript() && sealed_final.is_some() { + self.sealed_rejection_count += 1; + return IngestVerdict::RejectedSealedUtterance; + } + + if let AsrSessionEvent::Final(transcript) = &event { + self.sealed.insert(utterance_id, transcript.clone()); + } + self.last_sequence = Some(sequence_id); + self.last_accepted = Some(event.clone()); + self.accepted.push(event); + IngestVerdict::Accepted + } + + /// Accepted events, in accepted order. + pub fn accepted(&self) -> &[AsrSessionEvent] { + &self.accepted + } + + /// The final that sealed `utterance_id`, if one has. + pub fn sealed_final(&self, utterance_id: u64) -> Option<&TranscriptEvent> { + self.sealed.get(&utterance_id) + } + + /// Highest accepted sequence, or `None` before the first accepted event. + pub fn last_sequence(&self) -> Option { + self.last_sequence + } + + /// Re-deliveries absorbed idempotently. + pub fn duplicate_count(&self) -> u64 { + self.duplicate_count + } + + /// Events refused as out of order. + pub fn out_of_order_count(&self) -> u64 { + self.out_of_order_count + } + + /// Transcript events refused by a sealed utterance. + pub fn sealed_rejection_count(&self) -> u64 { + self.sealed_rejection_count + } + + /// Events refused as belonging to another session. + pub fn foreign_rejection_count(&self) -> u64 { + self.foreign_rejection_count + } +} diff --git a/core/asr_session/local_helper.rs b/core/asr_session/local_helper.rs new file mode 100644 index 00000000..7a202767 --- /dev/null +++ b/core/asr_session/local_helper.rs @@ -0,0 +1,294 @@ +//! Killable local Layer 1 helper boundary. +//! +//! Local weights never belong to the GUI process. This provider owns only a +//! child-process contract injected by the selected power-user runtime. The +//! concrete Qwen/Parakeet runner remains outside the app and outside this +//! crate; no model library is linked here and no failed helper can fall back to +//! in-process Whisper. +//! +//! Process exit is the reclaim authority. [`LocalHelperLifecycle::Stopped`] +//! after a session means the child was waited and reported exited — dropping a +//! handle or sending a shutdown request is not enough. + +use super::events::{AsrErrorKind, AsrSessionEvent}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; + +/// Observable local-helper lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalHelperLifecycle { + /// No child is owned. Initial state and confirmed post-exit state. + Stopped, + /// A child is being spawned and its session is being opened. + Starting, + /// The child accepted the session and can consume PCM. + Ready, + /// Shutdown was requested and the provider is waiting for process exit. + Cooling, +} + +/// Proof returned only after the operating system reports process exit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalHelperExit { + /// PID observed from the child handle. + pub pid: u32, + /// True only after a successful wait/reap. + pub exited: bool, +} + +/// One spawned helper process. +/// +/// Implementations own IPC and their bounded shutdown deadline. Both +/// [`Self::wait_for_exit`] and [`Self::kill_and_wait`] must reap the process; +/// returning `exited: false` is treated as a transport failure, never reclaim. +pub trait LocalHelperProcess: Send { + /// Operating-system process identifier. + fn pid(&self) -> u32; + + /// Open one provider-compatible ASR session in the child. + fn start(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind>; + + /// Forward one PCM chunk without blocking the Apple capture lane. + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind>; + + /// Drain typed events already available from the child. Never blocks. + fn drain(&mut self) -> Vec; + + /// Ask the child to finish its current session and exit. + fn request_shutdown(&mut self) -> Result<(), AsrErrorKind>; + + /// Wait within the implementation's bounded graceful-exit deadline. + fn wait_for_exit(&mut self) -> Result; + + /// Kill, then wait and reap. This is the final reclaim backstop. + fn kill_and_wait(&mut self) -> Result; +} + +/// Injected process factory. +/// +/// The stock app has no default implementation. A power-user runtime must make +/// an explicit model/download decision and inject its launcher. +pub trait LocalHelperLauncher: Send { + /// Spawn one helper process with no weights in the caller process. + fn spawn(&mut self) -> Result, AsrErrorKind>; +} + +/// Provider-compatible owner of a killable local helper. +pub struct LocalHelperAsrSession { + launcher: Box, + child: Option>, + lifecycle: LocalHelperLifecycle, + transitions: Vec, + last_exit: Option, + ever_opened: bool, +} + +impl LocalHelperAsrSession { + /// Build a stopped provider around an explicit launcher. + pub fn new(launcher: Box) -> Self { + Self { + launcher, + child: None, + lifecycle: LocalHelperLifecycle::Stopped, + transitions: vec![LocalHelperLifecycle::Stopped], + last_exit: None, + ever_opened: false, + } + } + + /// Current lifecycle state. + pub fn lifecycle(&self) -> LocalHelperLifecycle { + self.lifecycle + } + + /// Exact transition history, including the initial stopped state. + pub fn transitions(&self) -> &[LocalHelperLifecycle] { + &self.transitions + } + + /// PID of the currently owned child, if any. + pub fn child_pid(&self) -> Option { + self.child.as_ref().map(|child| child.pid()) + } + + /// Last confirmed process-exit proof. + pub fn last_exit(&self) -> Option { + self.last_exit + } + + fn transition(&mut self, next: LocalHelperLifecycle) { + self.lifecycle = next; + self.transitions.push(next); + } + + /// Reclaim `child`, preferring graceful exit and always falling back to a + /// kill+wait when graceful shutdown is refused or unconfirmed. + fn reclaim_child(child: &mut dyn LocalHelperProcess) -> Result { + let graceful = child + .request_shutdown() + .and_then(|()| child.wait_for_exit()); + match graceful { + Ok(proof) if proof.pid != 0 && proof.exited => Ok(proof), + Ok(_) | Err(_) => { + let proof = child.kill_and_wait()?; + if proof.pid == 0 || !proof.exited { + return Err(AsrErrorKind::Transport); + } + Ok(proof) + } + } + } + + fn stop_owned_child(&mut self) -> Result<(), AsrErrorKind> { + let Some(mut child) = self.child.take() else { + self.transition(LocalHelperLifecycle::Stopped); + return Ok(()); + }; + match Self::reclaim_child(child.as_mut()) { + Ok(proof) => { + self.last_exit = Some(proof); + self.transition(LocalHelperLifecycle::Stopped); + Ok(()) + } + Err(kind) => { + // We no longer claim Stopped: process exit was not proven. + self.child = Some(child); + Err(kind) + } + } + } +} + +impl AsrSessionProvider for LocalHelperAsrSession { + fn mode(&self) -> RefinerMode { + RefinerMode::LocalHelper + } + + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind> { + if self.lifecycle != LocalHelperLifecycle::Stopped || self.ever_opened { + return Err(AsrErrorKind::Protocol); + } + self.ever_opened = true; + self.transition(LocalHelperLifecycle::Starting); + + let mut child = match self.launcher.spawn() { + Ok(child) if child.pid() != 0 => child, + Ok(mut child) => { + let _ = Self::reclaim_child(child.as_mut()); + self.transition(LocalHelperLifecycle::Stopped); + return Err(AsrErrorKind::Protocol); + } + Err(kind) => { + self.transition(LocalHelperLifecycle::Stopped); + return Err(kind); + } + }; + + if let Err(kind) = child.start(input) { + let reclaim = Self::reclaim_child(child.as_mut()); + if let Ok(proof) = reclaim { + self.last_exit = Some(proof); + self.transition(LocalHelperLifecycle::Stopped); + return Err(kind); + } + self.child = Some(child); + return Err(AsrErrorKind::Transport); + } + + self.child = Some(child); + self.transition(LocalHelperLifecycle::Ready); + Ok(()) + } + + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind> { + if self.lifecycle != LocalHelperLifecycle::Ready { + return Err(AsrErrorKind::Protocol); + } + let result = self + .child + .as_mut() + .ok_or(AsrErrorKind::Protocol)? + .push_audio(samples); + if let Err(kind) = result { + self.transition(LocalHelperLifecycle::Cooling); + self.stop_owned_child()?; + return Err(kind); + } + Ok(()) + } + + fn drain(&mut self) -> Vec { + if self.lifecycle != LocalHelperLifecycle::Ready { + return Vec::new(); + } + self.child + .as_mut() + .map_or_else(Vec::new, |child| child.drain()) + } + + fn close(&mut self) -> Result<(), AsrErrorKind> { + if self.lifecycle != LocalHelperLifecycle::Ready { + return Err(AsrErrorKind::Protocol); + } + self.transition(LocalHelperLifecycle::Cooling); + self.stop_owned_child() + } +} + +impl Drop for LocalHelperAsrSession { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() + && let Ok(proof) = Self::reclaim_child(child.as_mut()) + { + self.last_exit = Some(proof); + self.lifecycle = LocalHelperLifecycle::Stopped; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FailingLauncher; + + impl LocalHelperLauncher for FailingLauncher { + fn spawn(&mut self) -> Result, AsrErrorKind> { + Err(AsrErrorKind::Transport) + } + } + + fn input() -> SessionInput { + SessionInput { + session_id: super::super::SessionId::new("local-helper-test").expect("session id"), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } + } + + #[test] + #[serial_test::serial] + fn spawn_failure_returns_to_stopped_without_model_fallback() { + let whisper_before = crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls(); + let provider = LocalHelperAsrSession::new(Box::new(FailingLauncher)); + let mut lane = super::super::RecorderLayer1Lane::open( + super::super::Layer1Decision::Armed(Box::new(provider)), + &input(), + ); + + assert_eq!( + lane.state(), + super::super::Layer1LaneState::Degraded(super::super::Layer1DegradeReason::OpenFailed( + AsrErrorKind::Transport + )) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + assert!(lane.stop().finals().is_empty()); + assert_eq!( + crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls(), + whisper_before, + "a failed local helper must remain Apple + lexicon, never initialize in-process Whisper" + ); + } +} diff --git a/core/asr_session/mod.rs b/core/asr_session/mod.rs new file mode 100644 index 00000000..7947c83c --- /dev/null +++ b/core/asr_session/mod.rs @@ -0,0 +1,102 @@ +//! Neutral Layer 1 ASR session contract. +//! +//! Layer 0 (the Apple live canvas) draws instantly and owns committed text. +//! Layer 1 is a *refiner*: it may fill gaps and patch tails, and it may never +//! rewrite what the canvas already committed. This module is the seam that +//! Layer 1 providers plug into — typed and vendor-neutral. +//! +//! ## What lives here +//! +//! - [`events`] — the typed event vocabulary: every event carries session, +//! utterance and sequence identity, partial-vs-final is a variant (not a +//! boolean), the audio span is optional and bounded, and errors/usage are +//! typed with no free-form payload. +//! - [`ingest`] — the ordering state machine: monotonic sequencing, idempotent +//! duplicate finals, and a sealed utterance that no later partial can reopen. +//! - [`provider`] — [`AsrSessionProvider`] plus the selection types that keep +//! Layer 0 canvas choice and Layer 1 refiner mode on two separate axes. +//! - [`fake`] — a deterministic in-memory provider for tests and later cuts. +//! - [`cloud`] — the dedicated live gateway session, bounded PCM transport, +//! and Codescribe-owned stream-global event sequencing. +//! - [`local_helper`] — the provider-compatible, injected child-process +//! boundary whose confirmed exit is the local-weight reclaim authority. +//! - [`bootstrap`] — the recording-start join between persisted mode/consent +//! truth and a validated, short-lived gateway session. +//! - [`recorder`] — the per-recording lane the live session drives: injected +//! [`recorder::Layer1Decision`], bounded non-blocking PCM fan-out, volatile +//! partial draft, and typed degrade paths that always land on canvas + +//! lexicon. +//! - [`consent`] — the audio-egress gate: a cloud session is constructible +//! only through an explicit-consent authorization witness, and every +//! refusal degrades to canvas + lexicon, never a local model. +//! +//! ## What deliberately does NOT live here +//! +//! No settings UI, gateway session-mint client, vendor protocol, or local +//! model. The live socket consumes only a normalized gateway contract; the +//! mode/consent *records* live in `crate::config::cloud_asr` (the settings +//! brain), while [`bootstrap`] joins that truth to the recorder. +//! +//! The existing whole-file `client::transcribe_cloud` / `transcribe_websocket` +//! API is **outside** this contract. It uploads one completed recording and is +//! a stop/recovery path, not a live session — routing it through this interface +//! and calling the result "live" is precisely the confusion this seam prevents. +//! +//! ## Doctrine encoded in the types +//! +//! - A refiner failure degrades to canvas + lexicon +//! ([`LayerSelection::degraded`]); it can never swap the canvas engine, and +//! nothing here can trigger a local model load. +//! - A final seals its utterance. Re-delivery of that same final is idempotent; +//! anything else aimed at a sealed utterance is refused rather than applied. +//! - Errors carry a typed kind and nothing else, so no transcript fragment, +//! audio, or credential can ride an error into a log line. + +/// Recording-start mode/consent/gateway integration for the real recorder path. +pub mod bootstrap; +/// Dedicated provider-neutral live cloud gateway transport and session adapter. +pub mod cloud; +/// Audio-egress consent gate in front of Layer 1 session construction (C2). +pub mod consent; +/// Typed Layer 1 session events, identity, bounded ranges, errors, and usage. +pub mod events; +/// Deterministic in-memory provider used by tests and follow-on transport cuts. +pub mod fake; +/// Ordering state machine: monotonic sequencing and idempotent duplicate finals. +pub mod ingest; +/// Killable local-helper lifecycle and injected process boundary (L0). +pub mod local_helper; +/// Provider trait plus the canvas/refiner selection split. +pub mod provider; +/// Recorder-side Layer 1 lane: injected decision, bounded fan-out, degrade paths. +pub mod recorder; + +#[cfg(test)] +mod tests; + +pub use bootstrap::{GatewaySessionAvailability, layer1_decision_for_recording}; +pub use cloud::{ + CloudGatewayTransport, CloudSessionLimits, CloudSessionTelemetry, GatewayConnection, + GatewayErrorCode, GatewayEvent, GatewayPcmFrame, GatewaySessionConfig, GatewayTransportPoll, + GatewayWebSocketTransport, LiveCloudAsrSession, +}; +pub use consent::{ + CloudEgressAuthorization, CloudSessionError, authorize_cloud_egress, refiner_for, +}; +pub use events::{ + AsrErrorKind, AsrSessionEvent, AudioRange, ErrorEvent, EventIdentity, SessionId, + TranscriptEvent, UsageEvent, +}; +pub use fake::FakeAsrSessionProvider; +pub use ingest::{IngestVerdict, SessionIngest}; +pub use local_helper::{ + LocalHelperAsrSession, LocalHelperExit, LocalHelperLauncher, LocalHelperLifecycle, + LocalHelperProcess, +}; +pub use provider::{AsrSessionProvider, CanvasEngine, LayerSelection, RefinerMode, SessionInput}; +pub use recorder::{ + FanOutVerdict, LAYER1_DEGRADED_WARNING_CODE, Layer1Decision, Layer1DegradeReason, + Layer1LaneState, Layer1LaneTelemetry, Layer1SessionOutcome, RecorderLayer1Lane, + RecorderLifecycleEvent, RecorderLifecycleEvents, RecorderLifecycleHandle, + apply_recorder_lifecycle_event, recorder_lifecycle_channel, +}; diff --git a/core/asr_session/provider.rs b/core/asr_session/provider.rs new file mode 100644 index 00000000..6b721222 --- /dev/null +++ b/core/asr_session/provider.rs @@ -0,0 +1,172 @@ +//! The Layer 1 provider seam, and the selection split it depends on. +//! +//! ## Two axes, not one dial +//! +//! Which engine draws the live canvas (Layer 0) and which refiner improves it +//! (Layer 1) are independent choices, and [`LayerSelection`] is what keeps them +//! that way. Collapsing them into a single "engine" setting is how a Layer 1 +//! failure ends up silently changing what the user sees being typed, and how a +//! refiner choice ends up loading local weights nobody asked for. +//! +//! The canvas axis already has an owner — the STT router's +//! `CODESCRIBE_STT_ENGINE` policy. [`LayerSelection::for_active_canvas`] reads +//! that decision rather than restating it, so this module can never become a +//! second, disagreeing source of truth about the canvas. +//! +//! The trait stays independent of transport and consent policy. The cloud +//! implementation in [`super::cloud`] supplies a normalized gateway transport +//! and bounded drain policy; recorder wiring and the consent gate remain +//! separate owners. + +use super::events::{AsrErrorKind, AsrSessionEvent, SessionId}; + +/// Which engine draws the instant live canvas (Layer 0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanvasEngine { + /// Apple Speech — the letter-level instant canvas, the product default. + AppleSpeech, + /// Local Whisper, when Apple is unavailable or explicitly overridden. + LocalWhisper, +} + +impl CanvasEngine { + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::AppleSpeech => "apple_speech", + Self::LocalWhisper => "local_whisper", + } + } +} + +/// Which Layer 1 refiner is armed. +/// +/// [`RefinerMode::Off`] is a complete, shipping product: canvas plus lexicon. +/// Every failure path in Layer 1 lands here, and landing here is never a +/// degraded-mode apology that justifies loading something heavier instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RefinerMode { + /// No Layer 1. Canvas plus lexicon carries the session. + #[default] + Off, + /// A normalized remote session behind the gateway contract. + CloudSession, + /// A killable local helper process holding its own weights. + LocalHelper, +} + +impl RefinerMode { + /// Whether this mode sends captured audio off the machine. + /// + /// This is the classifier the consent gate ([`super::consent`]) asks. + pub fn sends_audio_off_device(&self) -> bool { + matches!(self, Self::CloudSession) + } + + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Off => "off", + Self::CloudSession => "cloud_session", + Self::LocalHelper => "local_helper", + } + } +} + +/// The canvas/refiner pair, held together so neither can silently move the +/// other. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LayerSelection { + /// Layer 0 — who draws. + canvas: CanvasEngine, + /// Layer 1 — who refines. + refiner: RefinerMode, +} + +impl LayerSelection { + /// Pair an explicit canvas with an explicit refiner. + pub fn new(canvas: CanvasEngine, refiner: RefinerMode) -> Self { + Self { canvas, refiner } + } + + /// Pair the router's *live* canvas decision with an independent refiner. + /// + /// Reads `stt::active_engine_is_apple`, the same selector the live lane + /// uses, so the canvas reported here is the canvas that will actually draw. + /// The refiner argument is untouched by that read — that independence is + /// the whole point and is pinned by test. + pub fn for_active_canvas(refiner: RefinerMode) -> Self { + let canvas = if crate::stt::active_engine_is_apple() { + CanvasEngine::AppleSpeech + } else { + CanvasEngine::LocalWhisper + }; + Self::new(canvas, refiner) + } + + /// Layer 0 engine. + pub fn canvas(&self) -> CanvasEngine { + self.canvas + } + + /// Layer 1 mode. + pub fn refiner(&self) -> RefinerMode { + self.refiner + } + + /// The selection this degrades to when Layer 1 is unavailable. + /// + /// The canvas is carried through unchanged. A refiner that cannot run is a + /// missing improvement, never a reason to redraw the canvas with a + /// different engine or to reach for local weights. + pub fn degraded(&self) -> Self { + Self { + canvas: self.canvas, + refiner: RefinerMode::Off, + } + } +} + +/// Parameters a Layer 1 session opens with. +/// +/// Deliberately thin: identity, language, and audio format. Credentials, +/// endpoints, and consent are the gateway/settings cuts' business and must not +/// leak into the provider-facing shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionInput { + /// Identity every event from this session must carry. + pub session_id: SessionId, + /// BCP-47 language hint, when the product has one to give. + pub locale: Option, + /// Sample rate of the audio that will be pushed, in Hz. + pub sample_rate: u32, +} + +/// A Layer 1 refiner session. +/// +/// Object-safe on purpose: the recorder cut will hold a +/// `Box` chosen at runtime and must not be generic over +/// the transport. +/// +/// Lifecycle: [`open`](Self::open) once, then any number of +/// [`push_audio`](Self::push_audio) / [`drain`](Self::drain) calls, then +/// [`close`](Self::close). Calling out of order is a [`AsrErrorKind::Protocol`] +/// fault, not a panic — a live session must degrade, never abort the recording. +pub trait AsrSessionProvider { + /// Which refiner mode this provider implements. + fn mode(&self) -> RefinerMode; + + /// Open the session. Called at most once. + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind>; + + /// Feed captured audio. Session time is derived from what has been pushed, + /// so the caller does not have to keep a second clock in sync. + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind>; + + /// Take whatever events are ready. Never blocks. + fn drain(&mut self) -> Vec; + + /// Close the session. Trailing events remain available via + /// [`drain`](Self::drain). + fn close(&mut self) -> Result<(), AsrErrorKind>; +} diff --git a/core/asr_session/recorder.rs b/core/asr_session/recorder.rs new file mode 100644 index 00000000..bb731f16 --- /dev/null +++ b/core/asr_session/recorder.rs @@ -0,0 +1,1033 @@ +//! Recorder-side Layer 1 orchestration: the lane a live session drives. +//! +//! This is the C1 seam between audio capture and a Layer 1 refiner. The +//! recorder/session pipeline owns capture and the Apple canvas; this lane owns +//! everything a Layer 1 provider is *allowed* to do while a recording runs: +//! +//! - **Injected authority.** The lane never constructs a provider. It receives +//! a [`Layer1Decision`] — an already-authorized, typed decision made by the +//! consent/settings owner. [`Layer1Decision::Disarmed`] is the +//! stock product: canvas plus lexicon, no error, no fallback loading. +//! - **Bounded, non-blocking fan-out.** [`RecorderLayer1Lane::offer_pcm`] +//! returns immediately on every call. A refiner that cannot keep up costs +//! refinement frames, never capture: sustained overflow degrades the lane to +//! canvas + lexicon instead of ever exerting backpressure on audio. +//! - **Partials are volatile draft.** They live in the lane, are replaced +//! freely, and die with the lane. Nothing here can commit a partial to the +//! canvas. +//! - **Finals go through the doctrine seam.** Every final is vetted by +//! [`SessionIngest`] (ordering, idempotence, sealed utterances) and the +//! session outcome routes through [`crate::quality::merge_live_layer1`] — +//! the live floor is immutable; Layer 1 text can only fill gaps and tails. +//! - **Every failure lands on Apple + lexicon.** Overflow, disconnect, +//! sleep/wake, and an incomplete stop-drain all degrade to +//! [`RefinerMode::Off`]. Nothing in this module can reach local Whisper — +//! there is no import edge to `crate::stt`, and the fleet witness measures +//! the init counters to keep it that way. +//! +//! ## Degrade drops, stop closes +//! +//! [`AsrSessionProvider::close`] is bounded but may block briefly (the cloud +//! session drains its socket tail). Degradation happens on the live session +//! loop, where even a bounded stall would hold up canvas event drainage — so +//! a degrading lane *drops* its provider (the cloud transport aborts its actor +//! on drop) and only the deliberate stop path pays for a graceful close and +//! trailing-event drain. + +use std::collections::BTreeMap; +use std::fmt; + +use tracing::{info, warn}; + +use super::events::{AsrErrorKind, AsrSessionEvent, TranscriptEvent}; +use super::ingest::{IngestVerdict, SessionIngest}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; +use crate::quality::{Layer1MergedDelivery, merge_live_layer1}; + +/// Consecutive overflowed frames tolerated before the lane degrades. +/// +/// A single full queue is a hiccup and costs one refinement frame. A run of +/// them means the provider is not consuming; continuing to offer audio would +/// only burn CPU converting frames nobody reads. At the expected 200 ms frame +/// cadence this limit degrades after roughly 1.6 s of sustained overflow. +pub const OVERFLOW_DEGRADE_LIMIT: u32 = 8; + +/// Maximum post-close drain iterations before the stop path stops waiting. +/// +/// Each iteration consumes one non-empty [`AsrSessionProvider::drain`] batch. +/// The bound is iterations, not wall time, so tests need no clocks and a +/// misbehaving provider cannot hold the stop path hostage. +pub const STOP_DRAIN_MAX_POLLS: u32 = 32; + +/// `EngineEvent::Warning` code emitted when the live Layer 1 lane degrades. +/// +/// The message carries only the typed reason token — never transcript, audio, +/// or provider payload content. +pub const LAYER1_DEGRADED_WARNING_CODE: &str = "layer1_lane_degraded"; + +/// Host lifecycle boundary delivered to the active recording session. +/// +/// This channel is deliberately per recording. A sleep/wake notification must +/// never create a recorder, retry a provider, or affect a later session that +/// did not cross the boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecorderLifecycleEvent { + /// The host is about to sleep or has just resumed. + SleepWake, +} + +/// O(1) sender retained by the recording owner while one session is active. +#[derive(Debug, Clone)] +pub struct RecorderLifecycleHandle { + sender: tokio::sync::mpsc::UnboundedSender, +} + +impl RecorderLifecycleHandle { + /// Notify the active session of a sleep/wake boundary. + /// + /// Returns false only when the session has already gone away. Sending does + /// no model, disk, network, formatting, or transcript work. + pub fn note_sleep_wake(&self) -> bool { + self.sender.send(RecorderLifecycleEvent::SleepWake).is_ok() + } +} + +/// Receive side owned exclusively by the live transcription task. +#[derive(Debug)] +pub struct RecorderLifecycleEvents { + receiver: tokio::sync::mpsc::UnboundedReceiver, +} + +impl RecorderLifecycleEvents { + /// Wait for the next host lifecycle boundary. + pub async fn recv(&mut self) -> Option { + self.receiver.recv().await + } +} + +/// Create the per-recording lifecycle adapter shared by recorder and session. +pub fn recorder_lifecycle_channel() -> (RecorderLifecycleHandle, RecorderLifecycleEvents) { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + ( + RecorderLifecycleHandle { sender }, + RecorderLifecycleEvents { receiver }, + ) +} + +/// The injected, already-authorized Layer 1 decision a recording starts with. +/// +/// Construction and consent are deliberately *not* this module's business: the +/// settings/consent owner builds the provider and hands the finished decision +/// in. A recording that receives [`Self::Disarmed`] is the normal product — +/// not an error, and never a trigger for loading anything heavier. +pub enum Layer1Decision { + /// No Layer 1 refiner for this recording. Canvas plus lexicon, complete. + Disarmed, + /// An already-authorized provider, ready to open. + Armed(Box), +} + +impl Layer1Decision { + /// Whether this decision carries a provider. + pub fn is_armed(&self) -> bool { + matches!(self, Self::Armed(_)) + } +} + +impl Default for Layer1Decision { + /// The stock product decision: no Layer 1. + fn default() -> Self { + Self::Disarmed + } +} + +impl fmt::Debug for Layer1Decision { + /// Counts-only debug: the provider itself is never printed. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disarmed => f.write_str("Layer1Decision::Disarmed"), + Self::Armed(provider) => f + .debug_struct("Layer1Decision::Armed") + .field("mode", &provider.mode().as_token()) + .finish(), + } + } +} + +/// Why the lane fell back to canvas + lexicon. Typed, content-free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layer1DegradeReason { + /// The provider refused to open. + OpenFailed(AsrErrorKind), + /// Sustained fan-out overflow — the provider stopped consuming. + Overflow, + /// The provider reported or exhibited a session-fatal fault. + Disconnect(AsrErrorKind), + /// The host slept mid-recording; the session is presumed stale. + SleepWake, + /// Stop-drain hit its iteration bound before the provider went quiet. + StopDrainIncomplete, +} + +impl Layer1DegradeReason { + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::OpenFailed(_) => "open_failed", + Self::Overflow => "overflow", + Self::Disconnect(_) => "disconnect", + Self::SleepWake => "sleep_wake", + Self::StopDrainIncomplete => "stop_drain_incomplete", + } + } +} + +/// Where the lane is in its lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layer1LaneState { + /// Opened with [`Layer1Decision::Disarmed`] — normal Apple + lexicon. + Unarmed, + /// Provider session open and consuming fan-out. + Live, + /// Layer 1 is gone for this recording; canvas + lexicon carry it. + Degraded(Layer1DegradeReason), + /// The recording stopped and the lane completed its bounded drain. + Stopped, +} + +/// What one fan-out offer did. Informational — capture never branches on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FanOutVerdict { + /// The frame reached the provider. + Forwarded, + /// The provider's queue was full; the frame was dropped, capture continues. + DroppedOverflow, + /// The lane is not live; the frame was ignored. + Inactive, +} + +/// Content-free lane counters, reported at session end. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Layer1LaneTelemetry { + /// Frames capture offered to the lane (including while not live). + pub frames_offered: u64, + /// Frames actually forwarded to the provider. + pub frames_forwarded: u64, + /// Frames dropped because the provider queue was full. + pub overflow_frame_drops: u64, + /// Partials applied to the volatile draft. + pub partials_applied: u64, + /// Finals accepted by the ingest ledger. + pub finals_accepted: u64, + /// Events the ingest ledger refused (out of order, sealed, foreign). + pub events_rejected: u64, + /// Typed provider error events observed. + pub provider_errors: u64, +} + +/// Everything the lane knows once the recording is over. +#[derive(Debug)] +pub struct Layer1SessionOutcome { + /// Doctrine-vetted finals, in accepted order. + finals: Vec, + /// Content-free counters for the session log. + telemetry: Layer1LaneTelemetry, + /// Why the lane degraded, when it did. + degrade: Option, +} + +impl Layer1SessionOutcome { + /// Doctrine-vetted finals, in accepted order. + pub fn finals(&self) -> &[TranscriptEvent] { + &self.finals + } + + /// Content-free counters for the session log. + pub fn telemetry(&self) -> Layer1LaneTelemetry { + self.telemetry + } + + /// Why the lane degraded, when it did. + pub fn degrade_reason(&self) -> Option { + self.degrade + } + + /// The refiner's transcript candidate: sealed finals joined in order. + /// + /// `None` when the session produced no accepted finals — the caller keeps + /// the canvas untouched rather than merging against an empty candidate. + pub fn refined_transcript(&self) -> Option { + if self.finals.is_empty() { + return None; + } + Some( + self.finals + .iter() + .map(|event| event.text.trim()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "), + ) + } + + /// Route the outcome through the integrated doctrine-safe truth seam. + /// + /// This is [`merge_live_layer1`]: the committed live floor is immutable, + /// Layer 1 text may fill aligned gaps and extend the tail, and a + /// substitution always keeps the live token. Callers deliver + /// [`Layer1MergedDelivery::text`]; they never deliver the raw candidate. + pub fn adjudicate_against_live_floor(&self, live_floor: &str) -> Layer1MergedDelivery { + let candidate = self.refined_transcript(); + merge_live_layer1(live_floor, candidate.as_deref().unwrap_or("")) + } +} + +/// The per-recording Layer 1 lane: open at start, fan out, drain at stop. +/// +/// Owned by the live session loop. Every method is non-blocking except +/// [`Self::stop`], whose blocking is bounded by the provider's own close +/// contract plus [`STOP_DRAIN_MAX_POLLS`] drain iterations. +pub struct RecorderLayer1Lane { + /// Lifecycle position. + state: Layer1LaneState, + /// The open provider while [`Layer1LaneState::Live`]. + provider: Option>, + /// The doctrine ledger every provider event passes through. + ingest: SessionIngest, + /// Volatile partial text per open utterance. Never canvas; dies on degrade. + draft: BTreeMap, + /// Accepted finals in accepted order. + finals: Vec, + /// Content-free counters. + telemetry: Layer1LaneTelemetry, + /// Current run of consecutive overflowed frames. + consecutive_overflows: u32, + /// Sticky first degrade reason for the outcome record. + degrade: Option, + /// One-shot notice so the session can emit a single degrade warning event. + degrade_notice: Option, +} + +impl fmt::Debug for RecorderLayer1Lane { + /// Counts-only debug shape; the provider is summarized by mode token. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RecorderLayer1Lane") + .field("state", &self.state) + .field( + "provider_mode", + &self.provider.as_ref().map(|p| p.mode().as_token()), + ) + .field("draft_utterances", &self.draft.len()) + .field("finals", &self.finals.len()) + .field("telemetry", &self.telemetry) + .finish() + } +} + +impl RecorderLayer1Lane { + /// Open the lane at recording start. Never fails. + /// + /// A provider whose `open` fails is dropped on the spot and the lane + /// starts degraded — the recording proceeds on canvas + lexicon exactly as + /// if no provider had been injected. + pub fn open(decision: Layer1Decision, input: &SessionInput) -> Self { + let mut lane = Self { + state: Layer1LaneState::Unarmed, + provider: None, + ingest: SessionIngest::new(input.session_id.clone()), + draft: BTreeMap::new(), + finals: Vec::new(), + telemetry: Layer1LaneTelemetry::default(), + consecutive_overflows: 0, + degrade: None, + degrade_notice: None, + }; + match decision { + Layer1Decision::Disarmed => lane, + Layer1Decision::Armed(mut provider) => { + match provider.open(input) { + Ok(()) => { + info!( + refiner = provider.mode().as_token(), + sample_rate = input.sample_rate, + "Layer 1 lane opened at recording start" + ); + lane.state = Layer1LaneState::Live; + lane.provider = Some(provider); + } + Err(kind) => { + // The provider is dropped here; a failed open must not + // hold a half-connected session for the whole hold. + lane.degrade_dropping_provider(Layer1DegradeReason::OpenFailed(kind)); + } + } + lane + } + } + } + + /// Lifecycle position. + pub fn state(&self) -> Layer1LaneState { + self.state + } + + /// Whether a provider session is currently consuming fan-out. + pub fn is_live(&self) -> bool { + matches!(self.state, Layer1LaneState::Live) + } + + /// The refiner mode currently in effect. + /// + /// Anything other than [`Layer1LaneState::Live`] is [`RefinerMode::Off`]: + /// canvas plus lexicon, the complete shipping product. + pub fn refiner_mode(&self) -> RefinerMode { + match (&self.state, self.provider.as_ref()) { + (Layer1LaneState::Live, Some(provider)) => provider.mode(), + _ => RefinerMode::Off, + } + } + + /// Content-free counters so far. + pub fn telemetry(&self) -> Layer1LaneTelemetry { + self.telemetry + } + + /// Latest volatile partial for `utterance_id`, when one is open. + pub fn draft_text(&self, utterance_id: u64) -> Option<&str> { + self.draft.get(&utterance_id).map(String::as_str) + } + + /// Number of utterances with an open volatile draft. + pub fn draft_len(&self) -> usize { + self.draft.len() + } + + /// Finals accepted so far, in accepted order. + pub fn finals(&self) -> &[TranscriptEvent] { + &self.finals + } + + /// Take the one-shot degrade notice, if a degrade happened since the last + /// call. The session loop uses this to emit exactly one warning event. + pub fn take_degrade_notice(&mut self) -> Option { + self.degrade_notice.take() + } + + /// Offer one captured PCM frame. Returns immediately, always. + /// + /// Capture never branches on the verdict — it is informational. A frame + /// offered while the lane is not live is silently ignored, which is what + /// makes a degraded or disarmed lane indistinguishable from no lane at all + /// on the capture path. + pub fn offer_pcm(&mut self, samples: &[f32]) -> FanOutVerdict { + self.telemetry.frames_offered += 1; + if !self.is_live() || samples.is_empty() { + return FanOutVerdict::Inactive; + } + let Some(provider) = self.provider.as_mut() else { + return FanOutVerdict::Inactive; + }; + match provider.push_audio(samples) { + Ok(()) => { + self.consecutive_overflows = 0; + self.telemetry.frames_forwarded += 1; + FanOutVerdict::Forwarded + } + Err(AsrErrorKind::Overflow) => { + self.telemetry.overflow_frame_drops += 1; + self.consecutive_overflows += 1; + if self.consecutive_overflows >= OVERFLOW_DEGRADE_LIMIT { + self.degrade_dropping_provider(Layer1DegradeReason::Overflow); + } + FanOutVerdict::DroppedOverflow + } + Err(kind) => { + self.telemetry.provider_errors += 1; + self.degrade_dropping_provider(Layer1DegradeReason::Disconnect(kind)); + FanOutVerdict::Inactive + } + } + } + + /// Drain ready provider events and route them. Non-blocking. + pub fn poll(&mut self) { + if !self.is_live() { + return; + } + let Some(provider) = self.provider.as_mut() else { + return; + }; + let events = provider.drain(); + for event in events { + self.route_event(event); + if !self.is_live() { + // A fatal error event degraded the lane; anything still queued + // belonged to the session that just ended. + break; + } + } + } + + /// The host slept mid-recording: the provider session is presumed stale. + /// + /// Wired by the platform sleep observer when one is present; the state + /// transition is the contract either way. + pub fn note_sleep_wake(&mut self) { + if self.is_live() { + self.degrade_dropping_provider(Layer1DegradeReason::SleepWake); + } + } + + /// Stop the lane at recording end: graceful close, bounded trailing drain. + /// + /// This is the only lane call with bounded blocking (the provider's own + /// close contract). Whatever happens inside it, the method returns an + /// outcome and the lane ends [`Layer1LaneState::Stopped`] — the stop path + /// never propagates a Layer 1 failure. + pub fn stop(&mut self) -> Layer1SessionOutcome { + if let Some(mut provider) = self.provider.take() { + // Route anything already decoded before asking for the tail. + for event in provider.drain() { + self.route_event(event); + } + match provider.close() { + Ok(()) => { + let mut polls = 0u32; + loop { + let events = provider.drain(); + if events.is_empty() { + break; + } + for event in events { + self.route_event(event); + } + polls += 1; + if polls >= STOP_DRAIN_MAX_POLLS { + self.note_degrade(Layer1DegradeReason::StopDrainIncomplete); + break; + } + } + } + Err(kind) => { + self.telemetry.provider_errors += 1; + self.note_degrade(Layer1DegradeReason::Disconnect(kind)); + } + } + // The provider drops here; a cloud transport aborts on drop. + } + self.draft.clear(); + self.state = Layer1LaneState::Stopped; + Layer1SessionOutcome { + finals: std::mem::take(&mut self.finals), + telemetry: self.telemetry, + degrade: self.degrade, + } + } + + /// Pass one provider event through the doctrine ledger and apply it. + fn route_event(&mut self, event: AsrSessionEvent) { + let verdict = self.ingest.ingest(event.clone()); + match verdict { + IngestVerdict::Accepted => match event { + AsrSessionEvent::Partial(transcript) => { + self.telemetry.partials_applied += 1; + self.draft + .insert(transcript.identity.utterance_id(), transcript.text); + } + AsrSessionEvent::Final(transcript) => { + self.telemetry.finals_accepted += 1; + self.draft.remove(&transcript.identity.utterance_id()); + self.finals.push(transcript); + } + AsrSessionEvent::Error(error) => { + self.telemetry.provider_errors += 1; + if session_fatal(error.kind) && self.is_live() { + self.degrade_dropping_provider(Layer1DegradeReason::Disconnect(error.kind)); + } + } + AsrSessionEvent::Usage(_) => { + // Accounting only; nothing to apply. + } + }, + IngestVerdict::DuplicateIdempotent => { + // Re-delivery changed nothing, which is the point. + } + IngestVerdict::RejectedOutOfOrder + | IngestVerdict::RejectedSealedUtterance + | IngestVerdict::RejectedForeignSession => { + self.telemetry.events_rejected += 1; + } + } + } + + /// Record the sticky degrade reason and the one-shot notice. + fn note_degrade(&mut self, reason: Layer1DegradeReason) { + if self.degrade.is_none() { + self.degrade = Some(reason); + self.degrade_notice = Some(reason); + warn!( + reason = reason.as_token(), + "Layer 1 lane degraded — canvas + lexicon carry the session" + ); + } + } + + /// Degrade on the live path: drop the provider without a graceful close. + /// + /// Dropping (rather than closing) is deliberate — see the module docs. + /// The volatile draft dies with the lane; accepted finals stay, because + /// they already passed the doctrine seam and remain gap-fill candidates. + fn degrade_dropping_provider(&mut self, reason: Layer1DegradeReason) { + self.provider = None; + self.draft.clear(); + self.state = Layer1LaneState::Degraded(reason); + self.note_degrade(reason); + } +} + +/// Apply one host lifecycle boundary to the active Layer 1 lane. +/// +/// Kept as the single adapter used by the production session loop and its +/// deterministic channel-level regression. The transition itself remains +/// owned by [`RecorderLayer1Lane::note_sleep_wake`]. +pub fn apply_recorder_lifecycle_event( + lane: &mut RecorderLayer1Lane, + event: RecorderLifecycleEvent, +) { + match event { + RecorderLifecycleEvent::SleepWake => lane.note_sleep_wake(), + } +} + +/// Whether one typed error kind ends the session for this recording. +/// +/// `RateLimited` and `Overflow` describe pressure that the bounded fan-out +/// already absorbs frame by frame; everything else means the provider cannot +/// serve this session and the lane lands on canvas + lexicon. +fn session_fatal(kind: AsrErrorKind) -> bool { + !matches!(kind, AsrErrorKind::RateLimited | AsrErrorKind::Overflow) +} + +#[cfg(test)] +mod tests { + use super::super::events::{ + ErrorEvent, EventIdentity, SessionId, TranscriptEvent as Transcript, + }; + use super::super::fake::FakeAsrSessionProvider; + use super::*; + use crate::quality::Layer1MergeMode; + + /// Session identity every fixture in this module records under. + fn session_id() -> SessionId { + SessionId::new("recording-1").expect("non-blank session id") + } + + /// The thin open parameters the recorder hands the lane. + fn input() -> SessionInput { + SessionInput { + session_id: session_id(), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } + } + + /// Identity triple within the fixture session. + fn identity(utterance_id: u64, sequence_id: u64) -> EventIdentity { + EventIdentity::new(session_id(), utterance_id, sequence_id) + } + + /// Partial event fixture. + fn partial(utterance_id: u64, sequence_id: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Partial(Transcript { + identity: identity(utterance_id, sequence_id), + text: text.to_string(), + range: None, + }) + } + + /// Final event fixture. + fn final_event(utterance_id: u64, sequence_id: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Final(Transcript { + identity: identity(utterance_id, sequence_id), + text: text.to_string(), + range: None, + }) + } + + /// Typed error event fixture. + fn error_event(sequence_id: u64, kind: AsrErrorKind) -> AsrSessionEvent { + AsrSessionEvent::Error(ErrorEvent { + identity: identity(0, sequence_id), + kind, + }) + } + + /// An armed decision over a scripted fake provider. + fn armed(script: Vec) -> Layer1Decision { + Layer1Decision::Armed(Box::new(FakeAsrSessionProvider::with_script( + RefinerMode::CloudSession, + script, + ))) + } + + /// A missing provider is normal operation, not an error: the lane runs + /// unarmed, ignores fan-out, and stops with an empty outcome. + #[test] + fn disarmed_lane_is_normal_apple_plus_lexicon_operation() { + let mut lane = RecorderLayer1Lane::open(Layer1Decision::Disarmed, &input()); + assert_eq!(lane.state(), Layer1LaneState::Unarmed); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + lane.poll(); + assert!(lane.take_degrade_notice().is_none(), "no degrade to report"); + + let outcome = lane.stop(); + assert_eq!(lane.state(), Layer1LaneState::Stopped); + assert!(outcome.finals().is_empty()); + assert!(outcome.degrade_reason().is_none()); + assert!(outcome.refined_transcript().is_none()); + } + + /// A provider whose open fails is dropped and the recording proceeds + /// degraded — never an error surfaced to capture. + #[test] + fn open_failure_degrades_instead_of_erroring() { + // Pre-open the fake so the lane's open hits a Protocol fault. + let mut provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession); + provider.open(&input()).expect("first open succeeds"); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::OpenFailed(AsrErrorKind::Protocol)) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + assert_eq!( + lane.take_degrade_notice(), + Some(Layer1DegradeReason::OpenFailed(AsrErrorKind::Protocol)) + ); + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + } + + /// Partials are volatile draft: replaced freely, cleared by their final, + /// and never part of the outcome's committed candidate. + #[test] + fn partials_stay_volatile_draft_until_the_final_seals() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + partial(1, 1, "pacjent"), + partial(1, 2, "pacjent ma"), + final_event(1, 3, "pacjent ma goraczke"), + ]), + &input(), + ); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_text(1), Some("pacjent")); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_text(1), Some("pacjent ma"), "draft is replaced"); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_text(1), None, "the final clears its draft"); + assert_eq!(lane.finals().len(), 1); + assert_eq!(lane.finals()[0].text, "pacjent ma goraczke"); + } + + /// The ingest doctrine holds inside the lane: duplicates are idempotent, + /// stale finals cannot rewrite a sealed utterance. + #[test] + fn finals_route_through_the_ingest_doctrine() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + final_event(1, 2, "pacjent ma goraczke"), + final_event(1, 2, "pacjent ma goraczke"), // reconnect resend + final_event(1, 1, "pacjent"), // stale rewrite attempt + ]), + &input(), + ); + + for _ in 0..3 { + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + } + + assert_eq!(lane.finals().len(), 1, "one sealed final"); + assert_eq!(lane.finals()[0].text, "pacjent ma goraczke"); + let telemetry = lane.telemetry(); + assert_eq!(telemetry.finals_accepted, 1); + assert_eq!( + telemetry.events_rejected, 1, + "the stale rewrite was refused, not applied" + ); + assert!(lane.is_live(), "doctrine refusals do not degrade the lane"); + } + + /// Bounded overflow: frames are dropped and counted while the run is + /// short, and the lane stays live. + #[test] + fn overflow_below_the_budget_drops_frames_without_degrading() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Overflow); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + for _ in 0..(OVERFLOW_DEGRADE_LIMIT - 1) { + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::DroppedOverflow); + } + assert!(lane.is_live(), "a short overflow run is absorbed"); + assert_eq!( + lane.telemetry().overflow_frame_drops, + u64::from(OVERFLOW_DEGRADE_LIMIT - 1) + ); + } + + /// Sustained overflow degrades to canvas + lexicon; capture keeps offering + /// and the lane keeps returning instantly. + #[test] + fn sustained_overflow_degrades_and_capture_continues() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Overflow); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + for _ in 0..OVERFLOW_DEGRADE_LIMIT { + lane.offer_pcm(&[0.1; 320]); + } + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Overflow) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + assert_eq!( + lane.take_degrade_notice(), + Some(Layer1DegradeReason::Overflow) + ); + // Capture is oblivious: further offers are ignored, never errors. + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + } + + /// A successful push resets the consecutive-overflow run, so scattered + /// hiccups never accumulate into a degrade. + #[test] + fn interleaved_success_resets_the_overflow_run() { + // Script one event so the first push succeeds, then force overflows. + let mut lane = RecorderLayer1Lane::open(armed(vec![partial(1, 1, "a")]), &input()); + for _ in 0..(OVERFLOW_DEGRADE_LIMIT - 1) { + // The fake accepts pushes (no failure armed): every offer forwards + // and the overflow run stays at zero. + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Forwarded); + } + assert!(lane.is_live()); + assert_eq!(lane.telemetry().overflow_frame_drops, 0); + } + + /// A transport-fatal push failure degrades as a disconnect. + #[test] + fn transport_push_failure_degrades_as_disconnect() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Transport); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Disconnect(AsrErrorKind::Transport)) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + } + + /// A session-fatal error *event* degrades the lane; queued events behind + /// it are abandoned with the session. + #[test] + fn fatal_error_event_degrades_the_lane() { + let mut lane = + RecorderLayer1Lane::open(armed(vec![error_event(1, AsrErrorKind::Auth)]), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Disconnect(AsrErrorKind::Auth)) + ); + } + + /// Rate limiting is pressure, not death: the lane counts it and stays live. + #[test] + fn rate_limit_error_event_is_absorbed_without_degrading() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + error_event(1, AsrErrorKind::RateLimited), + final_event(1, 2, "pacjent ma goraczke"), + ]), + &input(), + ); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert!(lane.is_live(), "rate limiting must not end the session"); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.finals().len(), 1, "the session keeps producing"); + } + + /// Sleep/wake presumes the session stale and degrades immediately. + #[test] + fn sleep_wake_degrades_and_clears_the_draft() { + let mut lane = RecorderLayer1Lane::open(armed(vec![partial(1, 1, "pacjent")]), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_len(), 1); + + lane.note_sleep_wake(); + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::SleepWake) + ); + assert_eq!(lane.draft_len(), 0, "volatile draft dies with the lane"); + } + + /// The production lifecycle adapter, not a direct lane call, reaches the + /// active transition and preserves the fail-closed semantics. + #[tokio::test] + async fn recorder_lifecycle_adapter_reaches_active_lane_transition() { + let (handle, mut events) = recorder_lifecycle_channel(); + let mut lane = RecorderLayer1Lane::open(armed(vec![partial(1, 1, "pacjent")]), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_len(), 1); + + assert!(handle.note_sleep_wake(), "active adapter accepts boundary"); + let event = events + .recv() + .await + .expect("active session receives boundary"); + apply_recorder_lifecycle_event(&mut lane, event); + + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::SleepWake) + ); + assert_eq!(lane.draft_len(), 0, "adapter clears volatile draft"); + assert_eq!( + lane.take_degrade_notice(), + Some(Layer1DegradeReason::SleepWake), + "the session will emit one content-free degrade warning" + ); + } + + /// Stop drains the provider's tail (the fake flushes its remaining script + /// on close) and the outcome carries the doctrine-vetted finals. + #[test] + fn stop_drain_collects_trailing_finals_bounded() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + final_event(1, 1, "pacjent ma goraczke"), + final_event(2, 2, "podano plyny"), + ]), + &input(), + ); + // No pushes: the whole script is still queued when stop closes. + let outcome = lane.stop(); + + assert_eq!(lane.state(), Layer1LaneState::Stopped); + assert_eq!(outcome.finals().len(), 2); + assert_eq!( + outcome.refined_transcript().as_deref(), + Some("pacjent ma goraczke podano plyny") + ); + assert!( + outcome.degrade_reason().is_none(), + "a clean close is not a degrade" + ); + } + + /// Degrading mid-session keeps already-accepted finals: they passed the + /// doctrine seam and remain bounded gap-fill candidates. + #[test] + fn degrade_keeps_doctrine_vetted_finals_for_the_outcome() { + let provider = FakeAsrSessionProvider::with_script( + RefinerMode::CloudSession, + vec![ + final_event(1, 1, "pacjent ma goraczke"), + error_event(2, AsrErrorKind::Transport), + ], + ); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert!(matches!(lane.state(), Layer1LaneState::Degraded(_))); + + let outcome = lane.stop(); + assert_eq!(outcome.finals().len(), 1); + assert_eq!( + outcome.degrade_reason(), + Some(Layer1DegradeReason::Disconnect(AsrErrorKind::Transport)) + ); + } + + /// The outcome routes through the T0 truth seam: the committed live floor + /// is immutable and Layer 1 text only fills the tail/gaps. + #[test] + fn outcome_adjudication_preserves_the_live_floor() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![final_event( + 1, + 1, + "pacjent ma goraczke i wymioty od wczoraj", + )]), + &input(), + ); + let outcome = lane.stop(); + + let live_floor = "pacjent ma goraczke"; + let merged = outcome.adjudicate_against_live_floor(live_floor); + assert_eq!(merged.mode, Layer1MergeMode::LiveFloorGapFill); + assert!( + merged.text.starts_with(live_floor), + "committed live text must survive adjudication verbatim" + ); + assert!( + merged.text.contains("wymioty"), + "the provider tail may extend the floor" + ); + } + + /// With no finals the outcome refuses to fabricate a candidate, and the + /// seam reports the live floor untouched. + #[test] + fn empty_outcome_leaves_the_live_floor_alone() { + let mut lane = RecorderLayer1Lane::open(Layer1Decision::Disarmed, &input()); + let outcome = lane.stop(); + let merged = outcome.adjudicate_against_live_floor("pacjent ma goraczke"); + assert_eq!(merged.mode, Layer1MergeMode::LiveOnly); + assert_eq!(merged.text, "pacjent ma goraczke"); + } + + /// Stopping a degraded or unarmed lane is a quiet no-op path — the stop + /// path never propagates Layer 1 trouble. + #[test] + fn stop_after_degrade_is_quiet_and_final() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Transport); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + lane.offer_pcm(&[0.1; 320]); + assert!(matches!(lane.state(), Layer1LaneState::Degraded(_))); + + let outcome = lane.stop(); + assert_eq!(lane.state(), Layer1LaneState::Stopped); + assert_eq!( + outcome.degrade_reason(), + Some(Layer1DegradeReason::Disconnect(AsrErrorKind::Transport)) + ); + assert!(outcome.finals().is_empty()); + } +} diff --git a/core/asr_session/tests.rs b/core/asr_session/tests.rs new file mode 100644 index 00000000..75f59018 --- /dev/null +++ b/core/asr_session/tests.rs @@ -0,0 +1,516 @@ +//! Contract tests for the neutral Layer 1 session seam. +//! +//! These are the production witnesses behind the fleet-level RED probe in +//! `stt::fleet_red_contracts`: ordering, duplicate-final idempotence, bounded +//! ranges, payload-free errors, and the canvas/refiner split. + +use serial_test::serial; + +use super::events::{ + AsrErrorKind, AsrSessionEvent, AudioRange, ErrorEvent, EventIdentity, SessionId, + TranscriptEvent, UsageEvent, +}; +use super::fake::FakeAsrSessionProvider; +use super::ingest::{IngestVerdict, SessionIngest}; +use super::provider::{ + AsrSessionProvider, CanvasEngine, LayerSelection, RefinerMode, SessionInput, +}; + +/// Env key the STT router reads to pick the live canvas engine. +const ENV_STT_ENGINE: &str = "CODESCRIBE_STT_ENGINE"; + +/// Session id used across the ordering tests. +fn session() -> SessionId { + SessionId::new("session-a").expect("non-blank session id") +} + +/// Identity in the shared test session for `utterance` at `sequence`. +fn identity(utterance: u64, sequence: u64) -> EventIdentity { + EventIdentity::new(session(), utterance, sequence) +} + +/// Partial hypothesis for `utterance` at `sequence`. +fn partial(utterance: u64, sequence: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Partial(TranscriptEvent { + identity: identity(utterance, sequence), + text: text.to_string(), + range: None, + }) +} + +/// Sealing final for `utterance` at `sequence`. +fn final_event(utterance: u64, sequence: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Final(TranscriptEvent { + identity: identity(utterance, sequence), + text: text.to_string(), + range: None, + }) +} + +/// Typed failure for `utterance` at `sequence`. +fn error_event(utterance: u64, sequence: u64, kind: AsrErrorKind) -> AsrSessionEvent { + AsrSessionEvent::Error(ErrorEvent { + identity: identity(utterance, sequence), + kind, + }) +} + +/// Restores `CODESCRIBE_STT_ENGINE` after a canvas-selection test. +struct EngineEnvGuard { + /// Value the key held before the test pinned it. + previous: Option, +} + +impl EngineEnvGuard { + /// Pin the router's engine selector for this test scope. + fn set(value: &str) -> Self { + let previous = std::env::var(ENV_STT_ENGINE).ok(); + unsafe { std::env::set_var(ENV_STT_ENGINE, value) }; + Self { previous } + } +} + +impl Drop for EngineEnvGuard { + /// Restore the prior value (or remove the key) when the guard leaves scope. + fn drop(&mut self) { + match self.previous.as_deref() { + Some(value) => unsafe { std::env::set_var(ENV_STT_ENGINE, value) }, + None => unsafe { std::env::remove_var(ENV_STT_ENGINE) }, + } + } +} + +// ═══════════════════════════════════════════════════════════ +// Ordering and idempotence +// ═══════════════════════════════════════════════════════════ + +/// THE ORDERING MATRIX: a live provider replays. Only the sequence orders the +/// stream, a re-sent final changes nothing, and a stale final never lands. +#[test] +fn ingest_orders_by_sequence_and_absorbs_duplicate_finals() { + let mut ingest = SessionIngest::new(session()); + + let verdicts: Vec = vec![ + partial(7, 1, "pacjent ma"), + final_event(7, 2, "pacjent ma goraczke"), + final_event(7, 2, "pacjent ma goraczke"), // reconnect resend + final_event(7, 1, "pacjent"), // stale, arrives late + error_event(7, 3, AsrErrorKind::Transport), + ] + .into_iter() + .map(|event| ingest.ingest(event)) + .collect(); + + assert_eq!( + verdicts, + vec![ + IngestVerdict::Accepted, + IngestVerdict::Accepted, + IngestVerdict::DuplicateIdempotent, + IngestVerdict::RejectedOutOfOrder, + IngestVerdict::Accepted, + ] + ); + assert_eq!( + ingest.accepted(), + [ + partial(7, 1, "pacjent ma"), + final_event(7, 2, "pacjent ma goraczke"), + error_event(7, 3, AsrErrorKind::Transport), + ] + ); + assert_eq!(ingest.duplicate_count(), 1); + assert_eq!(ingest.out_of_order_count(), 1); + assert_eq!(ingest.last_sequence(), Some(3)); + + // The seal still holds the text the accepted final carried — the stale + // final did not rewrite it. + let sealed = ingest.sealed_final(7).expect("utterance 7 is sealed"); + assert_eq!(sealed.text, "pacjent ma goraczke"); +} + +/// A resend that arrives *after* newer events is still the same commitment, so +/// it is idempotent rather than "out of order". This is the reconnect case the +/// sequence check alone would misclassify. +#[test] +fn duplicate_final_is_idempotent_even_after_newer_events() { + let mut ingest = SessionIngest::new(session()); + assert!(ingest.ingest(final_event(1, 10, "raz dwa")).is_accepted()); + assert!(ingest.ingest(partial(2, 11, "trzy")).is_accepted()); + + assert_eq!( + ingest.ingest(final_event(1, 10, "raz dwa")), + IngestVerdict::DuplicateIdempotent + ); + assert_eq!(ingest.accepted().len(), 2); + assert_eq!(ingest.duplicate_count(), 1); + assert_eq!(ingest.out_of_order_count(), 0); +} + +/// A final is a commitment. A later partial for that utterance — even with a +/// perfectly monotonic sequence — must not reopen it. +#[test] +fn sealed_utterance_refuses_later_partials_and_conflicting_finals() { + let mut ingest = SessionIngest::new(session()); + assert!( + ingest + .ingest(final_event(3, 5, "badanie krwi wykazalo")) + .is_accepted() + ); + + assert_eq!( + ingest.ingest(partial(3, 6, "badanie krwi")), + IngestVerdict::RejectedSealedUtterance + ); + assert_eq!( + ingest.ingest(final_event(3, 7, "zupelnie inny tekst")), + IngestVerdict::RejectedSealedUtterance + ); + assert_eq!(ingest.sealed_rejection_count(), 2); + assert_eq!( + ingest.sealed_final(3).map(|event| event.text.as_str()), + Some("badanie krwi wykazalo") + ); + + // A different utterance is untouched by the seal. + assert!( + ingest + .ingest(partial(4, 8, "kolejna wypowiedz")) + .is_accepted() + ); +} + +/// Diagnostics are not text: an error for a sealed utterance still lands, so a +/// provider can report a failure after it has already committed a final. +#[test] +fn sealed_utterance_still_accepts_diagnostics() { + let mut ingest = SessionIngest::new(session()); + assert!(ingest.ingest(final_event(2, 4, "gotowe")).is_accepted()); + assert!( + ingest + .ingest(error_event(2, 5, AsrErrorKind::Transport)) + .is_accepted() + ); + assert_eq!(ingest.sealed_rejection_count(), 0); +} + +/// A reconnect that resumes the wrong stream is caught at the ledger edge. +#[test] +fn foreign_session_events_are_refused() { + let mut ingest = SessionIngest::new(session()); + let foreign = SessionId::new("session-b").expect("non-blank session id"); + let event = AsrSessionEvent::Final(TranscriptEvent { + identity: EventIdentity::new(foreign, 7, 1), + text: "z innej sesji".to_string(), + range: None, + }); + + assert_eq!(ingest.ingest(event), IngestVerdict::RejectedForeignSession); + assert!(ingest.accepted().is_empty()); + assert_eq!(ingest.foreign_rejection_count(), 1); + assert_eq!(ingest.last_sequence(), None); +} + +/// A blank session id would make every session compare equal and silently +/// disable the foreign-session guard. +#[test] +fn blank_session_ids_are_refused() { + assert!(SessionId::new("").is_none()); + assert!(SessionId::new(" \n").is_none()); + assert_eq!( + SessionId::new("s-1").map(|id| id.as_str().to_string()), + Some("s-1".to_string()) + ); +} + +// ═══════════════════════════════════════════════════════════ +// Bounded audio ranges +// ═══════════════════════════════════════════════════════════ + +/// The optional range is bounded on every axis that could turn a corrupt +/// timestamp into a plausible-looking window. +#[test] +fn audio_range_rejects_unusable_spans() { + let ok = AudioRange::new(1.0, 2.5).expect("valid span"); + assert_eq!(ok.start_secs(), 1.0); + assert_eq!(ok.end_secs(), 2.5); + assert_eq!(ok.duration_secs(), 1.5); + + assert!(AudioRange::new(f32::NAN, 1.0).is_none()); + assert!(AudioRange::new(0.0, f32::INFINITY).is_none()); + assert!(AudioRange::new(-0.5, 1.0).is_none()); + assert!(AudioRange::new(2.0, 2.0).is_none(), "empty span"); + assert!(AudioRange::new(3.0, 1.0).is_none(), "inverted span"); + assert!( + AudioRange::new(0.0, AudioRange::MAX_SPAN_SECS + 0.1).is_none(), + "a span wider than retained PCM describes audio nothing can re-read" + ); + assert!(AudioRange::new(0.0, AudioRange::MAX_SPAN_SECS).is_some()); +} + +/// The ceiling is the live PCM ring's retention, not an independent number that +/// can drift away from it. +#[test] +fn audio_range_ceiling_tracks_live_pcm_retention() { + assert_eq!( + AudioRange::MAX_SPAN_SECS, + crate::pipeline::streaming::live_audio_buffer::DEFAULT_RETENTION_SECS + ); +} + +// ═══════════════════════════════════════════════════════════ +// Typed errors and usage +// ═══════════════════════════════════════════════════════════ + +/// Errors carry a kind and nothing else, so no transcript fragment, audio path, +/// or credential can ride one into a log line. +#[test] +fn errors_are_typed_with_no_free_form_payload() { + let kinds = [ + (AsrErrorKind::Transport, "transport", true), + (AsrErrorKind::Auth, "auth", false), + (AsrErrorKind::RateLimited, "rate_limited", true), + (AsrErrorKind::Quota, "quota", false), + (AsrErrorKind::Overflow, "overflow", true), + (AsrErrorKind::Unsupported, "unsupported", false), + (AsrErrorKind::Protocol, "protocol", false), + (AsrErrorKind::Cancelled, "cancelled", false), + ]; + for (kind, token, retryable) in kinds { + assert_eq!(kind.as_token(), token); + assert_eq!(format!("{kind}"), token); + assert_eq!(kind.is_retryable(), retryable, "{token}"); + } +} + +/// Usage is accounting, not content. +#[test] +fn usage_events_carry_accounting_only() { + let usage = UsageEvent { + identity: identity(0, 9), + audio_secs: 12.5, + billable_units: Some(13), + }; + let event = AsrSessionEvent::Usage(usage); + assert_eq!(event.as_token(), "usage"); + assert!(!event.is_transcript()); + assert!(!event.is_final()); + assert_eq!(event.identity().sequence_id(), 9); +} + +/// Finality is a variant, so every consumer has to decide about it explicitly. +#[test] +fn finality_is_a_variant_not_a_flag() { + assert!(partial(1, 1, "x").is_transcript()); + assert!(!partial(1, 1, "x").is_final()); + assert!(final_event(1, 2, "x").is_final()); + assert_eq!(partial(1, 1, "x").as_token(), "partial"); + assert_eq!(final_event(1, 2, "x").as_token(), "final"); + assert_eq!( + error_event(1, 3, AsrErrorKind::Auth).as_token(), + "error", + "an error is never mistaken for text" + ); +} + +// ═══════════════════════════════════════════════════════════ +// Canvas selection versus refiner mode +// ═══════════════════════════════════════════════════════════ + +/// The two axes are independent: choosing a refiner never moves the canvas. +#[test] +fn refiner_mode_never_moves_the_canvas() { + for canvas in [CanvasEngine::AppleSpeech, CanvasEngine::LocalWhisper] { + for refiner in [ + RefinerMode::Off, + RefinerMode::CloudSession, + RefinerMode::LocalHelper, + ] { + let selection = LayerSelection::new(canvas, refiner); + assert_eq!(selection.canvas(), canvas, "{refiner:?} moved the canvas"); + assert_eq!(selection.refiner(), refiner); + + // Layer 1 failing is a missing improvement, never a redraw. + let degraded = selection.degraded(); + assert_eq!(degraded.canvas(), canvas); + assert_eq!(degraded.refiner(), RefinerMode::Off); + } + } +} + +/// `Off` is the shipping product, and it is the default. +#[test] +fn refiner_mode_defaults_to_off_and_classifies_audio_egress() { + assert_eq!(RefinerMode::default(), RefinerMode::Off); + assert!(!RefinerMode::Off.sends_audio_off_device()); + assert!(!RefinerMode::LocalHelper.sends_audio_off_device()); + assert!(RefinerMode::CloudSession.sends_audio_off_device()); + assert_eq!(CanvasEngine::AppleSpeech.as_token(), "apple_speech"); + assert_eq!(RefinerMode::CloudSession.as_token(), "cloud_session"); +} + +/// The canvas axis is read from the live router, and the router's selector has +/// no say over the refiner axis. +#[test] +#[serial] +fn active_canvas_is_read_from_the_router_and_leaves_the_refiner_alone() { + let _guard = EngineEnvGuard::set("candle"); + let selection = LayerSelection::for_active_canvas(RefinerMode::CloudSession); + assert_eq!(selection.canvas(), CanvasEngine::LocalWhisper); + assert_eq!(selection.refiner(), RefinerMode::CloudSession); + + unsafe { std::env::set_var(ENV_STT_ENGINE, "apple") }; + let selection = LayerSelection::for_active_canvas(RefinerMode::CloudSession); + assert_eq!(selection.canvas(), CanvasEngine::AppleSpeech); + assert_eq!( + selection.refiner(), + RefinerMode::CloudSession, + "the canvas selector must not reach the refiner axis" + ); + + // And the refiner axis cannot reach back: every mode reports the same + // canvas under the same router state. + for refiner in [ + RefinerMode::Off, + RefinerMode::CloudSession, + RefinerMode::LocalHelper, + ] { + assert_eq!( + LayerSelection::for_active_canvas(refiner).canvas(), + CanvasEngine::AppleSpeech + ); + } +} + +// ═══════════════════════════════════════════════════════════ +// The fake provider +// ═══════════════════════════════════════════════════════════ + +/// Session parameters for the fake. +fn fake_input() -> SessionInput { + SessionInput { + session_id: session(), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } +} + +/// Lifecycle faults degrade into typed errors — a live session must never +/// panic the recording. +#[test] +fn fake_provider_reports_lifecycle_faults_as_protocol_errors() { + let mut provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession); + assert_eq!(provider.mode(), RefinerMode::CloudSession); + + assert_eq!(provider.push_audio(&[0.0; 8]), Err(AsrErrorKind::Protocol)); + assert_eq!(provider.close(), Err(AsrErrorKind::Protocol)); + + provider.open(&fake_input()).expect("first open succeeds"); + assert_eq!(provider.open(&fake_input()), Err(AsrErrorKind::Protocol)); + + provider.close().expect("close after open succeeds"); + assert_eq!(provider.push_audio(&[0.0; 8]), Err(AsrErrorKind::Protocol)); +} + +/// The fake produces a whole session shape — scripted events in order, then a +/// trailing usage record whose sequence stays monotonic. +#[test] +fn fake_provider_emits_a_monotonic_session() { + let script = vec![ + partial(1, 1, "pacjent"), + partial(1, 2, "pacjent ma"), + final_event(1, 3, "pacjent ma goraczke"), + ]; + let mut provider = FakeAsrSessionProvider::with_script(RefinerMode::CloudSession, script); + provider.open(&fake_input()).expect("open"); + + assert!(provider.drain().is_empty(), "no audio pushed yet"); + provider.push_audio(&[0.0; 16_000]).expect("push"); + let first = provider.drain(); + assert_eq!(first, vec![partial(1, 1, "pacjent")]); + + provider.push_audio(&[0.0; 8_000]).expect("push"); + assert_eq!(provider.pushed_secs(), 1.5); + let second = provider.drain(); + assert_eq!(second, vec![partial(1, 2, "pacjent ma")]); + + provider.close().expect("close"); + assert!(provider.script_drained()); + let tail = provider.drain(); + assert_eq!(tail.len(), 2, "trailing final plus usage"); + assert_eq!(tail[0], final_event(1, 3, "pacjent ma goraczke")); + match &tail[1] { + AsrSessionEvent::Usage(usage) => { + assert_eq!(usage.identity.sequence_id(), 4, "usage stays monotonic"); + assert_eq!(usage.audio_secs, 1.5); + assert_eq!(usage.billable_units, None); + } + other => panic!("expected a usage record, got {other:?}"), + } +} + +/// A failing transport surfaces its typed kind on every push, and the product +/// answer is the degraded selection — canvas plus lexicon, same canvas. +#[test] +fn fake_provider_push_failure_degrades_to_canvas_only() { + let mut provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Transport); + provider.open(&fake_input()).expect("open"); + + let error = provider + .push_audio(&[0.0; 128]) + .expect_err("push must fail"); + assert_eq!(error, AsrErrorKind::Transport); + assert!(error.is_retryable()); + assert!(provider.drain().is_empty()); + + let selection = LayerSelection::new(CanvasEngine::AppleSpeech, RefinerMode::CloudSession); + let degraded = selection.degraded(); + assert_eq!(degraded.canvas(), CanvasEngine::AppleSpeech); + assert_eq!(degraded.refiner(), RefinerMode::Off); +} + +/// End to end: what the provider emits is what the ledger accepts, and a +/// replayed tail changes nothing. +#[test] +fn fake_provider_stream_survives_a_replayed_tail() { + let script = vec![ + partial(1, 1, "raz"), + final_event(1, 2, "raz dwa"), + final_event(1, 2, "raz dwa"), // the provider re-sends its final + partial(2, 3, "trzy"), + final_event(2, 4, "trzy cztery"), + ]; + let mut provider = FakeAsrSessionProvider::with_script(RefinerMode::LocalHelper, script); + provider.open(&fake_input()).expect("open"); + provider.close().expect("close flushes the whole script"); + + let mut ingest = SessionIngest::new(session()); + let verdicts: Vec = provider + .drain() + .into_iter() + .map(|event| ingest.ingest(event)) + .collect(); + + assert_eq!( + verdicts, + vec![ + IngestVerdict::Accepted, + IngestVerdict::Accepted, + IngestVerdict::DuplicateIdempotent, + IngestVerdict::Accepted, + IngestVerdict::Accepted, + IngestVerdict::Accepted, // the closing usage record + ] + ); + assert_eq!(ingest.duplicate_count(), 1); + assert_eq!(ingest.out_of_order_count(), 0); + assert_eq!( + ingest.sealed_final(1).map(|event| event.text.as_str()), + Some("raz dwa") + ); + assert_eq!( + ingest.sealed_final(2).map(|event| event.text.as_str()), + Some("trzy cztery") + ); +} diff --git a/core/audio/capture_receipt.rs b/core/audio/capture_receipt.rs new file mode 100644 index 00000000..fe02ac8b --- /dev/null +++ b/core/audio/capture_receipt.rs @@ -0,0 +1,527 @@ +//! Per-session capture-level receipt. +//! +//! The W30 input-level break (−38.3 → −43.9 dB, then −46.5 by W33) sat +//! invisible for three weeks because only per-buffer RMS ticks existed. +//! This receipt is the session aggregate: median RMS, peak, device, rate, +//! channels, plus the Amendment-3 active-speech key (sample count, clip, +//! dropout, noise, SNR). The all-audio median stays for debugging. +//! +//! WARN `capture_level_low` is a quality receipt. It must never join +//! [`USER_TERMINAL_WARNING_CODES`](crate::pipeline::contracts::USER_TERMINAL_WARNING_CODES). + +use std::sync::{Mutex, OnceLock}; + +use tracing::{info, warn}; + +use crate::pipeline::contracts::{EngineEvent, EventSink}; + +/// Session-end receipt code (log line + last-snapshot key). +pub const CAPTURE_LEVEL_RECEIPT_CODE: &str = "capture_level_receipt"; +/// Non-terminal WARN when the **active-speech** median sits below the floor. +pub const CAPTURE_LEVEL_LOW_CODE: &str = "capture_level_low"; +/// Env override for the low-level floor (dBFS). Default −52. +pub const CAPTURE_LEVEL_LOW_DB_ENV: &str = "CODESCRIBE_CAPTURE_LEVEL_LOW_DB"; +/// Corpus-derived floor: golden era ≈ −38, break ≈ −44, −52 leaves headroom. +pub const DEFAULT_CAPTURE_LEVEL_LOW_DB: f32 = -52.0; +/// macOS 27 gates silence to hard zeros (take 191351, both mic modes). +pub const DIGITAL_ZERO_ABS: f32 = 1.0e-8; +/// Linear RMS below this is not active speech (~−80 dBFS). +pub const ACTIVE_SPEECH_LINEAR_FLOOR: f32 = 1.0e-4; +/// Near-full-scale samples count as clipping. +pub const CLIP_ABS: f32 = 0.99; + +static LAST_RECEIPT: OnceLock>> = OnceLock::new(); +static LAST_OPEN_PATH: OnceLock>> = OnceLock::new(); + +fn last_receipt_slot() -> &'static Mutex> { + LAST_RECEIPT.get_or_init(|| Mutex::new(None)) +} + +fn last_open_path_slot() -> &'static Mutex> { + LAST_OPEN_PATH.get_or_init(|| Mutex::new(None)) +} + +/// Remember the live capture path (device / rate / channels) without a new TCC prompt. +pub fn publish_open_capture_path(meta: CapturePathMeta) { + *last_open_path_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(meta); +} + +/// Last opened capture path, if the recorder published one this process. +pub fn last_open_capture_path() -> Option { + last_open_path_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() +} + +/// Last finalized capture receipt in this process, if any. +pub fn last_capture_level_receipt() -> Option { + last_receipt_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() +} + +fn remember_last(receipt: &CaptureLevelReceipt) { + *last_receipt_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(receipt.clone()); +} + +/// Convert a linear RMS (0..~1) to dBFS. Non-positive values are −∞. +pub fn linear_to_db(linear: f32) -> f32 { + if !linear.is_finite() || linear <= 0.0 { + return f32::NEG_INFINITY; + } + 20.0 * linear.log10() +} + +/// Convert dBFS back to linear amplitude. +pub fn db_to_linear(db: f32) -> f32 { + if !db.is_finite() { + return 0.0; + } + 10.0_f32.powf(db / 20.0) +} + +/// Low-level floor, env-overridable. Invalid / missing env keeps the default. +pub fn capture_level_low_db() -> f32 { + match std::env::var(CAPTURE_LEVEL_LOW_DB_ENV) { + Ok(raw) => raw + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v < 0.0) + .unwrap_or(DEFAULT_CAPTURE_LEVEL_LOW_DB), + Err(_) => DEFAULT_CAPTURE_LEVEL_LOW_DB, + } +} + +/// Input-path identity attached at finalize (seconds stay at adapters). +#[derive(Debug, Clone, PartialEq)] +pub struct CapturePathMeta { + pub device_name: String, + pub sample_rate: u32, + pub channels: u16, +} + +impl CapturePathMeta { + /// Device from the already-open capture path / `AUDIO_INPUT_DEVICE`. + /// Never opens a new Core Audio query — no new permission prompt. + pub fn from_open_path(sample_rate: u32, channels: u16, device_name: Option<&str>) -> Self { + let device_name = device_name + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .or_else(|| { + std::env::var("AUDIO_INPUT_DEVICE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "system_default".to_string()); + Self { + device_name, + sample_rate, + channels: channels.max(1), + } + } + + /// Prefer the already-open recorder path; fall back to env / defaults. + pub fn resolve(sample_rate: u32, channels: u16, device_name: Option<&str>) -> Self { + match last_open_capture_path() { + Some(open) => Self { + device_name: device_name + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or(open.device_name), + sample_rate: if sample_rate > 0 { + sample_rate + } else { + open.sample_rate + }, + channels: channels.max(open.channels).max(1), + }, + None => Self::from_open_path(sample_rate, channels, device_name), + } + } +} + +/// Running per-buffer capture stats. Cheap enough for the CoreAudio callback. +#[derive(Debug, Default)] +pub struct CaptureLevelAccumulator { + sample_count: u64, + digital_zero_samples: u64, + clipping_samples: u64, + peak_linear: f32, + all_block_rms: Vec, + speech_block_rms: Vec, + noise_block_rms: Vec, + dropout_blocks: u64, + seen_speech: bool, + trailing_zero_run: u64, +} + +impl CaptureLevelAccumulator { + /// Empty accumulator for one session. + pub fn new() -> Self { + Self::default() + } + + /// Ingest one captured block (mono f32, already downmixed). + pub fn push_samples(&mut self, samples: &[f32]) { + if samples.is_empty() { + return; + } + let mut sum_sq = 0.0_f64; + let mut zeros = 0_u64; + let mut clips = 0_u64; + let mut peak = 0.0_f32; + for sample in samples { + let x = if sample.is_finite() { *sample } else { 0.0 }; + let abs = x.abs(); + if abs <= DIGITAL_ZERO_ABS { + zeros += 1; + } + if abs >= CLIP_ABS { + clips += 1; + } + if abs > peak { + peak = abs; + } + sum_sq += f64::from(x) * f64::from(x); + } + let rms = (sum_sq / samples.len() as f64).sqrt() as f32; + self.sample_count += samples.len() as u64; + self.digital_zero_samples += zeros; + self.clipping_samples += clips; + if peak > self.peak_linear { + self.peak_linear = peak; + } + self.all_block_rms.push(rms); + + let all_digital_zero = zeros == samples.len() as u64; + if rms >= ACTIVE_SPEECH_LINEAR_FLOOR && !all_digital_zero { + if self.seen_speech && self.trailing_zero_run > 0 { + self.dropout_blocks += self.trailing_zero_run; + } + self.speech_block_rms.push(rms); + self.seen_speech = true; + self.trailing_zero_run = 0; + } else if all_digital_zero || rms <= DIGITAL_ZERO_ABS { + if self.seen_speech { + self.trailing_zero_run += 1; + } + } else { + self.noise_block_rms.push(rms); + self.trailing_zero_run = 0; + } + } + + /// Freeze the session receipt. `meta` is path identity, not a second clock. + pub fn finalize(&self, meta: CapturePathMeta) -> CaptureLevelReceipt { + let all_audio_median_db = median_db(&self.all_block_rms); + let active_speech_median_db = median_db(&self.speech_block_rms); + let noise_floor_db = median_db(&self.noise_block_rms); + let peak_db = linear_to_db(self.peak_linear); + let snr_db = if active_speech_median_db.is_finite() && noise_floor_db.is_finite() { + Some(active_speech_median_db - noise_floor_db) + } else { + None + }; + let threshold_db = capture_level_low_db(); + let low = !active_speech_median_db.is_finite() || active_speech_median_db < threshold_db; + CaptureLevelReceipt { + code: CAPTURE_LEVEL_RECEIPT_CODE, + device_name: meta.device_name, + sample_rate: meta.sample_rate, + channels: meta.channels, + sample_count: self.sample_count, + digital_zero_samples: self.digital_zero_samples, + active_speech_samples: self.speech_block_count_samples(), + clipping_samples: self.clipping_samples, + dropout_blocks: self.dropout_blocks, + all_audio_median_db, + active_speech_median_db, + peak_db, + noise_floor_db, + snr_db, + threshold_db, + low, + } + } + + fn speech_block_count_samples(&self) -> u64 { + // Block size is not uniform; report the speech-block count as a + // sample-adjacent figure via the digital-zero complement when possible. + self.sample_count.saturating_sub(self.digital_zero_samples) + } +} + +fn median_db(values: &[f32]) -> f32 { + if values.is_empty() { + return f32::NEG_INFINITY; + } + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = sorted.len() / 2; + let linear = if sorted.len().is_multiple_of(2) { + (sorted[mid - 1] + sorted[mid]) * 0.5 + } else { + sorted[mid] + }; + linear_to_db(linear) +} + +/// Frozen session receipt. WARN is keyed on `active_speech_median_db`. +#[derive(Debug, Clone, PartialEq)] +pub struct CaptureLevelReceipt { + pub code: &'static str, + pub device_name: String, + pub sample_rate: u32, + pub channels: u16, + pub sample_count: u64, + pub digital_zero_samples: u64, + pub active_speech_samples: u64, + pub clipping_samples: u64, + pub dropout_blocks: u64, + pub all_audio_median_db: f32, + pub active_speech_median_db: f32, + pub peak_db: f32, + pub noise_floor_db: f32, + pub snr_db: Option, + pub threshold_db: f32, + pub low: bool, +} + +impl CaptureLevelReceipt { + /// Active-speech floor miss — the only WARN this receipt can raise. + pub fn is_low(&self) -> bool { + self.low + } + + /// Counts-only WARN text. No transcript content. + pub fn warning_message(&self) -> String { + format!( + "active_speech_median_db={:.1} threshold_db={:.1} all_audio_median_db={:.1} peak_db={:.1} samples={} digital_zero={} clip={} dropout={} device={} rate={} ch={}", + self.active_speech_median_db, + self.threshold_db, + self.all_audio_median_db, + self.peak_db, + self.sample_count, + self.digital_zero_samples, + self.clipping_samples, + self.dropout_blocks, + self.device_name, + self.sample_rate, + self.channels + ) + } + + /// Coarse quality token for a later Audio-menu surface. + pub fn quality_verdict(&self) -> &'static str { + if self.low { + "low" + } else if self.clipping_samples > 0 || self.dropout_blocks > 0 { + "degraded" + } else { + "ok" + } + } + + /// Session-end log line. Always info for the receipt; WARN is separate. + pub fn log(&self) { + info!( + code = self.code, + device = self.device_name.as_str(), + sample_rate = self.sample_rate, + channels = self.channels, + sample_count = self.sample_count, + digital_zero_samples = self.digital_zero_samples, + active_speech_samples = self.active_speech_samples, + clipping_samples = self.clipping_samples, + dropout_blocks = self.dropout_blocks, + all_audio_median_db = format!("{:.1}", self.all_audio_median_db), + active_speech_median_db = format!("{:.1}", self.active_speech_median_db), + peak_db = format!("{:.1}", self.peak_db), + noise_floor_db = format!("{:.1}", self.noise_floor_db), + snr_db = self.snr_db.map(|v| format!("{v:.1}")), + threshold_db = format!("{:.1}", self.threshold_db), + quality = self.quality_verdict(), + "capture_level_receipt" + ); + } +} + +/// Log the receipt and emit a non-terminal WARN when the active-speech floor +/// is missed. The sink still receives a Warning event; the bridge must keep +/// routing it off `on_error` via [`crate::pipeline::contracts::warning_is_user_terminal`]. +pub fn emit_capture_level_receipt(sink: &dyn EventSink, receipt: &CaptureLevelReceipt) { + receipt.log(); + remember_last(receipt); + if receipt.is_low() { + warn!( + code = CAPTURE_LEVEL_LOW_CODE, + active_speech_median_db = format!("{:.1}", receipt.active_speech_median_db), + threshold_db = format!("{:.1}", receipt.threshold_db), + "capture_level_low" + ); + sink.on_event(&EngineEvent::Warning { + code: CAPTURE_LEVEL_LOW_CODE.to_string(), + message: receipt.warning_message(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::contracts::{USER_TERMINAL_WARNING_CODES, warning_is_user_terminal}; + use std::sync::Mutex; + + struct CapturingSink { + events: Mutex>, + } + + impl EventSink for CapturingSink { + fn on_event(&self, event: &EngineEvent) { + self.events.lock().expect("sink").push(event.clone()); + } + } + + fn tone(amplitude: f32, n: usize) -> Vec { + // Square wave: RMS equals amplitude, matching `block_rms` contracts. + (0..n) + .map(|i| if i % 2 == 0 { amplitude } else { -amplitude }) + .collect() + } + + fn zeros(n: usize) -> Vec { + vec![0.0; n] + } + + /// Active-speech median keys the WARN; digital-zero silence must not drag + /// it. Attenuated speech below −52 dB warns. The WARN is never terminal. + #[test] + fn w13_capture_receipt_active_speech() { + assert_eq!( + USER_TERMINAL_WARNING_CODES, + &["transcription_failed"], + "W13-5 must not enlarge the terminal-warning list" + ); + assert!( + !warning_is_user_terminal(CAPTURE_LEVEL_LOW_CODE), + "capture_level_low must stay a quality receipt" + ); + assert!( + !warning_is_user_terminal(CAPTURE_LEVEL_RECEIPT_CODE), + "capture_level_receipt must stay a quality receipt" + ); + + let golden = db_to_linear(-38.0); + let attenuated = db_to_linear(-58.0); + let meta = CapturePathMeta { + device_name: "EarPods".into(), + sample_rate: 48_000, + channels: 1, + }; + + let mut healthy = CaptureLevelAccumulator::new(); + // 191351-class mix: digital-zero floors around speech, both mic modes. + for _ in 0..20 { + healthy.push_samples(&zeros(512)); + } + for _ in 0..8 { + healthy.push_samples(&tone(golden, 512)); + } + for _ in 0..20 { + healthy.push_samples(&zeros(512)); + } + let healthy_receipt = healthy.finalize(meta.clone()); + assert!( + healthy_receipt.active_speech_median_db.is_finite(), + "active-speech median must be defined when speech is present" + ); + assert!( + (healthy_receipt.active_speech_median_db + 38.0).abs() < 1.5, + "active-speech median should sit near the golden −38 dB, got {}", + healthy_receipt.active_speech_median_db + ); + assert!( + healthy_receipt.all_audio_median_db < healthy_receipt.active_speech_median_db - 10.0 + || !healthy_receipt.all_audio_median_db.is_finite(), + "all-audio median must be dragged by digital-zero floors (all={} active={})", + healthy_receipt.all_audio_median_db, + healthy_receipt.active_speech_median_db + ); + assert!( + !healthy_receipt.is_low(), + "golden-era active speech must not WARN (active={})", + healthy_receipt.active_speech_median_db + ); + assert_eq!(healthy_receipt.device_name, "EarPods"); + assert_eq!(healthy_receipt.sample_rate, 48_000); + assert_eq!(healthy_receipt.channels, 1); + assert!(healthy_receipt.digital_zero_samples > 0); + assert!(healthy_receipt.sample_count > healthy_receipt.digital_zero_samples); + assert_eq!(healthy_receipt.quality_verdict(), "ok"); + + let healthy_sink = CapturingSink { + events: Mutex::new(Vec::new()), + }; + emit_capture_level_receipt(&healthy_sink, &healthy_receipt); + assert!( + healthy_sink.events.lock().expect("sink").is_empty(), + "normal level must not emit capture_level_low" + ); + + let mut quiet = CaptureLevelAccumulator::new(); + for _ in 0..12 { + quiet.push_samples(&zeros(512)); + } + for _ in 0..8 { + quiet.push_samples(&tone(attenuated, 512)); + } + for _ in 0..12 { + quiet.push_samples(&zeros(512)); + } + let quiet_receipt = quiet.finalize(meta); + assert!( + quiet_receipt.active_speech_median_db < DEFAULT_CAPTURE_LEVEL_LOW_DB, + "attenuated take must sit below −52 dB, got {}", + quiet_receipt.active_speech_median_db + ); + assert!( + quiet_receipt.is_low(), + "attenuated active speech must WARN (active={})", + quiet_receipt.active_speech_median_db + ); + + let quiet_sink = CapturingSink { + events: Mutex::new(Vec::new()), + }; + emit_capture_level_receipt(&quiet_sink, &quiet_receipt); + let events = quiet_sink.events.lock().expect("sink"); + match events.as_slice() { + [EngineEvent::Warning { code, message }] => { + assert_eq!(code, CAPTURE_LEVEL_LOW_CODE); + assert!( + message.contains("active_speech_median_db="), + "WARN must name the active-speech key: {message}" + ); + assert!( + !message.contains("Dictation stopped"), + "WARN text must not look terminal: {message}" + ); + } + other => panic!("expected one capture_level_low warning, got {other:?}"), + } + assert!( + !warning_is_user_terminal(CAPTURE_LEVEL_LOW_CODE), + "emitting the WARN must not change the terminal class" + ); + } +} diff --git a/core/audio/chunker.rs b/core/audio/chunker.rs index 037f720f..65a72a8c 100644 --- a/core/audio/chunker.rs +++ b/core/audio/chunker.rs @@ -742,6 +742,16 @@ impl SpeechSession { events } + /// Half-open raw-sample range of the currently open Supervisor segment. + /// + /// `None` when Silero has not opened a speech edge. Used by the W13-3B + /// fusion lane to mint utterance identity on the same PCM cursor the + /// Apple worker already owns. + pub(crate) fn open_segment_raw_range(&self) -> Option<(u64, u64)> { + let start = self.segment_start?; + Some((start as u64, self.raw_cursor as u64)) + } + /// Close the session and emit whatever is still open. /// /// Recording usually stops mid-segment, so an open Supervisor segment is @@ -1090,6 +1100,16 @@ impl SpeechSession { self.output_sample_rate } + /// Whether Silero actually loaded for this session. + /// + /// A missing model is not fatal here — [`Self::predict_speech_prob`] reads + /// every frame as non-speech — but a consumer that *gates* on speech edges + /// (the Apple engine lifecycle) would then never see one and would rest + /// forever. Such consumers must ask first and fail open. + pub(crate) fn vad_available(&self) -> bool { + self.vad.is_some() + } + /// Speech probability at the last VAD Start/End boundary. pub(crate) fn boundary_prob(&self) -> f32 { self.last_boundary_prob diff --git a/core/audio/mod.rs b/core/audio/mod.rs index f7a1f278..3bf24258 100644 --- a/core/audio/mod.rs +++ b/core/audio/mod.rs @@ -8,6 +8,8 @@ /// Offline audio archive helpers (internal packaging of captured WAV/PCM). pub(crate) mod archive; +/// Per-session capture-level receipt (active-speech RMS, non-terminal WARN). +pub mod capture_receipt; /// Fixed-size PCM framing for STT windows and streaming hops. pub mod chunker; /// Decode audio files (WAV/MP3/…) into mono PCM for the STT path. diff --git a/core/audio/recorder.rs b/core/audio/recorder.rs index d3a28662..8f2674e0 100644 --- a/core/audio/recorder.rs +++ b/core/audio/recorder.rs @@ -62,7 +62,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{Device, Stream, StreamConfig}; use hound::{WavSpec, WavWriter}; use std::collections::VecDeque; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; @@ -273,6 +273,10 @@ pub struct Recorder { diagnostics: RecorderDiagnostics, /// Actual sample rate used for recording (may differ from config) actual_sample_rate: u32, + /// Last resolved input device name (empty until `start`). + last_input_device: String, + /// Native channel count of the last opened stream (1 after downmix). + last_native_channels: u16, on_data: Option, /// Disk spill of the full streaming take (operator decision B): survives /// the RAM ring cap; `None` when disabled or not a streaming session. @@ -320,6 +324,8 @@ impl Recorder { last_duration: 0.0, diagnostics: RecorderDiagnostics::default(), actual_sample_rate: config.sample_rate, // Will be updated in start() + last_input_device: String::new(), + last_native_channels: 1, on_data: None, on_vad_stop: None, recorder_vad: None, @@ -361,6 +367,17 @@ impl Recorder { self.actual_sample_rate } + /// Device name resolved for the last `start()`, if any. + pub fn last_input_device(&self) -> Option<&str> { + let name = self.last_input_device.trim(); + if name.is_empty() { None } else { Some(name) } + } + + /// Native channel count of the last opened input stream. + pub fn last_native_channels(&self) -> u16 { + self.last_native_channels.max(1) + } + /// Returns true when the recorder still has an active stream/session. /// /// This is used by higher-level state recovery to detect desyncs where the @@ -439,6 +456,7 @@ impl Recorder { .map(|d| d.to_string()) .unwrap_or_else(|_| "Unknown".to_string()); info!("Using input device: {}", device_name); + self.last_input_device = device_name; // Get supported config let supported_config = device @@ -449,6 +467,7 @@ impl Recorder { // (backend will handle resampling if needed) let native_sample_rate = supported_config.sample_rate(); let native_channels = supported_config.channels().max(1); + self.last_native_channels = native_channels; // Build stream config using native sample rate/channel count. The // callback downmixes interleaved native channels to mono for downstream. @@ -1056,6 +1075,27 @@ fn write_wav_file(path: &PathBuf, samples: &[i16], sample_rate: u32, channels: u Ok(()) } +/// Recorded length of a WAV file in seconds, read from its header. +/// +/// Header-only: `hound` parses the `fmt `/`data` chunk sizes and reports the +/// frame count without decoding a single sample, so this is cheap enough to sit +/// on a latency-sensitive stop path. `duration()` counts frames per channel, +/// which is what "seconds of audio" means for both the mono capture path here +/// and any multi-channel file that reaches it. +/// +/// Returns `None` on an unreadable or truncated header and on a zero sample +/// rate. Failure is never reported as `0.0`: a caller measuring speech density +/// must be able to tell "I could not measure this audio" apart from "this audio +/// is empty", because the two demand opposite decisions. +pub fn wav_duration_secs(path: &Path) -> Option { + let reader = hound::WavReader::open(path).ok()?; + let sample_rate = reader.spec().sample_rate; + if sample_rate == 0 { + return None; + } + Some(reader.duration() as f32 / sample_rate as f32) +} + /// Recorder defaults, auto-silence gating, streaming buffer cap, and downmix. #[cfg(test)] mod tests { @@ -1063,6 +1103,31 @@ mod tests { // Note: RMS tests removed - now using Silero VAD (see vad module tests) + /// The stop-path density guard divides by this number, so it must come from + /// the header exactly — and a file the probe cannot parse must report `None` + /// rather than a plausible `0.0`, which would read as total starvation. + #[test] + fn wav_duration_secs_reads_header_and_refuses_unreadable_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("take.wav"); + let rate = 16_000u32; + // 2.5 s of mono silence: duration must be frames/rate, not file size. + let samples = vec![0i16; rate as usize * 5 / 2]; + write_wav_file(&path, &samples, rate, 1).expect("write wav"); + + let secs = wav_duration_secs(&path).expect("written header must be readable"); + assert!((secs - 2.5).abs() < 1e-3, "expected 2.5 s, got {secs}"); + + let garbage = dir.path().join("not-a-wav.bin"); + std::fs::write(&garbage, b"definitely not RIFF").expect("write garbage"); + assert_eq!( + wav_duration_secs(&garbage), + None, + "an unparseable header must not report a duration" + ); + assert_eq!(wav_duration_secs(&dir.path().join("missing.wav")), None); + } + /// Operator decision B (2026-08-10): the RAM ring caps at /// STREAMING_BUFFER_CAP_SECONDS, so a phone-call take longer than 5 min /// lost its head in the archived WAV. The disk spill must retain EVERY diff --git a/core/audio/streaming_recorder.rs b/core/audio/streaming_recorder.rs index 491e4fdd..ea34b58e 100644 --- a/core/audio/streaming_recorder.rs +++ b/core/audio/streaming_recorder.rs @@ -12,11 +12,20 @@ //! seconds) before releasing the sink. Dropping the sink early truncates the //! tail of the delivered text. +use crate::asr_session::bootstrap::{GatewaySessionAvailability, layer1_decision_for_recording}; +use crate::asr_session::recorder::{ + Layer1Decision, RecorderLifecycleEvents, RecorderLifecycleHandle, recorder_lifecycle_channel, +}; use crate::audio::recorder::{Recorder, RecorderConfig}; -use crate::pipeline::contracts::EventSink; -use crate::pipeline::streaming::{SessionConfig, stream_log_path, transcription_session}; +use crate::config::UserSettings; +use crate::pipeline::contracts::{EngineEvent, EventSink}; +use crate::pipeline::streaming::{ + SessionConfig, collect_buffered_engine_events_with_config, stream_log_path, + transcription_session, +}; use anyhow::{Context, Result, anyhow}; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicU64, Ordering}; use tokio::sync::{Mutex, mpsc}; use tokio::task::JoinHandle; @@ -27,6 +36,87 @@ use tracing::{debug, info, warn}; /// Channel depth for cold Whisper load: first words queue instead of drop. const AUDIO_BACKLOG_CHUNKS: usize = 2048; +/// Content-free witness returned by the production PCM replay seam. +#[derive(Debug)] +pub struct ProductionSessionReplay { + /// Ordered event stream emitted by the same session implementation as live capture. + pub events: Vec, + /// Whether recording-start policy armed a Layer 1 provider before the + /// single-use decision was consumed by the session. + pub layer1_armed: bool, + /// Engine that actually owned the live canvas for this replay session. + pub streaming_engine_label: String, +} + +/// Resolve the production Layer 1 decision for one recording. +/// +/// Both the microphone owner and the replay seam call this symbol. Keeping the +/// settings/consent/gateway decision here prevents an evaluation harness from +/// silently substituting `Layer1Decision::Disarmed`. +pub fn production_layer1_decision( + settings: &UserSettings, + gateway: GatewaySessionAvailability, +) -> Layer1Decision { + layer1_decision_for_recording(settings, gateway) +} + +/// Build the exact engine session configuration consumed by live capture. +fn recording_session_config( + sample_rate: u32, + language: Option, + stream_log_path: Option, + utterance_silence_sec: Option, + layer1: Layer1Decision, + lifecycle_events: Option, +) -> SessionConfig { + SessionConfig { + sample_rate, + language, + stream_log_path, + utterance_silence_sec, + layer1, + lifecycle_events, + } +} + +/// Replay fixture PCM through the production recording-session cone. +/// +/// The only differing boundary is PCM ingress: 100 ms in-memory chunks replace +/// CoreAudio callback blocks. Decision construction, `SessionConfig`, session +/// semantics, Layer 1 fan-out, VAD, Apple/Whisper events, and shutdown drainage +/// all remain owned by the same production symbols as microphone capture. +pub async fn replay_production_session( + samples: &[f32], + sample_rate: u32, + language: Option, + settings: &UserSettings, + gateway: GatewaySessionAvailability, +) -> Result { + let layer1 = production_layer1_decision(settings, gateway); + let layer1_armed = layer1.is_armed(); + let streaming_engine_label = if crate::stt::active_engine_is_apple() { + "live_apple" + } else { + "streaming_whisper" + } + .to_string(); + let utterance_silence_sec = settings.toggle_silence_sec.filter(|&sec| sec >= 0.5); + let config = recording_session_config( + sample_rate, + language, + None, + utterance_silence_sec, + layer1, + None, + ); + let events = collect_buffered_engine_events_with_config(samples, config).await?; + Ok(ProductionSessionReplay { + events, + layer1_armed, + streaming_engine_label, + }) +} + /// A recording session that transcribes while it captures. /// /// Configure the sink and any callbacks first, then call @@ -47,6 +137,10 @@ pub struct StreamingRecorder { /// block (linear, 0..~1). Runs on the CoreAudio callback thread — keep it /// cheap and non-blocking (a broadcast send, an atomic store). level_callback: Option>, + /// Single-use Layer 1 decision consumed when the next session starts. + layer1_decision: StdMutex, + /// O(1) host lifecycle signal for the currently active session. + lifecycle_handle: Option, } impl StreamingRecorder { @@ -68,6 +162,8 @@ impl StreamingRecorder { dropped_chunks: Arc::new(AtomicU64::new(0)), event_sink: None, level_callback: None, + layer1_decision: StdMutex::new(Layer1Decision::Disarmed), + lifecycle_handle: None, }) } @@ -89,9 +185,25 @@ impl StreamingRecorder { dropped_chunks: Arc::new(AtomicU64::new(0)), event_sink: None, level_callback: None, + layer1_decision: StdMutex::new(Layer1Decision::Disarmed), + lifecycle_handle: None, }) } + /// Join live settings truth with one minted gateway session for the next + /// recording. Missing/invalid/offline gateway state safely disarms Layer 1. + pub fn configure_layer1( + &mut self, + settings: &UserSettings, + gateway: GatewaySessionAvailability, + ) { + *self + .layer1_decision + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + production_layer1_decision(settings, gateway); + } + /// Store a per-utterance text callback. /// /// Note: the stored value is currently never read by this type — completed @@ -136,6 +248,18 @@ impl StreamingRecorder { self.recorder.is_active() } + /// Notify the active transcription task that the host crossed sleep/wake. + /// + /// No active capture is a normal no-op. This method only enqueues a typed + /// boundary; the session loop owns the fail-closed Layer 1 transition. + pub fn note_sleep_wake(&self) -> bool { + self.recorder.is_active() + && self + .lifecycle_handle + .as_ref() + .is_some_and(RecorderLifecycleHandle::note_sleep_wake) + } + /// Start recording with the new event-based pipeline. /// /// Uses `transcription_session` which emits `EngineEvent`s to the configured @@ -175,6 +299,13 @@ impl StreamingRecorder { // Update sample rate to match real input stream let actual_sample_rate = self.recorder.actual_sample_rate(); + crate::audio::capture_receipt::publish_open_capture_path( + crate::audio::capture_receipt::CapturePathMeta::from_open_path( + actual_sample_rate, + self.recorder.last_native_channels(), + self.recorder.last_input_device(), + ), + ); if actual_sample_rate != self.sample_rate { info!( "StreamingRecorder sample_rate updated: config={}Hz -> actual={}Hz", @@ -186,16 +317,25 @@ impl StreamingRecorder { let log_path = stream_log_path(); let utterance_silence_sec = self.utterance_silence_sec; + let layer1 = std::mem::take( + self.layer1_decision + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + let (lifecycle_handle, lifecycle_events) = recorder_lifecycle_channel(); + self.lifecycle_handle = Some(lifecycle_handle); self.transcription_handle = Some(tokio::spawn(async move { transcription_session( rx, event_sink, - SessionConfig { - sample_rate: actual_sample_rate, + recording_session_config( + actual_sample_rate, language, - stream_log_path: log_path, + log_path, utterance_silence_sec, - }, + layer1, + Some(lifecycle_events), + ), ) .await; })); @@ -223,6 +363,7 @@ impl StreamingRecorder { // 1. Stop recording (drops callback and sender) let audio_path = self.recorder.stop().await?; + self.lifecycle_handle = None; // 2. Wait for worker to finish processing remaining chunks if let Some(handle) = self.transcription_handle.take() { @@ -279,6 +420,7 @@ impl StreamingRecorder { // 1. Stop recording (discard WAV path) let _ = self.recorder.stop().await?; + self.lifecycle_handle = None; // 2. Wait for worker to finish processing remaining chunks if let Some(handle) = self.transcription_handle.take() { @@ -341,6 +483,27 @@ mod tests { use std::path::{Path, PathBuf}; use tokio::time::Duration; + /// The replay seam must consume the same recording-start decision as the + /// microphone path; a test-only `Disarmed` shortcut would make corpus + /// quality evidence adjacent to production again. + #[test] + fn replay_production_session_cannot_hardcode_disarmed_layer1() { + let source = include_str!("streaming_recorder.rs"); + let replay_body = source + .split("pub async fn replay_production_session") + .nth(1) + .and_then(|tail| tail.split("impl StreamingRecorder").next()) + .expect("production replay body remains present"); + assert!( + replay_body.contains("production_layer1_decision(settings, gateway)"), + "replay must resolve the same production Layer 1 policy as microphone capture" + ); + assert!( + !replay_body.contains("Layer1Decision::Disarmed"), + "replay must not silently hard-code a disarmed Layer 1 lane" + ); + } + /// Empty/silence/full-scale blocks map to the 0 / 0 / ~1 energy ladder meters use. #[test] fn block_rms_measures_signal_energy() { diff --git a/core/bin/codescribe-stt-sidecar.rs b/core/bin/codescribe-stt-sidecar.rs new file mode 100644 index 00000000..5396f479 --- /dev/null +++ b/core/bin/codescribe-stt-sidecar.rs @@ -0,0 +1,87 @@ +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow, bail}; +use codescribe_core::stt::tail_provider::{ + FakeTailProvider, InProcessTailProvider, STT_SIDECAR_TOKEN_ENV, TailProvider, + TailProviderPayload, serve_sidecar, +}; + +struct Args { + bind: SocketAddr, + parent_pid: Option, + #[cfg(debug_assertions)] + fake_payload: Option, +} + +fn main() -> Result<()> { + let args = parse_args()?; + let token = std::env::var(STT_SIDECAR_TOKEN_ENV).context("sidecar process token is missing")?; + // SAFETY: main is still single-threaded and no library worker has started. + unsafe { std::env::remove_var(STT_SIDECAR_TOKEN_ENV) }; + + #[cfg(debug_assertions)] + let provider: Box = if let Some(path) = args.fake_payload { + let payload: TailProviderPayload = serde_json::from_slice( + &std::fs::read(&path).with_context(|| format!("read fixture {}", path.display()))?, + ) + .context("parse sidecar fixture payload")?; + Box::new(FakeTailProvider::new(payload)?) + } else { + Box::new(InProcessTailProvider) + }; + #[cfg(not(debug_assertions))] + let provider: Box = Box::new(InProcessTailProvider); + + serve_sidecar(args.bind, token, provider.as_ref(), args.parent_pid) +} + +fn parse_args() -> Result { + let mut bind: Option = None; + let mut parent_pid = None; + #[cfg(debug_assertions)] + let mut fake_payload = None; + let mut args = std::env::args_os().skip(1); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--bind") => { + let value = args.next().ok_or_else(|| anyhow!("--bind needs a value"))?; + bind = Some( + value + .to_string_lossy() + .parse() + .context("invalid --bind socket address")?, + ); + } + Some("--parent-pid") => { + let value = args + .next() + .ok_or_else(|| anyhow!("--parent-pid needs a value"))?; + parent_pid = Some( + value + .to_string_lossy() + .parse() + .context("invalid --parent-pid")?, + ); + } + #[cfg(debug_assertions)] + Some("--fake-payload") => { + fake_payload = Some(PathBuf::from( + args.next() + .ok_or_else(|| anyhow!("--fake-payload needs a value"))?, + )); + } + _ => bail!("unknown sidecar argument"), + } + } + let bind = bind.ok_or_else(|| anyhow!("--bind is required"))?; + if !bind.ip().is_loopback() { + bail!("sidecar bind must be loopback"); + } + Ok(Args { + bind, + parent_pid, + #[cfg(debug_assertions)] + fake_payload, + }) +} diff --git a/core/build.rs b/core/build.rs index 68433271..e597181f 100644 --- a/core/build.rs +++ b/core/build.rs @@ -31,9 +31,12 @@ use license_key_contract::{ }; /// Default Whisper model to embed -const DEFAULT_MODEL_NAME: &str = "whisper-large-v3-turbo-mlx-q8"; +const DEFAULT_MODEL_NAME: &str = "whisper-large-v3-turbo"; /// Hugging Face repo id for the default Whisper snapshot (HF cache + download hints). -const DEFAULT_WHISPER_REPO: &str = "LibraxisAI/whisper-large-v3-turbo-mlx-q8"; +/// The repo ships only config + fp16 weights; `make download-model` composes +/// tokenizer.json + mel_filters.npz from the legacy q8 repo, and runtime keeps +/// a legacy fallback (see core/config/models.rs). +const DEFAULT_WHISPER_REPO: &str = "mlx-community/whisper-large-v3-turbo"; /// Default TTS model to embed const DEFAULT_TTS_MODEL_NAME: &str = "csm-1b"; diff --git a/core/config/cloud_asr.rs b/core/config/cloud_asr.rs new file mode 100644 index 00000000..57da88c9 --- /dev/null +++ b/core/config/cloud_asr.rs @@ -0,0 +1,509 @@ +//! Product truth for the Layer 1 ASR mode and audio-egress consent (C2). +//! +//! One brain, three modes: `cloud | local_power | apple_only`. The resolver in +//! this module is the only place the persisted mode string, the consent record, +//! and the legacy `use_local_stt` choice combine into a runtime decision — the +//! settings UI, the session factory, and future onboarding all consume +//! [`ResolvedAsrMode`] instead of re-deriving policy from raw fields. +//! +//! ## Doctrine encoded here +//! +//! - **Fresh install is Apple-only.** No persisted choice and no legacy signal +//! resolves to [`AsrProductMode::AppleOnly`] — never a hidden local model +//! load, never cloud. +//! - **Cloud requires explicit audio-egress consent.** A `cloud` mode value +//! without a granted consent record resolves to Apple-only. Missing, +//! unknown, or denied consent are all the same answer: no egress. +//! - **Upgrades preserve the prior local/cloud choice.** An installed user who +//! explicitly persisted `use_local_stt` keeps the corresponding mode; a +//! prior cloud choice carries its own consent evidence +//! ([`ConsentSource::LegacyCloudChoice`]) because that user already +//! configured and used an off-device transcription path on purpose. +//! - **No consent fallback may select local weights.** Every refusal lands on +//! Apple-only; `local_power` is reachable only as an explicit choice. +//! - **The gateway mint config carries no vendor keys.** [`GatewaySessionMint`] +//! is an endpoint, not a credential: URLs with user-info or query material +//! are refused at construction, and there is no field a vendor key could +//! occupy. Short-lived session bearers are minted by the Libraxis gateway +//! outside the desktop and consumed by `asr_session::cloud`. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Wire value for a granted audio-egress consent record. +pub const CONSENT_WIRE_GRANTED: &str = "granted"; +/// Wire value for an explicitly denied audio-egress consent record. +pub const CONSENT_WIRE_DENIED: &str = "denied"; + +/// First-class Layer 1 product mode chosen by the user. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum AsrProductMode { + /// Normalized live cloud session behind the Libraxis gateway contract. + Cloud, + /// Power-user local helper with on-demand weights (killable process, L0). + LocalPower, + /// Apple canvas + lexicon only. The safe floor every failure resolves to. + AppleOnly, +} + +impl AsrProductMode { + /// Stable persisted identifier; round-trips through [`FromStr`]. + pub fn as_str(&self) -> &'static str { + match self { + Self::Cloud => "cloud", + Self::LocalPower => "local_power", + Self::AppleOnly => "apple_only", + } + } + + /// Human-readable name for the settings UI. Presentation only. + pub fn label(&self) -> &'static str { + match self { + Self::Cloud => "Cloud (Libraxis gateway)", + Self::LocalPower => "Local power (on-device weights)", + Self::AppleOnly => "Apple only", + } + } + + /// Whether this mode sends captured audio off the machine. + pub fn sends_audio_off_device(&self) -> bool { + matches!(self, Self::Cloud) + } +} + +impl FromStr for AsrProductMode { + type Err = String; + + /// Parse the persisted mode identifier. No aliases: an unknown value must + /// fail loudly so the resolver can fall back to Apple-only instead of + /// guessing a mode that moves audio or loads weights. + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "cloud" => Ok(Self::Cloud), + "local_power" => Ok(Self::LocalPower), + "apple_only" => Ok(Self::AppleOnly), + other => Err(format!("Unknown AsrProductMode: {other}")), + } + } +} + +/// Where a granted consent came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentSource { + /// The user answered the explicit audio-egress consent question. + ExplicitSettings, + /// A pre-mode install had already chosen cloud transcription + /// (`use_local_stt = false` persisted); the upgrade preserves that choice + /// and records this derivation instead of re-asking. + LegacyCloudChoice, +} + +/// Typed audio-egress consent state. +/// +/// Deliberately three-valued: "never asked" and "denied" both refuse egress, +/// but the settings UI needs to tell them apart (ask vs. respect the no). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioEgressConsent { + /// The user explicitly allowed sending captured audio off this machine. + Granted(ConsentSource), + /// The user explicitly refused. Only a new explicit grant changes this. + Denied, + /// No answer recorded. Resolves exactly like a denial: no egress. + Unanswered, +} + +impl AudioEgressConsent { + /// Parse the persisted wire value. Anything other than the two canonical + /// tokens (including tampered or truncated values) reads as + /// [`Self::Unanswered`] — fail closed, never fail open. + pub fn from_wire(wire: Option<&str>) -> Self { + match wire.map(|value| value.trim().to_ascii_lowercase()) { + Some(value) if value == CONSENT_WIRE_GRANTED => { + Self::Granted(ConsentSource::ExplicitSettings) + } + Some(value) if value == CONSENT_WIRE_DENIED => Self::Denied, + _ => Self::Unanswered, + } + } + + /// Whether audio may leave the machine under this consent state. + pub fn permits_egress(&self) -> bool { + matches!(self, Self::Granted(_)) + } +} + +/// Why the resolver picked the mode it picked. Diagnostics and UI copy only — +/// never a second policy axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModeDerivation { + /// The persisted `asr_mode` value was honored as written. + ExplicitChoice, + /// No mode persisted; the prior `use_local_stt = true` choice carried over. + LegacyLocalChoice, + /// No mode persisted; the prior `use_local_stt = false` cloud choice + /// carried over together with its derived consent. + LegacyCloudChoice, + /// Fresh install: no mode, no legacy signal. The safe floor. + FreshDefault, + /// `cloud` was persisted but no consent record exists. Egress refused. + ConsentMissingFallback, + /// `cloud` was persisted but consent is explicitly denied. Egress refused. + ConsentDeniedFallback, + /// The persisted mode value did not parse. Refuse to guess. + UnknownModeFallback, +} + +/// The single resolved answer the rest of the product consumes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedAsrMode { + /// Effective product mode after consent and legacy derivation. + pub mode: AsrProductMode, + /// Consent state the mode was resolved under. + pub consent: AudioEgressConsent, + /// How the resolver arrived here. + pub derivation: ModeDerivation, +} + +/// Resolve the effective Layer 1 product mode from the persisted mode string, +/// the persisted consent wire value, and the legacy local/cloud switch. +/// +/// Pure on purpose: no I/O, no env, no clock. Every input combination has an +/// asserted answer in the test matrix below, and every refusal lands on +/// [`AsrProductMode::AppleOnly`] — never on [`AsrProductMode::LocalPower`]. +pub fn resolve_asr_product_mode( + explicit_mode: Option<&str>, + consent_wire: Option<&str>, + legacy_use_local_stt: Option, +) -> ResolvedAsrMode { + let consent = AudioEgressConsent::from_wire(consent_wire); + + let explicit = explicit_mode + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(raw) = explicit { + return match raw.parse::() { + Ok(AsrProductMode::Cloud) => match consent { + AudioEgressConsent::Granted(_) => ResolvedAsrMode { + mode: AsrProductMode::Cloud, + consent, + derivation: ModeDerivation::ExplicitChoice, + }, + AudioEgressConsent::Denied => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::ConsentDeniedFallback, + }, + AudioEgressConsent::Unanswered => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::ConsentMissingFallback, + }, + }, + Ok(mode) => ResolvedAsrMode { + mode, + consent, + derivation: ModeDerivation::ExplicitChoice, + }, + Err(_) => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::UnknownModeFallback, + }, + }; + } + + match legacy_use_local_stt { + Some(true) => ResolvedAsrMode { + mode: AsrProductMode::LocalPower, + consent, + derivation: ModeDerivation::LegacyLocalChoice, + }, + Some(false) => { + // A persisted cloud choice predating the mode field. An explicit + // denial recorded since then wins over the derived grant. + if consent == AudioEgressConsent::Denied { + ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::ConsentDeniedFallback, + } + } else { + let consent = match consent { + AudioEgressConsent::Granted(_) => consent, + _ => AudioEgressConsent::Granted(ConsentSource::LegacyCloudChoice), + }; + ResolvedAsrMode { + mode: AsrProductMode::Cloud, + consent, + derivation: ModeDerivation::LegacyCloudChoice, + } + } + } + None => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::FreshDefault, + }, + } +} + +/// Why a gateway session-mint endpoint was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GatewayMintError { + /// Not a parseable absolute URL, or no host. + InvalidUrl, + /// Remote plaintext. `https` is required off loopback. + InsecureScheme, + /// URL user-info (`user:pass@`) — credentials never live in this config. + EmbeddedCredentials, + /// Query/fragment material — signed parameters and keys belong to the + /// minted session response, never to the persisted endpoint. + QueryNotAllowed, +} + +impl fmt::Display for GatewayMintError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::InvalidUrl => "gateway mint URL is not a valid absolute URL", + Self::InsecureScheme => "gateway mint URL must be https (http is loopback-only)", + Self::EmbeddedCredentials => "gateway mint URL must not embed credentials", + Self::QueryNotAllowed => "gateway mint URL must not carry query or fragment data", + }; + f.write_str(text) + } +} + +/// Validated Libraxis gateway session-mint endpoint. +/// +/// The desktop POSTs here to obtain a short-lived session (endpoint + bearer) +/// and hands the result to `asr_session::cloud::GatewayConnection`. By +/// construction this type holds an endpoint and nothing else: there is no +/// vendor key field, and URLs that try to smuggle credential material are +/// refused. Provider choice stays behind the gateway. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewaySessionMint { + url: String, +} + +impl GatewaySessionMint { + /// Validate a session-mint endpoint URL. + pub fn new(raw: &str) -> Result { + let raw = raw.trim(); + let parsed = reqwest::Url::parse(raw).map_err(|_| GatewayMintError::InvalidUrl)?; + let host = parsed + .host_str() + .map(|value| value.trim_matches(['[', ']'])) + .ok_or(GatewayMintError::InvalidUrl)?; + let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1"); + match parsed.scheme() { + "https" => {} + "http" if loopback => {} + _ => return Err(GatewayMintError::InsecureScheme), + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(GatewayMintError::EmbeddedCredentials); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(GatewayMintError::QueryNotAllowed); + } + Ok(Self { + url: raw.to_string(), + }) + } + + /// The validated endpoint URL. + pub fn url(&self) -> &str { + &self.url + } +} + +/// Resolution matrix, wire parsing, and mint-endpoint validation contracts. +#[cfg(test)] +mod tests { + use super::*; + + /// Mode identifiers round-trip and unknown values fail loudly. + #[test] + fn asr_mode_wire_round_trip() { + for mode in [ + AsrProductMode::Cloud, + AsrProductMode::LocalPower, + AsrProductMode::AppleOnly, + ] { + assert_eq!(mode.as_str().parse::(), Ok(mode)); + } + assert!("whisper".parse::().is_err()); + assert!("".parse::().is_err()); + } + + /// Only Cloud classifies as sending audio off the device. + #[test] + fn only_cloud_sends_audio_off_device() { + assert!(AsrProductMode::Cloud.sends_audio_off_device()); + assert!(!AsrProductMode::LocalPower.sends_audio_off_device()); + assert!(!AsrProductMode::AppleOnly.sends_audio_off_device()); + } + + /// Consent wire parsing fails closed on anything non-canonical. + #[test] + fn consent_wire_fails_closed() { + assert_eq!( + AudioEgressConsent::from_wire(Some("granted")), + AudioEgressConsent::Granted(ConsentSource::ExplicitSettings) + ); + assert_eq!( + AudioEgressConsent::from_wire(Some("denied")), + AudioEgressConsent::Denied + ); + for garbage in [None, Some(""), Some("yes"), Some("1"), Some("GRANTED!")] { + assert_eq!( + AudioEgressConsent::from_wire(garbage), + AudioEgressConsent::Unanswered, + "non-canonical wire {garbage:?} must read as Unanswered" + ); + } + // Canonical values are case/whitespace tolerant but nothing more. + assert!(AudioEgressConsent::from_wire(Some(" Granted ")).permits_egress()); + } + + /// Fresh install: no mode, no legacy signal, no consent → Apple-only. + #[test] + fn fresh_install_resolves_apple_only() { + let resolved = resolve_asr_product_mode(None, None, None); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::FreshDefault); + assert!(!resolved.consent.permits_egress()); + } + + /// Upgrade preservation: a persisted local choice stays local. + #[test] + fn upgrade_preserves_legacy_local_choice() { + let resolved = resolve_asr_product_mode(None, None, Some(true)); + assert_eq!(resolved.mode, AsrProductMode::LocalPower); + assert_eq!(resolved.derivation, ModeDerivation::LegacyLocalChoice); + } + + /// Upgrade preservation: a persisted cloud choice stays cloud, carrying a + /// derived consent instead of silently re-asking or silently refusing. + #[test] + fn upgrade_preserves_legacy_cloud_choice_with_derived_consent() { + let resolved = resolve_asr_product_mode(None, None, Some(false)); + assert_eq!(resolved.mode, AsrProductMode::Cloud); + assert_eq!(resolved.derivation, ModeDerivation::LegacyCloudChoice); + assert_eq!( + resolved.consent, + AudioEgressConsent::Granted(ConsentSource::LegacyCloudChoice) + ); + } + + /// An explicit denial recorded after the upgrade beats the legacy grant. + #[test] + fn explicit_denial_beats_legacy_cloud_choice() { + let resolved = resolve_asr_product_mode(None, Some("denied"), Some(false)); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::ConsentDeniedFallback); + } + + /// Explicit cloud with granted consent is honored. + #[test] + fn explicit_cloud_with_consent_is_cloud() { + let resolved = resolve_asr_product_mode(Some("cloud"), Some("granted"), None); + assert_eq!(resolved.mode, AsrProductMode::Cloud); + assert_eq!(resolved.derivation, ModeDerivation::ExplicitChoice); + } + + /// Explicit cloud without consent resolves to Apple-only — and never to + /// local weights, even when a legacy local signal is also present. + #[test] + fn cloud_without_consent_resolves_apple_only_never_local() { + for (consent, derivation) in [ + (None, ModeDerivation::ConsentMissingFallback), + (Some("denied"), ModeDerivation::ConsentDeniedFallback), + (Some("tampered"), ModeDerivation::ConsentMissingFallback), + ] { + for legacy in [None, Some(true), Some(false)] { + let resolved = resolve_asr_product_mode(Some("cloud"), consent, legacy); + assert_eq!( + resolved.mode, + AsrProductMode::AppleOnly, + "cloud with consent={consent:?} legacy={legacy:?} must refuse egress" + ); + assert_eq!(resolved.derivation, derivation); + } + } + } + + /// Explicit non-cloud modes need no consent record. + #[test] + fn explicit_local_and_apple_need_no_consent() { + let local = resolve_asr_product_mode(Some("local_power"), None, None); + assert_eq!(local.mode, AsrProductMode::LocalPower); + assert_eq!(local.derivation, ModeDerivation::ExplicitChoice); + + let apple = resolve_asr_product_mode(Some("apple_only"), Some("granted"), Some(false)); + assert_eq!(apple.mode, AsrProductMode::AppleOnly); + assert_eq!(apple.derivation, ModeDerivation::ExplicitChoice); + } + + /// An unparseable persisted mode refuses to guess: Apple-only, not legacy + /// derivation and not local weights. + #[test] + fn unknown_mode_value_resolves_apple_only() { + let resolved = resolve_asr_product_mode(Some("turbo_cloud"), Some("granted"), Some(true)); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::UnknownModeFallback); + } + + /// Mint endpoint accepts clean https (and loopback http for dev). + #[test] + fn gateway_mint_accepts_clean_endpoints() { + for url in [ + "https://gateway.libraxis.cloud/v1/asr/sessions", + "https://gateway.libraxis.cloud/mint", + "http://127.0.0.1:8089/mint", + "http://localhost:8089/mint", + ] { + let mint = GatewaySessionMint::new(url).expect("clean endpoint accepted"); + assert_eq!(mint.url(), url); + } + } + + /// Mint endpoint refuses anything that could smuggle a credential: remote + /// plaintext, user-info, query strings, fragments, relative junk. + #[test] + fn gateway_mint_refuses_credential_material() { + let cases = [ + ( + "http://gateway.libraxis.cloud/mint", + GatewayMintError::InsecureScheme, + ), + ( + "ftp://gateway.libraxis.cloud/mint", + GatewayMintError::InsecureScheme, + ), + ( + "https://user:secret@gateway.libraxis.cloud/mint", + GatewayMintError::EmbeddedCredentials, + ), + ( + "https://gateway.libraxis.cloud/mint?api_key=sk-123", + GatewayMintError::QueryNotAllowed, + ), + ( + "https://gateway.libraxis.cloud/mint#token", + GatewayMintError::QueryNotAllowed, + ), + ("not a url", GatewayMintError::InvalidUrl), + ("", GatewayMintError::InvalidUrl), + ]; + for (url, expected) in cases { + assert_eq!( + GatewaySessionMint::new(url), + Err(expected), + "endpoint {url:?} must be refused" + ); + } + } +} diff --git a/core/config/default_env.txt b/core/config/default_env.txt index 39153a55..6fef8f5b 100644 --- a/core/config/default_env.txt +++ b/core/config/default_env.txt @@ -9,14 +9,14 @@ LLM_FORMATTING_API_KEY= # === Core mode === USE_LOCAL_STT=1 -LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 +LOCAL_MODEL=whisper-large-v3-turbo WHISPER_LANGUAGE=auto # === Hotkeys === HOLD_EXCLUSIVE=1 HOLD_START_DELAY_MS=800 DOUBLE_TAP_INTERVAL_MS=200 -TOGGLE_SILENCE_SEC=5.0 +TOGGLE_SILENCE_SEC=5.0 # Apple epoch rest; not CODESCRIBE_VAD_* # Mode bindings are canonical and persisted in settings.json: # - dictation: hold_fn # - formatting: double_left_option @@ -54,6 +54,8 @@ CODESCRIBE_STT_THERMAL_SERIOUS_MULT=2.0 CODESCRIBE_STT_THERMAL_CRITICAL_MULT=4.0 # Runtime lexicon initial_prompt is opt-in; default OFF. CODESCRIBE_STT_INITIAL_PROMPT_ENABLED=0 +# W13-6B overlay highlight layer is opt-in; default OFF. +CODESCRIBE_OVERLAY_HIGHLIGHTS=0 # VAD config is hardcoded in core/vad/config.rs (Silero defaults, no env knobs). CODESCRIBE_STREAM_SIMILARITY=0.90 CODESCRIBE_STREAM_NOVELTY=0.20 diff --git a/core/config/loader.rs b/core/config/loader.rs index cc7ffabf..dcdcd222 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -38,6 +38,19 @@ static CONFIG_ENV_BOOTSTRAPPED: AtomicBool = AtomicBool::new(false); /// cannot both decide they are the first writer. static CONFIG_ENV_BOOTSTRAP_LOCK: OnceLock> = OnceLock::new(); +/// Serialize the full settings read-modify-write transaction behind public +/// Config mutation APIs. Atomic renames prevent torn files, but without this +/// outer lock two distinct UI writes can both load the same snapshot and the +/// later rename silently erase the earlier field. +static CONFIG_PERSISTENCE_LOCK: OnceLock> = OnceLock::new(); + +fn config_persistence_guard() -> std::sync::MutexGuard<'static, ()> { + CONFIG_PERSISTENCE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// Keys this process seeded itself. After bootstrap they are reported as absent /// by [`Config::config_runtime_env_var`], so a later Settings write wins over /// the value config planted at startup — that is what makes settings hot-apply @@ -75,6 +88,13 @@ impl Config { /// a stale `~/.codescribe/.env` cannot shadow a choice made in the UI. /// Only after that are defaults, settings, and finally explicit env applied. fn load_with_keychain_population(populate_keychain: bool) -> Self { + let _data_io = match super::storage_reset::begin_app_data_io() { + Ok(guard) => guard, + Err(error) => { + warn!(%error, "Config load skipped while app-data reset owns the process"); + return Self::default(); + } + }; let _bootstrap_guard = Self::config_env_bootstrap_guard(); let seed_process_env = Self::can_seed_process_env(); let env_path = Self::env_path(); @@ -652,6 +672,32 @@ impl Config { self.transcription_overlay_enabled = v; Self::safe_set_env("TRANSCRIPTION_OVERLAY_ENABLED", if v { "1" } else { "0" }); } + if Self::config_runtime_env_var("HOLD_INDICATOR").is_err() + && let Some(v) = settings.hold_indicator + { + self.hold_indicator = v; + } + if Self::config_runtime_env_var("HOLD_BADGE_SIZE").is_err() + && let Some(v) = settings.hold_badge_size + { + self.hold_badge_size = v.min(u32::MAX as u64) as u32; + } + if Self::config_runtime_env_var("RESTORE_CLIPBOARD").is_err() + && let Some(v) = settings.restore_clipboard + { + self.restore_clipboard = v; + } + if Self::config_runtime_env_var("RESTORE_CLIPBOARD_DELAY_MS").is_err() + && let Some(v) = settings.restore_clipboard_delay_ms + { + self.restore_clipboard_delay_ms = v; + } + if Self::config_runtime_env_var("CODESCRIBE_DEFERRED_INSERT_SHORTCUT").is_err() + && let Some(raw) = settings.deferred_insert_shortcut.as_deref() + && let Ok(shortcut) = raw.parse::() + { + self.deferred_insert_shortcut = shortcut; + } if Self::config_runtime_env_var("TRAY_START_ASSISTIVE").is_err() && let Some(v) = settings.tray_start_assistive { @@ -871,6 +917,8 @@ impl Config { /// This is a persistence write only. Process-env seeding is restricted to /// bootstrap loads; live readers must reload the config/settings snapshot. pub fn save_to_env(&self, key: &str, value: &str) -> anyhow::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io()?; + let _persistence = config_persistence_guard(); let normalized_formatting = (key == "FORMATTING_LEVEL") .then(|| FormattingPolicy::parse(value)) .transpose()? @@ -898,7 +946,9 @@ impl Config { | "DOUBLE_TAP_INTERVAL_MS" | "CODESCRIBE_BUFFER_DELAY_MS" | "CODESCRIBE_EMIT_WORDS_MAX" - | "BACKEND_MAX_UPLOAD_MB" => { + | "BACKEND_MAX_UPLOAD_MB" + | "HOLD_BADGE_SIZE" + | "RESTORE_CLIPBOARD_DELAY_MS" => { if let Ok(v) = value.parse::() { settings.set_u64(key, v); } @@ -926,7 +976,9 @@ impl Config { | "START_AT_LOGIN" | "QUBE_DAEMON_AUTOSTART" | "AGENT_ENTER_SENDS" - | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { + | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" + | "HOLD_INDICATOR" + | "RESTORE_CLIPBOARD" => { let bool_val = matches!(value, "1" | "true" | "yes" | "on"); settings.set_bool(key, bool_val); } @@ -962,7 +1014,13 @@ impl Config { HashMap::new() }; env_vars.insert(key.to_string(), value.to_string()); - Self::write_env_file(&env_path, &env_vars)?; + Self::write_env_file(&env_path, &env_vars).inspect_err(|error| { + // A power-user key that cannot persist is a dead UI control, and the + // Swift callers swallow the error — this line is the only witness + // (2026-08-10: an immutable .env killed the Pointer Indicator row + // with zero log output). + tracing::warn!(key, %error, "save_to_env: .env write failed; value NOT persisted"); + })?; Ok(()) } @@ -974,6 +1032,8 @@ impl Config { if entries.is_empty() { return Ok(()); } + let _data_io = super::storage_reset::begin_app_data_io()?; + let _persistence = config_persistence_guard(); let mut settings: Option = None; let mut env_vars: Option> = None; @@ -1046,6 +1106,14 @@ impl Config { settings_ref.layered_transcription = Some((*value).to_string()); Self::reconcile_stt_runtime_key(key, value); } + // C2: same validated writes as the single-key set_string + // path — a batch write must not bypass mode/consent/URL + // validation or silently drop these keys. + "CODESCRIBE_ASR_MODE" + | "CODESCRIBE_CLOUD_CONSENT" + | "CODESCRIBE_ASR_GATEWAY_URL" => { + settings_ref.set_string(key, value); + } // ── u64 ── "HOLD_START_DELAY_MS" => { if let Ok(v) = value.parse::() { @@ -1072,6 +1140,22 @@ impl Config { settings_ref.backend_max_upload_mb = Some(v); } } + "HOLD_BADGE_SIZE" => { + if let Ok(v) = value.parse::() { + settings_ref.hold_badge_size = Some(v); + } + } + "RESTORE_CLIPBOARD_DELAY_MS" => { + if let Ok(v) = value.parse::() { + settings_ref.restore_clipboard_delay_ms = Some(v); + } + } + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT" => { + if let Ok(shortcut) = value.parse::() { + settings_ref.deferred_insert_shortcut = + Some(shortcut.wire_id().to_string()); + } + } // ── f32 ── "TOGGLE_SILENCE_SEC" => { if let Ok(v) = value.parse::() { @@ -1109,7 +1193,9 @@ impl Config { | "START_AT_LOGIN" | "QUBE_DAEMON_AUTOSTART" | "AGENT_ENTER_SENDS" - | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { + | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" + | "HOLD_INDICATOR" + | "RESTORE_CLIPBOARD" => { let bv = matches!(*value, "1" | "true" | "yes" | "on"); match *key { "AI_FORMATTING_ENABLED" => { @@ -1137,6 +1223,8 @@ impl Config { "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { settings_ref.stt_initial_prompt_enabled = Some(bv) } + "HOLD_INDICATOR" => settings_ref.hold_indicator = Some(bv), + "RESTORE_CLIPBOARD" => settings_ref.restore_clipboard = Some(bv), _ => {} } } @@ -1166,7 +1254,11 @@ impl Config { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - Self::write_env_file(&path, &vars)?; + Self::write_env_file(&path, &vars).inspect_err(|error| { + // Same witness as the single-key path: Swift callers swallow + // the error, so an unwritable .env must at least leave a trace. + tracing::warn!(%error, "save_to_env_many: .env write failed; batch NOT persisted"); + })?; } Ok(()) @@ -1290,6 +1382,8 @@ impl Config { ) -> anyhow::Result<()> { use crate::safe_path::{safe_read_to_string_bounded, safe_write_bounded}; + let _data_io = super::storage_reset::begin_app_data_io()?; + // Use path's parent as root to support CODESCRIBE_ENV_PATH override (tests) let root = path .parent() @@ -1554,6 +1648,69 @@ mod tests { tmp } + /// Distinct UI writes are one read-modify-write transaction each. Start two + /// callers together and prove the later atomic rename cannot erase the + /// field persisted by the other caller. + #[test] + #[serial] + fn concurrent_config_updates_preserve_both_distinct_fields() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_CONFIG_RMW_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_CONFIG_RMW_WITNESS"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = TempDir::new().expect("config RMW witness dir"); + let witness = witness_dir.path().join("passed"); + let status = std::process::Command::new( + std::env::current_exe().expect("current core test executable"), + ) + .args([ + "--exact", + "config::loader::tests::concurrent_config_updates_preserve_both_distinct_fields", + "--nocapture", + ]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated config RMW regression"); + assert!(status.success(), "isolated config RMW regression failed"); + assert_eq!( + fs::read(witness).expect("child executed exact config RMW test"), + b"config-rmw-pass" + ); + return; + } + + let _tmp = setup_isolated_data_dir(); + let _auto_paste = TestEnvGuard::unset("AUTO_PASTE_ENABLED"); + let _dock = TestEnvGuard::unset("SHOW_DOCK_ICON"); + let start = std::sync::Arc::new(std::sync::Barrier::new(3)); + let first_start = start.clone(); + let first = std::thread::spawn(move || { + first_start.wait(); + Config::default() + .save_to_env("AUTO_PASTE_ENABLED", "0") + .expect("persist auto paste") + }); + let second_start = start.clone(); + let second = std::thread::spawn(move || { + second_start.wait(); + Config::default() + .save_to_env("SHOW_DOCK_ICON", "0") + .expect("persist dock icon") + }); + start.wait(); + first.join().expect("first config writer joins"); + second.join().expect("second config writer joins"); + + let persisted = UserSettings::load(); + assert_eq!(persisted.auto_paste_enabled, Some(false)); + assert_eq!(persisted.show_dock_icon, Some(false)); + fs::write( + std::env::var_os(CHILD_WITNESS).expect("config RMW child witness path"), + b"config-rmw-pass", + ) + .expect("write config RMW child witness"); + } + /// Every LLM write key as `(key, sample value, JSON pointer)`. A `None` /// pointer marks a key with no durable `settings.json` home — it is still /// exercised, to prove writing it does not invent one. @@ -1665,19 +1822,15 @@ mod tests { } } - /// Badge/indicator keys stay env-managed; settings.json bytes must not change. + /// Badge/indicator keys are promoted (2026-08-11): tray writes land in + /// settings.json — never `.env`, whose immutability killed the Pointer + /// Indicator row — and reload live without process-env shadowing. #[test] #[serial] - fn hold_indicator_ui_writes_existing_env_keys_without_settings_json_drift() { + fn hold_indicator_ui_writes_are_promoted_to_settings_json() { let _tmp = setup_isolated_data_dir(); let _indicator = TestEnvGuard::unset("HOLD_INDICATOR"); let _size = TestEnvGuard::unset("HOLD_BADGE_SIZE"); - let settings = UserSettings { - show_dock_icon: Some(true), - ..UserSettings::default() - }; - settings.save().expect("seed settings json"); - let settings_before = fs::read(UserSettings::settings_path()).expect("read settings json"); let config = Config::default(); config @@ -1686,53 +1839,81 @@ mod tests { config .save_to_env("HOLD_INDICATOR", "0") .expect("disable indicator"); - let disabled = Config::parse_env_file(&Config::env_path()).expect("read env"); - assert_eq!( - disabled.get("HOLD_INDICATOR").map(String::as_str), - Some("0") - ); - assert_eq!( - disabled.get("HOLD_BADGE_SIZE").map(String::as_str), - Some("8"), - "Off must preserve the stored badge size" - ); + let stored = UserSettings::load(); + assert_eq!(stored.hold_indicator, Some(false)); + assert_eq!(stored.hold_badge_size, Some(8)); let disabled_config = Config::load_without_keychain(); assert!(!disabled_config.hold_indicator); assert_eq!(disabled_config.hold_badge_size, 8); - for size in [4, 8, 12] { - let size = size.to_string(); + for size in [4u64, 8, 12] { + let size_str = size.to_string(); config - .save_to_env_many(&[("HOLD_INDICATOR", "1"), ("HOLD_BADGE_SIZE", &size)]) + .save_to_env_many(&[("HOLD_INDICATOR", "1"), ("HOLD_BADGE_SIZE", &size_str)]) .expect("save enabled badge size"); - let persisted = Config::parse_env_file(&Config::env_path()).expect("read env"); - assert_eq!( - persisted.get("HOLD_INDICATOR").map(String::as_str), - Some("1") - ); - assert_eq!( - persisted.get("HOLD_BADGE_SIZE").map(String::as_str), - Some(size.as_str()) - ); - // Test builds intentionally allow repeated process-env bootstrap, - // unlike production's one-shot tracked bootstrap. Remove the prior - // injected snapshot so this reload exercises the newly persisted - // values instead of the test-only stale process copy. - // SAFETY: this test is serial and the guards above restore both keys. - unsafe { - std::env::remove_var("HOLD_INDICATOR"); - std::env::remove_var("HOLD_BADGE_SIZE"); - } + let stored = UserSettings::load(); + assert_eq!(stored.hold_indicator, Some(true)); + assert_eq!(stored.hold_badge_size, Some(size)); let live = Config::load_without_keychain(); assert!(live.hold_indicator); - assert_eq!(live.hold_badge_size.to_string(), size); + assert_eq!(u64::from(live.hold_badge_size), size); } - let settings_after = fs::read(UserSettings::settings_path()).expect("read settings json"); + // The promoted keys must leave `.env` alone entirely. + let env_path = Config::env_path(); + if env_path.exists() { + let env = Config::parse_env_file(&env_path).expect("parse optional env"); + assert!(!env.contains_key("HOLD_INDICATOR")); + assert!(!env.contains_key("HOLD_BADGE_SIZE")); + } + } + + /// Deferred-insert + clipboard-restore keys are promoted: valid writes land + /// in settings.json and reload live; invalid chords are rejected without + /// touching disk. + #[test] + #[serial] + fn deferred_insert_and_restore_clipboard_writes_are_promoted() { + let _tmp = setup_isolated_data_dir(); + let _shortcut = TestEnvGuard::unset("CODESCRIBE_DEFERRED_INSERT_SHORTCUT"); + let _restore = TestEnvGuard::unset("RESTORE_CLIPBOARD"); + let _delay = TestEnvGuard::unset("RESTORE_CLIPBOARD_DELAY_MS"); + let config = Config::default(); + + config + .save_to_env("CODESCRIBE_DEFERRED_INSERT_SHORTCUT", "cmd_alt_v") + .expect("save shortcut alias"); + assert_eq!( + UserSettings::load().deferred_insert_shortcut.as_deref(), + Some("command_option_v"), + "aliases must persist as the canonical wire id" + ); assert_eq!( - settings_before, settings_after, - "settings.json must not gain badge keys" + Config::load_without_keychain().deferred_insert_shortcut, + DeferredInsertShortcut::CommandOptionV ); + + config + .save_to_env("CODESCRIBE_DEFERRED_INSERT_SHORTCUT", "not_a_chord") + .expect("invalid chord is a non-fatal no-op"); + assert_eq!( + UserSettings::load().deferred_insert_shortcut.as_deref(), + Some("command_option_v"), + "invalid chord must not clobber the stored one" + ); + + config + .save_to_env_many(&[ + ("RESTORE_CLIPBOARD", "0"), + ("RESTORE_CLIPBOARD_DELAY_MS", "450"), + ]) + .expect("save clipboard restore batch"); + let stored = UserSettings::load(); + assert_eq!(stored.restore_clipboard, Some(false)); + assert_eq!(stored.restore_clipboard_delay_ms, Some(450)); + let live = Config::load_without_keychain(); + assert!(!live.restore_clipboard); + assert_eq!(live.restore_clipboard_delay_ms, 450); } /// AUTO_PASTE single/batch writes reload live without shadowing process env. diff --git a/core/config/mod.rs b/core/config/mod.rs index 611a7c5a..82350382 100644 --- a/core/config/mod.rs +++ b/core/config/mod.rs @@ -17,6 +17,8 @@ //! //! Note: Config is loaded via `Config::load()` and accessed via shared state in main.rs. +/// Layer 1 ASR product mode, audio-egress consent, and gateway mint config. +pub mod cloud_asr; /// Serde default helpers and default model/endpoint constants. mod defaults; /// Stop-path final-pass routing mode shared by controller and bridge lanes. @@ -36,6 +38,8 @@ pub mod portable; pub mod prompts; /// GUI-managed user settings JSON (regular-user tier). pub mod settings; +/// Process-wide app-data I/O fence used by destructive reset. +pub mod storage_reset; /// Config enums and the main `Config` struct definitions. mod types; @@ -50,12 +54,17 @@ pub use types::{ ShortcutBinding, TranscriptSendMode, WorkMode, }; // Language re-exported for external consumers (GUI apps) +pub use cloud_asr::{ + AsrProductMode, AudioEgressConsent, ConsentSource, GatewayMintError, GatewaySessionMint, + ModeDerivation, ResolvedAsrMode, resolve_asr_product_mode, +}; pub use final_pass::{FinalPassRoutingMode, final_pass_routing_mode}; pub use portable::{ ImportPlan, PortableProfile, export_portable, import_portable_apply, import_portable_dry_run, write_portable_export, }; pub use settings::{FormattingPolicy, UserSettings}; +pub use storage_reset::{AppDataResetGuard, begin_app_data_reset}; pub use types::Language; // Re-export prompts API (public API for GUI apps) @@ -66,6 +75,7 @@ pub use prompts::{ get_formatting_prompt_for_policy, get_formatting_prompt_path, get_formatting_prompt_path_for_policy, open_prompt_file, open_prompts_folder, prompt_snapshot, reset_to_defaults, restore_prompt_to_default, write_prompt, write_prompt_bytes, + write_prompt_bytes_during_reset, }; #[cfg(test)] diff --git a/core/config/models.rs b/core/config/models.rs index db956a4f..22696858 100644 --- a/core/config/models.rs +++ b/core/config/models.rs @@ -11,10 +11,21 @@ use std::path::{Path, PathBuf}; use crate::hf_cache; /// Default Whisper model name used for runtime fallback lookup. -pub const DEFAULT_MODEL: &str = "whisper-large-v3-turbo-mlx-q8"; +pub const DEFAULT_MODEL: &str = "whisper-large-v3-turbo"; /// Hugging Face repo backing [`DEFAULT_MODEL`], used for cache lookup and for -/// the Settings → Dictation download. -pub const DEFAULT_WHISPER_REPO: &str = "LibraxisAI/whisper-large-v3-turbo-mlx-q8"; +/// the Settings → Dictation download. fp16 weights: no q8→F32 dequantization +/// on load, at the cost of a larger download than the q8 repo. +pub const DEFAULT_WHISPER_REPO: &str = "mlx-community/whisper-large-v3-turbo"; +/// Previous default (q8). Kept as a resolution fallback so installs that +/// already carry it keep transcribing without a re-download. +pub const LEGACY_MODEL: &str = "whisper-large-v3-turbo-mlx-q8"; +/// Repo behind [`LEGACY_MODEL`]. Also the companion source for +/// [`COMPANION_MODEL_FILES`], which [`DEFAULT_WHISPER_REPO`] does not ship. +pub const LEGACY_WHISPER_REPO: &str = "LibraxisAI/whisper-large-v3-turbo-mlx-q8"; +/// Files the mlx-community repo does not ship. Both are quantization-independent +/// (same tokenizer and mel filterbank across q8/fp16), so composing them from +/// the legacy repo yields a correct model directory. +const COMPANION_MODEL_FILES: [&str; 2] = ["tokenizer.json", "mel_filters.npz"]; /// Files that must all be present for a directory to count as a usable model. const REQUIRED_MODEL_FILES: [&str; 3] = ["config.json", "tokenizer.json", "mel_filters.npz"]; @@ -81,6 +92,14 @@ fn hf_snapshot_for_model(model_ref: &str) -> Option { ); } + if trimmed == LEGACY_MODEL { + return hf_cache::find_snapshot_with_any( + LEGACY_WHISPER_REPO, + &REQUIRED_MODEL_FILES, + &REQUIRED_MODEL_WEIGHTS, + ); + } + None } @@ -235,8 +254,10 @@ impl ModelManager { /// 1. Explicit `CODESCRIBE_MODEL_PATH` /// 2. Configured local model path / models-dir alias /// 3. Configured Hugging Face repo snapshot -/// 4. Default models-dir alias (`whisper-large-v3-turbo-mlx-q8`) -/// 5. Default Hugging Face snapshot (`LibraxisAI/whisper-large-v3-turbo-mlx-q8`) +/// 4. Default models-dir alias (`whisper-large-v3-turbo`) +/// 5. Default Hugging Face snapshot (`mlx-community/whisper-large-v3-turbo`) +/// 6. Legacy models-dir alias (`whisper-large-v3-turbo-mlx-q8`) +/// 7. Legacy Hugging Face snapshot (`LibraxisAI/whisper-large-v3-turbo-mlx-q8`) pub fn resolve_runtime_whisper_model_path(configured_model: Option<&str>) -> Result { if let Ok(path) = std::env::var("CODESCRIBE_MODEL_PATH") { let candidate = PathBuf::from(path.trim()); @@ -270,6 +291,15 @@ pub fn resolve_runtime_whisper_model_path(configured_model: Option<&str>) -> Res return Ok(snapshot); } + let legacy_local = manager.get_model_path(LEGACY_MODEL); + if is_complete_whisper_model_dir(&legacy_local) { + return Ok(canonicalize_or_self(legacy_local)); + } + + if let Some(snapshot) = hf_snapshot_for_model(LEGACY_MODEL) { + return Ok(snapshot); + } + Err(anyhow!( "Whisper runtime fallback model not available.\n\ Public builds do not embed Whisper; install it from Settings → Dictation,\n\ @@ -309,7 +339,7 @@ pub fn whisper_model_status() -> WhisperModelStatus { path, model_id: DEFAULT_MODEL.to_string(), repo: DEFAULT_WHISPER_REPO.to_string(), - size_hint: "~900 MB".to_string(), + size_hint: "~1.6 GB".to_string(), } } @@ -328,15 +358,25 @@ where return Ok(canonicalize_or_self(dest)); } - // Prefer an already-complete HF cache snapshot: hardlink/copy into user models dir - // when possible so Settings "Download" is a no-op if hf cache is warm. - if let Some(snapshot) = hf_snapshot_for_model(DEFAULT_MODEL) { - if snapshot != dest { - copy_complete_model_dir(&snapshot, &dest)?; - } - if is_complete_whisper_model_dir(&dest) { - return Ok(canonicalize_or_self(dest)); - } + // Compose from warm local sources first, so Settings "Download" is a no-op + // when the pieces are already on disk. The primary repo ships only + // config.json + weights; tokenizer.json and mel_filters.npz come from the + // legacy q8 sources. config.json and weights must stay paired to the + // primary repo — legacy weights under the new alias would mislabel q8 as + // fp16, so legacy sources only ever contribute the companion files. + if let Some(snapshot) = hf_cache::find_snapshot(DEFAULT_WHISPER_REPO, &["config.json"]) + && snapshot != dest + { + copy_model_files(&snapshot, &dest, &["config.json"])?; + copy_model_files(&snapshot, &dest, &REQUIRED_MODEL_WEIGHTS)?; + } + let legacy_local = manager.get_model_path(LEGACY_MODEL); + copy_model_files(&legacy_local, &dest, &COMPANION_MODEL_FILES)?; + if let Some(snapshot) = hf_snapshot_for_model(LEGACY_MODEL) { + copy_model_files(&snapshot, &dest, &COMPANION_MODEL_FILES)?; + } + if is_complete_whisper_model_dir(&dest) { + return Ok(canonicalize_or_self(dest)); } fs::create_dir_all(&dest).with_context(|| format!("create {}", dest.display()))?; @@ -347,25 +387,48 @@ where .build() .context("build HTTP client for Whisper download")?; - // Small files first so a failed auth fails fast before multi-hundred-MB weights. - for name in REQUIRED_MODEL_FILES { - download_hf_file( + // Small files first so a failed auth fails fast before multi-GB weights. + // config.json describes the primary weights, so it has no fallback source; + // the companion files fall back to the legacy repo when the primary 404s. + download_hf_file( + &client, + DEFAULT_WHISPER_REPO, + "config.json", + &dest.join("config.json"), + &mut on_progress, + )?; + for name in COMPANION_MODEL_FILES { + let target = dest.join(name); + if let Err(err) = download_hf_file( &client, DEFAULT_WHISPER_REPO, name, - &dest.join(name), + &target, &mut on_progress, - )?; + ) { + tracing::warn!( + error = %err, + file = name, + "primary repo does not ship this file; falling back to legacy repo" + ); + download_hf_file( + &client, + LEGACY_WHISPER_REPO, + name, + &target, + &mut on_progress, + )?; + } } - let weights_dest = dest.join("model.safetensors"); - let weights_alt = dest.join("weights.safetensors"); + let weights_dest = dest.join("weights.safetensors"); + let weights_alt = dest.join("model.safetensors"); if !weights_dest.exists() && !weights_alt.exists() { - // Prefer model.safetensors; fall back to weights.safetensors if 404. + // mlx-community ships weights.safetensors; fall back to model.safetensors if 404. match download_hf_file( &client, DEFAULT_WHISPER_REPO, - "model.safetensors", + "weights.safetensors", &weights_dest, &mut on_progress, ) { @@ -373,12 +436,12 @@ where Err(err) => { tracing::warn!( error = %err, - "model.safetensors missing; trying weights.safetensors" + "weights.safetensors missing; trying model.safetensors" ); download_hf_file( &client, DEFAULT_WHISPER_REPO, - "weights.safetensors", + "model.safetensors", &weights_alt, &mut on_progress, )?; @@ -396,27 +459,22 @@ where Ok(canonicalize_or_self(dest)) } -/// Copy a warm Hugging Face cache snapshot into the user models directory. +/// Copy selected model files from a local source into the user models directory. /// -/// Lets Settings → Download complete without network traffic when the cache is -/// already populated. Existing destination files are left alone, so an -/// interrupted copy resumes rather than restarting. -fn copy_complete_model_dir(src: &Path, dest: &Path) -> Result<()> { - fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?; - for name in REQUIRED_MODEL_FILES { - let from = src.join(name); - let to = dest.join(name); - if from.exists() && !to.exists() { - // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Both ends are internal: `name` comes from the REQUIRED_MODEL_FILES compile-time constant, and the only caller passes the HF cache snapshot dir and `ModelManager::get_model_path(DEFAULT_MODEL)`. No caller-supplied path component reaches here. - fs::copy(&from, &to) - .with_context(|| format!("copy {} → {}", from.display(), to.display()))?; - } +/// Lets Settings → Download complete without network traffic when the pieces are +/// already on disk (warm HF cache, legacy q8 install). A missing source is a +/// clean no-op and existing destination files are left alone, so an interrupted +/// composition resumes rather than restarting. +fn copy_model_files(src: &Path, dest: &Path, names: &[&str]) -> Result<()> { + if !src.is_dir() { + return Ok(()); } - for name in REQUIRED_MODEL_WEIGHTS { + fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?; + for name in names { let from = src.join(name); let to = dest.join(name); if from.exists() && !to.exists() { - // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Same as above: `name` is a REQUIRED_MODEL_WEIGHTS constant, both dirs are derived from DEFAULT_MODEL. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Both ends are internal: `name` comes from the REQUIRED_MODEL_* / COMPANION_MODEL_FILES compile-time constants, and callers pass HF cache snapshot dirs or ModelManager::get_model_path outputs. No caller-supplied path component reaches here. fs::copy(&from, &to) .with_context(|| format!("copy {} → {}", from.display(), to.display()))?; } @@ -761,7 +819,7 @@ mod tests { let status = whisper_model_status(); assert_eq!(status.model_id, DEFAULT_MODEL); assert_eq!(status.repo, DEFAULT_WHISPER_REPO); - assert!(status.size_hint.contains("MB")); + assert!(status.size_hint.contains("GB")); // embedded flag must match cfg(embed_model) payload; we only assert type wiring. let _ = status.available; let _ = status.embedded; diff --git a/core/config/prompts.rs b/core/config/prompts.rs index ceef399d..41f15dc6 100644 --- a/core/config/prompts.rs +++ b/core/config/prompts.rs @@ -390,6 +390,36 @@ pub fn write_prompt_bytes( kind: PromptKind, content: &[u8], reason: PromptWriteReason, +) -> std::io::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io().map_err(std::io::Error::other)?; + write_prompt_bytes_unfenced(kind, content, reason) +} + +/// Restore bytes captured by the reset owner after the live root has moved. +/// +/// This is the sole write allowed through the process-lifetime reset latch. +/// Possessing a reset guard is insufficient until the destructive phase has +/// actually been armed, so ordinary callers cannot use this as a fence bypass. +pub fn write_prompt_bytes_during_reset( + reset: &super::storage_reset::AppDataResetGuard, + kind: PromptKind, + content: &[u8], + reason: PromptWriteReason, +) -> std::io::Result<()> { + if !reset.permits_preserved_restore() { + return Err(std::io::Error::other( + "preserved prompt restore requires a destructively latched reset", + )); + } + write_prompt_bytes_unfenced(kind, content, reason) +} + +/// Raw prompt transaction used only after normal I/O admission or by the +/// token-bearing reset restore path above. +fn write_prompt_bytes_unfenced( + kind: PromptKind, + content: &[u8], + reason: PromptWriteReason, ) -> std::io::Result<()> { write_prompt_at_with_rename( &prompts_dir().join(kind.filename()), diff --git a/core/config/settings.rs b/core/config/settings.rs index 1c67b163..fde3b770 100644 --- a/core/config/settings.rs +++ b/core/config/settings.rs @@ -5,9 +5,23 @@ use super::types::{ModeBinding, ShortcutBinding, WorkMode, default_mode_bindings}; use directories::BaseDirs; use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; use tracing::{debug, info, warn}; +use uuid::Uuid; + +/// Serialize settings read/migrate/write transactions. A V1 load writes a +/// backup and a V3 replacement, so it is a writer even though the public API is +/// named `load`; one lock keeps concurrent migrations and saves from crossing. +fn settings_io_lock() -> MutexGuard<'static, ()> { + static SETTINGS_IO: OnceLock> = OnceLock::new(); + SETTINGS_IO + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} /// Canonical formatting policy shared by persistence, runtime selection, and UI. /// @@ -161,6 +175,19 @@ pub struct UserSettings { pub transcription_overlay_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tray_start_assistive: Option, + // Promoted 2026-08-11: these lived only in `.env`, so the tray/settings + // writers died silently once the file became unwritable (uchg lock) and + // the 2026-08-08 wipe erased the user's values outright. + #[serde(skip_serializing_if = "Option::is_none")] + pub hold_indicator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hold_badge_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_clipboard: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_clipboard_delay_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deferred_insert_shortcut: Option, // ── Promoted from .env (settings.json is now source of truth) ── #[serde(skip_serializing_if = "Option::is_none")] @@ -225,8 +252,8 @@ pub struct UserSettings { #[serde(skip_serializing_if = "Option::is_none")] pub final_pass_mode: Option, /// Layered incremental transcription phase ("off" | "phase1"). - /// Seeds `CODESCRIBE_LAYERED_TRANSCRIPTION`; anything other than - /// "phase1".."phase4" (or bare "1".."4") is treated as OFF by the core. + /// Seeds `CODESCRIBE_LAYERED_TRANSCRIPTION`. Absent matches the core + /// default (`unset` → phase1). Explicit "off" / "0" / "false" disarms. #[serde(skip_serializing_if = "Option::is_none")] pub layered_transcription: Option, /// Opt-in Whisper `initial_prompt` vocabulary hint. @@ -234,6 +261,30 @@ pub struct UserSettings { #[serde(skip_serializing_if = "Option::is_none")] pub stt_initial_prompt_enabled: Option, + // ── Layer 1 ASR product mode + audio-egress consent (C2) ── + /// Layer 1 product mode (`cloud` | `local_power` | `apple_only`). + /// `None` means "not yet chosen": the resolver derives the mode from the + /// legacy `use_local_stt` choice (upgrades) or lands on Apple-only (fresh). + /// Writes are validated through [`crate::config::cloud_asr::AsrProductMode`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub asr_mode: Option, + /// Audio-egress consent record (`granted` | `denied`). `None` means never + /// asked; anything non-canonical reads as unanswered (fail closed). Cloud + /// mode without a granted record resolves to Apple-only — see + /// [`UserSettings::resolved_asr_mode`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub cloud_consent: Option, + /// RFC 3339 timestamp of the last explicit consent answer. Informational + /// provenance only — never an input to the resolver. + #[serde(skip_serializing_if = "Option::is_none")] + pub cloud_consent_at: Option, + /// Libraxis gateway session-mint endpoint. Endpoint only, never a vendor + /// key: writes are validated through + /// [`crate::config::cloud_asr::GatewaySessionMint`], which refuses + /// user-info and query material. `None` means "not configured". + #[serde(skip_serializing_if = "Option::is_none")] + pub asr_gateway_url: Option, + // ── Agent workspace ── /// Workspace root directories the agent scans (`list_projects`) to resolve a /// project name to an absolute path. The Settings UI sends the @@ -320,6 +371,12 @@ struct InteractionV2 { /// dictation. Assistive and safety vetoes are enforced by the controller. #[serde(skip_serializing_if = "Option::is_none")] auto_paste_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + deferred_insert_shortcut: Option, + #[serde(skip_serializing_if = "Option::is_none")] + restore_clipboard: Option, + #[serde(skip_serializing_if = "Option::is_none")] + restore_clipboard_delay_ms: Option, } /// Timing of the tap-based triggers: how fast a double tap must be, and how @@ -395,6 +452,12 @@ struct SpeechEngineV2 { layered_transcription: Option, #[serde(skip_serializing_if = "Option::is_none")] initial_prompt_enabled: Option, + // C2: Layer 1 product mode (cloud | local_power | apple_only) and the + // gateway session-mint endpoint it uses when cloud is armed. + #[serde(skip_serializing_if = "Option::is_none")] + asr_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + gateway_session_url: Option, } /// LLM post-processing of the transcript: whether it runs, how aggressively, @@ -482,6 +545,10 @@ struct UiV2 { transcription_overlay_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] tray_start_assistive: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hold_indicator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hold_badge_size: Option, } /// `features` section: optional surfaces the user can switch off entirely, @@ -527,6 +594,12 @@ struct SystemV2 { // xAI account-login OAuth client id (non-secret app identity). #[serde(skip_serializing_if = "Option::is_none")] xai_oauth_client_id: Option, + // C2: audio-egress consent record — install-level privacy state, kept in + // `system` so engine-section rewrites can never touch it. + #[serde(skip_serializing_if = "Option::is_none")] + cloud_audio_egress_consent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cloud_audio_egress_consent_at: Option, } /// Canonical list of env keys that route to `settings.json` (not `.env`). @@ -558,6 +631,13 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ "SHOW_DOCK_ICON", "TRANSCRIPTION_OVERLAY_ENABLED", "TRAY_START_ASSISTIVE", + // Pointer indicator + delivery (promoted 2026-08-11: .env writes died + // silently under the uchg lock, killing the tray Pointer Indicator row) + "HOLD_INDICATOR", + "HOLD_BADGE_SIZE", + "RESTORE_CLIPBOARD", + "RESTORE_CLIPBOARD_DELAY_MS", + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT", // LLM endpoints "LLM_ENDPOINT", "LLM_MODEL", @@ -601,6 +681,11 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ // Promoted 2026-08-10: the un-promoted toggle wrote .env only, the stale // process env won the UI read-back, and the Layered switch snapped OFF. "CODESCRIBE_LAYERED_TRANSCRIPTION", + // C2: Layer 1 product mode, audio-egress consent, gateway mint endpoint. + // settings.json is the single brain — no .env dual-write for these. + "CODESCRIBE_ASR_MODE", + "CODESCRIBE_CLOUD_CONSENT", + "CODESCRIBE_ASR_GATEWAY_URL", // Still env-seedable when unset; not full dual-brain: // "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", ]; @@ -633,6 +718,9 @@ impl UserSettings { send_mode: self.transcript_send_mode.clone(), agent_enter_sends: self.agent_enter_sends, auto_paste_enabled: self.auto_paste_enabled, + deferred_insert_shortcut: self.deferred_insert_shortcut.clone(), + restore_clipboard: self.restore_clipboard, + restore_clipboard_delay_ms: self.restore_clipboard_delay_ms, }), speech: Some(SpeechV2 { language: self.whisper_language.clone(), @@ -648,6 +736,8 @@ impl UserSettings { final_pass_mode: self.final_pass_mode.clone(), layered_transcription: self.layered_transcription.clone(), initial_prompt_enabled: self.stt_initial_prompt_enabled, + asr_mode: self.asr_mode.clone(), + gateway_session_url: self.asr_gateway_url.clone(), }), formatting: Some(FormattingV2 { enabled: self.ai_formatting_enabled, @@ -688,6 +778,8 @@ impl UserSettings { show_dock_icon: self.show_dock_icon, transcription_overlay_enabled: self.transcription_overlay_enabled, tray_start_assistive: self.tray_start_assistive, + hold_indicator: self.hold_indicator, + hold_badge_size: self.hold_badge_size, }), features: Some(FeaturesV2 { history_enabled: self.history_enabled, @@ -703,6 +795,8 @@ impl UserSettings { openai_oauth_client_id: self.openai_oauth_client_id.clone(), anthropic_oauth_client_id: self.anthropic_oauth_client_id.clone(), xai_oauth_client_id: self.xai_oauth_client_id.clone(), + cloud_audio_egress_consent: self.cloud_consent.clone(), + cloud_audio_egress_consent_at: self.cloud_consent_at.clone(), }), agent: match ( self.agent_permissions.clone(), @@ -817,6 +911,20 @@ impl UserSettings { .as_ref() .and_then(|ui| ui.transcription_overlay_enabled), tray_start_assistive: v2.ui.as_ref().and_then(|ui| ui.tray_start_assistive), + hold_indicator: v2.ui.as_ref().and_then(|ui| ui.hold_indicator), + hold_badge_size: v2.ui.as_ref().and_then(|ui| ui.hold_badge_size), + deferred_insert_shortcut: v2 + .interaction + .as_ref() + .and_then(|interaction| interaction.deferred_insert_shortcut.clone()), + restore_clipboard: v2 + .interaction + .as_ref() + .and_then(|interaction| interaction.restore_clipboard), + restore_clipboard_delay_ms: v2 + .interaction + .as_ref() + .and_then(|interaction| interaction.restore_clipboard_delay_ms), llm_formatting_endpoint: v2 .speech .as_ref() @@ -931,6 +1039,24 @@ impl UserSettings { .as_ref() .and_then(|s| s.engine.as_ref()) .and_then(|e| e.initial_prompt_enabled), + asr_mode: v2 + .speech + .as_ref() + .and_then(|s| s.engine.as_ref()) + .and_then(|e| e.asr_mode.clone()), + asr_gateway_url: v2 + .speech + .as_ref() + .and_then(|s| s.engine.as_ref()) + .and_then(|e| e.gateway_session_url.clone()), + cloud_consent: v2 + .system + .as_ref() + .and_then(|s| s.cloud_audio_egress_consent.clone()), + cloud_consent_at: v2 + .system + .as_ref() + .and_then(|s| s.cloud_audio_egress_consent_at.clone()), agent_permissions: v2.agent.as_ref().and_then(|a| a.permissions.clone()), agent_capabilities: v2.agent.as_ref().and_then(|a| a.capabilities.clone()), } @@ -962,11 +1088,46 @@ impl UserSettings { /// Write via temp file plus rename, so a crash mid-write leaves the previous /// `settings.json` intact rather than a truncated one the app would treat /// as corrupt and silently replace with defaults. - fn write_json_atomic(path: &PathBuf, json: &str) -> anyhow::Result<()> { - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, json)?; - fs::rename(&tmp, path)?; - Ok(()) + fn write_json_atomic(path: &Path, json: &str) -> anyhow::Result<()> { + Self::write_json_atomic_with(path, json, |from, to| fs::rename(from, to)) + } + + /// Atomic settings write with the final rename injected for deterministic + /// failure tests. Production always passes `fs::rename`; tests never depend + /// on guessing the unique temp filename. + fn write_json_atomic_with(path: &Path, json: &str, rename: F) -> anyhow::Result<()> + where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, + { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("settings path has no parent: {}", path.display()))?; + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("settings.json"); + let tmp = parent.join(format!( + ".{filename}.tmp.{}.{}", + std::process::id(), + Uuid::new_v4() + )); + let outcome = (|| -> anyhow::Result<()> { + let mut file = OpenOptions::new().create_new(true).write(true).open(&tmp)?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + drop(file); + rename(&tmp, path)?; + // `parent` is derived only from the canonical internal settings + // path above; opening it read-only is the durability fsync, not a + // request-controlled file lookup. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + File::open(parent)?.sync_all()?; + Ok(()) + })(); + if outcome.is_err() { + let _ = fs::remove_file(&tmp); + } + outcome } /// Returns the settings directory. @@ -974,7 +1135,7 @@ impl UserSettings { /// Respects `CODESCRIBE_DATA_DIR` for test isolation; otherwise uses /// `~/Library/Application Support/Codescribe/`. pub fn settings_dir() -> PathBuf { - let dir = if let Ok(test_dir) = std::env::var("CODESCRIBE_DATA_DIR") { + if let Ok(test_dir) = std::env::var("CODESCRIBE_DATA_DIR") { PathBuf::from(test_dir) } else { BaseDirs::new() @@ -983,14 +1144,7 @@ impl UserSettings { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); PathBuf::from(home).join("Library/Application Support/Codescribe") }) - }; - - if !dir.exists() - && let Err(e) = fs::create_dir_all(&dir) - { - warn!("Failed to create settings dir {}: {e}", dir.display()); } - dir } /// Returns the path to `settings.json`. @@ -1000,6 +1154,19 @@ impl UserSettings { /// Loads settings from disk. Returns `Default` on any error. pub fn load() -> Self { + let _data_io = match super::storage_reset::begin_app_data_io() { + Ok(guard) => guard, + Err(error) => { + warn!(%error, "Settings load skipped while app-data reset owns the process"); + return Self::default(); + } + }; + let _settings_io = settings_io_lock(); + Self::load_unlocked() + } + + /// Load while the settings transaction lock and app-data admission are held. + fn load_unlocked() -> Self { let path = Self::settings_path(); match fs::read_to_string(&path) { Ok(contents) => match serde_json::from_str::(&contents) { @@ -1029,7 +1196,7 @@ impl UserSettings { backup_path.display() ); } - if let Err(e) = v1.save() { + if let Err(e) = v1.save_unlocked() { warn!("Failed hard-migrating settings V1 -> V2: {e}"); } else { info!( @@ -1063,6 +1230,13 @@ impl UserSettings { /// Persists current settings to disk as pretty-printed JSON. pub fn save(&self) -> anyhow::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io()?; + let _settings_io = settings_io_lock(); + self.save_unlocked() + } + + /// Persist while the settings transaction lock and app-data admission are held. + fn save_unlocked(&self) -> anyhow::Result<()> { let dir = Self::settings_dir(); fs::create_dir_all(&dir)?; let path = Self::settings_path(); @@ -1210,6 +1384,17 @@ impl UserSettings { return; } }, + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT" => { + match value.parse::() { + Ok(shortcut) => { + self.deferred_insert_shortcut = Some(shortcut.wire_id().to_string()) + } + Err(error) => { + warn!("Rejected deferred-insert shortcut write: {error}"); + return; + } + } + } "TRANSCRIPT_TAG_TEMPLATE" => self.transcript_tag_template = Some(value.to_owned()), "LLM_FORMATTING_ENDPOINT" => self.llm_formatting_endpoint = Some(value.to_owned()), "LLM_FORMATTING_MODEL" => self.llm_formatting_model = Some(value.to_owned()), @@ -1238,6 +1423,55 @@ impl UserSettings { "CODESCRIBE_LAYERED_TRANSCRIPTION" => { self.layered_transcription = Some(value.to_owned()) } + "CODESCRIBE_ASR_MODE" => { + // Empty clears back to derivation (legacy choice or Apple-only). + let trimmed = value.trim(); + if trimmed.is_empty() { + self.asr_mode = None; + } else { + match trimmed.parse::() { + Ok(mode) => self.asr_mode = Some(mode.as_str().to_string()), + Err(error) => { + warn!("Rejected ASR mode write: {error}"); + return; + } + } + } + } + "CODESCRIBE_CLOUD_CONSENT" => { + // Explicit answers only; empty clears the record back to + // "never asked". Every answer stamps its provenance timestamp. + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "" => { + self.cloud_consent = None; + self.cloud_consent_at = None; + } + crate::config::cloud_asr::CONSENT_WIRE_GRANTED + | crate::config::cloud_asr::CONSENT_WIRE_DENIED => { + self.cloud_consent = Some(normalized); + self.cloud_consent_at = Some(chrono::Utc::now().to_rfc3339()); + } + _ => { + warn!("Rejected cloud consent write (expected granted|denied): {value}"); + return; + } + } + } + "CODESCRIBE_ASR_GATEWAY_URL" => { + let trimmed = value.trim(); + if trimmed.is_empty() { + self.asr_gateway_url = None; + } else { + match crate::config::cloud_asr::GatewaySessionMint::new(trimmed) { + Ok(mint) => self.asr_gateway_url = Some(mint.url().to_string()), + Err(error) => { + warn!("Rejected ASR gateway URL write: {error}"); + return; + } + } + } + } "CODESCRIBE_QUBE_DONOR" => { let normalized = value.trim().to_ascii_lowercase(); match normalized.as_str() { @@ -1267,6 +1501,20 @@ impl UserSettings { self.save_if_changed(&before, "set_string", key); } + /// Resolve the effective Layer 1 product mode from this settings snapshot. + /// + /// The one sanctioned read path: combines the persisted `asr_mode`, the + /// consent record, and the legacy `use_local_stt` choice through + /// [`crate::config::cloud_asr::resolve_asr_product_mode`]. Callers must not + /// re-derive policy from the raw fields. + pub fn resolved_asr_mode(&self) -> crate::config::cloud_asr::ResolvedAsrMode { + crate::config::cloud_asr::resolve_asr_product_mode( + self.asr_mode.as_deref(), + self.cloud_consent.as_deref(), + self.use_local_stt, + ) + } + /// Sets a boolean-valued setting by its .env key name and saves. pub fn set_bool(&mut self, key: &str, value: bool) { let before = self.clone(); @@ -1278,6 +1526,8 @@ impl UserSettings { "SHOW_DOCK_ICON" => self.show_dock_icon = Some(value), "TRANSCRIPTION_OVERLAY_ENABLED" => self.transcription_overlay_enabled = Some(value), "TRAY_START_ASSISTIVE" => self.tray_start_assistive = Some(value), + "HOLD_INDICATOR" => self.hold_indicator = Some(value), + "RESTORE_CLIPBOARD" => self.restore_clipboard = Some(value), "HOLD_EXCLUSIVE" => self.hold_exclusive = Some(value), "USE_LOCAL_STT" => self.use_local_stt = Some(value), "HISTORY_ENABLED" => self.history_enabled = Some(value), @@ -1306,6 +1556,8 @@ impl UserSettings { "CODESCRIBE_BUFFER_DELAY_MS" => self.buffer_delay_ms = Some(value), "CODESCRIBE_EMIT_WORDS_MAX" => self.emit_words_max = Some(value), "BACKEND_MAX_UPLOAD_MB" => self.backend_max_upload_mb = Some(value), + "HOLD_BADGE_SIZE" => self.hold_badge_size = Some(value), + "RESTORE_CLIPBOARD_DELAY_MS" => self.restore_clipboard_delay_ms = Some(value), other => { warn!("Unknown u64 setting key: {other}"); return; @@ -1514,6 +1766,50 @@ mod tests { assert!(!UserSettings::settings_path().exists()); } + /// A failed final rename removes its unique temp and leaves the last + /// committed settings bytes untouched. This is the fault-injection seam for + /// atomic persistence; blocking a historical fixed temp name proves nothing + /// now that every transaction owns a UUID path. + #[test] + #[serial] + fn atomic_settings_rename_failure_preserves_committed_truth_and_cleans_temp() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let original = UserSettings { + auto_paste_enabled: Some(false), + ..Default::default() + }; + original.save().expect("seed committed settings"); + let before = fs::read(&path).expect("read committed settings"); + + let replacement = UserSettings { + auto_paste_enabled: Some(true), + ..original + }; + let json = serde_json::to_string_pretty(&replacement.to_v2()) + .expect("serialize replacement settings"); + let error = UserSettings::write_json_atomic_with(&path, &json, |_from, _to| { + Err(std::io::Error::other("forced settings rename failure")) + }) + .expect_err("forced rename must fail"); + assert!(error.to_string().contains("forced settings rename failure")); + assert_eq!( + fs::read(&path).expect("read settings after failed rename"), + before + ); + let orphan_temps: Vec<_> = fs::read_dir(UserSettings::settings_dir()) + .expect("read settings directory") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".settings.json.tmp.") + }) + .collect(); + assert!(orphan_temps.is_empty(), "failed write leaked a unique temp"); + } + /// A `false` written through the setter survives reload — the case a naive /// `skip_serializing_if` on a plain `bool` would silently lose. #[test] @@ -2028,4 +2324,131 @@ mod tests { ShortcutBinding::DoubleRightOption ); } + + // ── C2: Layer 1 ASR mode + audio-egress consent ── + + /// The three C2 keys are promoted: writes route to settings.json, never + /// to `.env`, and never to the Keychain. + #[test] + fn c2_keys_are_promoted() { + assert!(is_promoted_key("CODESCRIBE_ASR_MODE")); + assert!(is_promoted_key("CODESCRIBE_CLOUD_CONSENT")); + assert!(is_promoted_key("CODESCRIBE_ASR_GATEWAY_URL")); + } + + /// Mode, consent (with timestamp), and gateway URL survive the full disk + /// round-trip through the V2 schema — no ghosting. + #[test] + #[serial] + fn c2_fields_round_trip_through_v2_schema() { + let _tmp = setup_isolated_data_dir(); + let mut settings = UserSettings::default(); + settings.set_string("CODESCRIBE_ASR_MODE", "cloud"); + settings.set_string("CODESCRIBE_CLOUD_CONSENT", "granted"); + settings.set_string( + "CODESCRIBE_ASR_GATEWAY_URL", + "https://gateway.libraxis.cloud/v1/asr/sessions", + ); + + let loaded = UserSettings::load(); + assert_eq!(loaded.asr_mode.as_deref(), Some("cloud")); + assert_eq!(loaded.cloud_consent.as_deref(), Some("granted")); + assert!( + loaded.cloud_consent_at.is_some(), + "explicit consent answer must stamp its provenance timestamp" + ); + assert_eq!( + loaded.asr_gateway_url.as_deref(), + Some("https://gateway.libraxis.cloud/v1/asr/sessions") + ); + + // On-disk placement: mode + gateway in speech.engine, consent in system. + let raw: serde_json::Value = serde_json::from_str( + &fs::read_to_string(UserSettings::settings_path()).expect("read settings"), + ) + .expect("parse settings"); + assert_eq!( + raw.pointer("/speech/engine/asr_mode") + .and_then(|v| v.as_str()), + Some("cloud") + ); + assert_eq!( + raw.pointer("/system/cloud_audio_egress_consent") + .and_then(|v| v.as_str()), + Some("granted") + ); + } + + /// Invalid mode, consent, and gateway values are rejected without touching + /// the persisted state — a tampered write cannot arm egress. + #[test] + #[serial] + fn c2_setters_reject_invalid_values() { + let _tmp = setup_isolated_data_dir(); + let mut settings = UserSettings::default(); + + settings.set_string("CODESCRIBE_ASR_MODE", "whisper_cloud"); + assert_eq!(settings.asr_mode, None, "unknown mode must be rejected"); + + settings.set_string("CODESCRIBE_CLOUD_CONSENT", "yes"); + assert_eq!( + settings.cloud_consent, None, + "non-canonical consent must be rejected" + ); + assert_eq!(settings.cloud_consent_at, None); + + settings.set_string( + "CODESCRIBE_ASR_GATEWAY_URL", + "https://user:sk-key@gateway.libraxis.cloud/mint", + ); + assert_eq!( + settings.asr_gateway_url, None, + "credential-bearing URL must be rejected" + ); + + // Empty clears an existing consent record back to "never asked". + settings.set_string("CODESCRIBE_CLOUD_CONSENT", "denied"); + assert_eq!(settings.cloud_consent.as_deref(), Some("denied")); + settings.set_string("CODESCRIBE_CLOUD_CONSENT", ""); + assert_eq!(settings.cloud_consent, None); + assert_eq!(settings.cloud_consent_at, None); + } + + /// Resolution truth on the settings snapshot: fresh installs land on + /// Apple-only, upgrades preserve the prior local/cloud choice, and cloud + /// without a granted record refuses egress without reaching for weights. + #[test] + fn c2_resolved_asr_mode_covers_fresh_upgrade_and_consent_paths() { + use crate::config::cloud_asr::{AsrProductMode, ModeDerivation}; + + let fresh = UserSettings::default(); + let resolved = fresh.resolved_asr_mode(); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::FreshDefault); + + let legacy_local = UserSettings { + use_local_stt: Some(true), + ..UserSettings::default() + }; + assert_eq!( + legacy_local.resolved_asr_mode().mode, + AsrProductMode::LocalPower + ); + + let legacy_cloud = UserSettings { + use_local_stt: Some(false), + ..UserSettings::default() + }; + let resolved = legacy_cloud.resolved_asr_mode(); + assert_eq!(resolved.mode, AsrProductMode::Cloud); + assert_eq!(resolved.derivation, ModeDerivation::LegacyCloudChoice); + + let cloud_no_consent = UserSettings { + asr_mode: Some("cloud".to_string()), + ..UserSettings::default() + }; + let resolved = cloud_no_consent.resolved_asr_mode(); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::ConsentMissingFallback); + } } diff --git a/core/config/storage_reset.rs b/core/config/storage_reset.rs new file mode 100644 index 00000000..d9b088b8 --- /dev/null +++ b/core/config/storage_reset.rs @@ -0,0 +1,490 @@ +//! Process-wide coordination between configuration I/O and destructive reset. +//! +//! A reset moves the live data roots to Trash and the Swift host relaunches the +//! process immediately afterwards. Without a fence, a background config load +//! can finish a migration after the move and silently recreate `settings.json` +//! in the supposedly empty live root. The gate below gives reset exclusive +//! ownership of config/settings/prompt persistence, drains operations that +//! already started, and permanently rejects new configuration I/O once the +//! first destructive move is armed. The latch is +//! intentionally process-lifetime state: after a destructive reset, relaunch is +//! the only supported way back to an open data plane. + +use std::cell::Cell; +use std::fmt; +use std::marker::PhantomData; +use std::path::Path; +use std::rc::Rc; +use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; + +/// State of the process-wide configuration persistence plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResetPhase { + Open, + Resetting, + Latched, +} + +/// Mutable state protected by [`gate`]. +#[derive(Debug)] +struct ResetState { + phase: ResetPhase, + active_operations: usize, + waiting_operations: usize, +} + +impl Default for ResetState { + fn default() -> Self { + Self { + phase: ResetPhase::Open, + active_operations: 0, + waiting_operations: 0, + } + } +} + +/// One mutex/condition-variable pair owns the whole process data plane. +fn gate() -> &'static (Mutex, Condvar) { + static GATE: OnceLock<(Mutex, Condvar)> = OnceLock::new(); + GATE.get_or_init(|| (Mutex::new(ResetState::default()), Condvar::new())) +} + +/// Recover from a poisoned lock: a panic in one caller must not disable reset +/// protection for the rest of the process. +fn lock_state() -> MutexGuard<'static, ResetState> { + match gate().0.lock() { + Ok(state) => state, + Err(poisoned) => { + let mut state = poisoned.into_inner(); + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + state + } + } +} + +/// Wait while preserving the same poison-recovery policy as [`lock_state`]. +fn wait_state(guard: MutexGuard<'static, ResetState>) -> MutexGuard<'static, ResetState> { + match gate().1.wait(guard) { + Ok(state) => state, + Err(poisoned) => { + let mut state = poisoned.into_inner(); + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + state + } + } +} + +thread_local! { + /// Nested config operations are one logical active operation. This matters + /// because `Config::load()` calls `UserSettings::load()`, and a reset may + /// begin between those two calls. Counting the nested call separately would + /// deadlock the loader against the reset waiting for its outer guard. + static APP_DATA_IO_DEPTH: Cell = const { Cell::new(0) }; +} + +/// Configuration I/O is unavailable because a destructive reset owns the process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppDataUnavailable { + reason: UnavailableReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnavailableReason { + Phase(ResetPhase), + ReentrantReset, +} + +impl fmt::Display for AppDataUnavailable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.reason { + UnavailableReason::ReentrantReset => { + formatter.write_str("cannot start app-data reset inside an active data operation") + } + UnavailableReason::Phase(phase) => match phase { + ResetPhase::Open => formatter.write_str("app-data I/O unavailable"), + ResetPhase::Resetting => formatter.write_str("app-data reset is in progress"), + ResetPhase::Latched => { + formatter.write_str("app-data reset completed; process relaunch required") + } + }, + } + } +} + +impl std::error::Error for AppDataUnavailable {} + +/// RAII admission for one configuration persistence operation. +pub(crate) struct AppDataIoGuard { + counted_globally: bool, + /// The nesting counter is thread-local, so moving this guard across threads + /// would decrement a different thread's depth and corrupt the reset fence. + _not_send: PhantomData>, +} + +/// Enter the data plane, waiting for a non-destructive reset preparation to +/// finish. Once reset is latched, callers fail instead of touching live roots. +pub(crate) fn begin_app_data_io() -> Result { + let nested = APP_DATA_IO_DEPTH.with(|depth| { + let current = depth.get(); + if current > 0 { + depth.set(current + 1); + true + } else { + false + } + }); + if nested { + return Ok(AppDataIoGuard { + counted_globally: false, + _not_send: PhantomData, + }); + } + + let mut state = lock_state(); + while state.phase == ResetPhase::Resetting { + state.waiting_operations += 1; + gate().1.notify_all(); + state = wait_state(state); + if state.waiting_operations == 0 { + state.phase = ResetPhase::Latched; + } else { + state.waiting_operations -= 1; + } + } + if state.phase == ResetPhase::Latched { + return Err(AppDataUnavailable { + reason: UnavailableReason::Phase(state.phase), + }); + } + + state.active_operations += 1; + APP_DATA_IO_DEPTH.with(|depth| depth.set(1)); + Ok(AppDataIoGuard { + counted_globally: true, + _not_send: PhantomData, + }) +} + +impl Drop for AppDataIoGuard { + fn drop(&mut self) { + let (remaining_depth, depth_underflow) = APP_DATA_IO_DEPTH.with(|depth| { + let current = depth.get(); + let remaining = if current == 0 { 0 } else { current - 1 }; + depth.set(remaining); + (remaining, current == 0) + }); + + if depth_underflow { + let mut state = lock_state(); + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + return; + } + + if !self.counted_globally { + return; + } + let mut state = lock_state(); + if remaining_depth != 0 || state.active_operations == 0 { + // A broken RAII/nesting invariant must fail closed. Decrementing + // anyway could let reset move a root beneath a still-live writer. + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + return; + } + state.active_operations -= 1; + if state.active_operations == 0 { + gate().1.notify_all(); + } + } +} + +/// Exclusive ownership of the app-data plane during a reset. +/// +/// Dropping this guard before [`Self::mark_destructive_started`] reopens the +/// plane. Dropping it afterwards deliberately leaves the process latched. +pub struct AppDataResetGuard { + destructive_started: bool, +} + +/// Stop new app-data operations and wait until every already-admitted operation +/// has finished. A second reset is rejected instead of sharing ownership. +pub fn begin_app_data_reset() -> Result { + if APP_DATA_IO_DEPTH.with(|depth| depth.get() > 0) { + return Err(AppDataUnavailable { + reason: UnavailableReason::ReentrantReset, + }); + } + let mut state = lock_state(); + if state.phase != ResetPhase::Open { + return Err(AppDataUnavailable { + reason: UnavailableReason::Phase(state.phase), + }); + } + state.phase = ResetPhase::Resetting; + gate().1.notify_all(); + while state.active_operations > 0 { + state = wait_state(state); + } + Ok(AppDataResetGuard { + destructive_started: false, + }) +} + +impl AppDataResetGuard { + /// Whether any irreversible move/remove has happened. Callers must relaunch + /// even when later cleanup reports an error, because this process may no + /// longer resume normal app-data I/O. + pub fn relaunch_required(&self) -> bool { + self.destructive_started + } + + /// Try one atomic destructive filesystem operation. When the operation is + /// the first one in this reset, an error proves no atomic move occurred, so + /// the guard returns to `Resetting` and may safely perform a copy fallback. + /// Once any earlier destructive operation succeeded, the latch is permanent. + #[doc(hidden)] + pub fn rename_destructively( + &mut self, + source: &Path, + destination: &Path, + ) -> std::io::Result<()> { + if self.destructive_started { + return std::fs::rename(source, destination); + } + + // Keep the state lock across this one atomic syscall. New admissions + // therefore observe either Resetting after a failed rename or Latched + // after a successful one, never the speculative state in between. + let mut state = lock_state(); + debug_assert_eq!(state.phase, ResetPhase::Resetting); + state.phase = ResetPhase::Latched; + match std::fs::rename(source, destination) { + Ok(()) => { + self.destructive_started = true; + gate().1.notify_all(); + Ok(()) + } + Err(error) => { + state.phase = ResetPhase::Resetting; + Err(error) + } + } + } + + /// Arm the process-lifetime latch immediately before the first move/remove. + /// Waiting operations wake and fail without ever reaching the live roots. + pub fn mark_destructive_started(&mut self) { + if self.destructive_started { + return; + } + let mut state = lock_state(); + debug_assert_eq!(state.phase, ResetPhase::Resetting); + debug_assert_eq!(state.active_operations, 0); + state.phase = ResetPhase::Latched; + self.destructive_started = true; + gate().1.notify_all(); + } + + /// Only the reset owner may restore explicitly preserved bytes after the + /// live root has moved. Normal writers remain fenced out. + pub(crate) fn permits_preserved_restore(&self) -> bool { + self.destructive_started + } +} + +impl Drop for AppDataResetGuard { + fn drop(&mut self) { + if self.destructive_started { + return; + } + let mut state = lock_state(); + if state.phase == ResetPhase::Resetting { + state.phase = ResetPhase::Open; + gate().1.notify_all(); + } + } +} + +#[cfg(test)] +fn wait_for_blocked_io_for_tests() { + let mut state = lock_state(); + while state.waiting_operations == 0 { + state = wait_state(state); + } +} + +#[cfg(test)] +fn wait_for_resetting_for_tests() { + let mut state = lock_state(); + while state.phase != ResetPhase::Resetting { + state = wait_state(state); + } +} + +#[cfg(test)] +fn reopen_after_test() { + let mut state = lock_state(); + state.phase = ResetPhase::Open; + gate().1.notify_all(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, UserSettings}; + use serial_test::serial; + use std::ffi::OsString; + use std::fs; + use std::sync::mpsc; + use std::thread; + use tempfile::TempDir; + + /// Restore the process env only after the reset latch has held every other + /// config caller. Then reopen the gate so the rest of the test process sees + /// the restored root, never this test's disappearing temp directory. + struct TestResetCleanup { + previous_data_dir: Option, + } + + impl TestResetCleanup { + fn install(data_dir: &std::path::Path) -> Self { + let previous_data_dir = std::env::var_os("CODESCRIBE_DATA_DIR"); + // SAFETY: this regression owns the serial config/reset lane. + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", data_dir) }; + Self { previous_data_dir } + } + } + + impl Drop for TestResetCleanup { + fn drop(&mut self) { + // SAFETY: restore the exact process environment before waking any + // config caller that was held behind this test's reset latch. + unsafe { + match &self.previous_data_dir { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + } + reopen_after_test(); + } + } + + /// Deterministic reproduction of the I4E-F audit RED: a config load reaches + /// the reset fence while the legacy settings root is being moved. It must + /// return without migrating or recreating anything in the live root. + #[test] + #[serial] + fn reset_fence_prevents_concurrent_config_migration_from_recreating_live_root() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_RESET_FENCE_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_RESET_FENCE_WITNESS"; + const WITNESS_BYTES: &[u8] = b"reset-fence-pass"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = TempDir::new().expect("child witness dir"); + let witness = witness_dir.path().join("passed"); + let current_test = concat!( + "config::storage_reset::tests::", + "reset_fence_prevents_concurrent_config_migration_from_recreating_live_root" + ); + let status = std::process::Command::new( + std::env::current_exe().expect("current core test executable"), + ) + .args(["--exact", current_test, "--nocapture"]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated reset-fence regression"); + assert!(status.success(), "isolated reset-fence regression failed"); + assert_eq!( + fs::read(witness).expect("child completed exact reset-fence test"), + WITNESS_BYTES, + "child command exited successfully without executing the exact regression" + ); + return; + } + + let sandbox = TempDir::new().expect("reset race sandbox"); + let live_root = sandbox.path().join("live"); + let trashed_root = sandbox.path().join("trashed"); + fs::create_dir_all(&live_root).expect("create live root"); + fs::write(live_root.join("settings.json"), b"{}").expect("seed legacy settings"); + let _cleanup = TestResetCleanup::install(&live_root); + + // Admit one real settings transaction first. Reset must wait for it, + // while a later Config load must queue behind Resetting. + let admitted = begin_app_data_io().expect("admit pre-existing config transaction"); + let moved_live_root = live_root.clone(); + let moved_trashed_root = trashed_root.clone(); + let (reset_acquired_tx, reset_acquired_rx) = mpsc::channel(); + let resetter = thread::spawn(move || { + let mut reset = begin_app_data_reset().expect("reset drains admitted writer"); + reset_acquired_tx + .send(()) + .expect("report exclusive reset ownership"); + reset + .rename_destructively(&moved_live_root, &moved_trashed_root) + .expect("move live root after admitted writer drains"); + }); + wait_for_resetting_for_tests(); + assert!( + matches!(reset_acquired_rx.try_recv(), Err(mpsc::TryRecvError::Empty)), + "reset crossed an already-admitted app-data transaction" + ); + + let late_writer = thread::spawn(Config::load_without_keychain); + wait_for_blocked_io_for_tests(); + + // This is the exact operation seen in the audit residue: a V1 load + // writes its backup and V3 replacement. It is allowed to finish because + // it entered before reset, and reset may move only after this guard ends. + let _ = UserSettings::load(); + assert!(live_root.join("settings.v1.bak.json").is_file()); + drop(admitted); + + reset_acquired_rx + .recv() + .expect("reset acquires after admitted writer finishes"); + resetter.join().expect("reset thread joins"); + let _ = late_writer.join().expect("late config load joins"); + + let blocked_save = UserSettings::default().save(); + assert!( + blocked_save.is_err(), + "settings save must fail after reset latch" + ); + assert!(!live_root.exists(), "writer recreated the reset live root"); + let migrated: serde_json::Value = serde_json::from_slice( + &fs::read(trashed_root.join("settings.json")).expect("read trashed settings"), + ) + .expect("parse migrated settings"); + assert_eq!( + migrated + .get("schema_version") + .and_then(|value| value.as_u64()), + Some(3) + ); + assert_eq!( + fs::read(trashed_root.join("settings.v1.bak.json")).expect("read trashed V1 backup"), + b"{}" + ); + let mut names: Vec<_> = fs::read_dir(&trashed_root) + .expect("read trashed root") + .map(|entry| entry.expect("trashed entry").file_name()) + .collect(); + names.sort(); + assert_eq!( + names, + [ + OsString::from("settings.json"), + OsString::from("settings.v1.bak.json") + ] + ); + fs::write( + std::env::var_os(CHILD_WITNESS).expect("child witness path"), + WITNESS_BYTES, + ) + .expect("write reset-fence child witness"); + } +} diff --git a/core/config/types.rs b/core/config/types.rs index aaecde7b..78336cb0 100644 --- a/core/config/types.rs +++ b/core/config/types.rs @@ -194,6 +194,17 @@ impl DeferredInsertShortcut { pub fn is_enabled(self) -> bool { !matches!(self, Self::Disabled) } + + /// Canonical wire identifier — the exact string [`FromStr`] round-trips, + /// used when persisting the chord into settings.json. + pub fn wire_id(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::CommandOptionV => "command_option_v", + Self::CommandShiftV => "command_shift_v", + Self::CommandControlV => "command_control_v", + } + } } impl FromStr for DeferredInsertShortcut { diff --git a/core/embedder/singleton.rs b/core/embedder/singleton.rs index 3794b5ec..2eb53c7d 100644 --- a/core/embedder/singleton.rs +++ b/core/embedder/singleton.rs @@ -185,6 +185,34 @@ pub fn init() -> Result<()> { with_embedder(|_| Ok(())) } +/// Load the engine off the caller's thread, ignoring the outcome. +/// +/// The semantic guard is the only consumer, and it runs *after* AI formatting +/// returns — so a cold engine put its whole load on the stop path, in series +/// behind the model call. Measured 2026-08-12: `semantic_guard took_ms=1127`, +/// of which ~1.0s was `Embedder initialized from embedded model`, against 0.13s +/// of actual comparison. +/// +/// Call this when a formatting request is dispatched, not at startup. The LLM +/// round-trip is seconds of dead time the load fits inside entirely, and by +/// scoping the warm to lanes that are about to need the engine anyway, this +/// buys latency without lengthening how long 471 MB of weights sit resident — +/// the idle-unload budget stays exactly as configured. +/// +/// Idempotent and non-blocking: concurrent callers serialize on the same slot +/// mutex the guard itself takes, and a failed load is left for the guard to +/// report through its normal fail-open path. +pub fn warm() { + std::thread::Builder::new() + .name("embedder-warm".into()) + .spawn(|| { + if let Err(error) = init() { + warn!("Embedder warm-up failed (semantic guard will retry): {error}"); + } + }) + .ok(); +} + /// Initialize with custom configuration. /// /// The config is captured for (re)loads; the first config wins. Idempotent. diff --git a/core/examples/format_chain_probe.rs b/core/examples/format_chain_probe.rs new file mode 100644 index 00000000..1ff0ee48 --- /dev/null +++ b/core/examples/format_chain_probe.rs @@ -0,0 +1,22 @@ +//! One-shot probe: push a raw transcript through the PRODUCTION formatting +//! path twice in one process, so the second call runs as a CHAINED turn +//! (previous_response_id present) — the exact condition of the 2026-08-14 +//! promptless-chain leak. Prints both outputs verbatim for a 1:1 exhibit. +//! +//! Usage: +//! cargo run -p codescribe-core --example format_chain_probe -- + +#[tokio::main] +async fn main() { + let path = std::env::args() + .nth(1) + .expect("usage: format_chain_probe "); + let raw = std::fs::read_to_string(&path).expect("read raw transcript"); + println!("=== RAW ({} chars) from {path}", raw.chars().count()); + + for turn in 1..=2 { + let out = codescribe_core::llm::ai_formatting::format_text(&raw, Some("pl"), false).await; + println!("\n=== TURN {turn} ({} chars) ===", out.chars().count()); + println!("{out}"); + } +} diff --git a/core/examples/vad_atlas_probe.rs b/core/examples/vad_atlas_probe.rs new file mode 100644 index 00000000..57ccf308 --- /dev/null +++ b/core/examples/vad_atlas_probe.rs @@ -0,0 +1,67 @@ +//! Evidence probe: the PRODUCTION Silero VAD spectrum over a take WAV. +//! +//! Same embedded `silero_vad.onnx`, same `VadConfig::default()`, same +//! resampler the engine uses — fed in canonical 512-sample (32 ms @ 16 kHz) +//! chunks. Emits one JSON with per-chunk speech probability plus the +//! waveform envelope (RMS / peak) on the same chunk grid, so word ranges +//! from a seal-atlas dump can be overlaid on the identical time axis. +//! +//! Usage: +//! cargo run -p codescribe-core --example vad_atlas_probe -- + +use codescribe_core::audio::{load_audio_file, resample_to_16k}; +use codescribe_core::vad::{AccumulatingVad, CHUNK_SIZE, VAD_SAMPLE_RATE}; + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let wav = args + .next() + .expect("usage: vad_atlas_probe "); + let out = args + .next() + .expect("usage: vad_atlas_probe "); + + let (samples, capture_rate) = load_audio_file(std::path::Path::new(&wav))?; + let capture_len = samples.len() as u64; + let mono16k = resample_to_16k(&samples, capture_rate); + + // 16 kHz input → AccumulatingVad never resamples again; each feed of one + // full chunk runs exactly one Silero inference, so probs[i] belongs to + // samples [i*512, (i+1)*512) on the 16 kHz axis. + let mut vad = AccumulatingVad::new(VAD_SAMPLE_RATE)?; + let threshold = vad.threshold(); + + let mut probs: Vec = Vec::with_capacity(mono16k.len() / CHUNK_SIZE + 1); + let mut rms: Vec = Vec::with_capacity(probs.capacity()); + let mut peak: Vec = Vec::with_capacity(probs.capacity()); + for chunk in mono16k.chunks(CHUNK_SIZE) { + if chunk.len() < CHUNK_SIZE { + break; // trailing partial chunk carries no full inference + } + probs.push(vad.feed(chunk)); + let sum_sq: f32 = chunk.iter().map(|s| s * s).sum(); + rms.push((sum_sq / chunk.len() as f32).sqrt()); + peak.push(chunk.iter().fold(0.0f32, |m, s| m.max(s.abs()))); + } + + let atlas = serde_json::json!({ + "source_wav": wav, + "capture_sample_rate": capture_rate, + "capture_samples": capture_len, + "vad_sample_rate": VAD_SAMPLE_RATE, + "chunk_samples": CHUNK_SIZE, + "threshold": threshold, + "chunks": probs.len(), + "probs": probs, + "rms": rms, + "peak": peak, + }); + std::fs::write(&out, serde_json::to_vec(&atlas)?)?; + eprintln!( + "vad_atlas_probe: {} chunks ({:.1}s) -> {}", + probs.len(), + probs.len() as f32 * CHUNK_SIZE as f32 / VAD_SAMPLE_RATE as f32, + out + ); + Ok(()) +} diff --git a/core/lib.rs b/core/lib.rs index e013616d..822381da 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -38,6 +38,10 @@ /// Agent runtime: threads, sessions, capabilities, permissions, tool grants, /// and durable run monitoring. pub mod agent; +/// Neutral Layer 1 ASR session contract: typed events, monotonic ingest, the +/// provider seam, a live normalized gateway transport, and deterministic fakes. +/// No recorder wiring or local model. +pub mod asr_session; /// Attachment model and on-disk store used by agent chat and LLM context. pub mod attachment; /// Audio capture, loading, resampling, and playback primitives. diff --git a/core/llm/account_auth/device_code.rs b/core/llm/account_auth/device_code.rs index 579ff94d..13dd6038 100644 --- a/core/llm/account_auth/device_code.rs +++ b/core/llm/account_auth/device_code.rs @@ -27,6 +27,7 @@ use crate::llm::account_auth::pkce::PkceCodes; use crate::llm::account_auth::server::exchange_code_for_tokens; use crate::llm::account_auth::{ AccountAuthError, AccountTokens, issuer_for, provider_oauth_config, store_account_tokens, + verify_responses_write_access, }; use crate::llm::provider::ProviderKind; @@ -316,6 +317,7 @@ pub async fn complete_device_code_login( match config.provider { ProviderKind::XaiResponses => { let tokens = poll_xai_device_token(&client, config, device_code).await?; + verify_responses_write_access(config.provider, &tokens.access_token).await?; store_account_tokens(config.provider, &tokens) } _ => { @@ -345,6 +347,7 @@ pub async fn complete_device_code_login( &code_resp.authorization_code, ) .await?; + verify_responses_write_access(config.provider, &tokens.access_token).await?; store_account_tokens(config.provider, &tokens) } } diff --git a/core/llm/account_auth/mod.rs b/core/llm/account_auth/mod.rs index b85e9562..5cd3688d 100644 --- a/core/llm/account_auth/mod.rs +++ b/core/llm/account_auth/mod.rs @@ -508,6 +508,92 @@ pub fn store_account_tokens( save_key(account, &payload).map_err(|error| AccountAuthError::Storage(error.to_string())) } +/// Official Responses endpoint a freshly minted account token must be able to +/// write to. `None` ⇒ no probe exists for this provider and sign-in verifies +/// nothing extra. Scoped to OpenAI: its login above requests identity scopes +/// only, and a token without `api.responses.write` still exchanges cleanly — +/// the field failure this probe exists for (five raw 401s in one morning, +/// 2026-08-14). +fn responses_probe_endpoint(provider: ProviderKind) -> Option { + match provider { + // The env override exists for hermetic tests (and emergency ops): + // account tokens only ever ride to the official endpoint, so the + // probe defaults to the same place the runtime will send them. + ProviderKind::OpenAiResponses => Some( + std::env::var(RESPONSES_PROBE_URL_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| crate::config::DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()), + ), + _ => None, + } +} + +/// Env override for the sign-in Responses probe target. Hermetic tests point +/// it at a mock; production leaves it unset (official endpoint). +pub const RESPONSES_PROBE_URL_ENV: &str = "CODESCRIBE_RESPONSES_PROBE_URL"; + +/// Verify a just-exchanged account token can actually use the Responses API +/// BEFORE it is persisted as "connected". +/// +/// The probe spends no tokens: an empty JSON body is authorized before it is +/// validated, so a healthy token answers 400 (validation) while a +/// scope-starved one answers 401. Only that definitive 401 fails the login; +/// transport errors stay fail-open — the exchange itself just proved the +/// network, and a flaky probe must not lock out an otherwise valid sign-in. +pub async fn verify_responses_write_access( + provider: ProviderKind, + access_token: &str, +) -> Result<(), AccountAuthError> { + let Some(endpoint) = responses_probe_endpoint(provider) else { + return Ok(()); + }; + verify_responses_write_access_at(&endpoint, access_token).await +} + +/// Testable core of [`verify_responses_write_access`] with an explicit endpoint. +async fn verify_responses_write_access_at( + endpoint: &str, + access_token: &str, +) -> Result<(), AccountAuthError> { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + { + Ok(client) => client, + Err(error) => { + tracing::warn!("responses-write probe unavailable ({error}); keeping sign-in"); + return Ok(()); + } + }; + let response = match client + .post(endpoint) + .header("Authorization", format!("Bearer {access_token}")) + .header("Content-Type", "application/json") + .body("{}") + .send() + .await + { + Ok(response) => response, + Err(error) => { + tracing::warn!("responses-write probe did not complete ({error}); keeping sign-in"); + return Ok(()); + } + }; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + let body = response.text().await.unwrap_or_default(); + let detail: String = body.chars().take(300).collect(); + return Err(AccountAuthError::OAuth(format!( + "this account token cannot use the Responses API (HTTP 401 on the \ + authorization probe — missing scope `api.responses.write`?). \ + Sign-in was not saved; use an API key or an account with \ + Responses access. Provider answer: {detail}" + ))); + } + Ok(()) +} + /// Load a provider's stored tokens, or [`AccountAuthError::NotSignedIn`]. /// /// An env var named after the Keychain account is checked first: that is the @@ -715,6 +801,39 @@ mod tests { (EnvGuard::set_path("CODESCRIBE_DATA_DIR", dir.path()), dir) } + /// The 401 field failure (2026-08-14): a scope-starved token must fail the + /// login BEFORE persisting, while a healthy token's 400 validation answer + /// passes — the probe authorizes before it validates and spends nothing. + #[tokio::test] + async fn responses_probe_rejects_401_and_passes_validation_400() { + let mut starved_server = mockito::Server::new_async().await; + let starved = starved_server + .mock("POST", "/v1/responses") + .with_status(401) + .with_body(r#"{"error":"Missing scopes: api.responses.write"}"#) + .create_async() + .await; + let starved_url = format!("{}/v1/responses", starved_server.url()); + let error = verify_responses_write_access_at(&starved_url, "starved-token") + .await + .expect_err("401 must reject the sign-in"); + assert!(error.to_string().contains("api.responses.write")); + starved.assert_async().await; + + let mut healthy_server = mockito::Server::new_async().await; + let healthy = healthy_server + .mock("POST", "/v1/responses") + .with_status(400) + .with_body(r#"{"error":"invalid input"}"#) + .create_async() + .await; + let healthy_url = format!("{}/v1/responses", healthy_server.url()); + verify_responses_write_access_at(&healthy_url, "healthy-token") + .await + .expect("400 validation answer proves the scope"); + healthy.assert_async().await; + } + /// Missing client id must surface the shared registration-gate message and /// the failing provider's own setting/env keys (not another provider's). /// Anthropic is the remaining no-default row; OpenAI and xAI ship public ids. diff --git a/core/llm/account_auth/server.rs b/core/llm/account_auth/server.rs index 7cac13b1..a84aaa90 100644 --- a/core/llm/account_auth/server.rs +++ b/core/llm/account_auth/server.rs @@ -30,7 +30,7 @@ use tiny_http::{Header, Request, Response, Server, StatusCode}; use crate::llm::account_auth::pkce::{PkceCodes, generate_pkce}; use crate::llm::account_auth::{ AccountAuthError, AccountTokens, LoginFlow, ProviderOAuthConfig, TokenRequestEncoding, - issuer_for, provider_oauth_config, store_account_tokens, + issuer_for, provider_oauth_config, store_account_tokens, verify_responses_write_access, }; use crate::llm::provider::ProviderKind; @@ -333,6 +333,18 @@ async fn process_request( .await { Ok(tokens) => { + // A token that cannot write to the Responses API must never be + // shown as "connected" — the first real prompt would answer a + // raw 401 (field failure, 2026-08-14). Reject BEFORE persisting. + if let Err(error) = + verify_responses_write_access(opts.provider, &tokens.access_token).await + { + return HandledRequest::ResponseAndExit { + headers: Vec::new(), + body: format!("Sign-in rejected: {error}").into_bytes(), + result: Err(error), + }; + } if let Err(error) = store_account_tokens(opts.provider, &tokens) { return HandledRequest::Response( Response::from_string(format!("Unable to persist account tokens: {error}")) @@ -608,6 +620,20 @@ mod tests { let _tokens = EnvGuard::unset(OPENAI_ACCOUNT_TOKENS_ACCOUNT); let mut issuer = mockito::Server::new_async().await; + // Healthy scope: the probe's empty body earns a 400 validation answer. + // Pinned to the mock — an unpinned probe would leak to api.openai.com. + let probe = issuer + .mock("POST", "/v1/responses") + .match_header("authorization", "Bearer account-access") + .with_status(400) + .with_body(r#"{"error":"invalid input"}"#) + .expect(1) + .create_async() + .await; + let _probe_url = EnvGuard::set( + crate::llm::account_auth::RESPONSES_PROBE_URL_ENV, + &format!("{}/v1/responses", issuer.url()), + ); let _mock = issuer .mock("POST", "/oauth/token") .match_body(mockito::Matcher::AllOf(vec![ @@ -647,6 +673,60 @@ mod tests { load_account_tokens(ProviderKind::OpenAiResponses).expect("tokens were stored"); assert_eq!(stored.access_token, "account-access"); assert_eq!(stored.refresh_token.as_deref(), Some("account-refresh")); + probe.assert_async().await; + } + + /// The field failure (five raw 401s, 2026-08-14): a token that exchanges + /// cleanly but cannot write to the Responses API must FAIL the login and + /// must never be persisted as "connected". + #[tokio::test] + #[serial] + async fn scope_starved_token_fails_login_and_is_not_stored() { + let _disable = EnvGuard::set("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + let _tokens = EnvGuard::unset(OPENAI_ACCOUNT_TOKENS_ACCOUNT); + + let mut issuer = mockito::Server::new_async().await; + let _exchange = issuer + .mock("POST", "/oauth/token") + .with_status(200) + .with_body(r#"{"access_token":"scope-starved","expires_in":3600}"#) + .expect(1) + .create_async() + .await; + let probe = issuer + .mock("POST", "/v1/responses") + .match_header("authorization", "Bearer scope-starved") + .with_status(401) + .with_body(r#"{"error":"Missing scopes: api.responses.write"}"#) + .expect(1) + .create_async() + .await; + let _probe_url = EnvGuard::set( + crate::llm::account_auth::RESPONSES_PROBE_URL_ENV, + &format!("{}/v1/responses", issuer.url()), + ); + + let mut opts = openai_opts("client"); + opts.issuer = issuer.url(); + opts.port = 0; + opts.force_state = Some("starved-state".to_string()); + let login = run_login_server(opts).await.expect("bind login server"); + + let callback = format!( + "http://127.0.0.1:{}/auth/callback?code=auth-code&state=starved-state", + login.actual_port + ); + let _response = reqwest::get(&callback).await.expect("callback request"); + let error = login + .block_until_done() + .await + .expect_err("scope-starved login must fail"); + assert!(error.to_string().contains("api.responses.write")); + assert!( + load_account_tokens(ProviderKind::OpenAiResponses).is_err(), + "a rejected token must not be stored" + ); + probe.assert_async().await; } /// A forged `state` must be rejected before any token exchange, and the diff --git a/core/llm/ai_formatting.rs b/core/llm/ai_formatting.rs index 39beee81..f92fb128 100644 --- a/core/llm/ai_formatting.rs +++ b/core/llm/ai_formatting.rs @@ -235,6 +235,17 @@ fn duration_from_env_ms(key: &str, default_ms: u64) -> Duration { /// from several transports. The listed shapes are deterministic — an empty /// completion, a refusal, or a rejected request will reproduce identically on /// retry, so retrying only multiplies latency. +/// A chain id the requesting key cannot see (`previous_response_not_found`). +/// Measured mechanism (2026-08-12 22:31→23:02): the id was minted under the +/// OLD key, the operator swapped Keychain keys at 22:47–22:51, and the new +/// key's org cannot read the old org's response — three identical formatting +/// failures, transcript delivered raw. NOT retention: the same-key chain was +/// proven alive hours later (2026-08-14, full recall of the 10:38 take). The +/// stored id is poison for THIS key, so drop it and go unchained. +fn is_stale_chain_error(error: &anyhow::Error) -> bool { + error.to_string().contains("previous_response_not_found") +} + fn should_retry_provider_error(error: &anyhow::Error) -> bool { let message = error.to_string(); !(message.contains("No text content in SSE stream") @@ -861,6 +872,52 @@ struct ResponsesRequest { stream: bool, } +/// Instructions for a Responses request: the `instructions` PARAM goes only on +/// the first turn of a chain — endpoints reject the pair with +/// `previous_response_id` (HTTP 400 "instructions and previous_response_id +/// together"). +/// +/// But instructions are NOT preserved server-side across chained turns +/// (OpenAI Responses: "instructions … not carried over to the next response +/// when using previous_response_id"), so a chained turn MUST re-carry the +/// system prompt inside `input` — see [`build_responses_input`]. Dropping it +/// entirely left the formatter promptless mid-chain and the model answered as +/// a chat assistant instead of transforming (2026-08-14 leak: "Jasne — oto to +/// samo, przepisane czytelnie…" delivered as the formatted transcript). +fn chained_instructions(system_prompt: &str, previous_response_id: Option<&str>) -> Option { + if previous_response_id.is_some() { + None + } else { + Some(system_prompt.to_string()) + } +} + +/// Build the `input` items for a Responses request. On chained turns the +/// system prompt rides as a leading `developer` item, because the +/// `instructions` param is absent there (see [`chained_instructions`]) and the +/// chain does not carry it server-side. First turns carry the prompt via +/// `instructions` only — no duplicate developer item. +fn build_responses_input( + system_prompt: &str, + previous_response_id: Option<&str>, + user_content: Vec, +) -> Vec { + let mut input = Vec::with_capacity(2); + if previous_response_id.is_some() { + input.push(InputItem { + role: "developer", + content: vec![InputContent::Text { + text: system_prompt.to_string(), + }], + }); + } + input.push(InputItem { + role: "user", + content: user_content, + }); + input +} + /// Input item for Responses API #[derive(Debug, Serialize)] struct InputItem { @@ -1496,7 +1553,21 @@ async fn format_text_with_status_channels_for_policy( retry_policy.inter_chunk_timeout ); - for attempt in 0..=max_retries { + // Mode key for the conversation chain this call rides on — needed by the + // stale-chain self-heal below to reset the RIGHT stream (modes have + // separate chains and separate key slots). + let ai_mode = if assistive { + crate::state::conversation::AiMode::Assistive + } else { + crate::state::conversation::AiMode::Formatting + }; + // One-shot chain self-heal: a stale stored response_id re-runs the SAME + // attempt unchained instead of consuming the retry budget (the budget is + // often 0, and a poisoned chain would otherwise hard-fail every take + // until restart). + let mut stale_chain_retry_used = false; + let mut attempt = 0; + while attempt <= max_retries { info!( "AI formatting attempt {} (assistive={}, input_len={})", attempt + 1, @@ -1601,6 +1672,14 @@ async fn format_text_with_status_channels_for_policy( max_retries + 1, e ); + if !stale_chain_retry_used && is_stale_chain_error(&e) { + warn!( + "stale conversation chain: dropping stored response_id and retrying unchained" + ); + crate::state::conversation::reset_conversation_for_mode(ai_mode); + stale_chain_retry_used = true; + continue; + } None } } @@ -1633,6 +1712,14 @@ async fn format_text_with_status_channels_for_policy( max_retries + 1, e ); + if !stale_chain_retry_used && is_stale_chain_error(&e) { + warn!( + "stale conversation chain: dropping stored response_id and retrying unchained" + ); + crate::state::conversation::reset_conversation_for_mode(ai_mode); + stale_chain_retry_used = true; + continue; + } None } Err(_) => { @@ -1705,6 +1792,7 @@ async fn format_text_with_status_channels_for_policy( if should_retry { if attempt < max_retries { warn!("Triggering retry..."); + attempt += 1; continue; } else { warn!("Max retries reached, accepting output."); @@ -1738,6 +1826,7 @@ async fn format_text_with_status_channels_for_policy( warn!("Provider returned deterministic empty-content error; skipping retries"); break; } + attempt += 1; } // All providers failed @@ -1981,13 +2070,14 @@ async fn call_llm_endpoint( // Build Responses API request (no token limit - let API decide) let request = ResponsesRequest { model, - input: vec![InputItem { - role: "user", - content: build_responses_user_content(user_message), - }], + input: build_responses_input( + system_prompt, + previous_response_id.as_deref(), + build_responses_user_content(user_message), + ), + // Param on the first turn only; chained turns carry the prompt in input. + instructions: chained_instructions(system_prompt, previous_response_id.as_deref()), previous_response_id: previous_response_id.clone(), - // Only send instructions on first request - Responses API preserves them via previous_response_id - instructions: Some(system_prompt.to_string()), max_output_tokens: None, temperature, stream: false, @@ -2030,6 +2120,96 @@ async fn call_llm_endpoint( Ok(output) } +/// One chained Responses request over the formatting lane, chain owned by the +/// caller (W13-1 inline-format buffer). +/// +/// Deliberately does NOT touch [`crate::state::conversation`]: the inline +/// buffer keeps its own `previous_response_id` per dictation session, so chunk +/// chaining can reset per session without disturbing the persistent +/// formatting-mode conversation. Returns `(assistant_text, response_id)`. +pub(crate) async fn format_inline_chunk( + chunk_text: &str, + language: Option<&str>, + previous_response_id: Option, + system_prompt: &str, +) -> Result<(String, Option)> { + let endpoint = get_formatting_endpoint()?; + let model = get_formatting_model()?; + let api_key = get_formatting_api_key()?; + format_inline_chunk_resolved( + chunk_text, + language, + previous_response_id, + system_prompt, + &endpoint, + &model, + &api_key, + ) + .await +} + +/// Send one inline-chunk Responses request against explicitly supplied wire +/// values. Resolution is split out (mirroring +/// [`call_anthropic_messages_resolved`]) so the delivery harness can exercise +/// the wire contract against a mock without config in play. +pub(crate) async fn format_inline_chunk_resolved( + chunk_text: &str, + language: Option<&str>, + previous_response_id: Option, + system_prompt: &str, + endpoint: &str, + model: &str, + api_key: &str, +) -> Result<(String, Option)> { + let user_message = match language { + Some(lang) => format!("[Language: {lang}]\n\n{chunk_text}"), + None => chunk_text.to_string(), + }; + let request = ResponsesRequest { + model: model.to_string(), + input: build_responses_input( + system_prompt, + previous_response_id.as_deref(), + vec![InputContent::Text { text: user_message }], + ), + // Param on the first turn only; chained turns carry the prompt in input. + instructions: chained_instructions(system_prompt, previous_response_id.as_deref()), + previous_response_id, + max_output_tokens: None, + temperature: get_temperature(false), + stream: false, + }; + + let response = get_client() + .post(endpoint) + .header("Authorization", format!("Bearer {api_key}")) + .header("x-api-key", api_key) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .context("Inline chunk request failed")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Inline chunk HTTP {} - {}", status, body); + } + + let responses_result: ResponsesResponse = response + .json() + .await + .context("Failed to parse inline chunk response")?; + let output = extract_output_channels(&responses_result.output); + if output.assistant_text.is_empty() { + anyhow::bail!( + "No text content in inline chunk response (id: {})", + responses_result.id + ); + } + Ok((output.assistant_text, Some(responses_result.id))) +} + /// Resolve assistive-lane auth: signed-in ChatGPT OAuth wins over a stored API key. /// Returns `(secret, bearer_only)` — OAuth tokens must not also go out as `x-api-key`. async fn resolve_assistive_auth(lane: &AssistiveLaneSnapshot) -> Result<(String, bool)> { @@ -2107,13 +2287,14 @@ async fn call_llm_endpoint_streaming( // No token limit - let API decide let request = ResponsesRequest { model, - input: vec![InputItem { - role: "user", - content: build_responses_user_content(user_message), - }], + input: build_responses_input( + system_prompt, + previous_response_id.as_deref(), + build_responses_user_content(user_message), + ), + // Param on the first turn only; chained turns carry the prompt in input. + instructions: chained_instructions(system_prompt, previous_response_id.as_deref()), previous_response_id: previous_response_id.clone(), - // Only send instructions on first request - Responses API preserves them via previous_response_id - instructions: Some(system_prompt.to_string()), max_output_tokens: None, temperature, stream: true, @@ -2306,6 +2487,74 @@ mod tests { /// Env flag set in the lane-truth child process so nested tests skip re-spawn. const LANE_TRUTH_TEST_CHILD: &str = "CODESCRIBE_LANE_TRUTH_TEST_CHILD"; + /// The stale-chain classifier keys on the provider's error code alone: + /// `previous_response_not_found` (id minted under a rotated-away key) is + /// self-healable; everything else is not a chain problem. + #[test] + fn stale_chain_classifier_matches_only_the_not_found_code() { + let stale = anyhow::anyhow!( + "HTTP 400 Bad Request - {{\"error\":{{\"code\":\"previous_response_not_found\"}}}}" + ); + assert!(is_stale_chain_error(&stale)); + let pair = anyhow::anyhow!("HTTP 400 - instructions and previous_response_id together"); + assert!(!is_stale_chain_error(&pair)); + let auth = anyhow::anyhow!("HTTP 401 Unauthorized - missing scopes"); + assert!(!is_stale_chain_error(&auth)); + } + + /// Regression for the field HTTP 400 ("instructions and + /// previous_response_id together", 2026-08-14): every chained Responses + /// request must drop the `instructions` PARAM. First turn keeps it. + #[test] + fn responses_chain_never_carries_instructions_with_previous_id() { + assert_eq!(chained_instructions("SYS", None).as_deref(), Some("SYS")); + assert_eq!(chained_instructions("SYS", Some("resp_123")), None); + + // Wire proof: the chained request serializes without an + // `instructions` key at all (serde skips the None). + let request = ResponsesRequest { + model: "m".into(), + input: vec![], + instructions: chained_instructions("SYS", Some("resp_123")), + previous_response_id: Some("resp_123".into()), + max_output_tokens: None, + temperature: None, + stream: false, + }; + let wire = serde_json::to_value(&request).expect("serialize"); + assert!(wire.get("instructions").is_none()); + assert_eq!(wire["previous_response_id"], "resp_123"); + } + + /// Regression for the promptless-chain leak (2026-08-14, build 661): + /// dropping `instructions` on a chained turn left the formatter with NO + /// system prompt — the chain does NOT preserve instructions server-side — + /// and the model replied as a chat assistant ("Jasne — oto to samo, + /// przepisane czytelnie…") which was delivered as the formatted + /// transcript. A chained turn must re-carry the prompt as a leading + /// developer input item; a first turn must NOT duplicate it there. + #[test] + fn chained_turn_recarries_system_prompt_as_developer_input() { + let user = vec![InputContent::Text { text: "RAW".into() }]; + let chained = build_responses_input("SYS", Some("resp_123"), user); + assert_eq!(chained.len(), 2); + assert_eq!(chained[0].role, "developer"); + match &chained[0].content[0] { + InputContent::Text { text } => assert_eq!(text, "SYS"), + other => panic!("developer item must be text, got {other:?}"), + } + assert_eq!(chained[1].role, "user"); + + let first = + build_responses_input("SYS", None, vec![InputContent::Text { text: "RAW".into() }]); + assert_eq!( + first.len(), + 1, + "first turn carries the prompt via the instructions param only" + ); + assert_eq!(first[0].role, "user"); + } + /// RAII holder that restores one env var to its prior value on drop. /// /// Captures the previous value rather than assuming the variable was unset, diff --git a/core/llm/client.rs b/core/llm/client.rs index ab42f15e..104606e6 100644 --- a/core/llm/client.rs +++ b/core/llm/client.rs @@ -393,7 +393,11 @@ async fn transcribe_external( // WebSocket Streaming STT // ============================================================================ -/// Transcribe audio via WebSocket streaming +/// LEGACY STOP/RECOVERY ONLY: upload one completed audio file over WebSocket. +/// +/// This is not the live Layer 1 session transport. Do not add microphone frame +/// streaming, normalized session state, or live adjudication here; those belong +/// to `crate::asr_session::cloud` behind `AsrSessionProvider`. /// /// Protocol: /// 1. Connect to WebSocket diff --git a/core/llm/inline_format.rs b/core/llm/inline_format.rs new file mode 100644 index 00000000..7b827a65 --- /dev/null +++ b/core/llm/inline_format.rs @@ -0,0 +1,1118 @@ +//! Inline-format buffer (W13-1, "Backspace Magic"). +//! +//! Formats dictated text with the formatting LLM **while dictation is still +//! running**, one sealed utterance chunk at a time, chained through the +//! Responses API `previous_response_id` so consecutive chunks keep style and +//! context without resending the transcript. At stop, the already-formatted +//! prefix is composed with a single final request that formats **only the +//! unformatted tail** and closes the text coherently — instead of paying the +//! measured 8.6–13.8 s full-text format on the stop path. +//! +//! Doctrine constraints carried here: +//! - **Feature-flagged, default OFF** (`CODESCRIBE_INLINE_FORMAT=1` to arm). +//! - **Fail-open per chunk**: an LLM error/timeout keeps the raw chunk text and +//! logs a receipt; the session is never blocked. +//! - **Anti-invention guard**: a formatted chunk whose word-set materially +//! exceeds its input is rejected (raw kept + receipt). The formatter may +//! punctuate and case, never add words — a formatter that invents text was +//! observed live on 2026-08-12/13. +//! - **Seal = "format now" signal** (wave atlas amendment 2): sealed utterances +//! are byte-stable, so they are the natural chunk boundary; the chunk store +//! is keyed by the sealed span id. +//! +//! Receipts are stable INFO log lines (`inline_format_chunk`, +//! `inline_format_compose`, `inline_format_fallback`) following the +//! `stop_path_budget` convention. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use tokio::sync::{mpsc, oneshot}; +use tracing::{info, warn}; + +use super::ai_formatting::{self, AiFormatResult, AiFormatStatus}; + +/// Master switch. Unset/anything-else = OFF; the operator flips it (⛔). +pub const INLINE_FORMAT_ENV: &str = "CODESCRIBE_INLINE_FORMAT"; + +/// Per-chunk LLM budget; a chunk that misses it keeps its raw text. +const DEFAULT_CHUNK_TIMEOUT_MS: u64 = 10_000; +/// Stop-path wait for the worker to drain queued chunks before composing. +const DEFAULT_FLUSH_TIMEOUT_MS: u64 = 2_500; +/// Stop-path budget for the single tail-close request. +const DEFAULT_TAIL_TIMEOUT_MS: u64 = 15_000; +/// Chunks shorter than this are recorded raw without an LLM round-trip. +const MIN_CHUNK_CHARS: usize = 8; +/// Hard cap on chunks per session (runaway guard). +const MAX_CHUNKS_PER_SESSION: usize = 240; + +/// System prompt for a mid-dictation chunk. New prompt on purpose — the +/// final-pass formatter prompt is out of scope for this lane. +const INLINE_CHUNK_PROMPT: &str = "You format live dictation transcripts. Each user message is the next \ +consecutive chunk of one ongoing dictation session. Format ONLY the current \ +chunk: fix punctuation, capitalization, spacing, and obvious dictation \ +artifacts. Keep every word — never add, remove, translate, reorder, or invent \ +words. Never repeat or rewrite earlier chunks. Never answer questions or add \ +commentary. Keep the language of the input. Return only the formatted chunk."; + +/// System prompt for the stop-path tail: same contract plus the coherent close. +const INLINE_CLOSE_PROMPT: &str = "You format live dictation transcripts. This is the FINAL chunk of the \ +dictation session. Format it exactly like the previous chunks: fix \ +punctuation, capitalization, spacing, and obvious dictation artifacts. Keep \ +every word — never add, remove, translate, reorder, or invent words. Close \ +the text coherently: the last sentence must end with proper terminal \ +punctuation. Never repeat earlier chunks. Return only the formatted final \ +chunk."; + +/// Whether the inline-format buffer is armed for this process. +pub fn enabled() -> bool { + std::env::var(INLINE_FORMAT_ENV) + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on")) + .unwrap_or(false) +} + +fn env_ms(key: &str, default_ms: u64) -> Duration { + Duration::from_millis( + std::env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default_ms), + ) +} + +fn chunk_timeout() -> Duration { + env_ms( + "CODESCRIBE_INLINE_FORMAT_CHUNK_TIMEOUT_MS", + DEFAULT_CHUNK_TIMEOUT_MS, + ) +} + +fn flush_timeout() -> Duration { + env_ms( + "CODESCRIBE_INLINE_FORMAT_FLUSH_TIMEOUT_MS", + DEFAULT_FLUSH_TIMEOUT_MS, + ) +} + +fn tail_timeout() -> Duration { + env_ms( + "CODESCRIBE_INLINE_FORMAT_TAIL_TIMEOUT_MS", + DEFAULT_TAIL_TIMEOUT_MS, + ) +} + +// ── Session store ─────────────────────────────────────────────────────────── + +/// How one chunk's in-flight format attempt ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChunkStatus { + /// Enqueued or mid-request; composes as raw until resolved. + Pending, + /// LLM output accepted (guard passed, lexicon re-applied). + Applied, + /// LLM error or timeout — raw kept (fail-open). + Failed, + /// Guard rejected invented/dropped words — raw kept. + RejectedInvention, + /// Below the char floor — never sent. + Skipped, +} + +impl ChunkStatus { + fn label(self) -> &'static str { + match self { + ChunkStatus::Pending => "pending", + ChunkStatus::Applied => "applied", + ChunkStatus::Failed => "failed", + ChunkStatus::RejectedInvention => "rejected_invention", + ChunkStatus::Skipped => "skipped", + } + } +} + +/// One sealed-span chunk and its formatting outcome, keyed by the span id. +#[derive(Debug, Clone)] +pub(crate) struct ChunkRecord { + /// Sealed span / utterance id (identity within the session). + pub id: u64, + /// Sealed text exactly as fed (post-lexicon, byte-stable). + pub raw: String, + /// Accepted formatted text; `None` composes as raw. + pub formatted: Option, + pub status: ChunkStatus, +} + +impl ChunkRecord { + fn display_text(&self) -> &str { + self.formatted.as_deref().unwrap_or(&self.raw) + } +} + +#[derive(Debug, Default, Clone)] +struct SessionStore { + generation: u64, + language: Option, + chunks: Vec, + /// Responses chain id of the last accepted chunk; resets per session. + chain: Option, +} + +static STORE: OnceLock>> = OnceLock::new(); +static GENERATION: AtomicU64 = AtomicU64::new(0); +static SENDER: OnceLock> = OnceLock::new(); + +fn store() -> &'static Arc> { + STORE.get_or_init(|| Arc::new(Mutex::new(SessionStore::default()))) +} + +enum Cmd { + Begin { + generation: u64, + language: Option, + }, + Chunk { + generation: u64, + id: u64, + text: String, + }, + Flush { + ack: oneshot::Sender<()>, + }, +} + +// ── Live-session hooks ────────────────────────────────────────────────────── + +/// Arm the buffer for a new live session. Must run inside a tokio runtime +/// (spawns the sequential worker on first use); resets chunks and the chain. +/// No-op when the feature flag is off. +pub fn begin_session(language: Option<&str>) { + if !enabled() { + return; + } + let Ok(handle) = tokio::runtime::Handle::try_current() else { + warn!("inline_format_fallback reason=no_tokio_runtime (begin_session outside runtime)"); + return; + }; + let tx = SENDER.get_or_init(|| { + let (tx, rx) = mpsc::unbounded_channel(); + let shared = Arc::clone(store()); + handle.spawn(worker_loop(rx, shared)); + tx + }); + let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + if tx + .send(Cmd::Begin { + generation, + language: language.map(str::to_string), + }) + .is_err() + { + warn!("inline_format_fallback reason=worker_gone (begin_session send failed)"); + } +} + +/// Feed one sealed span. Sync + non-blocking (safe from the blocking seal +/// worker thread). No-op when disabled or when no session was begun. +pub fn on_chunk_sealed(id: u64, text: &str) { + if !enabled() { + return; + } + let Some(tx) = SENDER.get() else { + return; + }; + let generation = GENERATION.load(Ordering::SeqCst); + if generation == 0 { + return; + } + let _ = tx.send(Cmd::Chunk { + generation, + id, + text: text.to_string(), + }); +} + +// ── Worker ────────────────────────────────────────────────────────────────── + +async fn worker_loop(mut rx: mpsc::UnboundedReceiver, shared: Arc>) { + while let Some(cmd) = rx.recv().await { + match cmd { + Cmd::Begin { + generation, + language, + } => { + if let Ok(mut s) = shared.lock() { + *s = SessionStore { + generation, + language, + chunks: Vec::new(), + chain: None, + }; + } + info!("inline_format_session_begin generation={generation}"); + } + Cmd::Chunk { + generation, + id, + text, + } => { + process_chunk(&shared, generation, id, text).await; + } + Cmd::Flush { ack } => { + let _ = ack.send(()); + } + } + } +} + +async fn process_chunk(shared: &Arc>, generation: u64, id: u64, text: String) { + let trimmed = text.trim().to_string(); + let (idx, language, chain) = { + let Ok(mut s) = shared.lock() else { + return; + }; + if s.generation != generation + || trimmed.is_empty() + || s.chunks.len() >= MAX_CHUNKS_PER_SESSION + { + return; + } + let status = if trimmed.chars().count() < MIN_CHUNK_CHARS { + ChunkStatus::Skipped + } else { + ChunkStatus::Pending + }; + s.chunks.push(ChunkRecord { + id, + raw: trimmed.clone(), + formatted: None, + status, + }); + if status == ChunkStatus::Skipped { + return; + } + (s.chunks.len() - 1, s.language.clone(), s.chain.clone()) + }; + + let chained = chain.is_some(); + let started = Instant::now(); + let outcome = tokio::time::timeout( + chunk_timeout(), + ai_formatting::format_inline_chunk( + &trimmed, + language.as_deref(), + chain, + INLINE_CHUNK_PROMPT, + ), + ) + .await; + let latency_ms = started.elapsed().as_millis(); + + let (status, formatted, response_id) = match outcome { + Ok(Ok((raw_out, response_id))) => { + let cleaned = crate::stream_postprocess::apply_lexicon(raw_out.trim()); + if invention_guard_rejects(&trimmed, &cleaned) { + (ChunkStatus::RejectedInvention, None, None) + } else { + (ChunkStatus::Applied, Some(cleaned), response_id) + } + } + Ok(Err(error)) => { + warn!("inline format chunk request failed: {error:#}"); + (ChunkStatus::Failed, None, None) + } + Err(_) => (ChunkStatus::Failed, None, None), + }; + + let chars_in = trimmed.chars().count(); + let chars_out = formatted + .as_deref() + .map(|t| t.chars().count()) + .unwrap_or(chars_in); + if let Ok(mut s) = shared.lock() { + // The session may have been reset or consumed mid-request; only write + // back into the record this request was created for. + if s.generation == generation + && let Some(record) = s.chunks.get_mut(idx) + && record.id == id + { + record.status = status; + record.formatted = formatted; + if status == ChunkStatus::Applied + && let Some(rid) = response_id.filter(|r| !r.is_empty()) + { + s.chain = Some(rid); + } + } + } + info!( + "inline_format_chunk id={id} status={} latency_ms={latency_ms} chained={chained} chars_in={chars_in} chars_out={chars_out}", + status.label(), + ); +} + +// ── Anti-invention guard ──────────────────────────────────────────────────── + +fn normalize_token(token: &str) -> String { + token + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn normalized_words(text: &str) -> Vec { + text.split_whitespace() + .map(normalize_token) + .filter(|w| !w.is_empty()) + .collect() +} + +/// True when the formatted text materially exceeds (or guts) the input words. +/// +/// The formatter's licence is punctuation/casing/spacing — so the normalized +/// word multiset must stay essentially the same. Budget: 2 novel words or 10% +/// of the input, whichever is larger (absorbs digit↔word style flips without +/// admitting invented sentences). Losing more than half the words is equally +/// rejected: a truncated chunk silently drops the user's speech. +pub(crate) fn invention_guard_rejects(raw: &str, formatted: &str) -> bool { + let raw_words = normalized_words(raw); + if raw_words.is_empty() { + return false; + } + let formatted_words = normalized_words(formatted); + if formatted_words.len() * 2 < raw_words.len() { + return true; + } + let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for w in &raw_words { + *counts.entry(w.as_str()).or_default() += 1; + } + let mut novel = 0usize; + for w in &formatted_words { + match counts.get_mut(w.as_str()) { + Some(c) if *c > 0 => *c -= 1, + _ => novel += 1, + } + } + let budget = (raw_words.len() / 10).max(2); + novel > budget +} + +// ── Stop-path composition ─────────────────────────────────────────────────── + +/// Outcome of matching the session's chunks against the delivered transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PrefixMatchOutcome { + /// Formatted (or raw, for failed chunks) prefix text, chunk-joined. + pub formatted_prefix: String, + /// Byte offset in the full text where the unmatched tail begins. + pub tail_start_byte: usize, + /// Chunks whose words matched the transcript prefix in order. + pub chunks_matched: usize, + /// Matched chunks that carry accepted LLM formatting. + pub formatted_matched: usize, +} + +/// Word starts (normalized token + byte offset) over the full text. +fn word_spans(text: &str) -> Vec<(String, usize)> { + let mut spans = Vec::new(); + let mut word_start: Option = None; + for (i, c) in text.char_indices() { + if c.is_whitespace() { + if let Some(start) = word_start.take() { + let token = normalize_token(&text[start..i]); + if !token.is_empty() { + spans.push((token, start)); + } + } + } else if word_start.is_none() { + word_start = Some(i); + } + } + if let Some(start) = word_start { + let token = normalize_token(&text[start..]); + if !token.is_empty() { + spans.push((token, start)); + } + } + spans +} + +/// Match chunks (in order) against the head of the delivered transcript. +/// +/// Comparison is word-based and punctuation/case-insensitive, so lexicon or +/// Light+ drift at chunk boundaries does not break the match. The first chunk +/// that fails to match stops the walk — everything after it (gap-appends, +/// diverged text) becomes the tail and is formatted fresh at stop. This is the +/// fail-open posture: a mismatch costs latency, never words. +pub(crate) fn match_chunks_against_text( + chunks: &[ChunkRecord], + full_text: &str, +) -> PrefixMatchOutcome { + let spans = word_spans(full_text); + let mut cursor = 0usize; + let mut chunks_matched = 0usize; + let mut formatted_matched = 0usize; + let mut prefix_parts: Vec = Vec::new(); + + for chunk in chunks { + let chunk_words = normalized_words(&chunk.raw); + if chunk_words.is_empty() { + chunks_matched += 1; + continue; + } + let end = cursor + chunk_words.len(); + if end > spans.len() { + break; + } + let matches = spans[cursor..end] + .iter() + .zip(chunk_words.iter()) + .all(|((span_word, _), chunk_word)| span_word == chunk_word); + if !matches { + break; + } + cursor = end; + chunks_matched += 1; + if chunk.formatted.is_some() { + formatted_matched += 1; + } + let display = chunk.display_text().trim(); + if !display.is_empty() { + prefix_parts.push(display.to_string()); + } + } + + let tail_start_byte = spans + .get(cursor) + .map(|(_, b)| *b) + .unwrap_or(full_text.len()); + PrefixMatchOutcome { + formatted_prefix: prefix_parts.join(" "), + tail_start_byte, + chunks_matched, + formatted_matched, + } +} + +/// Snapshot a session's chunks and consume them (one stop = one consumption; +/// a later non-live recording can never reuse a stale buffer). +fn snapshot_and_consume(shared: &Arc>) -> SessionStore { + let Ok(mut s) = shared.lock() else { + return SessionStore::default(); + }; + let snapshot = s.clone(); + s.chunks.clear(); + s.chain = None; + snapshot +} + +/// Stop-path entry point: compose formatted chunks + freshly formatted tail, +/// falling back to the classic full-text format whenever the buffer cannot +/// prove it covers the transcript. Drop-in replacement for +/// [`ai_formatting::format_text_with_status`] on the formatting lanes. +pub async fn format_text_with_inline_buffer(text: &str, language: Option<&str>) -> AiFormatResult { + if enabled() + && let Some(tx) = SENDER.get() + { + let started = Instant::now(); + if let Some(result) = compose_and_close_with(tx, store(), text, language).await { + info!( + "inline_format_stop total_ms={} composed_chars={}", + started.elapsed().as_millis(), + result.text.chars().count() + ); + return result; + } + } + ai_formatting::format_text_with_status(text, language, false, None).await +} + +/// Compose against an explicit worker channel + store. Split from the global +/// entry point so the delivery harness can drive a private worker without +/// touching (or being polluted by) process-global session state. +async fn compose_and_close_with( + tx: &mpsc::UnboundedSender, + shared: &Arc>, + full_text: &str, + language: Option<&str>, +) -> Option { + // Drain queued chunks so the freshest seal (often emitted during + // recorder stop) is formatted before we snapshot. Bounded: a stuck + // worker degrades to raw-tail composition, never to a blocked stop. + let flush_started = Instant::now(); + let (ack_tx, ack_rx) = oneshot::channel(); + let flushed = if tx.send(Cmd::Flush { ack: ack_tx }).is_ok() { + tokio::time::timeout(flush_timeout(), ack_rx).await.is_ok() + } else { + false + }; + let flush_wait_ms = flush_started.elapsed().as_millis(); + + let snapshot = snapshot_and_consume(shared); + if snapshot.chunks.is_empty() { + info!("inline_format_fallback reason=no_chunks flush_wait_ms={flush_wait_ms}"); + return None; + } + + let matched = match_chunks_against_text(&snapshot.chunks, full_text); + if matched.chunks_matched == 0 || matched.formatted_matched == 0 { + info!( + "inline_format_fallback reason=prefix_mismatch chunks={} matched={} formatted={} flush_wait_ms={flush_wait_ms}", + snapshot.chunks.len(), + matched.chunks_matched, + matched.formatted_matched + ); + return None; + } + + let tail_raw = full_text[matched.tail_start_byte..].trim(); + let tail_chars = tail_raw.chars().count(); + let (tail_text, tail_status) = if tail_raw.is_empty() { + (String::new(), "empty") + } else { + match tokio::time::timeout( + tail_timeout(), + ai_formatting::format_inline_chunk( + tail_raw, + language.or(snapshot.language.as_deref()), + snapshot.chain.clone(), + INLINE_CLOSE_PROMPT, + ), + ) + .await + { + Ok(Ok((raw_out, _response_id))) => { + let cleaned = crate::stream_postprocess::apply_lexicon(raw_out.trim()); + if invention_guard_rejects(tail_raw, &cleaned) { + (tail_raw.to_string(), "rejected_invention") + } else { + (cleaned, "applied") + } + } + Ok(Err(error)) => { + warn!("inline format tail request failed: {error:#}"); + (tail_raw.to_string(), "failed") + } + Err(_) => (tail_raw.to_string(), "timeout"), + } + }; + + let mut composed = matched.formatted_prefix.clone(); + if !tail_text.is_empty() { + if !composed.is_empty() { + composed.push(' '); + } + composed.push_str(&tail_text); + } + if composed.trim().is_empty() { + info!("inline_format_fallback reason=empty_composition flush_wait_ms={flush_wait_ms}"); + return None; + } + + info!( + "inline_format_compose chunks={} matched={} formatted={} tail_chars={tail_chars} tail_status={tail_status} flushed={flushed} flush_wait_ms={flush_wait_ms}", + snapshot.chunks.len(), + matched.chunks_matched, + matched.formatted_matched, + ); + + Some(AiFormatResult { + text: composed, + reasoning_text: None, + status: AiFormatStatus::Applied, + }) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn record(id: u64, raw: &str, formatted: Option<&str>) -> ChunkRecord { + ChunkRecord { + id, + raw: raw.to_string(), + formatted: formatted.map(str::to_string), + status: if formatted.is_some() { + ChunkStatus::Applied + } else { + ChunkStatus::Failed + }, + } + } + + /// Punctuation and casing may change freely; the guard only counts words. + #[test] + fn guard_accepts_punctuation_and_casing_changes() { + assert!(!invention_guard_rejects( + "no dobra to jest test dyktowania w codescribe", + "No dobra, to jest test dyktowania w Codescribe." + )); + } + + /// A formatter that answers instead of formatting is rejected. + #[test] + fn guard_rejects_invented_content() { + assert!(invention_guard_rejects( + "kup mleko i chleb", + "Oczywiście! Oto sformatowana lista zakupów: kup mleko i chleb, a także masło." + )); + } + + /// A formatter that eats most of the chunk is rejected too. + #[test] + fn guard_rejects_heavy_truncation() { + assert!(invention_guard_rejects( + "pierwsza część zdania oraz druga część zdania oraz trzecia część zdania", + "pierwsza część." + )); + } + + /// Small novel-word drift (within budget) is tolerated. + #[test] + fn guard_tolerates_tiny_drift() { + assert!(!invention_guard_rejects( + "spotkanie jutro o ósmej rano w klinice", + "Spotkanie jutro o 8 rano w klinice." + )); + } + + /// Matched chunks compose the formatted prefix; the tail byte offset points + /// at the first unmatched word — including multibyte Polish input. + #[test] + fn matcher_matches_prefix_and_finds_tail() { + let chunks = vec![ + record( + 1, + "pierwsze zdanie o żółwiu", + Some("Pierwsze zdanie o żółwiu."), + ), + record(2, "drugie zdanie o jeżu", Some("Drugie zdanie o jeżu.")), + ]; + let full = "pierwsze zdanie o żółwiu drugie zdanie o jeżu i ogon który został"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 2); + assert_eq!(m.formatted_matched, 2); + assert_eq!( + m.formatted_prefix, + "Pierwsze zdanie o żółwiu. Drugie zdanie o jeżu." + ); + assert_eq!(&full[m.tail_start_byte..], "i ogon który został"); + } + + /// Canvas drift (gap-append between chunks) stops the walk at the last + /// provable chunk; the rest becomes tail. Words are never lost. + #[test] + fn matcher_partial_match_on_gap_append() { + let chunks = vec![ + record(1, "pierwsze zdanie", Some("Pierwsze zdanie.")), + record(2, "trzecie zdanie", Some("Trzecie zdanie.")), + ]; + let full = "Pierwsze zdanie wstawka z gap append trzecie zdanie"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 1); + assert_eq!(m.formatted_prefix, "Pierwsze zdanie."); + assert_eq!( + &full[m.tail_start_byte..], + "wstawka z gap append trzecie zdanie" + ); + } + + /// A failed chunk (no formatted text) still matches and composes raw — + /// fail-open never drops the chunk from the prefix. + #[test] + fn matcher_failed_chunk_composes_raw() { + let chunks = vec![ + record(1, "pierwsze zdanie", Some("Pierwsze zdanie.")), + record(2, "drugie zdanie", None), + ]; + let full = "pierwsze zdanie drugie zdanie ogon"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 2); + assert_eq!(m.formatted_matched, 1); + assert_eq!(m.formatted_prefix, "Pierwsze zdanie. drugie zdanie"); + assert_eq!(&full[m.tail_start_byte..], "ogon"); + } + + /// Zero matches → the caller must fall back to full-text formatting. + #[test] + fn matcher_no_match_yields_zero() { + let chunks = vec![record( + 1, + "zupełnie inny tekst", + Some("Zupełnie inny tekst."), + )]; + let full = "to nagranie nie ma nic wspólnego z buforem"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 0); + assert_eq!(m.tail_start_byte, 0); + } + + /// Fully covered transcript → empty tail (stop pays zero LLM requests). + #[test] + fn matcher_full_coverage_leaves_empty_tail() { + let chunks = vec![record(1, "całość wypowiedzi", Some("Całość wypowiedzi."))]; + let full = "całość wypowiedzi"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 1); + assert_eq!(full[m.tail_start_byte..].trim(), ""); + } + + /// Delivery-verifier seam harness: a private worker + mock Responses + /// provider drive the full chunk→chain→compose path without process-global + /// state, so parallel tests (or a concurrent live session) cannot pollute + /// the measurement. + mod seam { + use super::super::*; + use mockito::Matcher; + use serde_json::json; + use serial_test::serial; + + /// RAII env pin (mirrors `ai_formatting`'s test guard): captures the + /// prior value so an operator dotenv leaves the env as it found it. + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let prev = std::env::var(key).ok(); + unsafe { std::env::set_var(key, value) }; + Self { key, prev } + } + + fn remove(key: &'static str) -> Self { + let prev = std::env::var(key).ok(); + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.prev.as_deref() { + Some(value) => unsafe { std::env::set_var(self.key, value) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + + fn pin_formatting_lane(server_url: &str) -> Vec { + vec![ + EnvGuard::set( + "LLM_FORMATTING_ENDPOINT", + &format!("{server_url}/v1/responses"), + ), + EnvGuard::set("LLM_FORMATTING_MODEL", "mock-nano"), + EnvGuard::set("LLM_FORMATTING_API_KEY", "mock-key"), + EnvGuard::remove("LLM_FORMATTING_TEMPERATURE"), + EnvGuard::remove("LLM_TEMPERATURE"), + // Pin the operation clocks to their defaults: the operator's + // dotenv injects into every test process, and the settle + // waiter's 30s backstop is calibrated against THESE numbers. + EnvGuard::set("CODESCRIBE_INLINE_FORMAT_CHUNK_TIMEOUT_MS", "10000"), + EnvGuard::set("CODESCRIBE_INLINE_FORMAT_FLUSH_TIMEOUT_MS", "2500"), + EnvGuard::set("CODESCRIBE_INLINE_FORMAT_TAIL_TIMEOUT_MS", "15000"), + ] + } + + fn responses_body(id: &str, text: &str) -> String { + json!({ + "id": id, + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": text}] + }] + }) + .to_string() + } + + struct Harness { + tx: mpsc::UnboundedSender, + shared: Arc>, + } + + fn spawn_private_worker(language: &str) -> Harness { + let shared = Arc::new(Mutex::new(SessionStore::default())); + let (tx, rx) = mpsc::unbounded_channel(); + let worker = tokio::spawn(worker_loop(rx, Arc::clone(&shared))); + // Surface a silent worker panic instead of an opaque settle timeout. + tokio::spawn(async move { + if let Err(join_error) = worker.await { + eprintln!("inline-format test worker died: {join_error:?}"); + } + }); + tx.send(Cmd::Begin { + generation: 1, + language: Some(language.to_string()), + }) + .expect("worker alive"); + Harness { tx, shared } + } + + // Backstop, not a claim: it must sit OUT OF REACH of the chunk + // request's own 10s budget, or the two clocks race under machine load + // (measured flake 2026-08-14: chunk still Pending at the waiter's + // 10s while its own timeout was about to settle it). + async fn wait_for_settled_chunks(shared: &Arc>, expected: usize) { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + { + let s = shared.lock().expect("store lock"); + if s.chunks.len() == expected + && s.chunks.iter().all(|c| c.status != ChunkStatus::Pending) + { + return; + } + } + assert!( + Instant::now() < deadline, + "chunks did not settle in flight within 30s: {:?}", + shared.lock().expect("store lock").chunks + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// The stop path pays exactly ONE provider round-trip — the tail close + /// — because both sealed chunks were formatted in flight and chained + /// via `previous_response_id`. This is the W13-1 delivery seam: with a + /// local mock provider the whole stop composition fits far inside the + /// <3 s budget; real-network cost is the single ~1–2 s nano tail call. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn stop_seam_pays_only_the_tail_request() { + let mut server = mockito::Server::new_async().await; + let _env = pin_formatting_lane(&server.url()); + + let chunk1 = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("consecutive chunk".into()), + Matcher::Regex("pierwsze zdanie o testowaniu bufora".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_1", + "Pierwsze zdanie o testowaniu bufora.", + )) + .expect(1) + .create_async() + .await; + let chunk2 = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("drugie zdanie o zamykaniu wypowiedzi".into()), + Matcher::Regex(r#""previous_response_id":"resp_1""#.into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_2", + "Drugie zdanie o zamykaniu wypowiedzi.", + )) + .expect(1) + .create_async() + .await; + let tail = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("FINAL chunk".into()), + Matcher::Regex(r#""previous_response_id":"resp_2""#.into()), + Matcher::Regex("ogon który nie został".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_3", + "Ogon, który nie został zapieczętowany.", + )) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 1, + text: "pierwsze zdanie o testowaniu bufora".into(), + }) + .unwrap(); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 2, + text: "drugie zdanie o zamykaniu wypowiedzi".into(), + }) + .unwrap(); + + // Both chunks format DURING dictation (before any stop call). + wait_for_settled_chunks(&h.shared, 2).await; + { + let s = h.shared.lock().unwrap(); + assert!( + s.chunks.iter().all(|c| c.status == ChunkStatus::Applied), + "both chunks must be accepted in flight: {:?}", + s.chunks + ); + assert_eq!( + s.chain.as_deref(), + Some("resp_2"), + "chain must advance to the last accepted chunk" + ); + } + + let full_text = "pierwsze zdanie o testowaniu bufora drugie zdanie o \ + zamykaniu wypowiedzi ogon który nie został zapieczętowany"; + let started = Instant::now(); + let result = compose_and_close_with(&h.tx, &h.shared, full_text, Some("pl")) + .await + .expect("compose must succeed when chunks cover the prefix"); + let stop_secs = started.elapsed().as_secs_f64(); + + assert_eq!(result.status, AiFormatStatus::Applied); + assert_eq!( + result.text, + "Pierwsze zdanie o testowaniu bufora. Drugie zdanie o zamykaniu \ + wypowiedzi. Ogon, który nie został zapieczętowany." + ); + chunk1.assert_async().await; + chunk2.assert_async().await; + tail.assert_async().await; + assert!( + stop_secs < 3.0, + "stop seam must fit the <3s budget with a local provider (measured {stop_secs:.3}s)" + ); + // Emit the measured number so the report can quote it. + eprintln!( + "inline_format_stop_seam_secs={stop_secs:.3} chunks_in_flight=2 stop_requests=1" + ); + } + + /// Fail-open per chunk: a provider 500 keeps the raw chunk text, the + /// chain stays on the last accepted id, and stop still composes — + /// the session is never blocked by a failed chunk. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn chunk_failure_is_fail_open_and_keeps_chain() { + let mut server = mockito::Server::new_async().await; + let _env = pin_formatting_lane(&server.url()); + + let chunk1 = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex("pierwszy kawalek dyktowania".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body("resp_1", "Pierwszy kawalek dyktowania.")) + .expect(1) + .create_async() + .await; + // One ordered regex over the CURRENT wire truth (5d62aacb): a + // chained request re-carries the closing prompt as a leading + // `developer` input item (instructions do NOT persist server-side + // across previous_response_id), and `input` serializes before + // `previous_response_id`. Proves this is the closing request AND + // that the chain still points at the last ACCEPTED chunk. + let tail = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex( + r#""role":"developer"[\s\S]*FINAL chunk[\s\S]*"previous_response_id":"resp_1""# + .into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body("resp_9", "Ogon po awarii.")) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 1, + text: "pierwszy kawalek dyktowania".into(), + }) + .unwrap(); + wait_for_settled_chunks(&h.shared, 1).await; + + // Real transport failure for chunk 2: the formatting lane briefly + // points at a closed port (connection refused — the same fail-open + // arm a dead provider takes in production). The guard's captured + // previous value restores the mock endpoint before the tail runs. + { + let _dead_lane = + EnvGuard::set("LLM_FORMATTING_ENDPOINT", "http://127.0.0.1:1/v1/responses"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 2, + text: "drugi kawalek ktory pada".into(), + }) + .unwrap(); + wait_for_settled_chunks(&h.shared, 2).await; + } + { + let s = h.shared.lock().unwrap(); + assert_eq!(s.chunks[0].status, ChunkStatus::Applied); + assert_eq!(s.chunks[1].status, ChunkStatus::Failed); + assert_eq!(s.chunks[1].formatted, None, "failed chunk keeps raw"); + assert_eq!(s.chain.as_deref(), Some("resp_1")); + } + + let full_text = "pierwszy kawalek dyktowania drugi kawalek ktory pada ogon po awarii"; + let result = compose_and_close_with(&h.tx, &h.shared, full_text, Some("pl")) + .await + .expect("fail-open compose must still succeed"); + + assert_eq!( + result.text, + "Pierwszy kawalek dyktowania. drugi kawalek ktory pada Ogon po awarii." + ); + chunk1.assert_async().await; + tail.assert_async().await; + } + + /// An anti-invention violation from the provider is rejected: the raw + /// chunk survives and the chain does not advance onto the poisoned id. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn invented_chunk_output_is_rejected_with_raw_kept() { + let mut server = mockito::Server::new_async().await; + let _env = pin_formatting_lane(&server.url()); + + let chunk = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex("kup mleko i chleb dla kliniki".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_bad", + "Oczywiście! Oto lista: kup mleko i chleb dla kliniki, a także \ + opatrunki, strzykawki i wszystko czego potrzebuje przychodnia.", + )) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 1, + text: "kup mleko i chleb dla kliniki".into(), + }) + .unwrap(); + wait_for_settled_chunks(&h.shared, 1).await; + + { + let s = h.shared.lock().unwrap(); + assert_eq!(s.chunks[0].status, ChunkStatus::RejectedInvention); + assert_eq!(s.chunks[0].formatted, None, "invented text must not land"); + assert_eq!(s.chain, None, "chain must not advance onto a rejected id"); + } + chunk.assert_async().await; + } + } +} diff --git a/core/llm/key_liveness.rs b/core/llm/key_liveness.rs index 29061e47..3e6e3164 100644 --- a/core/llm/key_liveness.rs +++ b/core/llm/key_liveness.rs @@ -8,6 +8,7 @@ use std::time::Duration; use reqwest::StatusCode; +use reqwest::blocking::multipart::{Form, Part}; use reqwest::blocking::{Client, Response}; use serde_json::json; @@ -98,14 +99,6 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { ); }; - if account == "STT_API_KEY" { - return ApiKeyLivenessResult::new( - account, - ApiKeyLivenessStatus::Unsupported, - "no cheap liveness probe is available for this STT key", - ); - } - let client = match Client::builder() .timeout(PROBE_TIMEOUT) .connect_timeout(PROBE_TIMEOUT) @@ -121,6 +114,10 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { } }; + if account == "STT_API_KEY" { + return probe_stt_key(&client, &config, account, &api_key); + } + if account == "GITHUB_TOKEN" { return probe_github_token(&client, account, &api_key); } @@ -154,6 +151,66 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { } } +/// Probe the configured multipart STT slot with 100 ms of synthetic silence. +/// The response body is never surfaced; only auth/quota/transport status is. +fn probe_stt_key( + client: &Client, + config: &Config, + account: &str, + api_key: &str, +) -> ApiKeyLivenessResult { + let endpoint = config + .stt_endpoint + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "http://127.0.0.1:8000/v1/audio/transcriptions".to_string()); + if crate::stt::tail_provider::validate_remote_endpoint(&endpoint).is_err() { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + "configured STT endpoint is invalid or insecure", + ) + .with_probed_endpoint(endpoint); + } + let silence = [0.0_f32; 1_600]; + let wav = match crate::stt::tail_provider::pcm16_wav(&silence, 16_000) { + Ok(wav) => wav, + Err(_) => { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + "could not build the STT liveness probe", + ) + .with_probed_endpoint(endpoint); + } + }; + let file = match Part::bytes(wav) + .file_name("codescribe-key-probe.wav") + .mime_str("audio/wav") + { + Ok(file) => file, + Err(_) => { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + "could not build the STT liveness probe", + ) + .with_probed_endpoint(endpoint); + } + }; + let form = Form::new() + .part("file", file) + .text("model", "whisper-1") + .text("language", "pl") + .text("response_format", "json"); + let response = client + .post(&endpoint) + .header("x-api-key", api_key) + .multipart(form) + .send(); + response_result(account, endpoint, response) +} + /// Classify one provider HTTP response. This is the tested contract; network /// errors are classified at the request boundary because there is no HTTP status. pub fn classify_probe_response(status: StatusCode, body: &str) -> ApiKeyLivenessStatus { @@ -397,6 +454,46 @@ mod tests { ); } + /// The STT slot has a real multipart probe instead of the historical + /// Unsupported verdict, and reports the endpoint that answered. + #[test] + fn stt_probe_uses_the_multipart_endpoint() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind STT probe server"); + let address = listener.local_addr().expect("STT probe address"); + let endpoint = format!("http://{address}/v1/audio/transcriptions"); + let expected_endpoint = endpoint.clone(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept STT probe request"); + let mut buffer = [0_u8; 8192]; + let bytes_read = stream.read(&mut buffer).expect("read STT probe request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"text\":\"\"}", + ) + .expect("write STT probe response"); + String::from_utf8_lossy(&buffer[..bytes_read]).to_string() + }); + let client = Client::builder() + .timeout(PROBE_TIMEOUT) + .connect_timeout(PROBE_TIMEOUT) + .build() + .expect("build STT probe client"); + let config = Config { + stt_endpoint: Some(endpoint), + ..Config::default() + }; + let result = probe_stt_key(&client, &config, "STT_API_KEY", "test-key"); + assert_eq!(result.status, ApiKeyLivenessStatus::Ok); + assert_eq!( + result.probed_endpoint.as_deref(), + Some(expected_endpoint.as_str()) + ); + let request = server.join().expect("STT probe server"); + assert!(request.starts_with("POST /v1/audio/transcriptions HTTP/1.1")); + assert!(request.to_ascii_lowercase().contains("x-api-key: test-key")); + assert!(request.contains("codescribe-key-probe.wav")); + } + /// 2xx means the provider accepted the key and returned a usable response. #[test] fn classifies_success_as_ok() { diff --git a/core/llm/mod.rs b/core/llm/mod.rs index 9055100f..df63c857 100644 --- a/core/llm/mod.rs +++ b/core/llm/mod.rs @@ -8,6 +8,9 @@ pub mod account_auth; pub mod ai_formatting; /// HTTP client for cloud STT / LLM multipart upload paths. pub mod client; +/// Inline-format buffer: chunk-chained live formatting so stop pays only the +/// tail (W13-1, feature-flagged via `CODESCRIBE_INLINE_FORMAT`). +pub mod inline_format; /// Minimal API-key liveness probes for Settings (one cheap call per key). pub mod key_liveness; /// Canonical resolution of lane secrets, endpoints, and model ids. diff --git a/core/mcp/client.rs b/core/mcp/client.rs index 3f0167fb..b0e2016b 100644 --- a/core/mcp/client.rs +++ b/core/mcp/client.rs @@ -923,28 +923,15 @@ impl StdioConnection { } /// Mark the child's stdin pipe so writes to a dead peer return EPIPE instead -/// of raising SIGPIPE. Per-fd (`F_SETNOSIGPIPE`) on purpose: it protects the -/// MCP exchange without mutating the host process' signal table. -#[cfg(target_os = "macos")] +/// of raising SIGPIPE. +/// +/// Now a thin alias over [`crate::util::pipes::disable_sigpipe`]: the same +/// hazard reached the Apple STT bridge (2026-08-12), so the remedy lives in one +/// place rather than being rediscovered per child. fn disable_sigpipe(stdin: &ChildStdin) { - use std::os::fd::AsRawFd; - - // Darwin `sys/fcntl.h`: `#define F_SETNOSIGPIPE 73` — the libc crate does - // not export this per-fd fcntl command (only the socket-level - // `SO_NOSIGPIPE`), so pin the value here. - /// Darwin fcntl command: mark a fd so broken-pipe writes return EPIPE, not SIGPIPE. - const F_SETNOSIGPIPE: libc::c_int = 73; - - // SAFETY: fcntl on an fd we own for the child's lifetime; F_SETNOSIGPIPE - // only flips a per-fd flag. A failure leaves the old behavior in place and - // is tolerable — the try_wait guard in `shutdown` still narrows exposure. - let _ = unsafe { libc::fcntl(stdin.as_raw_fd(), F_SETNOSIGPIPE, 1) }; + crate::util::pipes::disable_sigpipe(stdin); } -/// No-op outside macOS: `F_SETNOSIGPIPE` is a Darwin-specific fcntl. -#[cfg(not(target_os = "macos"))] -fn disable_sigpipe(_stdin: &ChildStdin) {} - /// Build the `PATH` a spawned server sees: the server's own configured `PATH` /// first, then the process `PATH`, then the user bins and system fallbacks. /// diff --git a/core/pipeline/contracts.rs b/core/pipeline/contracts.rs index 89d89846..d8932153 100644 --- a/core/pipeline/contracts.rs +++ b/core/pipeline/contracts.rs @@ -755,6 +755,26 @@ pub enum EngineEvent { Warning { code: String, message: String }, } +/// Warning codes that mean the user's action actually FAILED and must surface +/// as an error on the UI (`listener.on_error`). +/// +/// Every other warning is a quality receipt — the engine degraded or +/// normalized something and *kept going*. Those must never ride the error +/// channel: the composer treats `on_error` during capture as terminal, so a +/// routine receipt painted "Dictation stopped" over a live session, desynced +/// the toggle parity, and the next toggle started a second, orphaned capture +/// that held the microphone behind an Idle tray (2026-08-12 incident). +pub const USER_TERMINAL_WARNING_CODES: &[&str] = &["transcription_failed"]; + +/// Whether a [`EngineEvent::Warning`] code is a user-terminal failure +/// (forward to `on_error`) rather than a quality receipt (log only). +/// +/// W13-5 `capture_level_low` is a receipt. It must never be added here — +/// the composer treats `on_error` during capture as "Dictation stopped". +pub fn warning_is_user_terminal(code: &str) -> bool { + USER_TERMINAL_WARNING_CODES.contains(&code) +} + /// Layer that produced a bounded replacement. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/core/pipeline/highlight.rs b/core/pipeline/highlight.rs new file mode 100644 index 00000000..8cecfb5b --- /dev/null +++ b/core/pipeline/highlight.rs @@ -0,0 +1,219 @@ +//! W13-6B — span-based overlay highlights (lexicon corrections + speech gaps). +//! +//! The canvas stays append-only. Highlights are a read-only layer over +//! provenance that already exists after W13-3A: integer PCM sample ranges +//! plus the char offsets the overlay already receives on `ReplaceRange`. +//! Seconds never live here. +//! +//! Lane flag [`OVERLAY_HIGHLIGHTS_ENV`] is **default OFF**. + +use crate::stt::tail_provider::{TailSampleRange, TimedTailSegment}; + +/// Opt-in gate for the overlay highlight layer. Unset / `0` / `false` / `off` +/// / `no` keep the shipped canvas unstyled. +pub const OVERLAY_HIGHLIGHTS_ENV: &str = "CODESCRIBE_OVERLAY_HIGHLIGHTS"; + +/// Visible gap glyph for a Silero-speech span that landed no words. +pub const SPEECH_GAP_MARKER: &str = "∅"; + +/// Kind of a canvas highlight. Typed evidence, not a confidence score. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlayHighlightKind { + /// A lexicon rewrite already applied to committed text. + LexiconCorrected, + /// Silero heard speech; no engine word landed in the span (pustka). + SpeechGap, +} + +impl OverlayHighlightKind { + pub fn as_str(self) -> &'static str { + match self { + Self::LexiconCorrected => "lexicon_corrected", + Self::SpeechGap => "speech_gap", + } + } +} + +/// One highlight keyed by utterance identity and a half-open sample range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverlayHighlight { + pub kind: OverlayHighlightKind, + pub utterance_id: u64, + /// Inclusive UTF-8 char start inside the utterance text *after* the edit. + pub char_start: u64, + /// Exclusive UTF-8 char end inside the utterance text *after* the edit. + pub char_end: u64, + pub range: TailSampleRange, + /// Text the lexicon replaced (empty on a speech gap). + pub before: String, + /// Text now on the canvas (gap marker for a pustka). + pub after: String, +} + +/// Parse the overlay-highlights flag. `None` (unset) is OFF. +pub fn parse_overlay_highlights_flag(raw: Option<&str>) -> bool { + match raw { + Some(value) => { + let value = value.trim().to_ascii_lowercase(); + matches!(value.as_str(), "1" | "true" | "yes" | "on") + } + None => false, + } +} + +/// Whether the highlight layer is armed. Default OFF. +pub fn overlay_highlights_enabled() -> bool { + parse_overlay_highlights_flag(std::env::var(OVERLAY_HIGHLIGHTS_ENV).ok().as_deref()) +} + +/// Build a lexicon-correction highlight from a `ReplaceRange` already on the +/// bridge (`source = Lexicon`) plus optional 3A sample identity. +pub fn lexicon_corrected_highlight( + utterance_id: u64, + char_start: u64, + replacement: &str, + before: &str, + range: TailSampleRange, +) -> Option { + if replacement.trim().is_empty() { + return None; + } + let char_end = char_start.saturating_add(replacement.chars().count() as u64); + Some(OverlayHighlight { + kind: OverlayHighlightKind::LexiconCorrected, + utterance_id, + char_start, + char_end, + range, + before: before.to_string(), + after: replacement.to_string(), + }) +} + +/// A Silero-bounded speech span with no word evidence is a pustka. +/// +/// Words whose sample range overlaps `speech` count as coverage. An empty +/// word list, or words that all sit outside the speech range, yields a gap. +pub fn speech_gap_highlight( + utterance_id: u64, + speech: TailSampleRange, + words: &[TimedTailSegment], +) -> Option { + if speech.sample_end <= speech.sample_start { + return None; + } + let covered = words + .iter() + .any(|word| ranges_overlap(&speech, &word.range)); + if covered { + return None; + } + Some(OverlayHighlight { + kind: OverlayHighlightKind::SpeechGap, + utterance_id, + char_start: 0, + char_end: 0, + range: speech, + before: String::new(), + after: SPEECH_GAP_MARKER.to_string(), + }) +} + +/// Empty `UtteranceFinal` after measured speech is the Swift-side pustka +/// signal (data already crossing the bridge: `on_final` + VAD / speech_pct). +pub fn empty_final_speech_gap( + utterance_id: u64, + text: &str, + speech_was_active: bool, + speech_pct: Option, + range: TailSampleRange, +) -> Option { + if !text.trim().is_empty() { + return None; + } + let heard = speech_was_active || speech_pct.is_some_and(|pct| pct > 0.0); + if !heard { + return None; + } + speech_gap_highlight(utterance_id, range, &[]) +} + +fn ranges_overlap(left: &TailSampleRange, right: &TailSampleRange) -> bool { + left.session == right.session + && left.capture_epoch == right.capture_epoch + && left.sample_start < right.sample_end + && right.sample_start < left.sample_end +} + +#[cfg(test)] +mod tests { + use super::*; + + fn range(start: u64, end: u64) -> TailSampleRange { + TailSampleRange { + session: "s1".into(), + capture_epoch: 1, + sample_start: start, + sample_end: end, + } + } + + #[test] + fn highlight_flag_defaults_off_and_accepts_truthy() { + assert!(!parse_overlay_highlights_flag(None)); + assert!(!parse_overlay_highlights_flag(Some(""))); + assert!(!parse_overlay_highlights_flag(Some("0"))); + assert!(!parse_overlay_highlights_flag(Some("off"))); + assert!(!parse_overlay_highlights_flag(Some("false"))); + assert!(parse_overlay_highlights_flag(Some("1"))); + assert!(parse_overlay_highlights_flag(Some("ON"))); + assert!(parse_overlay_highlights_flag(Some(" true "))); + } + + #[test] + fn lexicon_highlight_pins_char_span_and_sample_range() { + let highlight = + lexicon_corrected_highlight(7, 4, "Junie", "uni agentka", range(16_000, 24_000)) + .expect("replacement"); + assert_eq!(highlight.kind, OverlayHighlightKind::LexiconCorrected); + assert_eq!(highlight.utterance_id, 7); + assert_eq!(highlight.char_start, 4); + assert_eq!(highlight.char_end, 9); + assert_eq!(highlight.before, "uni agentka"); + assert_eq!(highlight.after, "Junie"); + assert_eq!(highlight.range.sample_start, 16_000); + assert_eq!(highlight.range.sample_end, 24_000); + assert_eq!(highlight.kind.as_str(), "lexicon_corrected"); + } + + #[test] + fn lexicon_highlight_rejects_empty_replacement() { + assert!(lexicon_corrected_highlight(1, 0, " ", "x", range(0, 10)).is_none()); + } + + #[test] + fn speech_gap_when_silero_range_has_no_overlapping_words() { + let words = [TimedTailSegment { + text: "hello".into(), + range: range(0, 1_000), + }]; + let gap = speech_gap_highlight(3, range(8_000, 16_000), &words).expect("pustka"); + assert_eq!(gap.kind, OverlayHighlightKind::SpeechGap); + assert_eq!(gap.after, SPEECH_GAP_MARKER); + assert_eq!(gap.range.sample_start, 8_000); + assert!(speech_gap_highlight(3, range(0, 500), &words).is_none()); + assert!(speech_gap_highlight(3, range(10, 10), &[]).is_none()); + } + + #[test] + fn empty_final_becomes_gap_only_after_measured_speech() { + assert!(empty_final_speech_gap(1, "słowo", true, Some(0.8), range(0, 100)).is_none()); + assert!(empty_final_speech_gap(1, " ", false, None, range(0, 100)).is_none()); + assert!(empty_final_speech_gap(1, "", false, Some(0.0), range(0, 100)).is_none()); + let from_vad = empty_final_speech_gap(2, "", true, None, range(100, 200)).expect("vad"); + assert_eq!(from_vad.kind, OverlayHighlightKind::SpeechGap); + let from_pct = empty_final_speech_gap(3, " \n", false, Some(0.4), range(200, 400)) + .expect("speech_pct"); + assert_eq!(from_pct.utterance_id, 3); + } +} diff --git a/core/pipeline/mod.rs b/core/pipeline/mod.rs index 247a410c..11ee15d4 100644 --- a/core/pipeline/mod.rs +++ b/core/pipeline/mod.rs @@ -6,6 +6,8 @@ pub mod contracts; /// Overlap/duplicate utterance suppression for streamed transcript events. pub mod dedup; +/// W13-6B overlay highlight layer (lexicon corrections + speech-gap pustki). +pub mod highlight; /// Light-plus post-pass for low-latency transcript cleanup. pub mod light_plus; /// MiniLM meaning check for AI-formatted deliveries (calibrated floor). diff --git a/core/pipeline/stream_postprocess.rs b/core/pipeline/stream_postprocess.rs index 3815aacb..8d0207bc 100644 --- a/core/pipeline/stream_postprocess.rs +++ b/core/pipeline/stream_postprocess.rs @@ -67,8 +67,23 @@ const MAX_DROPS_IN_ROW: u8 = 2; const FINAL_PASS_ARTIFACT_TOKENS: &[&str] = &["going", "use"]; /// Whisper `initial_prompt` token budget; over-approximated so the decoder never truncates. pub const WHISPER_INITIAL_PROMPT_TOKEN_BUDGET: usize = 224; +/// SFSpeechRecognizer accepts at most one hundred contextual strings. +pub const STT_CONTEXTUAL_STRINGS_MAX: usize = 100; /// Fixed prefix for the Whisper vocabulary hint string built by `build_whisper_initial_prompt`. const WHISPER_INITIAL_PROMPT_PREFIX: &str = "Vocabulary:"; +/// Stable last-priority vocabulary for the W13 operator domain. These terms are +/// useful before the first acoustic occurrence, so a context match reorders +/// them but never determines whether they are eligible. +const STT_DOMAIN_PROMPT_TERMS: &[&str] = &[ + "Vibecrafted", + "worktree", + "worktrees", + "binarka", + "binarki", + "akapity", + "reports", + "editors", +]; /// Env override for Whisper initial-prompt opt-in; wins over persisted config when set. pub const STT_INITIAL_PROMPT_ENABLED_ENV: &str = "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED"; @@ -171,6 +186,23 @@ static GLOBAL_LEXICON: LazyLock> = LazyLock::new(|| { RwLock::new(lex) }); +/// Warm the global lexicon off the caller's thread. +/// +/// The singleton compiles ~14.5k rules in seconds; when the first toucher is +/// the Apple live-session thread, that compile sits between "audio stream +/// started" and "recognizer ready" and the first dictation after launch arms +/// seconds late (session a5623d55, 2026-08-12: 5.1 s). Call at startup so the +/// first recording finds the table already built. Idempotent and non-blocking; +/// concurrent first-touchers simply block on the same `LazyLock` as before. +pub fn warm_lexicon() { + std::thread::Builder::new() + .name("lexicon-warm".into()) + .spawn(|| { + drop(GLOBAL_LEXICON.read()); + }) + .ok(); +} + impl Lexicon { /// Compile the full rule set from every source, in load order. /// @@ -333,10 +365,15 @@ impl Lexicon { /// Domain-vocabulary hint for this rule set: protected terms first, then the /// operator's custom canonicals, trimmed to the Whisper prompt budget. - fn whisper_initial_prompt(&self) -> Option { - build_whisper_initial_prompt( + fn whisper_initial_prompt_receipt( + &self, + window_context: Option<&str>, + ) -> Option { + let domain_terms = prioritized_domain_terms(window_context); + build_lexicon_voice_receipt( &self.protected_canonicals, &self.custom_canonicals, + &domain_terms, WHISPER_INITIAL_PROMPT_TOKEN_BUDGET, ) } @@ -384,6 +421,27 @@ pub fn build_whisper_initial_prompt( custom_terms: &[String], token_budget: usize, ) -> Option { + build_lexicon_voice_receipt(protected_terms, custom_terms, &[], token_budget) + .map(|receipt| receipt.prompt) +} + +/// Evidence emitted for every prompt-bearing recognition window. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LexiconVoiceReceipt { + pub prompt: String, + pub terms: Vec, + pub estimated_tokens: usize, +} + +/// Deterministic three-tier selection: protected, custom, then domain terms. +/// A term that does not fit is skipped so one long entry cannot starve later +/// short entries in the same (or lower) priority tier. +pub fn build_lexicon_voice_receipt( + protected_terms: &[String], + custom_terms: &[String], + domain_terms: &[String], + token_budget: usize, +) -> Option { if token_budget == 0 { return None; } @@ -392,7 +450,14 @@ pub fn build_whisper_initial_prompt( let mut selected = Vec::new(); let mut used_tokens = 1usize; // `Vocabulary:` - for term in protected_terms.iter().chain(custom_terms.iter()) { + for term in protected_terms + .iter() + .chain(custom_terms.iter()) + .chain(domain_terms.iter()) + { + if selected.len() == STT_CONTEXTUAL_STRINGS_MAX { + break; + } let term = term.trim(); if term.is_empty() { continue; @@ -403,15 +468,36 @@ pub fn build_whisper_initial_prompt( let term_tokens = estimated_prompt_tokens(term) + 1; // term plus separator/punctuation if used_tokens + term_tokens > token_budget { - break; + continue; } used_tokens += term_tokens; selected.push(term.to_string()); } - (!selected.is_empty()) - .then(|| format!("{WHISPER_INITIAL_PROMPT_PREFIX} {}.", selected.join("; "))) + (!selected.is_empty()).then(|| LexiconVoiceReceipt { + prompt: format!("{WHISPER_INITIAL_PROMPT_PREFIX} {}.", selected.join("; ")), + terms: selected, + estimated_tokens: used_tokens, + }) +} + +fn prioritized_domain_terms(window_context: Option<&str>) -> Vec { + let context = window_context.unwrap_or_default().to_lowercase(); + let terms: Vec = STT_DOMAIN_PROMPT_TERMS + .iter() + .map(|term| (*term).to_string()) + .collect(); + terms + .iter() + .filter(|term| context.contains(&term.to_lowercase())) + .chain( + terms + .iter() + .filter(|term| !context.contains(&term.to_lowercase())), + ) + .cloned() + .collect() } /// Whether the vocabulary hint may be fed to the decoder. @@ -433,6 +519,15 @@ pub fn stt_initial_prompt_enabled() -> bool { /// disabled or no terms are registered. Hot-reloads the custom file first so a /// freshly taught term can reach the very next decode. pub fn whisper_initial_prompt() -> Option { + whisper_initial_prompt_for_window(None).map(|receipt| receipt.prompt) +} + +/// Build and attest the vocabulary used for one Whisper tail/utterance window. +/// No transcript content is logged; the selected vocabulary and token estimate +/// are sufficient to reproduce the selection. +pub fn whisper_initial_prompt_for_window( + window_context: Option<&str>, +) -> Option { if !stt_initial_prompt_enabled() { return None; } @@ -440,7 +535,35 @@ pub fn whisper_initial_prompt() -> Option { let lexicon = GLOBAL_LEXICON .read() .expect("global lexicon read lock poisoned"); - lexicon.whisper_initial_prompt() + let receipt = lexicon.whisper_initial_prompt_receipt(window_context)?; + info!( + scope = "window", + selected_terms = ?receipt.terms, + estimated_tokens = receipt.estimated_tokens, + "STT lexicon voice receipt" + ); + Some(receipt) +} + +/// Preserve the rolling acoustic context and append lexicon vocabulary only +/// for this decode window. With the flag off, the pre-existing context passes +/// through byte-for-byte. +pub fn compose_whisper_window_prompt(previous_context: Option<&str>) -> Option { + let previous = previous_context + .map(str::trim) + .filter(|value| !value.is_empty()); + let receipt = whisper_initial_prompt_for_window(previous); + match (previous, receipt) { + (Some(context), Some(receipt)) => Some(format!("{context}\n{}", receipt.prompt)), + (Some(context), None) => Some(context.to_string()), + (None, Some(receipt)) => Some(receipt.prompt), + (None, None) => None, + } +} + +/// Apple contextual strings use the same flag, ordering and budget as Whisper. +pub fn apple_contextual_strings() -> Option> { + whisper_initial_prompt_for_window(None).map(|receipt| receipt.terms) } /// Coarse token estimate for prompt budgeting: one token per whitespace-separated @@ -1320,6 +1443,25 @@ mod tests { assert_eq!(prompt, "Vocabulary: Loctree."); } + /// W13-6A acceptance: selection is per-window, deterministic, receipted, + /// budget-aware, and preserves protected > custom > domain priority. + #[test] + fn w13_lexicon_voice_per_window_receipt() { + let protected = vec![ + "Loctree".to_string(), + "too many words right here".to_string(), + ]; + let custom = vec!["my term".to_string()]; + let domain = vec!["Vibecrafted".to_string(), "worktree".to_string()]; + + let receipt = + build_lexicon_voice_receipt(&protected, &custom, &domain, 8).expect("window receipt"); + + assert_eq!(receipt.terms, vec!["Loctree", "my term", "Vibecrafted"]); + assert_eq!(receipt.estimated_tokens, 8); + assert_eq!(receipt.prompt, "Vocabulary: Loctree; my term; Vibecrafted."); + } + /// Fresh/default config must not inject an initial prompt even when terms exist. #[test] #[serial] diff --git a/core/pipeline/streaming/apple_live_session.rs b/core/pipeline/streaming/apple_live_session.rs index 471b6140..6060e60c 100644 --- a/core/pipeline/streaming/apple_live_session.rs +++ b/core/pipeline/streaming/apple_live_session.rs @@ -39,17 +39,34 @@ use futures_util::stream::FuturesOrdered; use tokio::sync::mpsc; use tracing::{info, warn}; +use crate::asr_session::recorder::{ + LAYER1_DEGRADED_WARNING_CODE, Layer1DegradeReason, RecorderLayer1Lane, + apply_recorder_lifecycle_event, +}; +use crate::asr_session::{SessionId as Layer1SessionId, SessionInput as Layer1SessionInput}; +use crate::audio::capture_receipt::{ + CaptureLevelAccumulator, CapturePathMeta, emit_capture_level_receipt, +}; use crate::pipeline::contracts::{DropKind, EngineEvent, EventSink, TranscriptSegment}; use crate::pipeline::stream_postprocess::StreamPostProcessor; use crate::stt::apple_stt::{LiveStreamEvent, LiveStreamSession}; -use crate::stt::tail_patcher::{TailPatchConfig, TailPatchOutcome}; +use crate::stt::tail_patcher::{SkipReasonCode, TailPatchConfig, TailPatchOutcome}; +use crate::stt::tail_provider::{ + TailEvidenceSource, TailEvidenceStability, TailProviderEvidence, TailProviderPayload, + TailProviderRequest, TailRequestIdentity, TailSampleRange, TailTimingQuality, TimedTailSegment, +}; -use super::live_audio_buffer::{DEFAULT_RETENTION_SECS, LiveAudioBuffer}; -use super::progressive_seal::{ProgressiveSealMachine, seal_span_text}; +use super::live_audio_buffer::{DEFAULT_RETENTION_SECS, LiveAudioBuffer, ResolvedAudioWindow}; +use super::progressive_seal::{AppleCommit, ProgressiveSealMachine, SealTick, seal_span_text}; #[cfg(test)] use super::session::emit_tail_patch_result; use super::session::{ - SessionConfig, compute_tail_patch_job, emit_session_finalised, tail_patch_enabled, + SessionConfig, TailPatchJobResult, UNDER_COMMIT_WARNING_CODE, compute_tail_patch_job, + emit_session_finalised, log_tail_patch_session_receipt, tail_patch_enabled, +}; +use super::silero_fusion::{ + FusionContextMode, FusionWord, SileroIngress, bound_context_range, conservative_fuse, + fusion_receipt, lane_enabled, slice_apple_words, }; use super::stream_log::append_to_stream_log; @@ -62,14 +79,43 @@ use super::stream_log::append_to_stream_log; /// backpressure shape: Whisper falling behind costs patches, never audio. const TAIL_PATCH_QUEUE_CAP: usize = 8; +/// How long the end-of-session closure loop waits for one outstanding Layer 1 +/// job to report back. +/// +/// This sits directly on the stop path, in front of an operator watching the +/// overlay, so it is a product budget rather than an engineering safety net. +/// Every job it waits for was queued during capture against a model that is +/// already warm, and the observed windows close well under a second; the cap is +/// here for a genuinely wedged job, not for normal completion. It was 30s until +/// 2026-08-12, when a stop that owed nothing at all still paid the full 30s +/// because the loop was waiting on the wrong condition. +const TAIL_PATCH_CLOSURE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Content-free marker emitted when an Apple final callback contained segment +/// time already committed by an earlier callback. The overlapping portion is +/// removed before a new utterance id can be allocated. +pub const APPLE_FINAL_OVERLAP_WARNING_CODE: &str = "apple_final_window_overlap_normalized"; + /// One sealed utterance handed from the worker thread to the async Layer 1 lane. struct TailPatchRequest { utterance_id: u64, /// Byte-identical to the emitted `UtteranceFinal.text` — the string every /// `ReplaceRange` char offset is computed against. committed_text: String, + /// Canvas already sealed BEFORE this utterance. + /// + /// Layer 1 sees one utterance at a time, so a phrase the previous + /// utterance already carries reads as a gap here and is appended a second + /// time — measured 2026-08-14 the moment recoveries first reached the + /// canvas ("…hard pruna I road która pozwoli nam na zrobienie hard Pru."), + /// which cost more WER than the recovery gained. The neighbour context is + /// read-only: it is never patched, only consulted so a duplicate is + /// escalated instead of placed. + neighbour_context: String, /// PCM behind exactly this utterance: `[previous seal end, end_ts)`. audio: Vec, + /// Exact capture range behind `audio`; this is the window-start authority. + provider_request: TailProviderRequest, covered_through_secs: f32, } @@ -78,6 +124,7 @@ struct TailPatchCompletion { utterance_id: u64, covered_through_secs: f32, outcome: TailPatchOutcome, + payload: Option, } /// Async Layer 1 lane for the Apple progressive path. @@ -86,38 +133,42 @@ struct TailPatchCompletion { /// `SessionFinalised.layer_summary` reports. Jobs are boxed so the lane can be /// driven by a stub future in tests without a model on disk. struct AppleTailPatchLane { - jobs: FuturesOrdered>>, - sample_rate: u32, + jobs: FuturesOrdered>>, language: Option, config: TailPatchConfig, replacements: u64, + /// Jobs whose entire output was rejected (Skipped or failed). Feeds the + /// session-level starvation receipt — the 116-skips/0-applied class of + /// silent lane death must be one WARN, not a grep across log history. + skipped: u64, } impl AppleTailPatchLane { /// Open an empty lane. `TailPatchConfig::from_env` is read once here so the /// whole session judges every patch against the same thresholds, even if the /// env flips mid-hold. - fn new(sample_rate: u32, language: Option) -> Self { + fn new(_sample_rate: u32, language: Option) -> Self { Self { jobs: FuturesOrdered::new(), - sample_rate, language, // F2: thresholds stay exactly where the shared primitive puts them. config: TailPatchConfig::from_env(), replacements: 0, + skipped: 0, } } /// Turn a sealed utterance into a Whisper gap-fill job and queue it. The job /// is only constructed — inference happens inside it on `spawn_blocking`, so /// this call never sits on the event-drain path. - fn push_request(&mut self, req: TailPatchRequest) { + fn push_request(&mut self, mut req: TailPatchRequest) { + req.provider_request.language = self.language.clone(); let job = compute_tail_patch_job( req.utterance_id, req.committed_text, + req.neighbour_context, req.audio, - self.sample_rate, - self.language.clone(), + req.provider_request, self.config, ); self.push_job(Box::pin(job)); @@ -125,14 +176,14 @@ impl AppleTailPatchLane { /// Queue an already-built job. Boxed and separate from `push_request` so /// tests can drive the lane with a stub future, with no model on disk. - fn push_job(&mut self, job: BoxFuture<'static, Result<(u64, TailPatchOutcome)>>) { + fn push_job(&mut self, job: BoxFuture<'static, Result>) { self.jobs.push_back(job); } /// Await the next finished job. `FuturesOrdered` (not `Unordered`) is the /// point: patches must reach the sink in seal order, or a later utterance's /// `ReplaceRange` could land before an earlier one's. - async fn next(&mut self) -> Option> { + async fn next(&mut self) -> Option> { self.jobs.next().await } @@ -140,7 +191,13 @@ impl AppleTailPatchLane { /// session total. A skipped or failed patch contributes zero — only text /// that actually reached the canvas is counted. #[cfg(test)] - fn complete(&mut self, event_sink: &dyn EventSink, result: Result<(u64, TailPatchOutcome)>) { + fn complete(&mut self, event_sink: &dyn EventSink, result: Result) { + if matches!(&result, Ok(job) if matches!(job.outcome, TailPatchOutcome::Skipped { .. })) + || result.is_err() + { + self.skipped = self.skipped.saturating_add(1); + } + let result = result.map(TailPatchJobResult::into_outcome); self.replacements = self .replacements .saturating_add(emit_tail_patch_result(event_sink, result)); @@ -153,39 +210,70 @@ impl AppleTailPatchLane { &mut self, request_id: u64, req_end_secs: f32, - result: Result<(u64, TailPatchOutcome)>, + result: Result, ) -> TailPatchCompletion { match result { - Ok((utterance_id, outcome)) => { - if let TailPatchOutcome::Patches(events) = &outcome { - self.replacements = self.replacements.saturating_add( - events - .iter() - .filter(|event| matches!(event, EngineEvent::ReplaceRange { .. })) - .count() as u64, - ); - } + Ok(job) => { + let utterance_id = job.utterance_id; + let outcome = job.outcome; TailPatchCompletion { utterance_id, covered_through_secs: req_end_secs, outcome, + payload: Some(job.payload), } } Err(error) => TailPatchCompletion { utterance_id: request_id, covered_through_secs: req_end_secs, - outcome: TailPatchOutcome::Skipped { - reason: format!("tail patch failed: {error}"), - }, + outcome: TailPatchOutcome::skipped( + crate::stt::tail_patcher::SkipReasonCode::ProviderError, + format!("tail patch failed: {error}"), + ), + payload: None, }, } } + /// Hand a completion to the live seal owner and only then account it in + /// the session receipt. A closed receiver means the worker has already + /// sealed raw and no patch can reach the canvas. + fn forward_completion_to_worker( + &mut self, + tx: &std_mpsc::Sender, + completion: TailPatchCompletion, + ) -> bool { + // `events()` covers both bearing arms: ordinary patches and the + // gap-appends an under-commit recovered. Counting only `Patches` would + // drop recovered speech from the session summary. + let replacements = completion + .outcome + .events() + .iter() + .filter(|event| matches!(event, EngineEvent::ReplaceRange { .. })) + .count() as u64; + let skipped = u64::from(matches!( + &completion.outcome, + TailPatchOutcome::Skipped { .. } + )); + if tx.send(completion).is_err() { + return false; + } + self.replacements = self.replacements.saturating_add(replacements); + self.skipped = self.skipped.saturating_add(skipped); + true + } + /// How many bounded replacements Layer 1 landed this session — the number /// `SessionFinalised.layer_summary` reports. fn replacements(&self) -> u64 { self.replacements } + + /// How many jobs put nothing on the canvas (skipped or failed). + fn skipped(&self) -> u64 { + self.skipped + } } /// Deliver one engine event to the sink, writing the same per-utterance @@ -205,6 +293,17 @@ fn deliver_event( event_sink.on_event(event); } +/// Surface one Layer 1 lane degrade as a counts-only warning event. +/// +/// The message is the typed reason token and nothing else — no transcript, +/// audio, provider payload, or endpoint detail can ride this event into a log. +fn emit_layer1_degrade_warning(event_sink: &dyn EventSink, reason: Layer1DegradeReason) { + event_sink.on_event(&EngineEvent::Warning { + code: LAYER1_DEGRADED_WARNING_CODE.to_string(), + message: reason.as_token().to_string(), + }); +} + /// Drive one progressive Apple stream session until the audio channel closes. pub(crate) async fn apple_stream_transcription_session( mut chunk_receiver: mpsc::Receiver>, @@ -216,16 +315,20 @@ pub(crate) async fn apple_stream_transcription_session( language, stream_log_path, utterance_silence_sec, + layer1, + mut lifecycle_events, } = config; - // SFSpeech owns phrase boundaries in progressive mode, so the VAD-path - // silence knob cannot apply. Say so instead of silently differing from - // the `CODESCRIBE_APPLE_STT_LIVE_MODE=wav` escape hatch. + let mut capture_level = CaptureLevelAccumulator::new(); + // Hands-free silence is the ENGINE LIFECYCLE on this lane, not a chunker + // knob: SFSpeech still owns phrase boundaries inside an utterance, but the + // threshold decides when the engine rests (mic + Silero keep watching) and + // when a fresh epoch wakes on the next speech edge. Unset = one continuous + // stream for the whole take, the pre-lifecycle behaviour. if let Some(sec) = utterance_silence_sec { - warn!( + info!( utterance_silence_sec = sec, - "Apple progressive live mode ignores utterance_silence_sec \ - (SFSpeech decides phrase boundaries; use CODESCRIBE_APPLE_STT_LIVE_MODE=wav \ - for the VAD silence contract)" + "Apple progressive live mode: engine lifecycle armed on the hands-free silence \ + threshold (speech epochs)" ); } @@ -235,6 +338,27 @@ pub(crate) async fn apple_stream_transcription_session( ); let session_id = uuid::Uuid::new_v4().to_string(); + // W13-1 inline-format buffer: arm a fresh chunk/chain session (no-op when + // `CODESCRIBE_INLINE_FORMAT` is off). Must happen on the async side — the + // blocking seal worker only ever enqueues sealed chunks. + crate::llm::inline_format::begin_session(language.as_deref()); + + // C1: open the injected Layer 1 lane at recording start. `Disarmed` is the + // stock product (canvas + lexicon); an armed provider only ever arrives + // here already authorized — construction and consent live with the + // settings owner, not in this pipeline. Every lane failure from here on + // degrades back to exactly the disarmed behavior. + let lane_input = Layer1SessionInput { + session_id: Layer1SessionId::new(session_id.clone()) + .expect("uuid session ids are never blank"), + locale: language.clone(), + sample_rate, + }; + let mut layer1_lane = RecorderLayer1Lane::open(layer1, &lane_input); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); + } + // PCM → worker (None = EOF). Unbounded so the async select loop never // blocks on a full sync_channel while live Preview events wait to drain // (bounded sync_channel + blocking send would re-stall presentation). @@ -263,14 +387,19 @@ pub(crate) async fn apple_stream_transcription_session( // and its branch never yields: zero jobs, zero behaviour change. let worker_tp_tx = tail_patch_on.then_some(tp_tx); + let worker_session_id = session_id.clone(); let worker = thread::spawn(move || { apple_stream_worker( pcm_rx, ev_tx, - sample_rate, - language.as_deref(), worker_tp_tx, tp_done_rx, + AppleWorkerConfig { + sample_rate, + language: language.as_deref(), + session_id: worker_session_id, + utterance_silence_sec, + }, ) }); @@ -299,6 +428,14 @@ pub(crate) async fn apple_stream_transcription_session( chunk = chunk_receiver.recv(), if !audio_eof => { match chunk { Some(chunk) => { + capture_level.push_samples(&chunk); + // C1 fan-out: offer the frame to the Layer 1 lane + // before forwarding to the Apple worker. The offer + // returns immediately, always — a refiner that cannot + // keep up costs refinement frames, never capture, and + // sustained overflow degrades the lane instead of + // exerting backpressure here. + layer1_lane.offer_pcm(&chunk); if pcm_tx.send(Some(chunk)).is_err() { warn!("Apple live stream worker dropped PCM channel"); audio_eof = true; @@ -312,6 +449,22 @@ pub(crate) async fn apple_stream_transcription_session( } } } + lifecycle = async { + match lifecycle_events.as_mut() { + Some(events) => events.recv().await, + None => std::future::pending().await, + } + } => { + match lifecycle { + Some(event) => { + apply_recorder_lifecycle_event(&mut layer1_lane, event); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); + } + } + None => lifecycle_events = None, + } + } // Admit one sealed utterance into Layer 1 at a time. The Whisper // call itself runs on `spawn_blocking` inside the job, so this loop // only ever schedules and collects — inference never sits on the @@ -329,9 +482,22 @@ pub(crate) async fn apple_stream_transcription_session( tail_patch_in_flight = false; let (id, end) = tail_patch_lane_in_flight.take().unwrap_or_default(); let completion = tail_patch_lane.finish_for_worker(id, end, result); - let _ = tp_done_tx.send(completion); + if !tail_patch_lane.forward_completion_to_worker(&tp_done_tx, completion) { + warn!( + utterance_id = id, + "Layer 1 completion rejected — Apple seal worker already closed" + ); + } } } + // C1: drain whatever the Layer 1 provider has ready. Partials stay + // volatile draft inside the lane (never canvas); finals pass the + // ingest doctrine. Non-blocking, so live Preview drainage above is + // never delayed by the refiner. + layer1_lane.poll(); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); + } } // Worker exited (event channel closed). If audio is still open, keep @@ -347,35 +513,47 @@ pub(crate) async fn apple_stream_transcription_session( while chunk_receiver.recv().await.is_some() {} } - // No new seals can arrive — but seals the worker emitted just before - // exiting (the trailing summary, the open partial) may still be queued. - // Whether the select loop admitted them before `ev_rx` closed is a race, so - // finish the backlog here instead: a patch that lands only sometimes is - // worse than one that always lands. - // - // This is bounded work, not a stop-time re-pass: at most `TAIL_PATCH_QUEUE_CAP` - // already-sealed utterances, one job at a time, each emitting only bounded - // `ReplaceRange` events. No Preview is waiting on it — capture is over. - let mut settled_at_stop = 0u64; - loop { - while let Some(result) = tail_patch_lane.next().await { - let (id, end) = tail_patch_lane_in_flight.take().unwrap_or_default(); - let completion = tail_patch_lane.finish_for_worker(id, end, result); - let _ = tp_done_tx.send(completion); - } - match tp_rx.try_recv() { - Ok(req) => { - tail_patch_lane_in_flight = Some((req.utterance_id, req.covered_through_secs)); - tail_patch_lane.push_request(req); - settled_at_stop = settled_at_stop.saturating_add(1); - } - Err(_) => break, - } + // `ev_rx` closes only when the seal worker has returned and dropped both + // its event sender and completion receiver. Running queued Whisper jobs at + // this point cannot change canvas; it only lengthens stop and used to make + // the receipt count undeliverable patches. Preserve the Apple floor and + // abandon the orphaned refinement work explicitly. + let mut abandoned_tail_patch_jobs = u64::from(tail_patch_in_flight); + while tp_rx.try_recv().is_ok() { + abandoned_tail_patch_jobs = abandoned_tail_patch_jobs.saturating_add(1); + } + if abandoned_tail_patch_jobs > 0 { + warn!( + abandoned_tail_patch_jobs, + "Layer 1 tail-patch work abandoned after Apple seal worker closed" + ); + } + + // C1 stop-drain: close the Layer 1 lane with its bounded drain. Whatever + // happened inside (clean close, disconnect, incomplete drain), the method + // returns and the recording finishes on Apple + lexicon. The outcome's + // finals are doctrine-vetted gap-fill candidates: their one road to + // delivered text is `Layer1SessionOutcome::adjudicate_against_live_floor` + // (the T0 `merge_live_layer1` seam), owned by the stop-path truth + // adjudicator once the settings cut arms real providers. + let layer1_outcome = layer1_lane.stop(); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); } - if settled_at_stop > 0 { + let layer1_counts = layer1_outcome.telemetry(); + if layer1_counts.frames_offered > 0 || layer1_counts.finals_accepted > 0 { info!( - settled_at_stop, - "Layer 1 tail-patch backlog settled after capture stopped" + frames_forwarded = layer1_counts.frames_forwarded, + overflow_frame_drops = layer1_counts.overflow_frame_drops, + partials_applied = layer1_counts.partials_applied, + finals_accepted = layer1_counts.finals_accepted, + events_rejected = layer1_counts.events_rejected, + provider_errors = layer1_counts.provider_errors, + degrade_reason = layer1_outcome + .degrade_reason() + .map(|reason| reason.as_token()) + .unwrap_or("none"), + "Layer 1 live lane closed" ); } @@ -385,6 +563,7 @@ pub(crate) async fn apple_stream_transcription_session( sealed = outcome.sealed, filtered_empty_drops = outcome.filtered_empty_drops, unresolved_windows = outcome.unresolved_windows, + under_commit_escalations = outcome.under_commit_escalations, "Apple progressive live session finished" ); } @@ -402,6 +581,11 @@ pub(crate) async fn apple_stream_transcription_session( } } + log_tail_patch_session_receipt(tail_patch_lane.replacements(), tail_patch_lane.skipped()); + emit_capture_level_receipt( + event_sink.as_ref(), + &capture_level.finalize(CapturePathMeta::resolve(sample_rate, 1, None)), + ); emit_session_finalised( event_sink.as_ref(), session_id, @@ -423,10 +607,14 @@ struct PendingAppleSeal { } struct AppleSealState { + session_id: String, + capture_epoch: u64, + sample_rate: u32, postprocessor: StreamPostProcessor, preview_rev: u64, utterance_id: u64, open_partial: String, + open_partial_segments: Vec, sealed_count: u64, filtered_empty_drops: u64, /// Bounded PCM retention, so a sealed boundary can be resolved back to the @@ -435,12 +623,24 @@ struct AppleSealState { /// Session time of the previous seal — the lower bound of the next /// utterance's audio window. last_sealed_end: f32, + /// End of the last Apple segment admitted to committed canvas. Unlike the + /// PCM retention cursor, this advances even when Layer 1 audio lookup is + /// unavailable: Apple segment time is the authority for text disjointness. + last_apple_segment_end: f32, /// Seals whose audio window could not be resolved (F3 falsification). unresolved_windows: u64, + /// Seals where Layer 1 recovered speech it could not place on the canvas + /// (W-C). A non-zero count means the stop path is owed a residual gap fill. + under_commit_escalations: u64, /// Layer 1 hand-off, present only when layered transcription is armed. tail_patch: Option>, /// Seals whose tail-patch request found the queue full (F1 backpressure). tail_patch_backpressure_drops: u64, + /// Requests accepted by the Layer 1 queue that have not reported back yet. + /// This — not the pending-seal queue — is what the end-of-session closure + /// loop waits on: a span can also be held by the Apple volatile window, and + /// no Whisper completion will ever clear that gate. + tail_patch_awaiting_completion: u64, /// Concatenation of already progressive-sealed text — left context for /// Light+ casing on the next seal (w2-b). sealed_prefix: String, @@ -450,34 +650,59 @@ struct AppleSealState { pending_events: BTreeMap, /// Whisper outcomes retained until their final has been emitted. tail_patch_outcomes: BTreeMap, + /// The session's single Silero: Supervisor VAD + utterance ledger. `None` + /// only when neither consumer wants it, or when the model failed to load. + fusion: Option, + /// Whether Silero identity may reach the seal (`CODESCRIBE_SILERO_FUSION`, + /// default ON). Independent of [`Self::fusion`] existing: the engine + /// lifecycle needs the VAD even when an operator has pinned the seal path + /// back to Apple's own segment boundaries. + fusion_seal_armed: bool, + fusion_context: FusionContextMode, } impl AppleSealState { /// Fresh seal state with Layer 1 disabled (`tail_patch: None`) — the default /// shape when `CODESCRIBE_LAYERED_TRANSCRIPTION` is unset. + #[cfg(test)] fn new(sample_rate: u32) -> Self { + Self::new_for_session(sample_rate, uuid::Uuid::new_v4().to_string()) + } + + fn new_for_session(sample_rate: u32, session_id: String) -> Self { Self { + session_id, + capture_epoch: 0, + sample_rate, postprocessor: StreamPostProcessor::new(), preview_rev: 0, utterance_id: 0, open_partial: String::new(), + open_partial_segments: Vec::new(), sealed_count: 0, filtered_empty_drops: 0, audio: LiveAudioBuffer::new(sample_rate, DEFAULT_RETENTION_SECS), last_sealed_end: 0.0, + last_apple_segment_end: 0.0, unresolved_windows: 0, + under_commit_escalations: 0, tail_patch: None, tail_patch_backpressure_drops: 0, + tail_patch_awaiting_completion: 0, sealed_prefix: String::new(), progressive: ProgressiveSealMachine::new(), pending_events: BTreeMap::new(), tail_patch_outcomes: BTreeMap::new(), + fusion: None, + fusion_seal_armed: false, + fusion_context: FusionContextMode::UtteranceOnly, } } /// Same state, armed with the Layer 1 hand-off. Holding the sender is what /// makes `seal_utterance_final` clone the committed text at all — with no /// wire there is nothing to diff against later. + #[cfg(test)] fn new_with_tail_patch(sample_rate: u32, tail_patch: mpsc::Sender) -> Self { Self { tail_patch: Some(tail_patch), @@ -485,6 +710,17 @@ impl AppleSealState { } } + fn new_with_tail_patch_for_session( + sample_rate: u32, + session_id: String, + tail_patch: mpsc::Sender, + ) -> Self { + Self { + tail_patch: Some(tail_patch), + ..Self::new_for_session(sample_rate, session_id) + } + } + /// Apply one elapsed Whisper window, then emit every newly double-closed /// span. Finals and their bounded patches share `ev_tx`, so ordering cannot /// invert on the async side. @@ -495,11 +731,55 @@ impl AppleSealState { now_secs: f32, ) { let utterance_id = completion.utterance_id; - self.tail_patch_outcomes - .insert(utterance_id, completion.outcome); + self.tail_patch_awaiting_completion = self.tail_patch_awaiting_completion.saturating_sub(1); + let (evidence, words) = completion.payload.map_or((None, Vec::new()), |payload| { + (Some(payload.evidence), payload.segments) + }); + let outcome = if self.fusion.is_some() { + apply_conservative_fusion(self, ev_tx, utterance_id, &words, completion.outcome) + } else { + completion.outcome + }; + self.tail_patch_outcomes.insert(utterance_id, outcome); self.progressive - .note_whisper_window_elapsed(utterance_id, completion.covered_through_secs); + .note_whisper_window_elapsed_with_provenance( + utterance_id, + completion.covered_through_secs, + evidence, + words, + ); self.emit_ready_progressive_seals(ev_tx, now_secs); + // A window that finishes AFTER its span sealed had no reader: the only + // drain of `tail_patch_outcomes` runs inside the seal tick, so a patch + // arriving even a millisecond late sat in the map until the session + // dropped it. Measured 2026-08-14 on the operator's take: the patcher + // logged two `residual_required` recoveries, the session counted + // `under_commit_escalations=0`, and zero warnings reached the UI — the + // recovered speech was computed, stored, and never delivered. Ordering + // is unchanged for the normal case (still emitted after `UtteranceFinal`, + // which the seal already sent). + self.deliver_sealed_tail_patch(ev_tx, utterance_id); + } + + /// Deliver a tail-patch outcome whose span is already sealed and emitted. + /// No-op while the span is still pending — the seal tick owns that path. + fn deliver_sealed_tail_patch( + &mut self, + ev_tx: &mpsc::UnboundedSender, + utterance_id: u64, + ) { + if !self + .progressive + .sealed_spans() + .iter() + .any(|span| span.id == utterance_id) + { + return; + } + let Some(outcome) = self.tail_patch_outcomes.remove(&utterance_id) else { + return; + }; + self.emit_tail_patch_outcome(ev_tx, utterance_id, outcome); } fn emit_ready_progressive_seals( @@ -508,6 +788,21 @@ impl AppleSealState { now_secs: f32, ) { let tick = self.progressive.try_seal(now_secs, false); + self.emit_seal_tick(ev_tx, tick); + } + + /// End-of-session drain: seal whatever the double-close gates still hold. + /// + /// Shares the emit path with the live tick on purpose — a span sealed at + /// session end must reach the sink as the same `UtteranceFinal` (+ patches) + /// a mid-session seal would, or the last utterance of every take would be + /// delivered by a different route than all the others. + fn seal_remaining_at_session_end(&mut self, ev_tx: &mpsc::UnboundedSender) { + let tick = self.progressive.seal_remaining_at_session_end(false); + self.emit_seal_tick(ev_tx, tick); + } + + fn emit_seal_tick(&mut self, ev_tx: &mpsc::UnboundedSender, tick: SealTick) { for sealed in tick.newly_sealed { let Some(pending) = self.pending_events.remove(&sealed.id) else { warn!( @@ -518,6 +813,10 @@ impl AppleSealState { }; self.sealed_count = self.sealed_count.saturating_add(1); self.sealed_prefix = self.progressive.sealed_prefix(); + // Seal = "format now" signal (W13-1): a sealed span is byte-stable, + // so the inline-format buffer may chunk-format it while dictation + // continues. Sync + non-blocking; no-op unless the flag is armed. + crate::llm::inline_format::on_chunk_sealed(sealed.id, &sealed.text); let _ = ev_tx.send(EngineEvent::UtteranceFinal { utterance_id: sealed.id, text: sealed.text, @@ -531,15 +830,36 @@ impl AppleSealState { quality_gate_dropped: false, confidence_flags: Vec::new(), }); - if let Some(TailPatchOutcome::Patches(events)) = - self.tail_patch_outcomes.remove(&sealed.id) - { - for event in events { - let _ = ev_tx.send(event); - } + if let Some(outcome) = self.tail_patch_outcomes.remove(&sealed.id) { + self.emit_tail_patch_outcome(ev_tx, sealed.id, outcome); } } } + + /// Send one Layer 1 outcome for an already-emitted `UtteranceFinal`. + /// + /// Shared by the seal tick and the late-completion path so a recovery is + /// delivered identically whichever side wins the race. + fn emit_tail_patch_outcome( + &mut self, + ev_tx: &mpsc::UnboundedSender, + utterance_id: u64, + outcome: TailPatchOutcome, + ) { + // Escalate before the appends so the starved-canvas signal is never + // lost if a later send fails; both ride the same channel as the final, + // so ordering after `UtteranceFinal` holds. + if outcome.residual_required() { + self.under_commit_escalations = self.under_commit_escalations.saturating_add(1); + let _ = ev_tx.send(EngineEvent::Warning { + code: UNDER_COMMIT_WARNING_CODE.to_string(), + message: format!("residual gap fill required for utterance {utterance_id}"), + }); + } + for event in outcome.into_events() { + let _ = ev_tx.send(event); + } + } } /// What the worker sealed, and what seal-time postprocess filtered away. @@ -547,6 +867,8 @@ struct AppleStreamOutcome { sealed: u64, filtered_empty_drops: u64, unresolved_windows: u64, + /// How many seals escalated an unplaceable Layer 1 under-commit (W-C). + under_commit_escalations: u64, } /// Resolve a sealed utterance back to its audio span, then release what can @@ -558,18 +880,58 @@ struct AppleStreamOutcome { /// older than the retention cap — that must be visible here, in the live path. /// A silent miss would surface as canvas patched from the wrong audio, so an /// unresolved boundary yields `None` and never reaches Layer 1. -fn resolve_sealed_audio_window(state: &mut AppleSealState, end_ts: f32) -> Option> { - let from = state.last_sealed_end; - match state.audio.window(from, end_ts) { +fn resolve_sealed_audio_window( + state: &mut AppleSealState, + end_ts: f32, +) -> Option { + let mut from = state.last_sealed_end; + // A `from` that fell off retention is not a disagreeing clock — that audio + // is gone because SFSpeech withheld its first final past the retention + // horizon (measured 2026-08-14: a 247 s take whose first final arrived at + // 156 s went 11/11 unresolved and starved Layer 1 for the WHOLE take, + // because one miss keeps `last_sealed_end` pinned forever). Clamp the + // start to retained audio; genuine clock lies (an `end_ts` that itself + // precedes retention or overshoots the session) stay fail-closed below. + let retained_start = state.audio.retained_start_secs(); + if from < retained_start && end_ts > retained_start { + warn!( + from_secs = from, + retained_start_secs = retained_start, + end_ts, + "Apple seal window start fell off retention — clamped to retained audio" + ); + from = retained_start; + } + match state.audio.window_with_range(from, end_ts) { Some(window) => { - state.last_sealed_end = end_ts; + // `window_with_range` is the ingestion boundary where Apple's + // floating span clock becomes the canonical integer PCM clock. A + // small Apple overshoot is intentionally clamped there; carrying + // the *requested* `end_ts` forward would make the next window + // start beyond captured audio even though this window resolved. + let pcm_start_secs = window.sample_start as f32 / state.sample_rate.max(1) as f32; + let pcm_end_secs = window.sample_end as f32 / state.sample_rate.max(1) as f32; + state.last_sealed_end = pcm_end_secs; // Everything before this utterance is committed canvas; no future // patch reaches back past it. - state.audio.committed_through(from); + state.audio.committed_through(pcm_start_secs); + if window.samples.is_empty() { + // A cumulative final may assert novel text after the PCM clock + // has reached EOF. Content still seals, but zero samples are + // not a Whisper window and this known clamp is not a clock lie. + tracing::debug!( + from_secs = from, + requested_end_secs = end_ts, + pcm_end_secs, + "Apple seal resolved at PCM boundary with no new audio" + ); + return None; + } tracing::debug!( from_secs = from, - end_ts, - window_samples = window.len(), + requested_end_secs = end_ts, + pcm_end_secs, + window_samples = window.samples.len(), retained_samples = state.audio.len(), "Apple seal resolved to audio window" ); @@ -589,6 +951,424 @@ fn resolve_sealed_audio_window(state: &mut AppleSealState, end_ts: f32) -> Optio } } +fn seconds_to_captured_sample(seconds: f32, sample_rate: u32, captured_end: u64) -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + ((seconds as f64 * sample_rate.max(1) as f64).round() as u64).min(captured_end) +} + +fn apple_segments_on_pcm_clock( + state: &AppleSealState, + segments: &[TranscriptSegment], +) -> Vec { + let captured_end = state.audio.session_sample_end(); + segments + .iter() + .map(|segment| { + let sample_start = + seconds_to_captured_sample(segment.start_ts, state.sample_rate, captured_end); + let sample_end = + seconds_to_captured_sample(segment.end_ts, state.sample_rate, captured_end) + .max(sample_start); + TimedTailSegment { + text: segment.text.clone(), + range: TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start, + sample_end, + }, + } + }) + .collect() +} + +/// Longest callback prefix the canvas already carries, tolerating the word +/// revisions SFSpeech makes when it re-states a phrase. +/// +/// # Why exact substring matching was the repetition defect +/// +/// Cumulative Apple finals do not merely extend the previous hypothesis — they +/// REVISE it ("szuty" → "skróty", "dokładnie" → "dokładność"). An exact +/// `canvas.contains(prefix)` probe is anchored at the callback's first word and +/// all-or-nothing, so one revised word anywhere in the prefix invalidates every +/// probe length at once and the whole restatement re-commits as "novel". +/// Measured on the 2026-08-12 18:44 take: 30 of 42 rescues matched exactly one +/// word, the delivery carried 72% of its words inside a repeated 6-gram, and +/// the production replay of the same WAV reproduced full-sentence re-commits +/// differing by a single word ([28]/[29]/[30] in the replay finals). +/// +/// # Match rule +/// +/// For the longest `k`, some canvas window must be within `allowed(k)` word +/// edits (substitution, insertion, deletion) of `probe[..k]`, where `allowed` +/// is 0 for `k ≤ 2` and `max(1, k/5)` (20%) beyond that. Edit distance rather +/// than positional comparison on purpose: revisions include insertions and +/// deletions ("spotkałem się" → "się", an interjected "a"), and under a +/// positional rule one inserted word shifts every later word and cascades into +/// wholesale mismatch — the verified replay showed 15–22-word restatements +/// collapsing to a 6-word match exactly this way. Short probes stay exact: at +/// one or two words a tolerated edit is not a revision, it is a different word. +/// +/// One asymmetry is deliberate: the LAST word of the matched prefix must itself +/// align to a canvas word (match or substitution). Otherwise a trailing novel +/// word could be "deleted into" the match — "alpha beta revised" against a +/// canvas holding "alpha beta" is one deletion away as a whole, and treating +/// that as re-heard would demote genuinely new tail speech to the preview lane. +/// A trailing deletion therefore shortens `k` instead of costing an edit. +/// +/// Only the canvas tail (`2 × probe len + 16` words) is searched — a +/// restatement re-states recent speech, and the bound keeps the DP cost flat +/// no matter how long the session canvas grows. +/// +/// # Why fuzziness is safe in this branch +/// +/// This runs only for finals whose every segment was consumed by the trusted +/// timing boundary — Apple itself asserts the audio was already judged. Genuine +/// new speech arrives with fresh segment timestamps and never enters here, so a +/// near-match against the canvas is a re-hearing, not the operator saying a +/// similar sentence twice. +/// +/// Returns `(known_prefix_words, word_edits_in_the_match)`. +fn revision_tolerant_known_prefix(probe: &[String], canvas: &[&str]) -> (usize, usize) { + if probe.is_empty() || canvas.is_empty() { + return (0, 0); + } + let n = probe.len(); + let band = (n / 5).max(1); + let tail_start = canvas.len().saturating_sub(2 * n + 16); + let tail = &canvas[tail_start..]; + + let allowed = |k: usize| if k <= 2 { 0 } else { (k / 5).max(1) }; + let mut best_k = 0usize; + let mut best_edits = 0usize; + + // One banded edit-distance DP per window start: row `i` covers probe[..i], + // column `j` the window tail[s..s+j]. For every prefix length the cheapest + // window end is `min over j`, so a single pass scores all `k` at once. + for s in 0..tail.len() { + let jmax = (tail.len() - s).min(n + band); + let mut prev: Vec = (0..=jmax).collect(); + for i in 1..=n { + let mut current = vec![usize::MAX; jmax + 1]; + current[0] = i; + // Best score whose final operation aligns probe[i-1] to a canvas + // word — the only endings that may close a matched prefix (see the + // trailing-deletion note in the doc comment). + let mut aligned_end = usize::MAX; + for j in 1..=jmax { + // Outside the band the distance already exceeds every budget. + if i.abs_diff(j) > band { + continue; + } + let substitute = if probe[i - 1] == tail[s + j - 1] { + prev[j - 1] + } else { + prev[j - 1].saturating_add(1) + }; + aligned_end = aligned_end.min(substitute); + let delete = prev[j].saturating_add(1); + let insert = current[j - 1].saturating_add(1); + current[j] = substitute.min(delete).min(insert); + } + let edits = aligned_end; + if edits <= allowed(i) && (i > best_k || (i == best_k && edits < best_edits)) { + best_k = i; + best_edits = edits; + } + prev = current; + } + } + (best_k, best_edits) +} + +/// Case- and punctuation-insensitive projection for canvas containment checks +/// (the sealed canvas carries Light+ casing and sentence terminals, raw +/// callbacks carry neither). +fn normalize_for_containment(text: &str) -> String { + text.chars() + .map(|c| { + if c.is_alphanumeric() { + c.to_lowercase().next().unwrap_or(c) + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Fuse Whisper words onto the pending Apple span through the rewrite fence. +/// +/// Agreements and clear gap fills become the pending text. Unresolved +/// alternatives stay on Apple and emit a content-free receipt. The LCS +/// `ReplaceRange` outcome is dropped: the fused text is already in the span. +fn apply_conservative_fusion( + state: &mut AppleSealState, + ev_tx: &mpsc::UnboundedSender, + utterance_id: u64, + whisper_words: &[TimedTailSegment], + fallback: TailPatchOutcome, +) -> TailPatchOutcome { + let apple_words: Vec = state + .progressive + .pending_spans() + .iter() + .find(|span| span.id == utterance_id) + .map(|span| span.words.iter().map(FusionWord::from_timed).collect()) + .unwrap_or_default(); + let whisper: Vec = whisper_words.iter().map(FusionWord::from_timed).collect(); + if apple_words.is_empty() && whisper.is_empty() { + return fallback; + } + let decision = conservative_fuse(&apple_words, &whisper); + if !state.progressive.try_rewrite(utterance_id, &decision.text) { + // The span sealed before fusion could rewrite it. That is a refusal of + // THIS route, not a verdict on the recovery: Layer 1 already computed + // bounded, append-only patches for the same audio, and they remain + // valid against sealed text. Returning `Skipped` here discarded them — + // measured 2026-08-14 on the operator's take, where the patcher logged + // two `residual_required` recoveries and the session delivered zero. + // Hand the fallback back instead: fusion loses the race, the append + // lane still lands. + let _ = ev_tx.send(EngineEvent::Warning { + code: SkipReasonCode::SealedFence.as_str().to_string(), + message: format!( + "fusion rewrite refused for utterance {utterance_id}; \ + falling back to bounded tail patches" + ), + }); + return fallback; + } + if !decision.unresolved.is_empty() { + let receipt = fusion_receipt(utterance_id, &decision); + let _ = ev_tx.send(EngineEvent::Warning { + code: receipt.code.as_str().to_string(), + message: format!( + "fusion unresolved={} agreements={} gap_fills={}", + receipt.unresolved, receipt.agreements, receipt.gap_fills + ), + }); + } + TailPatchOutcome::NoChange +} + +/// Slice a cumulative Apple final onto Silero-minted utterance ranges. +/// +/// Returns `true` when at least one Silero span accepted words (the callback +/// is consumed). `false` leaves the caller on the Apple-boundary path so +/// speech is never dropped when Silero has not yet opened an edge. +fn seal_sliced_by_silero( + state: &mut AppleSealState, + ev_tx: &mpsc::UnboundedSender, + raw_text: &str, + after_lexicon: &str, + start_ts: f32, + end_ts: f32, + disjoint: &[TranscriptSegment], +) -> bool { + let Some(ledger) = state.fusion.as_ref().map(|fusion| fusion.ledger().clone()) else { + return false; + }; + if ledger.utterances().is_empty() { + return false; + } + let apple_words = apple_segments_on_pcm_clock(state, disjoint); + let fusion_words: Vec = apple_words.iter().map(FusionWord::from_timed).collect(); + let (sliced, leftover) = slice_apple_words(&ledger, &fusion_words); + if sliced.is_empty() { + if !leftover.is_empty() { + let _ = ev_tx.send(EngineEvent::Warning { + code: SkipReasonCode::NoTimeOverlap.as_str().to_string(), + message: format!( + "apple words={} had no Silero utterance overlap", + leftover.len() + ), + }); + } + return false; + } + if !leftover.is_empty() { + let _ = ev_tx.send(EngineEvent::Warning { + code: SkipReasonCode::NoTimeOverlap.as_str().to_string(), + message: format!( + "apple leftover_words={} sliced_utterances={}", + leftover.len(), + sliced.len() + ), + }); + } + + let rate = state.sample_rate.max(1) as f32; + let pad_samples = (super::silero_fusion::DEFAULT_LEFT_PAD_SECS * rate).round() as u64; + let long_silence = (super::silero_fusion::LONG_SILENCE_FENCE_SECS * rate).round() as u64; + let context = state.fusion_context; + + for (utterance_id, words) in sliced { + let Some(silero) = ledger + .utterances() + .iter() + .find(|utterance| utterance.id == utterance_id) + .cloned() + else { + continue; + }; + if !state.progressive.may_rewrite(utterance_id) + && state + .progressive + .sealed_spans() + .iter() + .any(|span| span.id == utterance_id) + { + continue; + } + let text = words + .iter() + .map(|word| word.text.as_str()) + .collect::>() + .join(" "); + let text = if text.trim().is_empty() { + after_lexicon.to_string() + } else { + text + }; + let span_start = words + .first() + .map(|word| word.sample_start as f32 / rate) + .unwrap_or(start_ts); + let span_end = words + .last() + .map(|word| word.sample_end as f32 / rate) + .unwrap_or(end_ts); + let timed: Vec = words + .iter() + .map(|word| TimedTailSegment { + text: word.text.clone(), + range: TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start: word.sample_start, + sample_end: word.sample_end, + }, + }) + .collect(); + if state + .progressive + .pending_spans() + .iter() + .any(|p| p.id == utterance_id) + { + let _ = state.progressive.try_rewrite(utterance_id, &text); + } else { + if !state.progressive.note_apple_commit_timed(AppleCommit { + id: utterance_id, + raw_text: text.clone(), + end_secs: span_end, + committed_at_secs: span_end, + // The span IS the Silero utterance here: identity and range + // both come from the edge, not from Apple's segment clock. + range: silero.range.clone(), + words: timed, + apple_evidence: TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: None, + }, + silero_utterance_id: Some(utterance_id), + }) { + continue; + } + state.pending_events.insert( + utterance_id, + PendingAppleSeal { + raw_text: raw_text.to_string(), + start_ts: span_start, + end_ts: span_end, + segments: disjoint.to_vec(), + }, + ); + } + + let fence = ledger + .utterances() + .iter() + .rev() + .find(|prev| prev.closed && prev.range.sample_end <= silero.range.sample_start) + .map(|prev| { + let gap = silero + .range + .sample_start + .saturating_sub(prev.range.sample_end); + if gap >= long_silence { + silero.range.sample_start + } else { + 0 + } + }) + .unwrap_or(0); + let request_range = bound_context_range(&silero.range, fence, context, pad_samples); + let window = state + .audio + .window_by_samples(request_range.sample_start, request_range.sample_end); + let queued = if let (Some(window), Some(tx)) = (window, state.tail_patch.as_ref()) { + let committed_text = seal_span_text(&text, &state.sealed_prefix, false); + match tx.try_send(TailPatchRequest { + utterance_id, + committed_text, + neighbour_context: state.sealed_prefix.clone(), + audio: window.samples, + provider_request: TailProviderRequest { + identity: TailRequestIdentity { + request_id: utterance_id, + range: TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start: window.sample_start, + sample_end: window.sample_end, + }, + }, + sample_rate: state.sample_rate, + language: None, + }, + covered_through_secs: span_end, + }) { + Ok(()) => { + state.tail_patch_awaiting_completion = + state.tail_patch_awaiting_completion.saturating_add(1); + true + } + Err(error) => { + state.tail_patch_backpressure_drops = + state.tail_patch_backpressure_drops.saturating_add(1); + warn!( + utterance_id, + "Layer 1 tail-patch request dropped — queue full or lane gone: {error}" + ); + false + } + } + } else { + false + }; + if !queued { + state + .progressive + .note_whisper_window_elapsed(utterance_id, span_end); + state.emit_ready_progressive_seals( + ev_tx, + span_end + super::progressive_seal::APPLE_VOLATILE_WINDOW_SECS + 0.001, + ); + } + state.utterance_id = state.utterance_id.max(utterance_id); + } + true +} + /// Seal one Apple utterance: run the shared lexicon + cleanup pass, then emit /// `UtteranceFinal`. Returns `false` when postprocess filtered the text to /// empty — mirroring `PostprocessDrop::FilteredEmpty` on the VAD path, an @@ -600,19 +1380,143 @@ fn seal_utterance_final( state: &mut AppleSealState, ev_tx: &mpsc::UnboundedSender, raw: &str, - start_ts: f32, - end_ts: f32, segments: Vec, + audio_secs: f32, ) -> bool { - let raw_text = raw.trim().to_string(); + const BOUNDARY_EPSILON_SECS: f32 = 0.002; + + let callback_text = raw.trim().to_string(); + let original_segment_count = segments.len(); + let mut disjoint = Vec::with_capacity(original_segment_count); + let mut cursor = state.last_apple_segment_end; + let mut overlap_normalized = false; + + for mut segment in segments { + let text = segment.text.trim(); + if text.is_empty() + || !segment.start_ts.is_finite() + || !segment.end_ts.is_finite() + || segment.end_ts <= segment.start_ts + { + continue; + } + if segment.end_ts <= cursor + BOUNDARY_EPSILON_SECS + || segment.start_ts < cursor - BOUNDARY_EPSILON_SECS + { + overlap_normalized = true; + continue; + } + if segment.start_ts < cursor { + segment.start_ts = cursor; + } + segment.text = text.to_string(); + cursor = segment.end_ts; + disjoint.push(segment); + } + + if overlap_normalized { + let _ = ev_tx.send(EngineEvent::Warning { + code: APPLE_FINAL_OVERLAP_WARNING_CODE.to_string(), + message: "Apple final overlap removed at segment boundary".to_string(), + }); + } + + if disjoint.is_empty() { + if callback_text.is_empty() { + return false; + } + // Append doctrine: text Apple asserted must never die in the preview + // lane — the next partial replaces `open_partial` wholesale and the + // only copy is gone (session a5623d55, 2026-08-12). The trusted + // timing boundary exists to dedupe RE-HEARD text, so demotion is only + // legal for text already on the canvas. Cumulative callbacks re-state + // the whole phrase, so the longest canvas-known prefix splits off and + // only the NOVEL suffix commits, with a session-clock window (the + // fallback the doc header always promised for segment-less finals). + let mut canvas = state.progressive.sealed_prefix(); + for span in state.progressive.pending_spans() { + canvas.push(' '); + canvas.push_str(&span.raw_text); + } + let canvas = normalize_for_containment(&canvas); + let words: Vec<&str> = callback_text.split_whitespace().collect(); + // Each probe word runs the lexicon, because the canvas already has: + // `PendingSpan::raw_text` is `process_utterance` output (lexicon first) + // and `sealed_prefix()` is `seal_span_text` output. Probing raw words + // compares "doker" with "docker" and mismatches at every rewrite — + // f8519df2 shipped exactly that and re-committed whole phrases; + // `cumulative_final_prefix_survives_words_the_lexicon_rewrites` pins it. + let probe_words: Vec = words + .iter() + .map(|word| normalize_for_containment(&seal_span_text(word, "", true))) + .collect(); + let canvas_words: Vec<&str> = canvas.split_whitespace().collect(); + let (known_prefix_words, revised_words) = + revision_tolerant_known_prefix(&probe_words, &canvas_words); + if revised_words > 0 { + info!( + known_prefix_words, + revised_words, + callback_words = words.len(), + "apple_lifecycle: restated prefix matched through engine revisions" + ); + } + let novel_text = words[known_prefix_words..].join(" "); + if novel_text.is_empty() { + // Fully re-heard text has no volatile tail. Keeping the cumulative + // callback as Preview makes session renderers show + // `committed canvas + restatement`; on the Apple path that duplicate + // survived into the stop-time delivery buffer because the session + // closes with `SessionFinalised`, not `Stats`. + state.open_partial.clear(); + state.open_partial_segments.clear(); + state.preview_rev = state.preview_rev.saturating_add(1); + let _ = ev_tx.send(EngineEvent::Preview { + rev: state.preview_rev, + text: String::new(), + }); + return false; + } + let start_ts = state.last_apple_segment_end.max(state.last_sealed_end); + let end_ts = audio_secs.max(start_ts + BOUNDARY_EPSILON_SECS); + info!( + audio_secs, + synthesized_start = start_ts, + synthesized_end = end_ts, + known_prefix_words, + text_chars = novel_text.chars().count(), + "apple_lifecycle: novel final suffix rescued with synthesized window" + ); + disjoint.push(TranscriptSegment { + text: novel_text, + start_ts, + end_ts, + }); + } + + let start_ts = disjoint.first().map_or(0.0, |segment| segment.start_ts); + let end_ts = disjoint.last().map_or(start_ts, |segment| segment.end_ts); + let raw_text = if !overlap_normalized && disjoint.len() == original_segment_count { + callback_text + } else { + disjoint + .iter() + .map(|segment| segment.text.as_str()) + .collect::>() + .join(" ") + }; if raw_text.is_empty() { return false; } + // Consume the Apple boundary even if cleanup filters the text. A later + // cumulative callback must not resurrect audio the product already judged. + state.last_apple_segment_end = end_ts; + let Some(corrected) = state.postprocessor.process_utterance(&raw_text) else { state.filtered_empty_drops = state.filtered_empty_drops.saturating_add(1); warn!( - raw_text = %raw_text, + raw_chars = raw_text.chars().count(), "Apple seal dropped: empty after lexicon/cleanup" ); let _ = ev_tx.send(EngineEvent::Drop { @@ -624,31 +1528,124 @@ fn seal_utterance_final( }; let after_lexicon = corrected.trim().to_string(); - state.utterance_id = state.utterance_id.saturating_add(1); - let utterance_id = state.utterance_id; - state - .progressive - .note_apple_commit(utterance_id, after_lexicon.clone(), end_ts, end_ts); + if state.fusion_seal_armed + && seal_sliced_by_silero( + state, + ev_tx, + &raw_text, + &after_lexicon, + start_ts, + end_ts, + &disjoint, + ) + { + return true; + } + let apple_words = apple_segments_on_pcm_clock(state, &disjoint); + let captured_end = state.audio.session_sample_end(); + let span_sample_start = apple_words.first().map_or_else( + || seconds_to_captured_sample(start_ts, state.sample_rate, captured_end), + |word| word.range.sample_start, + ); + let span_sample_end = apple_words + .last() + .map_or_else( + || seconds_to_captured_sample(end_ts, state.sample_rate, captured_end), + |word| word.range.sample_end, + ) + .max(span_sample_start); + // Bind this span to the spectrum even off the sliced path: when a Silero + // edge already encloses every sample Apple claimed, the utterance range is + // the canonical one and the span records which identity it came from. No + // enclosing edge (Silero off, model missing, an edge still open, or a span + // that straddles two utterances) leaves the Apple-derived range untouched — + // binding never costs content. + let apple_range = TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start: span_sample_start, + sample_end: span_sample_end, + }; + let (span_range, silero_utterance_id) = match state + .fusion + .as_ref() + .filter(|_| state.fusion_seal_armed) + .and_then(|fusion| { + fusion + .ledger() + .utterance_enclosing(span_sample_start, span_sample_end) + }) { + Some(utterance) => (utterance.range.clone(), Some(utterance.id)), + None => (apple_range, None), + }; + // One id space. While the seal path can mint span ids FROM the ledger, the + // fallback must burn its id there too, or Silero would later mint the same + // id for a real utterance — `note_apple_commit_timed` is idempotent on id, + // so the collision would silently merge two unrelated spans. With the seal + // path disarmed no ledger id ever becomes a span id, and the counter stays + // the plain monotonic one the Apple-boundary lane always used. + let utterance_id = match state.fusion.as_mut().filter(|_| state.fusion_seal_armed) { + Some(fusion) => fusion.ledger_mut().reserve_id(), + None => state.utterance_id.saturating_add(1), + }; + state.utterance_id = state.utterance_id.max(utterance_id); + if !state.progressive.note_apple_commit_timed(AppleCommit { + id: utterance_id, + raw_text: after_lexicon.clone(), + end_secs: end_ts, + committed_at_secs: end_ts, + range: span_range, + words: apple_words, + apple_evidence: TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: None, + }, + silero_utterance_id, + }) { + return false; + } state.pending_events.insert( utterance_id, PendingAppleSeal { raw_text, start_ts, end_ts, - segments, + segments: disjoint, }, ); let window = resolve_sealed_audio_window(state, end_ts); let committed_text = seal_span_text(&after_lexicon, &state.sealed_prefix, false); - let queued = if let (Some(audio), Some(tx)) = (window, state.tail_patch.as_ref()) { + let queued = if let (Some(window), Some(tx)) = (window, state.tail_patch.as_ref()) { + let provider_request = TailProviderRequest { + identity: TailRequestIdentity { + request_id: utterance_id, + range: TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start: window.sample_start, + sample_end: window.sample_end, + }, + }, + sample_rate: state.sample_rate, + language: None, + }; match tx.try_send(TailPatchRequest { utterance_id, committed_text, - audio, + neighbour_context: state.sealed_prefix.clone(), + audio: window.samples, + provider_request, covered_through_secs: end_ts, }) { - Ok(()) => true, + Ok(()) => { + state.tail_patch_awaiting_completion = + state.tail_patch_awaiting_completion.saturating_add(1); + true + } Err(e) => { state.tail_patch_backpressure_drops = state.tail_patch_backpressure_drops.saturating_add(1); @@ -678,20 +1675,301 @@ fn seal_utterance_final( true } -/// Blocking worker: owns the stream session for its full lifetime. -fn apple_stream_worker( - pcm_rx: std_mpsc::Receiver>>, - ev_tx: mpsc::UnboundedSender, +// ═══════════════════════════════════════════════════════════ +// Engine lifecycle: speech epochs (hands-free silence) +// ═══════════════════════════════════════════════════════════ + +/// Audio replayed into a fresh epoch ahead of the detected speech edge, so the +/// first phoneme is not eaten by bridge spin-up (~0.24 s measured). Same value +/// the fusion lane pads windows with. +const EPOCH_PREROLL_SECS: f32 = super::silero_fusion::DEFAULT_LEFT_PAD_SECS; + +/// Lift one poll's worth of bridge events onto the session PCM clock. +/// +/// Bridge time is **per request**: every `LiveStreamSession` restarts its +/// segment clock at zero, while every consumer downstream +/// ([`apple_segments_on_pcm_clock`], the seal windows, the Layer 1 ranges) +/// reads those seconds as session time. With one stream per take the two +/// clocks coincide and this is the identity; with an epoch lifecycle they +/// diverge by exactly the epoch base, so the shift happens once, here, before +/// any event reaches [`emit_stream_events`]. +fn shift_events(events: Vec, base_secs: f32) -> Vec { + if !base_secs.is_finite() || base_secs <= 0.0 { + return events; + } + events + .into_iter() + .map(|event| match event { + LiveStreamEvent::Partial { text, segments } => LiveStreamEvent::Partial { + text, + segments: shift_segments(segments, base_secs), + }, + LiveStreamEvent::PhraseFinal { text, segments } => LiveStreamEvent::PhraseFinal { + text, + segments: shift_segments(segments, base_secs), + }, + LiveStreamEvent::Summary { + text, + segments, + ok, + error, + } => LiveStreamEvent::Summary { + text, + segments: shift_segments(segments, base_secs), + ok, + error, + }, + other @ (LiveStreamEvent::Ready + | LiveStreamEvent::End + | LiveStreamEvent::Error { .. }) => other, + }) + .collect() +} + +fn shift_segments(segments: Vec, base_secs: f32) -> Vec { + segments + .into_iter() + .map(|mut segment| { + if segment.start_ts.is_finite() { + segment.start_ts += base_secs; + } + if segment.end_ts.is_finite() { + segment.end_ts += base_secs; + } + segment + }) + .collect() +} + +/// What the worker must do with one capture chunk under the epoch lifecycle. +#[derive(Debug, Clone, Copy, PartialEq)] +enum EpochDecision { + /// Write the chunk into the currently open stream. + Forward, + /// Speech edge while asleep: open a stream based at `preroll_from`. + Wake { preroll_from: u64 }, + /// Silence threshold crossed: close the epoch. Chunk is trailing silence. + Sleep { silence_secs: f32 }, + /// Asleep and still silent — retain audio only. + Idle, +} + +/// Engine lifecycle for the Apple progressive lane: speech opens an SFSpeech +/// epoch, silence past the product threshold closes it, and the engine rests +/// (mic + Silero keep running) until the next speech edge. +/// +/// Disarmed (`utterance_silence_sec: None`, or no Silero) it answers +/// [`EpochDecision::Forward`] to everything, which is the pre-epoch worker bit +/// for bit. +/// +/// # The gate observes nothing itself +/// +/// It is a pure state machine over one bit per chunk — `speech_live` — supplied +/// by [`SileroIngress::ingest`], the session's single VAD. It used to own a +/// second `SpeechSession` of its own, which meant two Silero instances scoring +/// the same PCM: the lifecycle woke and slept on one set of edges while the +/// fusion ledger minted utterance identity on another, and nothing kept the two +/// spectra in step. One session, one spectrum, one set of boundaries. +/// +/// Speech is "live" while a Supervisor segment is open, and for the chunk a +/// segment closes in — so the silence counter starts at the segment close, i.e. +/// **after** Silero's own hysteresis (`0.55 s` by default) has already elapsed. +/// The wall silence before an epoch closes is therefore the product threshold +/// plus that hysteresis, never less than the setting. +struct EpochGate { + armed: bool, + sample_rate: u32, + silence_threshold_samples: u64, + preroll_samples: u64, + awake: bool, + /// Session cursor of the last chunk speech was live in. + last_speech_sample: u64, + /// Session cursor the previous epoch closed at — the pre-roll floor, so a + /// new epoch never re-feeds audio the closed one already carried. + epoch_closed_at: u64, +} + +impl EpochGate { + /// Legacy lane: one stream for the whole take. + fn disarmed() -> Self { + Self { + armed: false, + sample_rate: 1, + silence_threshold_samples: 0, + preroll_samples: 0, + awake: false, + last_speech_sample: 0, + epoch_closed_at: 0, + } + } + + fn armed(sample_rate: u32, silence_sec: f32) -> Self { + let rate = sample_rate.max(1); + Self { + armed: true, + sample_rate: rate, + silence_threshold_samples: (silence_sec.max(0.1) * rate as f32) as u64, + preroll_samples: (EPOCH_PREROLL_SECS * rate as f32) as u64, + awake: false, + last_speech_sample: 0, + epoch_closed_at: 0, + } + } + + /// Build the gate the session config asks for. No silence setting → legacy; + /// no session Silero → legacy, because without edges an armed gate would + /// rest forever and the take would be silent (fail open). + fn for_session( + sample_rate: u32, + utterance_silence_sec: Option, + speech_edges_available: bool, + ) -> Self { + let Some(silence_sec) = utterance_silence_sec else { + return Self::disarmed(); + }; + if !speech_edges_available { + warn!( + utterance_silence_sec = silence_sec, + "Silero unavailable — Apple engine lifecycle disarmed, falling back to one \ + continuous stream for this session" + ); + return Self::disarmed(); + } + Self::armed(sample_rate, silence_sec) + } + + fn is_armed(&self) -> bool { + self.armed + } + + /// One chunk. `speech_live` is the session Silero's verdict on it — the + /// same observation the utterance ledger was minted from. + fn feed_pcm(&mut self, samples: &[f32], samples_seen: u64, speech_live: bool) -> EpochDecision { + if !self.armed { + return EpochDecision::Forward; + } + let chunk_start = samples_seen.saturating_sub(samples.len() as u64); + if speech_live { + self.last_speech_sample = samples_seen; + if self.awake { + return EpochDecision::Forward; + } + self.awake = true; + let preroll_from = chunk_start + .saturating_sub(self.preroll_samples) + .max(self.epoch_closed_at); + return EpochDecision::Wake { preroll_from }; + } + if !self.awake { + return EpochDecision::Idle; + } + let silence = samples_seen.saturating_sub(self.last_speech_sample); + if silence >= self.silence_threshold_samples { + self.awake = false; + self.epoch_closed_at = samples_seen; + return EpochDecision::Sleep { + silence_secs: silence as f32 / self.sample_rate as f32, + }; + } + EpochDecision::Forward + } +} + +/// Session-time base of the open epoch, in seconds. +fn epoch_base_secs(epoch_base_samples: u64, sample_rate: u32) -> f32 { + epoch_base_samples as f32 / sample_rate.max(1) as f32 +} + +/// Seal an open partial that never received a phrase final. +/// +/// Shared by the two places a stream can end without one: capture EOF (stop +/// mid-phrase) and an epoch close. Both must run the same seal-time correction +/// — a phrase that ends by silence must not be the one route that commits +/// uncorrected text, or dies in the preview lane. +fn seal_open_partial( + state: &mut AppleSealState, + ev_tx: &mpsc::UnboundedSender, + audio_secs: f32, +) { + let open = state.open_partial.trim().to_string(); + if open.is_empty() { + return; + } + let segments = std::mem::take(&mut state.open_partial_segments); + seal_utterance_final(state, ev_tx, &open, segments, audio_secs); + state.open_partial.clear(); +} + +/// Everything the blocking worker needs that is not a channel. +struct AppleWorkerConfig<'a> { sample_rate: u32, - language: Option<&str>, + language: Option<&'a str>, + session_id: String, + /// Product "Hands-free silence". `Some` arms the engine lifecycle (speech + /// epochs); `None` keeps one continuous SFSpeech stream for the whole take. + utterance_silence_sec: Option, +} + +/// Blocking worker: owns the SFSpeech stream(s) for the session's full lifetime. +fn apple_stream_worker( + pcm_rx: std_mpsc::Receiver>>, + ev_tx: mpsc::UnboundedSender, tail_patch: Option>, tail_patch_done: std_mpsc::Receiver, + config: AppleWorkerConfig<'_>, ) -> anyhow::Result { - let mut stream = LiveStreamSession::open(language, sample_rate)?; + let AppleWorkerConfig { + sample_rate, + language, + session_id, + utterance_silence_sec, + } = config; let mut state = match tail_patch { - Some(tx) => AppleSealState::new_with_tail_patch(sample_rate, tx), - None => AppleSealState::new(sample_rate), + Some(tx) => AppleSealState::new_with_tail_patch_for_session(sample_rate, session_id, tx), + None => AppleSealState::new_for_session(sample_rate, session_id), + }; + // The session's ONE Silero. Both consumers of speech edges read it: the + // utterance ledger (identity, ranges) and the engine lifecycle (wake/sleep). + // It is built whenever either consumer wants it — the fusion flag decides + // whether identity reaches the seal, not whether the VAD exists. + state.fusion_seal_armed = lane_enabled(); + if state.fusion_seal_armed || utterance_silence_sec.is_some() { + let ingress = + SileroIngress::new(sample_rate, state.session_id.clone(), state.capture_epoch); + if ingress.vad_available() { + state.fusion_context = FusionContextMode::from_env(); + info!( + context = state.fusion_context.as_str(), + seal_armed = state.fusion_seal_armed, + lifecycle_armed = utterance_silence_sec.is_some(), + "Silero ingress armed — single VAD feeding utterance identity and engine lifecycle" + ); + state.fusion = Some(ingress); + } else { + warn!( + "Silero model unavailable — no utterance identity and no engine lifecycle \ + this session; Apple segment boundaries stay the seal authority" + ); + } + } + // Engine lifecycle. Disarmed → one stream opened here for the whole take + // (legacy). Armed → the bridge stays unspawned until the first speech edge, + // and every epoch closes on the product silence threshold. + let mut epoch = + EpochGate::for_session(sample_rate, utterance_silence_sec, state.fusion.is_some()); + let mut stream = if epoch.is_armed() { + info!( + utterance_silence_sec = utterance_silence_sec.unwrap_or_default(), + preroll_secs = EPOCH_PREROLL_SECS, + "Apple progressive engine lifecycle armed — SFSpeech rests between utterances" + ); + None + } else { + Some(LiveStreamSession::open(language, sample_rate)?) }; + // Session-time base of the open epoch. Zero for the legacy single stream, + // which is what makes `shift_events` the identity on that path. + let mut epoch_base_samples: u64 = 0; let mut samples_seen: u64 = 0; loop { @@ -711,37 +1989,125 @@ fn apple_stream_worker( // This is worker-side on purpose: the async select loop stays // lock-free (2026-07-27 interleave contract) because the buffer // is never shared across the thread boundary. + // + // Retention runs in every lifecycle state, including while the + // engine rests: it is what the pre-roll of the next epoch is cut + // from, and what Layer 1 windows still resolve against. state.audio.push(&samples); - stream.write_pcm(&samples)?; + // One observation of the spectrum, two consumers: the ledger + // mints identity from it and the lifecycle wakes/sleeps on it. + let speech_live = state + .fusion + .as_mut() + .is_some_and(|fusion| fusion.ingest(&samples, samples_seen).speech_live); let audio_secs = samples_seen as f32 / sample_rate.max(1) as f32; - emit_stream_events(stream.poll_events(), &ev_tx, &mut state, audio_secs); + match epoch.feed_pcm(&samples, samples_seen, speech_live) { + EpochDecision::Forward => { + if let Some(session) = stream.as_mut() { + session.write_pcm(&samples)?; + let events = shift_events( + session.poll_events(), + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(events, &ev_tx, &mut state, audio_secs); + } + } + EpochDecision::Wake { preroll_from } => { + let mut session = LiveStreamSession::open(language, sample_rate)?; + let chunk_start = samples_seen.saturating_sub(samples.len() as u64); + // The base is whatever audio this epoch ACTUALLY starts + // with, never what was asked for: a pre-roll that fell + // off retention resolves to nothing, and basing the + // epoch on it would shift every timestamp in it earlier + // by the missing audio. + let preroll = state.audio.window_by_samples(preroll_from, chunk_start); + epoch_base_samples = + preroll.as_ref().map_or(chunk_start, |w| w.sample_start); + let preroll_samples = + preroll.as_ref().map_or(0, |window| window.samples.len()); + if let Some(window) = preroll.filter(|w| !w.samples.is_empty()) { + session.write_pcm(&window.samples)?; + } + session.write_pcm(&samples)?; + info!( + audio_secs, + epoch_base_secs = epoch_base_secs(epoch_base_samples, sample_rate), + preroll_samples, + "apple_lifecycle: epoch open (speech edge)" + ); + let events = shift_events( + session.poll_events(), + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(events, &ev_tx, &mut state, audio_secs); + stream = Some(session); + } + EpochDecision::Sleep { silence_secs } => { + if let Some(session) = stream.take() { + let base_secs = epoch_base_secs(epoch_base_samples, sample_rate); + let trailing = shift_events(session.finish()?, base_secs); + emit_stream_events(trailing, &ev_tx, &mut state, audio_secs); + // Same close as capture EOF: whatever the engine + // left open is sealed here, because no later + // callback from this epoch can arrive. + seal_open_partial(&mut state, &ev_tx, audio_secs); + info!( + audio_secs, + silence_secs, + epoch_base_secs = base_secs, + "apple_lifecycle: epoch close (hands-free silence)" + ); + } + } + // Resting: audio is retained, the engine is not running. + EpochDecision::Idle => {} + } } Ok(None) => break, // EOF from async side Err(std_mpsc::RecvTimeoutError::Timeout) => { let audio_secs = samples_seen as f32 / sample_rate.max(1) as f32; - emit_stream_events(stream.poll_events(), &ev_tx, &mut state, audio_secs); + if let Some(session) = stream.as_mut() { + let events = shift_events( + session.poll_events(), + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(events, &ev_tx, &mut state, audio_secs); + } } Err(std_mpsc::RecvTimeoutError::Disconnected) => break, } } let audio_secs = samples_seen as f32 / sample_rate.max(1) as f32; - let trailing = stream.finish()?; - emit_stream_events(trailing, &ev_tx, &mut state, audio_secs); + if let Some(fusion) = state.fusion.as_mut() { + fusion.flush(samples_seen); + } + if let Some(session) = stream.take() { + let trailing = shift_events( + session.finish()?, + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(trailing, &ev_tx, &mut state, audio_secs); + } // Seal open partial that never got a phrase final (stop mid-phrase). // Same seal-time correction as the phrase path — a stop mid-utterance must // not be the one route that commits uncorrected text. - let open = state.open_partial.trim().to_string(); - if !open.is_empty() { - seal_utterance_final(&mut state, &ev_tx, &open, 0.0, audio_secs, Vec::new()); - } + seal_open_partial(&mut state, &ev_tx, audio_secs); // Every accepted Layer 1 request must close (success, no-change, or // explicit skip) before the session task returns. This is bounded by the // queue cap and happens while the async side is still draining jobs. - while !state.progressive.pending_spans().is_empty() { - match tail_patch_done.recv_timeout(Duration::from_secs(30)) { + // + // Wait on the *jobs*, not on the pending-seal queue. Those are different + // conditions: a span still pending can be blocked by the Apple volatile + // window rather than by a missing Whisper window, and no completion will + // ever clear that gate. Waiting on the seal queue therefore parked the stop + // path on the full timeout whenever the last span was volatile-blocked — + // measured 2026-08-12, `rec_stop=36.701s` of which 30.005s was this loop + // waiting for a completion that had already arrived for every job it sent. + while state.tail_patch_awaiting_completion > 0 { + match tail_patch_done.recv_timeout(TAIL_PATCH_CLOSURE_TIMEOUT) { Ok(completion) => state.complete_whisper_window( &ev_tx, completion, @@ -755,10 +2121,89 @@ fn apple_stream_worker( } } + // Capture is over: no later Apple callback can revise a span and no further + // Whisper window can arrive, so both double-close gates are satisfied by + // definition. Seal the remainder here instead of leaving it to the residual + // path — the machine's own span timestamps are the clock, because the audio + // clock is frozen at EOF and can sit milliseconds behind them. + state.seal_remaining_at_session_end(&ev_tx); + + // Evidence surface: when `CODESCRIBE_SEAL_ATLAS_DUMP` names a path, write + // every sealed span with its PCM-pinned word payload as JSON. Runs on the + // worker's own final state after the session-end seal, so the file is what + // the session actually delivered — never a reconstruction. No env, no-op. + if let Ok(dump_path) = std::env::var("CODESCRIBE_SEAL_ATLAS_DUMP") + && !dump_path.trim().is_empty() + { + let spans: Vec = state + .progressive + .sealed_spans() + .iter() + .map(|span| { + serde_json::json!({ + "id": span.id, + "text": span.text, + "end_secs_millis": span.end_secs_millis, + "range": span.range, + "words": span.words, + "apple_evidence": span.apple_evidence, + "whisper_evidence": span.whisper_evidence, + "whisper_words": span.whisper_words, + // Which spectrum edge this span's range came from. `null` + // means no Silero edge enclosed it and the range is Apple's. + "silero_utterance_id": span.silero_utterance_id, + }) + }) + .collect(); + // The other half of the binding proof: the edges themselves, so a span's + // `silero_utterance_id` can be resolved to the sample range Silero + // actually minted and every word checked against it. + let silero_utterances: Vec = state + .fusion + .as_ref() + .map(|fusion| { + fusion + .ledger() + .utterances() + .iter() + .map(|utterance| { + serde_json::json!({ + "id": utterance.id, + "sample_start": utterance.range.sample_start, + "sample_end": utterance.range.sample_end, + "closed": utterance.closed, + }) + }) + .collect() + }) + .unwrap_or_default(); + let atlas = serde_json::json!({ + "session": state.session_id, + "capture_epoch": state.capture_epoch, + "sample_rate": sample_rate, + "audio_samples_seen": samples_seen, + "silero_seal_armed": state.fusion_seal_armed, + "silero_utterances": silero_utterances, + "sealed_spans": spans, + }); + match serde_json::to_vec_pretty(&atlas) + .map_err(anyhow::Error::from) + .and_then(|bytes| std::fs::write(&dump_path, bytes).map_err(anyhow::Error::from)) + { + Ok(()) => info!( + path = %dump_path, + spans = state.progressive.sealed_spans().len(), + "seal atlas dump written" + ), + Err(error) => warn!(path = %dump_path, %error, "seal atlas dump failed"), + } + } + Ok(AppleStreamOutcome { sealed: state.sealed_count, filtered_empty_drops: state.filtered_empty_drops, unresolved_windows: state.unresolved_windows, + under_commit_escalations: state.under_commit_escalations, }) } @@ -778,22 +2223,33 @@ fn apple_stream_worker( /// collapse onto a short shared opener overwrote the prior utterance without /// sealing it. /// -/// Freeze on collapse unless `next` is a **true substantial prefix** of `prev` -/// (`len > 15`) — a same-phrase rewind, not a 1–2 word opener every sentence -/// shares. Kept in lockstep with `SfSpeechPhraseAccumulator` in -/// `codescribe-stt-bridge.swift`. +/// Freeze whenever `next` does not retain `prev` in full. The old restart +/// thresholds classify telemetry only; revision and same-phrase rewind are +/// retained too because this call site otherwise overwrites the only copy. +/// Kept in lockstep with `SfSpeechPhraseAccumulator` in the Swift bridge. pub(crate) fn phrase_restart_should_freeze_prior(prev: &str, next: &str) -> bool { + phrase_retention_reason(prev, next).is_some() +} + +/// Telemetry classification for a retention decision. Text safety depends +/// only on forward containment, never on the restart/revision classifier. +fn phrase_retention_reason(prev: &str, next: &str) -> Option<&'static str> { let prev = prev.trim(); let next = next.trim(); - if prev.is_empty() || next.is_empty() { - return false; + if prev.is_empty() || next.contains(prev) { + return None; } - let restarted = (next.len() * 3 < prev.len()) || (next.len() <= 15 && prev.len() >= 25); - if !restarted { - return false; + if next.is_empty() { + return Some("empty_collapse_retained"); } - let same_phrase_rewind = prev.starts_with(next) && next.len() > 15; - !same_phrase_rewind + let prev_chars = prev.chars().count(); + let next_chars = next.chars().count(); + let restarted = (next_chars * 3 < prev_chars) || (next_chars <= 15 && prev_chars >= 25); + Some(if restarted { + "restart_retained" + } else { + "revision_retained" + }) } /// Map one poll's worth of bridge events onto `EngineEvent`s, sealing where the @@ -834,28 +2290,30 @@ fn emit_stream_events( "apple_lifecycle: recognizer end" ); } - LiveStreamEvent::Partial { text } => { + LiveStreamEvent::Partial { text, segments } => { // Safety net for the named drop mechanism: if the bridge // missed a freeze (shared opener collapse), seal the open // partial here before the rewrite lands. if phrase_restart_should_freeze_prior(&state.open_partial, &text) { + let reason = phrase_retention_reason(&state.open_partial, &text) + .expect("freeze decision must carry a telemetry reason"); let frozen = state.open_partial.clone(); info!( audio_secs, - prev_chars = frozen.len(), - next_chars = text.len(), - prev_head = %frozen.chars().take(40).collect::(), - next_head = %text.chars().take(40).collect::(), - reason = "shared_opener_restart_suppresses_freeze", + prev_chars = frozen.chars().count(), + next_chars = text.chars().count(), + reason, "apple_lifecycle: freeze open partial before restart partial" ); - seal_utterance_final(state, ev_tx, &frozen, 0.0, audio_secs, Vec::new()); + let frozen_segments = std::mem::take(&mut state.open_partial_segments); + seal_utterance_final(state, ev_tx, &frozen, frozen_segments, audio_secs); state.open_partial.clear(); } // Previews stay RAW: they are in-flight presentation, not // canvas, and correcting them would make the lexicon rewrite // flicker letter by letter while the phrase is still forming. state.open_partial = text.clone(); + state.open_partial_segments = segments; state.progressive.note_session_partial(&text, audio_secs); state.preview_rev = state.preview_rev.saturating_add(1); let _ = ev_tx.send(EngineEvent::Preview { @@ -869,14 +2327,11 @@ fn emit_stream_events( audio_secs, sealed_before = state.sealed_count, text_chars = text.len(), - text_head = %text.chars().take(40).collect::(), "apple_lifecycle: phrase final received" ); state.open_partial.clear(); - let start_ts = segments.first().map(|s| s.start_ts).unwrap_or(0.0); - let end_ts = segments.last().map(|s| s.end_ts).unwrap_or(audio_secs); - let committed = - seal_utterance_final(state, ev_tx, &text, start_ts, end_ts, segments); + state.open_partial_segments.clear(); + let committed = seal_utterance_final(state, ev_tx, &text, segments, audio_secs); info!( audio_secs, committed, @@ -906,12 +2361,14 @@ fn emit_stream_events( } // No phrase finals → seal the full summary once (partials-only engine). if state.utterance_id == 0 { - if seal_utterance_final(state, ev_tx, &text, 0.0, audio_secs, segments) { + if seal_utterance_final(state, ev_tx, &text, segments, audio_secs) { state.open_partial.clear(); + state.open_partial_segments.clear(); } } else { // Phrase seals already emitted; don't double-seal open partial. state.open_partial.clear(); + state.open_partial_segments.clear(); } } } @@ -924,15 +2381,39 @@ mod tests { use super::*; use crate::pipeline::contracts::LayerSource; use crate::stt::apple_stt::parse_stream_stdout_line; - use crate::stt::tail_patcher::{LAYERED_TRANSCRIPTION_ENV, compute_tail_patch}; - use serial_test::serial; - use std::ffi::OsString; + use crate::stt::tail_patcher::{ + compute_tail_patch, layered_phase_from_raw, parse_layered_phase_value, + }; use std::sync::Mutex; /// Capture rate the Apple bridge is opened with; these tests exercise seal /// text, not audio retention, so any valid rate is representative. const TEST_SAMPLE_RATE: u32 = 16_000; + fn synthetic_tail_payload( + request_id: u64, + range: TailSampleRange, + segments: Vec, + ) -> TailProviderPayload { + TailProviderPayload { + identity: TailRequestIdentity { request_id, range }, + text: String::new(), + segments, + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + provider_id: crate::stt::tail_provider::TailProviderId::Fake, + elapsed_ms: 0, + evidence: TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: Some("synthetic-test".to_string()), + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + } + } + /// Integration boundary: the real Apple state owns the progressive /// machine. Apple commit + live partial alone stay pending; an elapsed /// Whisper window seals before session end. @@ -947,6 +2428,7 @@ mod tests { vec![ LiveStreamEvent::Partial { text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], }, LiveStreamEvent::PhraseFinal { text: "uruchom doker".into(), @@ -970,12 +2452,18 @@ mod tests { "Apple commit alone must not bypass the double-seal condition" ); + let whisper_range = state.progressive.pending_spans()[0].range.clone(); + let whisper_word = TimedTailSegment { + text: "doker".to_string(), + range: whisper_range.clone(), + }; state.complete_whisper_window( &tx, TailPatchCompletion { utterance_id: 1, covered_through_secs: 2.0, outcome: TailPatchOutcome::NoChange, + payload: Some(synthetic_tail_payload(1, whisper_range, vec![whisper_word])), }, 5.0, ); @@ -991,6 +2479,106 @@ mod tests { "double-closed span must seal live" ); assert_eq!(state.progressive.sealed_spans().len(), 1); + let sealed = &state.progressive.sealed_spans()[0]; + assert_eq!(sealed.range.sample_start, 8_000); + assert_eq!(sealed.range.sample_end, 32_000); + assert_eq!(sealed.words.len(), 1); + assert_eq!(sealed.words[0].range, sealed.range); + assert_eq!( + sealed.apple_evidence.source, + TailEvidenceSource::AppleSpeech + ); + assert_eq!( + sealed + .whisper_evidence + .as_ref() + .map(|evidence| evidence.source), + Some(TailEvidenceSource::Whisper) + ); + assert_eq!(sealed.whisper_words.len(), 1); + } + + /// W-C: an under-commit's gap-appends reach the canvas through the same + /// seal gate as ordinary patches — strictly after `UtteranceFinal` — and an + /// unplaceable remainder rides out as the residual escalation. + #[test] + fn apple_seal_emits_under_commit_gap_appends_and_escalation() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let (tp_tx, _tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); + let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); + push_capture(&mut state, 10.0); + + emit_stream_events( + vec![ + LiveStreamEvent::Partial { + text: "raz dwa trzy cztery piec".into(), + segments: vec![segment("raz dwa trzy cztery piec", 0.5, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "raz dwa trzy cztery piec".into(), + segments: vec![segment("raz dwa trzy cztery piec", 0.5, 2.0)], + }, + ], + &tx, + &mut state, + 2.0, + ); + while rx.try_recv().is_ok() {} + + // Whisper recovered a tail that only partly has a safe anchor. + let outcome = compute_tail_patch( + "raz dwa trzy cztery piec szesc", + "raz dwa trzy alfa beta gamma cztery piec siedem osiem dziewiec dziesiec", + 1, + &TailPatchConfig::default(), + ); + assert!( + outcome.residual_required(), + "fixture must carry an unplaceable remainder" + ); + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: 1, + covered_through_secs: 2.0, + outcome, + payload: None, + }, + 5.0, + ); + + let mut after = Vec::new(); + while let Ok(event) = rx.try_recv() { + after.push(event); + } + let final_at = after + .iter() + .position(|e| matches!(e, EngineEvent::UtteranceFinal { .. })) + .expect("span must seal"); + let patch_at = after + .iter() + .position(|e| { + matches!( + e, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) + }) + .expect("recovered gap must reach the canvas, not be discarded"); + assert!( + final_at < patch_at, + "a gap-append must never overtake the final it addresses" + ); + assert!( + after.iter().any(|e| matches!( + e, + EngineEvent::Warning { code, .. } if code == UNDER_COMMIT_WARNING_CODE + )), + "unplaceable recovered speech must escalate outward" + ); + assert_eq!(state.under_commit_escalations, 1); } /// Partial → Preview; each phrase final → UtteranceFinal with rising ids. @@ -1002,14 +2590,15 @@ mod tests { vec![ LiveStreamEvent::Partial { text: "hello".into(), + segments: vec![segment("hello", 0.0, 0.5)], }, LiveStreamEvent::PhraseFinal { text: "hello world".into(), - segments: vec![], + segments: vec![segment("hello world", 0.0, 1.0)], }, LiveStreamEvent::PhraseFinal { text: "second".into(), - segments: vec![], + segments: vec![segment("second", 1.0, 2.0)], }, ], &tx, @@ -1057,6 +2646,98 @@ mod tests { } } + /// Append doctrine (session a5623d55, 2026-08-12): a phrase final whose + /// segments are entirely consumed by the trusted timing boundary but whose + /// text carries NOVEL content must still reach the canvas. Demoting it to + /// the preview lane is a silent replacement channel — the very next + /// partial overwrites `open_partial` wholesale and the only copy dies. + #[test] + fn boundary_consumed_final_with_novel_text_still_reaches_canvas() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 40.0); + + // Utterance 1 commits normally; trusted boundary moves to 14.0. + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "Zmienili zobacz".into(), + segments: vec![segment("Zmienili zobacz", 0.5, 14.0)], + }], + &tx, + &mut state, + 14.2, + ); + + // SFSpeech restart re-delivers with stale timings BEHIND the boundary + // but novel words; the collapsed restart partial lands right after. + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "Czyli dupa zbita".into(), + segments: vec![segment("Czyli dupa zbita", 10.0, 13.5)], + }, + LiveStreamEvent::Partial { + text: "Tak".into(), + segments: vec![segment("Tak", 17.0, 17.4)], + }, + ], + &tx, + &mut state, + 17.5, + ); + + let mut finals = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let EngineEvent::UtteranceFinal { text, .. } = event { + finals.push(text); + } + } + let canvas = finals.join(" "); + assert!( + canvas.contains("Czyli dupa zbita"), + "Apple-asserted novel text died in the preview lane (podmianka): canvas={canvas:?}" + ); + } + + /// Append doctrine, freeze path: the safety-net freeze seals the open + /// partial WITHOUT segments. That seal must not die on `disjoint.is_empty()` + /// — the frozen text is the only copy of a whole utterance. + #[test] + fn frozen_partial_without_segments_still_reaches_canvas() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 30.0); + + emit_stream_events( + vec![ + LiveStreamEvent::Partial { + text: "pojebany tekst czyli dupa".into(), + segments: Vec::new(), + }, + // Collapsed restart: freeze must seal the prior hypothesis. + LiveStreamEvent::Partial { + text: "Tak".into(), + segments: Vec::new(), + }, + ], + &tx, + &mut state, + 12.0, + ); + + let mut finals = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let EngineEvent::UtteranceFinal { text, .. } = event { + finals.push(text); + } + } + let canvas = normalize_for_containment(&finals.join(" ")); + assert!( + canvas.contains("pojebany tekst czyli dupa"), + "frozen open partial died sealing without segments: canvas={canvas:?}" + ); + } + /// F3 wiring contract: a seal must resolve to the audio actually retained /// for this session, and advance the lower bound for the next utterance. /// This is what W2-A's tail-patch will stand on. @@ -1093,21 +2774,442 @@ mod tests { assert!(state.audio.window(2.5, 4.0).is_some()); } - /// Falsification arm: an `end_ts` that does not describe this session's PCM - /// must be counted and surfaced, never silently truncated into a window. #[test] - fn seal_window_beyond_captured_audio_is_counted_unresolved() { - let (tx, _rx) = mpsc::unbounded_channel(); + fn cumulative_apple_final_commits_only_segments_after_last_boundary() { + let (tx, mut rx) = mpsc::unbounded_channel(); let mut state = AppleSealState::new(TEST_SAMPLE_RATE); - push_capture(&mut state, 2.0); + push_capture(&mut state, 4.0); emit_stream_events( - vec![LiveStreamEvent::PhraseFinal { - text: "zdanie z przyszlosci".into(), - segments: vec![segment("zdanie z przyszlosci", 8.0, 9.0)], - }], - &tx, - &mut state, + vec![ + LiveStreamEvent::PhraseFinal { + text: "alpha beta".into(), + segments: vec![segment("alpha", 0.0, 1.0), segment("beta", 1.0, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "alpha beta gamma".into(), + segments: vec![ + segment("alpha", 0.0, 1.0), + segment("beta", 1.0, 2.0), + segment("gamma", 2.0, 3.0), + ], + }, + ], + &tx, + &mut state, + 4.0, + ); + + let mut finals = Vec::new(); + let mut overlap_warnings = 0; + while let Ok(event) = rx.try_recv() { + match event { + EngineEvent::UtteranceFinal { + raw_text, + start_ts, + end_ts, + .. + } => finals.push((raw_text, start_ts, end_ts)), + EngineEvent::Warning { code, .. } if code == APPLE_FINAL_OVERLAP_WARNING_CODE => { + overlap_warnings += 1; + } + _ => {} + } + } + assert_eq!( + finals, + vec![("alpha beta".into(), 0.0, 2.0), ("gamma".into(), 2.0, 3.0)] + ); + assert_eq!(overlap_warnings, 1); + } + + #[test] + fn cumulative_final_commits_only_its_novel_suffix() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 3.0); + + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "alpha beta".into(), + segments: vec![segment("alpha", 0.0, 1.0), segment("beta", 1.0, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "alpha beta revised".into(), + segments: vec![segment("alpha beta revised", 0.0, 2.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + // Append doctrine: the canvas-known prefix "alpha beta" must not + // double-commit, but the novel suffix must never die in preview. + let finals: Vec<&String> = events + .iter() + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(finals.len(), 2, "novel suffix must commit: {events:?}"); + assert!( + normalize_for_containment(finals[1]).contains("revised"), + "second final must carry only the novel suffix: {finals:?}" + ); + assert!( + !normalize_for_containment(finals[1]).contains("alpha"), + "canvas-known prefix must not double-commit: {finals:?}" + ); + assert_eq!(state.utterance_id, 2, "novel suffix gets a fresh ID"); + assert_eq!( + state.last_apple_segment_end, 3.0, + "synthesized window consumes the boundary to the session clock" + ); + } + + /// A trailing cumulative callback can assert novel text after capture has + /// already reached EOF. The text still belongs on the append-only canvas, + /// but its synthetic Apple boundary must clamp to the canonical PCM clock: + /// advancing the window floor to the unclamped Apple timestamp makes every + /// later suffix start beyond retained audio and queues an empty Whisper + /// window before the failure becomes visible. + #[test] + fn eof_clamped_novel_suffixes_do_not_poison_pcm_window_floor_or_queue_empty_audio() { + let (tx, _rx) = mpsc::unbounded_channel(); + let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); + let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); + push_capture(&mut state, 3.0); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "alpha beta".into(), + segments: vec![segment("alpha beta", 0.0, 3.0)], + }], + &tx, + &mut state, + 3.0, + ); + let initial = tp_rx + .try_recv() + .expect("the real captured span must reach Layer 1"); + assert!(!initial.audio.is_empty()); + + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "alpha beta gamma".into(), + segments: vec![segment("alpha beta gamma", 0.0, 3.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "alpha beta gamma delta".into(), + segments: vec![segment("alpha beta gamma delta", 0.0, 3.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + + let extra_windows = std::iter::from_fn(|| tp_rx.try_recv().ok()).collect::>(); + assert!( + extra_windows.is_empty(), + "novel text at EOF has no new PCM and must not queue empty Layer 1 windows: {:?}", + extra_windows + .iter() + .map(|request| request.audio.len()) + .collect::>() + ); + assert_eq!( + state.last_sealed_end, 3.0, + "the window floor is canonical PCM time, never an unclamped Apple timestamp" + ); + assert_eq!( + state.unresolved_windows, 0, + "a clamped EOF suffix is known to have no new PCM; it is not a clock lie" + ); + let landed = state + .progressive + .pending_spans() + .iter() + .map(|span| normalize_for_containment(&span.raw_text)) + .chain( + state + .progressive + .sealed_spans() + .iter() + .map(|span| normalize_for_containment(&span.text)), + ) + .collect::>(); + assert!( + landed.iter().any(|text| text.contains("gamma")), + "the first EOF suffix must remain on the canvas: {landed:?}" + ); + assert!( + landed.iter().any(|text| text.contains("delta")), + "the later EOF suffix must remain on the canvas: {landed:?}" + ); + } + + /// Regression guard for the prefix probe: the canvas-known prefix must be + /// recognised even when the lexicon rewrites words inside it. + /// + /// `cumulative_final_commits_only_its_novel_suffix` cannot catch this — its + /// "alpha beta revised" survives every rewrite table untouched, so it stayed + /// green through the whole defect. Here "doker" → "Docker" puts a real + /// rewrite inside the shared prefix, which is what broke the match: the + /// probe was normalised through `seal_span_text` while the canvas is built + /// from post-`process_utterance` text, so the two sides disagreed at the + /// first rewritten word and nearly the whole phrase re-committed as novel. + /// Measured on session f72fbbb7 (2026-08-12): 603 live words against 318 + /// spoken, +90%. + #[test] + fn cumulative_final_prefix_survives_words_the_lexicon_rewrites() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 3.0); + + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom", 0.0, 1.0), segment("doker", 1.0, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "uruchom doker i restart".into(), + segments: vec![segment("uruchom doker i restart", 0.0, 2.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let finals: Vec<&String> = events + .iter() + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!( + finals.len(), + 2, + "the novel suffix must still commit: {events:?}" + ); + + let novel = normalize_for_containment(finals[1]); + assert!( + novel.contains("restart"), + "novel suffix must reach the canvas: {finals:?}" + ); + assert!( + !novel.contains("uruchom"), + "a rewritten prefix is still a known prefix — re-committing it is the repetition defect: {finals:?}" + ); + assert!( + !novel.contains("doker") && !novel.contains("docker"), + "the WHOLE known prefix must be consumed, not just the words the lexicon left alone — \ + stopping at the first rewritten word is exactly how a phrase re-commits: {finals:?}" + ); + } + + /// Threshold contract of the fuzzy prefix: short probes stay exact, longer + /// ones absorb ~20% revisions. At one or two words a tolerated mismatch is + /// not a revision, it is a different word — loosening that end would let + /// any two-word opener "match" the canvas and silently eat real speech. + #[test] + fn revision_tolerance_is_zero_for_short_probes_and_bounded_after() { + let canvas = vec!["ala", "ma", "kota", "i", "psa"]; + let one = |s: &str| vec![s.to_string()]; + let owned = |words: &[&str]| words.iter().map(|w| (*w).to_string()).collect::>(); + + // k=1..2: exact only. + assert_eq!(revision_tolerant_known_prefix(&one("ala"), &canvas), (1, 0)); + assert_eq!(revision_tolerant_known_prefix(&one("ela"), &canvas), (0, 0)); + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ela", "ma"]), &canvas), + (0, 0), + "a two-word probe with a revision must NOT match — that is a different phrase" + ); + + // k=3: one revision allowed ("ela" for "ala"). + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ela", "ma", "kota"]), &canvas), + (3, 1) + ); + // Two revisions in three words: too different. + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ela", "je", "kota"]), &canvas), + (0, 0) + ); + // Full exact run wins with zero revisions. + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ala", "ma", "kota", "i", "psa"]), &canvas), + (5, 0) + ); + + // Insertions and deletions are revisions too: a positional rule would + // cascade every word after the shift into a mismatch. Measured on the + // 2026-08-12 replay — 15-22-word restatements collapsed to a 6-word + // match because Apple interjected or dropped a single word mid-phrase. + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ala", "ma", "dużego", "kota", "i"]), &canvas), + (5, 1), + "one inserted word must cost one edit, not shift-poison the rest" + ); + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ala", "kota", "i", "psa"]), &canvas), + (4, 1), + "one dropped word must cost one edit" + ); + } + + /// The 2026-08-12 18:44 repetition mechanism, pinned: a cumulative final + /// that REVISES its opening word ("szuty" → "skróty") used to defeat every + /// probe length at once, because the exact-substring prefix match was + /// anchored at the callback's first word. The whole restatement then + /// re-committed — the delivered take carried 72% of its words inside a + /// repeated 6-gram. `revision_tolerant_known_prefix` absorbs the revision. + #[test] + fn cumulative_final_with_revised_opening_word_must_not_recommit_the_phrase() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 40.0); + + let heard_first = "szuty klawiszowe to podwójny lewy przycisk myszy"; + let restated = + "skróty klawiszowe to podwójny lewy przycisk myszy lub klawisz na klawiaturze"; + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: heard_first.into(), + segments: vec![segment(heard_first, 0.0, 5.0)], + }], + &tx, + &mut state, + 40.0, + ); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: restated.into(), + segments: vec![segment(restated, 0.0, 6.0)], + }], + &tx, + &mut state, + 40.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let finals: Vec<&String> = events + .iter() + .filter_map(|e| match e { + EngineEvent::UtteranceFinal { text, .. } => Some(text), + _ => None, + }) + .collect(); + let all = finals + .iter() + .map(|t| normalize_for_containment(t)) + .collect::>() + .join(" "); + let count = all.matches("lewy przycisk myszy").count(); + assert_eq!( + count, 1, + "a restatement with one revised opening word must not re-commit the whole phrase: {finals:?}" + ); + } + + /// A later cumulative final can be entirely covered by already-committed + /// spans. It is not an active tail: surfacing the whole callback as Preview + /// makes the presentation reducer render `committed + restatement` and the + /// delivery buffer duplicates the take at stop. + #[test] + fn fully_reheard_cumulative_final_clears_preview_instead_of_repeating_canvas() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 8.0); + + let heard_first = "szuty klawiszowe to podwójny lewy przycisk myszy"; + let restated = + "skróty klawiszowe to podwójny lewy przycisk myszy lub klawisz na klawiaturze"; + + for (text, end) in [(heard_first, 5.0), (restated, 6.0), (restated, 6.5)] { + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: text.into(), + segments: vec![segment(text, 0.0, end)], + }], + &tx, + &mut state, + 8.0, + ); + } + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let last_preview = events.iter().rev().find_map(|event| match event { + EngineEvent::Preview { text, .. } => Some(text.as_str()), + _ => None, + }); + assert_eq!( + last_preview, + Some(""), + "a fully re-heard final must clear the volatile tail, not repeat the canvas: {events:?}" + ); + assert!( + state.open_partial.is_empty(), + "a fully re-heard final must not survive as stop-time open partial" + ); + } + + #[test] + fn legitimate_repeated_words_survive_disjoint_apple_windows() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 3.0); + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "tak".into(), + segments: vec![segment("tak", 0.0, 1.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "tak".into(), + segments: vec![segment("tak", 1.0, 2.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + let raw_finals = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { raw_text, .. } => Some(raw_text), + _ => None, + }) + .collect::>(); + assert_eq!(raw_finals, vec!["tak", "tak"]); + } + + /// Falsification arm: an `end_ts` that does not describe this session's PCM + /// must be counted and surfaced, never silently truncated into a window. + #[test] + fn seal_window_beyond_captured_audio_is_counted_unresolved() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 2.0); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "zdanie z przyszlosci".into(), + segments: vec![segment("zdanie z przyszlosci", 8.0, 9.0)], + }], + &tx, + &mut state, 2.0, ); @@ -1182,7 +3284,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::PhraseFinal { text: "uruchom doker teraz".into(), - segments: vec![], + segments: vec![segment("uruchom doker teraz", 0.0, 1.0)], }], &tx, &mut state, @@ -1212,6 +3314,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::Partial { text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.0, 1.0)], }], &tx, &mut state, @@ -1236,7 +3339,7 @@ mod tests { // Trailing-":D" burst: a known ASR artifact that cleanup strips // to nothing. text: ":D".into(), - segments: vec![], + segments: vec![segment(":D", 0.0, 1.0)], }], &tx, &mut state, @@ -1266,7 +3369,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::Summary { text: "zbuduj obraz doker".into(), - segments: vec![], + segments: vec![segment("zbuduj obraz doker", 0.0, 2.0)], ok: true, error: None, }], @@ -1292,7 +3395,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::PhraseFinal { text: "uruchom doker teraz".into(), - segments: vec![], + segments: vec![segment("uruchom doker teraz", 0.0, 1.0)], }], &tx, &mut state, @@ -1312,33 +3415,6 @@ mod tests { // ── W2-A · Layer 1 tail-patch on the Apple progressive path ────────────── - /// Restores an env var to its pre-test value, so a serial env test cannot - /// leak a phase flag into the rest of the binary. - struct EnvRestore { - key: &'static str, - previous: Option, - } - - impl EnvRestore { - /// Snapshot `key`'s current value (or absence) for later restore on drop. - fn capture(key: &'static str) -> Self { - Self { - key, - previous: std::env::var_os(key), - } - } - } - - impl Drop for EnvRestore { - /// Put the env var back exactly as it was when `capture` ran. - fn drop(&mut self) { - match &self.previous { - Some(value) => unsafe { std::env::set_var(self.key, value) }, - None => unsafe { std::env::remove_var(self.key) }, - } - } - } - /// Collecting sink for Layer 1 / SessionFinalised event assertions. #[derive(Default)] struct RecordingSink(Mutex>); @@ -1357,6 +3433,69 @@ mod tests { } } + fn synthetic_tail_job(utterance_id: u64, outcome: TailPatchOutcome) -> TailPatchJobResult { + let range = TailSampleRange { + session: "test-session".to_string(), + capture_epoch: 0, + sample_start: 0, + sample_end: 0, + }; + TailPatchJobResult { + utterance_id, + outcome, + payload: synthetic_tail_payload(utterance_id, range, Vec::new()), + } + } + + /// Computing a bearing patch is not delivery. The worker can already have + /// timed out and dropped its completion receiver; counting before that + /// hand-off makes `tail_patch_session_receipt` claim patches that never + /// reached the canvas. + #[test] + fn finishing_tail_patch_does_not_count_before_worker_accepts_it() { + let mut lane = AppleTailPatchLane::new(TEST_SAMPLE_RATE, None); + let outcome = compute_tail_patch( + "ala ma kota w domu", + "ala ma kota w domu swoim", + 1, + &TailPatchConfig::default(), + ); + let completion = lane.finish_for_worker(1, 2.0, Ok(synthetic_tail_job(1, outcome))); + assert!( + completion + .outcome + .events() + .iter() + .any(|event| matches!(event, EngineEvent::ReplaceRange { .. })), + "fixture must carry a bearing patch" + ); + assert_eq!( + lane.replacements(), + 0, + "completion construction alone must not report canvas delivery" + ); + + let (done_tx, done_rx) = std_mpsc::channel(); + assert!(lane.forward_completion_to_worker(&done_tx, completion)); + assert_eq!(lane.replacements(), 1); + let _accepted = done_rx.try_recv().expect("live worker receives completion"); + + drop(done_rx); + let rejected_outcome = compute_tail_patch( + "drugi fragment", + "drugi fragment odzyskany", + 2, + &TailPatchConfig::default(), + ); + let rejected = lane.finish_for_worker(2, 3.0, Ok(synthetic_tail_job(2, rejected_outcome))); + assert!(!lane.forward_completion_to_worker(&done_tx, rejected)); + assert_eq!( + lane.replacements(), + 1, + "a closed worker cannot turn computed text into reported delivery" + ); + } + /// Wiring contract: a sealed utterance must hand Layer 1 the exact audio /// behind it plus the exact committed string `ReplaceRange` offsets are /// computed against. Anything else patches canvas from the wrong source. @@ -1390,6 +3529,130 @@ mod tests { 2 * TEST_SAMPLE_RATE as usize, "window is [previous seal end, end_ts) at session rate" ); + assert_eq!(req.provider_request.identity.range.sample_start, 0); + assert_eq!(req.provider_request.identity.range.sample_end, 32_000); + assert_eq!(req.provider_request.identity.request_id, req.utterance_id); + assert_eq!( + state.tail_patch_awaiting_completion, 1, + "an accepted request is what the end-of-session closure loop owes a wait to" + ); + } + + /// A first final that arrives after the retention horizon must not poison + /// the whole session. Measured live 2026-08-14: a 247 s take whose first + /// SFSpeech final came at 156 s went 11/11 unresolved — `last_sealed_end` + /// stayed 0.0 because it only advances on success, so Layer 1 received + /// zero windows for the entire take. The window start clamps to retained + /// audio (everything older is committed canvas by definition); a genuinely + /// lying `end_ts` stays fail-closed. + #[test] + fn seal_window_clamps_start_after_retention_eviction() { + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 200.0); + let retained_start = state.audio.retained_start_secs(); + assert!( + retained_start > 0.0, + "fixture must push past the retention cap to evict the session head" + ); + + let window = resolve_sealed_audio_window(&mut state, 150.0) + .expect("stale `from` must clamp to retained audio, not fail the take"); + assert_eq!( + window.sample_start, + (retained_start as f64 * TEST_SAMPLE_RATE as f64) as u64, + "clamped window starts at the oldest retained sample" + ); + assert_eq!(window.sample_end, 150 * TEST_SAMPLE_RATE as u64); + + // The poison spiral is broken: the next window chains normally. + let next = resolve_sealed_audio_window(&mut state, 180.0) + .expect("later windows must resolve once the first seal landed"); + assert_eq!(next.sample_start, 150 * TEST_SAMPLE_RATE as u64); + + // A boundary that precedes the already-sealed canvas is still a lie. + assert!( + resolve_sealed_audio_window(&mut state, 100.0).is_none(), + "end_ts behind the sealed canvas must stay fail-closed" + ); + assert_eq!(state.unresolved_windows, 1); + } + + /// SFSpeech may report a word end a few milliseconds past PCM capture. + /// Ingestion clamps it once onto the integer sample clock; later stages do + /// not compare the two floating clocks as if they were identical. + #[test] + fn apple_segments_map_to_captured_pcm_samples_at_ingestion() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 2.0); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "zegar pcm".into(), + segments: vec![segment("zegar pcm", 0.5, 2.002)], + }], + &tx, + &mut state, + 2.0, + ); + + let sealed = &state.progressive.sealed_spans()[0]; + assert_eq!(sealed.range.sample_start, 8_000); + assert_eq!(sealed.range.sample_end, 32_000); + assert_eq!(sealed.words[0].range.sample_end, 32_000); + assert_eq!(sealed.end_secs_millis, 2_002, "legacy adapter unchanged"); + } + + /// The closure loop must wait on outstanding Layer 1 *jobs*, never on the + /// pending-seal queue. The two diverge the moment a span is held by the + /// Apple volatile window: no completion can clear that gate, so a loop + /// watching the seal queue waits for an event that is not coming. That is + /// what parked the stop path for the full timeout on 2026-08-12. + #[test] + fn tail_patch_closure_counter_tracks_jobs_not_pending_seals() { + let (tx, _rx) = mpsc::unbounded_channel(); + let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); + let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); + push_capture(&mut state, 6.0); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], + }], + &tx, + &mut state, + 6.0, + ); + let req = tp_rx + .try_recv() + .expect("sealed utterance enqueues a request"); + assert_eq!(state.tail_patch_awaiting_completion, 1); + + // Close the job on a clock that is still inside the span's volatile + // window — the exact shape the old exit condition could not express. + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: req.utterance_id, + covered_through_secs: req.covered_through_secs, + outcome: TailPatchOutcome::skipped( + crate::stt::tail_patcher::SkipReasonCode::EmptyRetranscription, + "no change", + ), + payload: None, + }, + 2.1, + ); + + assert_eq!( + state.tail_patch_awaiting_completion, 0, + "every job reported back — the stop path owes no further wait" + ); + assert!( + !state.progressive.pending_spans().is_empty(), + "yet a span is still pending: waiting on this queue would hang on nothing" + ); } /// F3 carry-over: a boundary that does not address retained audio already @@ -1492,7 +3755,7 @@ mod tests { 1, &TailPatchConfig::default(), ); - lane.push_job(Box::pin(async move { Ok((1u64, outcome)) })); + lane.push_job(Box::pin(async move { Ok(synthetic_tail_job(1, outcome)) })); let result = lane.next().await.expect("one job in flight"); lane.complete(&sink, result); @@ -1543,12 +3806,17 @@ mod tests { matches!(outcome, TailPatchOutcome::Skipped { .. }), "shared threshold must reject a wholesale divergence" ); - lane.push_job(Box::pin(async move { Ok((1u64, outcome)) })); + lane.push_job(Box::pin(async move { Ok(synthetic_tail_job(1, outcome)) })); let result = lane.next().await.expect("one job in flight"); lane.complete(&sink, result); assert_eq!(lane.replacements(), 0); + assert_eq!( + lane.skipped(), + 1, + "a skipped job must count toward the starvation receipt" + ); assert!( sink.events().is_empty(), "a skipped patch must not touch committed canvas" @@ -1559,18 +3827,20 @@ mod tests { /// live tail patch is a core element of the triangulation, not an opt-in /// (operator directive 2026-08-09). Explicit `off` is the one way out. #[test] - #[serial] fn apple_tail_patch_lane_is_wired_by_default_and_off_disarms() { - let _restore = EnvRestore::capture(LAYERED_TRANSCRIPTION_ENV); - - unsafe { std::env::remove_var(LAYERED_TRANSCRIPTION_ENV) }; - assert!(tail_patch_enabled(), "default arms the live tail patch"); - - unsafe { std::env::set_var(LAYERED_TRANSCRIPTION_ENV, "off") }; - assert!(!tail_patch_enabled(), "explicit off disarms"); - - unsafe { std::env::set_var(LAYERED_TRANSCRIPTION_ENV, "phase1") }; - assert!(tail_patch_enabled(), "phase1 arms Layer 1"); + assert!( + layered_phase_from_raw(None).is_some_and(|phase| phase >= 1), + "the unset production default must arm the live tail patch" + ); + assert!( + parse_layered_phase_value("off").is_none(), + "explicit off disarms" + ); + assert_eq!( + parse_layered_phase_value("phase1"), + Some(1), + "phase1 arms Layer 1" + ); } /// Bridge stdout lines with multiple `final` events parse as phrase seals. @@ -1596,6 +3866,51 @@ mod tests { // ── w1-b utterance_drop: shared_opener_restart_suppresses_freeze ──────── + /// One checked-in vector source is consumed by this Rust mirror and the + /// Swift bridge self-test. The measured 40→20 non-prefix collapse is the + /// RED discriminator: threshold-only restart detection currently loses it. + #[test] + fn fleet_red_retention_missed_collapse_40_to_20() { + let vectors = include_str!("../../../tests/fixtures/phrase_restart_vectors.tsv"); + let required_ids = [ + "measured_restart_47_to_12", + "measured_revision_95_to_79", + "missed_collapse_40_to_20", + "shared_opener_sentence_restart", + "shared_opener_spoken_variant", + ]; + let mut seen_ids = std::collections::BTreeSet::new(); + + for line in vectors.lines().filter(|line| !line.starts_with('#')) { + let fields: Vec<_> = line.split('\t').collect(); + assert_eq!(fields.len(), 4, "malformed phrase restart vector: {line}"); + seen_ids.insert(fields[0]); + let expected = fields[1] + .parse::() + .expect("expected_freeze must be true or false"); + let actual = phrase_restart_should_freeze_prior(fields[2], fields[3]); + if fields[0] == "missed_collapse_40_to_20" { + assert_eq!(fields[2].chars().count(), 40); + assert_eq!(fields[3].chars().count(), 20); + } + assert_eq!( + actual, + expected, + "phrase restart vector {} diverged: prev_chars={} next_chars={}", + fields[0], + fields[2].chars().count(), + fields[3].chars().count() + ); + } + + for required_id in required_ids { + assert!( + seen_ids.contains(required_id), + "required phrase restart vector missing: {required_id}" + ); + } + } + /// Measured three-way pattern: after a long open partial, SFSpeech collapses /// onto the next sentence's shared opener (`Zdanie`). That collapse MUST /// freeze the prior utterance — the old rule did not, and s6/s8/s10 vanished. @@ -1616,31 +3931,38 @@ mod tests { ); } - /// Mid-phrase revision keeps most of the text without being a prefix - /// collapse — must NOT freeze (would double-seal the same span). + /// Revisions and rewinds must retain the prior text; only a forward + /// extension that contains the full prior hypothesis may replace it. #[test] - fn utterance_drop_revision_mid_reword_does_not_freeze() { - // 95 → 79 char mid-reword: not a restart collapse by the measured rule. + fn utterance_drop_revision_and_rewind_retain_prior() { + // 95 → 79 char mid-reword is classified as a revision, but still + // freezes because otherwise its removed span has no retained copy. let prev = format!("{}MIDDLE{}", "x".repeat(50), "y".repeat(39)); let next = format!("{}REVISE{}", "x".repeat(50), "y".repeat(23)); assert_eq!(prev.len(), 95); assert_eq!(next.len(), 79); assert!( - !phrase_restart_should_freeze_prior(&prev, &next), - "revision must not freeze (residual duplication bar)" + phrase_restart_should_freeze_prior(&prev, &next), + "revision must retain the prior hypothesis" ); - // Growth is never a restart. + // Forward growth contains the complete prior hypothesis. assert!(!phrase_restart_should_freeze_prior( "Zdanie", "Zdanie szóste spokojnie" )); - // Same-phrase rewind to a substantial true prefix stays open. + // Same-phrase rewind is not safe unless the prior copy is retained. let long = "Hello world this is a long phrase that continues for a while more text here"; let rewind: String = long.chars().take(40).collect(); assert!( - !phrase_restart_should_freeze_prior(long, &rewind), - "substantial true-prefix rewind is same-phrase, not a freeze" + phrase_restart_should_freeze_prior(long, &rewind), + "substantial true-prefix rewind must retain its removed suffix" ); + assert!(phrase_restart_should_freeze_prior(long, "")); + assert!(!phrase_restart_should_freeze_prior("", "new phrase")); + assert!(!phrase_restart_should_freeze_prior( + "middle retained", + "new prefix middle retained and suffix" + )); } /// End-to-end at the adjudication layer: a partial sequence that used to @@ -1658,27 +3980,39 @@ mod tests { vec![ LiveStreamEvent::Partial { text: s5.to_string(), + segments: vec![segment(s5, 0.0, 5.0)], }, // Stressor phrase seals cleanly (isFinal or prior freeze). LiveStreamEvent::PhraseFinal { text: s5.to_string(), - segments: vec![], + segments: vec![segment(s5, 0.0, 5.0)], }, // Post-stressor sentence builds as open partial… LiveStreamEvent::Partial { text: s6.to_string(), + segments: vec![segment(s6, 5.0, 10.0)], }, // …then SFSpeech restarts onto the next opener without isFinal. // Old rule overwrote s6; new rule freezes it first. LiveStreamEvent::Partial { text: "Zdanie".to_string(), + segments: vec![segment("Zdanie", 10.0, 10.5)], }, LiveStreamEvent::Partial { text: "Zdanie siódme Overlap cztery angielskie terminy w polskim".to_string(), + segments: vec![segment( + "Zdanie siódme Overlap cztery angielskie terminy w polskim", + 10.0, + 15.0, + )], }, LiveStreamEvent::PhraseFinal { text: "Zdanie siódme Overlap cztery angielskie terminy w polskim".to_string(), - segments: vec![], + segments: vec![segment( + "Zdanie siódme Overlap cztery angielskie terminy w polskim", + 10.0, + 15.0, + )], }, ], &tx, @@ -1710,4 +4044,446 @@ mod tests { state.sealed_count ); } + + // ═══════════════════════════════════════════════════════════ + // Engine lifecycle: speech epochs (hands-free silence) + // ═══════════════════════════════════════════════════════════ + + /// Amplitude stand-in for the session Silero's `speech_live` bit, so the + /// epoch state machine can be driven on synthetic PCM without loading the + /// VAD model (unit tests must not depend on `init_silero_vad` succeeding). + fn amplitude_edge(samples: &[f32], threshold: f32) -> bool { + samples.iter().any(|s| s.abs() >= threshold) + } + + /// One second of 200 Hz tone at `amplitude`, the "speech" side of the fixture. + fn tone(secs: f32, amplitude: f32) -> Vec { + let total = (secs * TEST_SAMPLE_RATE as f32) as usize; + (0..total) + .map(|i| { + let t = i as f32 / TEST_SAMPLE_RATE as f32; + amplitude * (2.0 * std::f32::consts::PI * 200.0 * t).sin() + }) + .collect() + } + + fn silence(secs: f32) -> Vec { + vec![0.0; (secs * TEST_SAMPLE_RATE as f32) as usize] + } + + /// Drive the gate the way the worker does — chunk by chunk — collecting + /// every decision together with the cursor it was taken at. + fn drive(gate: &mut EpochGate, audio: &[f32], samples_seen: &mut u64) -> Vec { + let mut out = Vec::new(); + for chunk in audio.chunks(1024) { + *samples_seen += chunk.len() as u64; + out.push(gate.feed_pcm(chunk, *samples_seen, amplitude_edge(chunk, 0.1))); + } + out + } + + /// Timestamp shim: bridge time is per-epoch (seconds since that SFSpeech + /// request opened), so every event leaving a non-zero epoch must be lifted + /// onto the session PCM clock before any seal maps it to samples. + #[test] + fn epoch_shift_lifts_segment_times_onto_the_session_pcm_clock() { + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 110.0); + + let shifted = shift_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], + }], + 100.0, + ); + let LiveStreamEvent::PhraseFinal { segments, .. } = &shifted[0] else { + panic!("shim must preserve the event kind"); + }; + assert_eq!(segments[0].start_ts, 100.5); + assert_eq!(segments[0].end_ts, 102.0); + + let on_pcm = apple_segments_on_pcm_clock(&state, segments); + assert_eq!( + on_pcm[0].range.sample_start, + (100.5 * TEST_SAMPLE_RATE as f32) as u64 + ); + assert_eq!( + on_pcm[0].range.sample_end, + (102.0 * TEST_SAMPLE_RATE as f32) as u64 + ); + } + + /// The first epoch is based at 0, so the shim must be the identity there — + /// this is what keeps a single-epoch take bit-identical to the legacy lane. + #[test] + fn epoch_shift_at_base_zero_is_identity() { + let shifted = shift_events( + vec![ + LiveStreamEvent::Partial { + text: "uruchom".into(), + segments: vec![segment("uruchom", 0.5, 2.0)], + }, + LiveStreamEvent::Summary { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 4.0)], + ok: true, + error: None, + }, + ], + 0.0, + ); + let LiveStreamEvent::Partial { segments, .. } = &shifted[0] else { + panic!("kind preserved"); + }; + assert_eq!((segments[0].start_ts, segments[0].end_ts), (0.5, 2.0)); + let LiveStreamEvent::Summary { segments, .. } = &shifted[1] else { + panic!("kind preserved"); + }; + assert_eq!((segments[0].start_ts, segments[0].end_ts), (0.5, 4.0)); + } + + /// Engine lifecycle: speech opens an epoch, silence past the product + /// threshold closes it, and the next speech edge wakes a new one whose + /// base carries the pre-roll. + #[test] + fn epoch_gate_sleeps_after_threshold_silence_and_wakes_with_preroll() { + let mut gate = EpochGate::armed(TEST_SAMPLE_RATE, 5.0); + let mut seen = 0u64; + + let speech = drive(&mut gate, &tone(2.0, 0.5), &mut seen); + assert!( + matches!( + speech.first(), + Some(EpochDecision::Wake { preroll_from: 0 }) + ), + "first speech chunk must open epoch 0 (nothing retained before it), got {:?}", + speech.first() + ); + assert!( + speech[1..].iter().all(|d| *d == EpochDecision::Forward), + "speech after the wake must forward, got {:?}", + &speech[1..] + ); + + let quiet = drive(&mut gate, &silence(6.0), &mut seen); + let sleep_at = quiet + .iter() + .position(|d| matches!(d, EpochDecision::Sleep { .. })) + .expect("6 s of silence at a 5 s threshold must close the epoch"); + let sleep_secs = (sleep_at + 1) as f32 * 1024.0 / TEST_SAMPLE_RATE as f32; + assert!( + (5.0..5.2).contains(&sleep_secs), + "epoch must close within a chunk of the 5 s threshold, closed at {sleep_secs}s" + ); + assert!( + quiet[sleep_at + 1..] + .iter() + .all(|d| *d == EpochDecision::Idle), + "after sleeping the engine rests until the next speech edge" + ); + + let sleep_cursor = seen - (quiet.len() - sleep_at - 1) as u64 * 1024; + let resume_cursor = seen; + let woke = drive(&mut gate, &tone(1.0, 0.5), &mut seen); + let EpochDecision::Wake { preroll_from } = woke[0] else { + panic!("speech after rest must wake a new epoch, got {:?}", woke[0]); + }; + let preroll = (EPOCH_PREROLL_SECS * TEST_SAMPLE_RATE as f32) as u64; + assert_eq!( + preroll_from, + resume_cursor.saturating_sub(preroll), + "the new epoch base is one pre-roll ahead of the waking chunk" + ); + assert!( + preroll_from >= sleep_cursor, + "pre-roll must not reach back into the closed epoch ({preroll_from} < {sleep_cursor})" + ); + } + + /// `utterance_silence_sec: None` is the legacy contract: one stream for the + /// whole take, no epoch decisions at all. + #[test] + fn epoch_gate_disarmed_never_sleeps_or_wakes() { + let mut gate = EpochGate::disarmed(); + assert!(!gate.is_armed()); + let mut seen = 0u64; + let mut decisions = drive(&mut gate, &tone(1.0, 0.5), &mut seen); + decisions.extend(drive(&mut gate, &silence(30.0), &mut seen)); + decisions.extend(drive(&mut gate, &tone(1.0, 0.5), &mut seen)); + assert!( + decisions.iter().all(|d| *d == EpochDecision::Forward), + "disarmed gate must forward every chunk, got {:?}", + decisions + .iter() + .filter(|d| **d != EpochDecision::Forward) + .collect::>() + ); + } + + /// No Silero ⇒ no edges ⇒ the lifecycle must NOT arm, or the take would rest + /// forever on a stream that never opened. Fail open, every time. + #[test] + fn epoch_gate_without_speech_edges_falls_back_to_one_stream() { + let gate = EpochGate::for_session(TEST_SAMPLE_RATE, Some(5.0), false); + assert!( + !gate.is_armed(), + "an armed gate with no edge source would sleep the engine forever" + ); + let armed = EpochGate::for_session(TEST_SAMPLE_RATE, Some(5.0), true); + assert!(armed.is_armed()); + assert!( + !EpochGate::for_session(TEST_SAMPLE_RATE, None, true).is_armed(), + "no hands-free silence setting is still the legacy single stream" + ); + } + + // ═══════════════════════════════════════════════════════════ + // Utterance identity bound to the spectrum + // ═══════════════════════════════════════════════════════════ + + /// Samples per second at the test rate, as a `u64` sample cursor. + fn at(secs: f32) -> u64 { + (secs * TEST_SAMPLE_RATE as f32) as u64 + } + + /// Arm a state with the session Silero and mint two utterances separated by + /// a silence wider than the long-silence fence, exactly as the Supervisor + /// would: an open edge that extends, then a close, then a new edge. + /// + /// The ledger is driven through the production decision function + /// ([`SileroIngress::observe`]) rather than a synthetic ledger, so what the + /// seal reads is what a real chunk observation produces. Only the two facts + /// Silero derives from the waveform are supplied by the fixture — the unit + /// suite must not depend on `init_silero_vad` succeeding. + fn arm_two_utterances(state: &mut AppleSealState) -> (u64, u64) { + let mut ingress = SileroIngress::new(TEST_SAMPLE_RATE, state.session_id.clone(), 0); + let first = ingress + .observe(Some((at(0.0), at(1.0))), false, at(1.0)) + .open + .expect("first speech edge mints an identity"); + ingress.observe(Some((at(0.0), at(2.0))), false, at(2.0)); + let closed = ingress.observe(None, true, at(2.0)).closed; + assert_eq!(closed, vec![first]); + + // Silence well past LONG_SILENCE_FENCE_SECS, then a second edge. + let gap = at(super::super::silero_fusion::LONG_SILENCE_FENCE_SECS) + at(1.0); + let second_start = at(2.0) + gap; + let second = ingress + .observe( + Some((second_start, second_start + at(2.0))), + false, + second_start + at(2.0), + ) + .open + .expect("speech after the fence mints a SECOND identity"); + assert_ne!(first, second, "the fence must split identity"); + + state.fusion = Some(ingress); + state.fusion_seal_armed = true; + (first, second) + } + + /// (a) Utterance identity comes from the spectrum, and the seal carries it. + /// + /// Two Apple finals landing inside two Silero-bounded utterances must seal + /// as two spans whose ids ARE the ledger ids and whose ranges ARE the + /// ledger ranges — not Apple's own segment boundaries. + #[test] + fn sealed_spans_take_identity_and_range_from_silero_edges() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 12.0); + let (first, second) = arm_two_utterances(&mut state); + let ledger = state.fusion.as_ref().unwrap().ledger().clone(); + + // One final inside utterance 1, one inside utterance 2. + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "pierwsza fraza".into(), + segments: vec![segment("pierwsza fraza", 0.2, 1.8)], + }], + &tx, + &mut state, + 2.2, + ); + let second_start = + ledger.utterances()[1].range.sample_start as f32 / TEST_SAMPLE_RATE as f32; + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "druga fraza".into(), + segments: vec![segment( + "druga fraza", + second_start + 0.2, + second_start + 1.8, + )], + }], + &tx, + &mut state, + second_start + 2.2, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!(sealed.len(), 2, "two utterances ⇒ two spans: {sealed:#?}"); + for (span, utterance_id) in sealed.iter().zip([first, second]) { + let utterance = ledger + .utterances() + .iter() + .find(|u| u.id == utterance_id) + .expect("fixture identity must exist in the ledger"); + assert_eq!( + span.silero_utterance_id, + Some(utterance_id), + "span {} did not record the spectrum edge it came from", + span.id + ); + assert_eq!( + span.range.sample_start, utterance.range.sample_start, + "span {} start is not the Silero edge", + span.id + ); + assert_eq!( + span.range.sample_end, utterance.range.sample_end, + "span {} end is not the Silero edge", + span.id + ); + } + assert_ne!( + sealed[0].silero_utterance_id, sealed[1].silero_utterance_id, + "a fenced silence must produce two DIFFERENT identities" + ); + } + + /// (c) Words stay pinned to the PCM counter after binding: every Apple word + /// range on a bound span lies inside the utterance range it was bound to. + /// This is the "words on spectrum events" claim — without it a span could + /// carry an utterance id while its words describe other seconds. + #[test] + fn bound_span_words_stay_inside_their_utterance_on_the_pcm_clock() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 12.0); + arm_two_utterances(&mut state); + let ledger = state.fusion.as_ref().unwrap().ledger().clone(); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom", 0.2, 0.9), segment("doker", 0.9, 1.8)], + }], + &tx, + &mut state, + 2.2, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!(sealed.len(), 1); + let span = &sealed[0]; + let utterance_id = span + .silero_utterance_id + .expect("the span must be bound to an edge"); + let utterance = ledger + .utterances() + .iter() + .find(|u| u.id == utterance_id) + .unwrap(); + assert!(!span.words.is_empty(), "a bound span must keep its words"); + for word in &span.words { + assert!( + word.range.sample_start >= utterance.range.sample_start + && word.range.sample_end <= utterance.range.sample_end, + "word {:?} at {}..{} escapes utterance {} at {}..{}", + word.text, + word.range.sample_start, + word.range.sample_end, + utterance_id, + utterance.range.sample_start, + utterance.range.sample_end + ); + assert!( + word.range.sample_start < word.range.sample_end, + "a word must occupy real samples, not a point" + ); + } + assert_eq!( + span.words.first().unwrap().range.sample_start, + at(0.2), + "word start must stay on the PCM counter it was mapped from" + ); + assert_eq!(span.words.last().unwrap().range.sample_end, at(1.8)); + } + + /// A span the spectrum does not enclose keeps Apple's own range and records + /// no identity — binding is fail-open and never costs content. + #[test] + fn span_outside_every_silero_edge_keeps_the_apple_range() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 30.0); + arm_two_utterances(&mut state); + + // 20 s is past every minted edge; slicing finds no cover either, so the + // Apple-boundary path runs and must still seal. + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "poza spektrum".into(), + segments: vec![segment("poza spektrum", 20.0, 21.0)], + }], + &tx, + &mut state, + 21.5, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!( + sealed.len(), + 1, + "content must never be dropped for want of an edge" + ); + assert_eq!( + sealed[0].silero_utterance_id, None, + "no enclosing edge ⇒ no identity claimed" + ); + assert_eq!(sealed[0].range.sample_start, at(20.0)); + assert_eq!(sealed[0].range.sample_end, at(21.0)); + assert!( + !state + .fusion + .as_ref() + .unwrap() + .ledger() + .utterances() + .iter() + .any(|u| u.id == sealed[0].id), + "the fallback id must be reserved out of the ledger's id space, \ + never collide with a minted utterance" + ); + } + + /// (b) Fail-open: no Silero at all is today's behaviour, bit for bit. + /// Spans still seal, on Apple's own boundaries, with no identity claimed. + #[test] + fn without_silero_the_seal_path_is_unchanged() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 12.0); + assert!(state.fusion.is_none(), "fixture has no VAD"); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], + }], + &tx, + &mut state, + 2.2, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!(sealed.len(), 1); + assert_eq!(sealed[0].id, 1, "legacy ids still start at 1"); + assert_eq!(sealed[0].silero_utterance_id, None); + assert_eq!(sealed[0].range.sample_start, at(0.5)); + assert_eq!(sealed[0].range.sample_end, at(2.0)); + } } diff --git a/core/pipeline/streaming/correction.rs b/core/pipeline/streaming/correction.rs index 480582b5..5dd67586 100644 --- a/core/pipeline/streaming/correction.rs +++ b/core/pipeline/streaming/correction.rs @@ -734,13 +734,16 @@ pub(crate) fn schedule_partial_pass( "BOUNDARY correction_scheduled" ); + let window_prompt = crate::pipeline::stream_postprocess::compose_whisper_window_prompt( + previous_window_prompt.as_deref(), + ); match stt_scheduler.submit_for_utterance_with_prompt( SttLane::Refine, audio, output_sample_rate, pipeline_language, window_id, - previous_window_prompt, + window_prompt, ) { Ok(handle) => { rolling_window.commit(correction_audio_buf, output_sample_rate, &candidate); diff --git a/core/pipeline/streaming/emitter.rs b/core/pipeline/streaming/emitter.rs index 4c353215..335d8c2b 100644 --- a/core/pipeline/streaming/emitter.rs +++ b/core/pipeline/streaming/emitter.rs @@ -154,8 +154,12 @@ impl BufferedEmitter { } /// Overwrite the shared transcript buffer wholesale, bypassing the animation. - /// For callers that already hold the authoritative text (e.g. a finalized - /// pass) and need the buffer to match immediately. + /// + /// This is the ONLY writer of the shared buffer. The tick loop animates the + /// `DeltaSink` but never touches the buffer: when both wrote (snapshot of the + /// full target + the same suffix appended again by the animation), repeated + /// sentences tripled in the final RAW (2026-08-14 incident: a 264-char + /// cumulative preview became a 791-char transcript). pub async fn store_transcript_snapshot(&self, snapshot: String) { let mut buffer = self.transcript_buffer.lock().await; *buffer = snapshot; @@ -216,10 +220,6 @@ impl BufferedEmitter { && let Some(delta) = build_redacted_delta(&self.emitted_text, &corrected) { apply_delta_to_string(&mut self.emitted_text, &delta); - { - let mut buffer = self.transcript_buffer.lock().await; - *buffer = self.emitted_text.clone(); - } if let Some(sink) = &self.delta_callback { sink.apply(&TranscriptDelta::from_raw(&delta)); } @@ -245,10 +245,6 @@ impl BufferedEmitter { if let Some(delta) = self.next_emit_chunk() { self.has_output = true; self.emitted_text.push_str(&delta); - { - let mut buffer = self.transcript_buffer.lock().await; - apply_delta_to_string(&mut buffer, &delta); - } if let Some(sink) = &self.delta_callback { sink.apply(&TranscriptDelta::from_raw(&delta)); diff --git a/core/pipeline/streaming/live_assembly.rs b/core/pipeline/streaming/live_assembly.rs index 476727de..b81e19ff 100644 --- a/core/pipeline/streaming/live_assembly.rs +++ b/core/pipeline/streaming/live_assembly.rs @@ -1,7 +1,8 @@ //! Product live assembly: freezed sealed utterances + open interim tail. //! //! This is the Codescribe engine contract for overlay and delivery floor: -//! - each non-empty `UtteranceFinal` freezes a segment (append) +//! - each new non-empty `UtteranceFinal` ID freezes a segment; the same ID +//! revises that slot in place //! - `Preview` / `Correction` only replace the open tail //! - full live text = freezed segments joined + optional open preview //! @@ -69,8 +70,12 @@ pub fn assemble_live_from_events(events: &[EngineEvent]) -> LiveAssembly { } => { let trimmed = text.trim(); if !trimmed.is_empty() { - freezed.push(trimmed.to_string()); - freezed_ids.push(*utterance_id); + if let Some(index) = freezed_ids.iter().rposition(|id| id == utterance_id) { + freezed[index] = trimmed.to_string(); + } else { + freezed.push(trimmed.to_string()); + freezed_ids.push(*utterance_id); + } } preview.clear(); } @@ -80,14 +85,9 @@ pub fn assemble_live_from_events(events: &[EngineEvent]) -> LiveAssembly { // has to be visible here too — otherwise a layered-on run reads // exactly like a layered-off one and the bar gates nothing. // - // `rposition` mirrors `lastIndex(where:)` in BOTH Swift replays — - // `OverlayState.onReplaceRange` and `ComposerDictation` - // `.onReplaceRange` — so if an id was sealed twice all three patch - // the same slot. The mirror covers the PATCH lookup only: on a - // repeated seal this assembly appends a slot while both Swift - // surfaces overwrite in place, which is a real divergence and is - // pinned (not fixed) by - // `re_sealed_utterance_id_duplicates_here_but_not_in_the_swift_surfaces`. + // `rposition` mirrors `lastIndex(where:)` in both Swift replays. + // Finals are upserted by id above, so this always resolves the same + // unique slot as the production presentation reducer. EngineEvent::ReplaceRange { utterance_id, .. } => { if let Some(index) = freezed_ids.iter().rposition(|id| id == utterance_id) { // Out-of-range windows are dropped, not clamped: a patch @@ -134,33 +134,10 @@ mod tests { } } - /// Characterization, not aspiration: this pins what a **re-sealed** - /// utterance id does here, because the three surfaces that replay this same - /// event stream disagree about it and nothing was measuring the gap. - /// - /// | surface | on a repeated `UtteranceFinal` id | on `ReplaceRange` | - /// |---|---|---| - /// | this assembly (measured by the parity harness) | **appends a 2nd slot** | `rposition` → the 2nd | - /// | `OverlayState.swift:1580` (what the operator sees) | **updates in place** | `lastIndex` → the only one | - /// | `ComposerDictation.swift:257` (the agent draft) | **updates in place** | `firstIndex` → the only one | - /// - /// So under a re-seal the measured assembly renders the utterance TWICE - /// while both shipped UIs render it once. That is not an index nit: it is - /// the parity harness scoring text the product never displays, and it - /// surfaces as inflated word count — the exact shape - /// `e2e_apple_live_parity`'s ratio bar reports as "duplicated phrases". - /// - /// The contract (`EngineEvent::UtteranceFinal`, contracts.rs) says "emitted - /// once per VAD-bounded speech segment", so today this is unreachable and - /// all three agree by accident of uniqueness. Nothing enforces it, and - /// `rposition`'s own comment reasons about "if an id was sealed twice" — so - /// the assumption is load-bearing, unstated, and defended three different - /// ways. Whether the fix is to enforce uniqueness upstream or to make this - /// assembly upsert like the UIs do is a design decision with parity-number - /// consequences; this test only makes the divergence impossible to ship - /// unnoticed. + /// Re-sealing one utterance id revises its existing slot. This mirrors the + /// production presentation reducer and both Swift live surfaces. #[test] - fn re_sealed_utterance_id_duplicates_here_but_not_in_the_swift_surfaces() { + fn re_sealed_utterance_id_upserts_one_slot_on_every_surface() { let events = vec![ final_ev(1, "pierwsze zdanie"), // Same id sealed again — the Swift surfaces would overwrite slot 0. @@ -170,18 +147,16 @@ mod tests { assert_eq!( assembly.sealed_count(), - 2, - "re-seal appends here; if this ever becomes 1 the assembly was made to \ - upsert like the Swift surfaces — update the table above and re-measure \ - the parity arms, because the word-count denominator moves with it" + 1, + "a revised final id must remain one measured and displayed slot" ); assert_eq!( assembly.streaming_floor(), - "pierwsze zdanie pierwsze zdanie poprawione", - "the measured floor carries BOTH seals — the overlay would carry only the second" + "pierwsze zdanie poprawione", + "the measured floor must match the overlay's revised slot" ); - // And the patch lands on the LAST slot, not the first: `rposition`. + // The bounded patch lands on that one revised slot. let mut patched = events.clone(); patched.push(EngineEvent::ReplaceRange { utterance_id: 1, @@ -191,12 +166,7 @@ mod tests { source: LayerSource::TailPatch, }); let assembly = assemble_live_from_events(&patched); - assert_eq!( - assembly.streaming_floor(), - "pierwsze zdanie PIERWSZE zdanie poprawione", - "`rposition` targets the newest slot; `ComposerDictation.swift` uses \ - `firstIndex`, which would target the oldest if a duplicate ever existed" - ); + assert_eq!(assembly.streaming_floor(), "PIERWSZE zdanie poprawione"); } /// Multiple UtteranceFinal seals must append freezed segments into the full live string. diff --git a/core/pipeline/streaming/live_audio_buffer.rs b/core/pipeline/streaming/live_audio_buffer.rs index 25bbf26e..b439b273 100644 --- a/core/pipeline/streaming/live_audio_buffer.rs +++ b/core/pipeline/streaming/live_audio_buffer.rs @@ -55,6 +55,13 @@ pub(crate) struct LiveAudioBuffer { capacity: usize, } +/// One resolved retained window with its canonical session-sample identity. +pub(crate) struct ResolvedAudioWindow { + pub(crate) samples: Vec, + pub(crate) sample_start: u64, + pub(crate) sample_end: u64, +} + impl LiveAudioBuffer { /// Build a buffer for `sample_rate`, retaining at most `retention_secs`. pub(crate) fn new(sample_rate: u32, retention_secs: f32) -> Self { @@ -97,7 +104,19 @@ impl LiveAudioBuffer { /// /// Refusing beats truncating: a short window looks like a success and would /// address the wrong audio. + #[cfg(test)] pub(crate) fn window(&self, from_secs: f32, to_secs: f32) -> Option> { + self.window_with_range(from_secs, to_secs) + .map(|window| window.samples) + } + + /// Timestamp-safe variant of [`window`](Self::window) that keeps the exact + /// integer PCM bounds used to cut the returned samples. + pub(crate) fn window_with_range( + &self, + from_secs: f32, + to_secs: f32, + ) -> Option { let from = self.index_for(from_secs)?; let to = self.index_for(to_secs)?; if to < from || from < self.start_index || from > self.end_index { @@ -115,7 +134,11 @@ impl LiveAudioBuffer { }; let lo = (from - self.start_index) as usize; let hi = (to - self.start_index) as usize; - Some(self.samples.range(lo..hi).copied().collect()) + Some(ResolvedAudioWindow { + samples: self.samples.range(lo..hi).copied().collect(), + sample_start: from, + sample_end: to, + }) } /// Release everything before `secs` — audio already committed downstream @@ -152,6 +175,37 @@ impl LiveAudioBuffer { self.end_index as f32 / self.sample_rate as f32 } + /// Total capture samples seen, retained or evicted. + pub(crate) fn session_sample_end(&self) -> u64 { + self.end_index + } + + /// Cut `[sample_start, sample_end)` on the capture PCM clock. + /// + /// `None` when the range is inverted or has already fallen off retention. + /// Unlike [`window_with_range`](Self::window_with_range) this never + /// converts through seconds. + pub(crate) fn window_by_samples( + &self, + sample_start: u64, + sample_end: u64, + ) -> Option { + if sample_end < sample_start + || sample_start < self.start_index + || sample_start > self.end_index + { + return None; + } + let to = sample_end.min(self.end_index); + let lo = (sample_start - self.start_index) as usize; + let hi = (to - self.start_index) as usize; + Some(ResolvedAudioWindow { + samples: self.samples.range(lo..hi).copied().collect(), + sample_start, + sample_end: to, + }) + } + /// Absolute session-sample index for a session-time second, or `None` when /// the value cannot address audio at all. fn index_for(&self, secs: f32) -> Option { diff --git a/core/pipeline/streaming/mod.rs b/core/pipeline/streaming/mod.rs index 0a22ca5e..cb078180 100644 --- a/core/pipeline/streaming/mod.rs +++ b/core/pipeline/streaming/mod.rs @@ -28,6 +28,10 @@ pub mod progressive_seal; pub(crate) mod quality_gate; /// Event-based transcription session: VAD ingestion, the Whisper inference loop, final emission. pub(crate) mod session; +/// W13-3B Silero identity + conservative per-word fusion (lane flag default OFF). +pub(crate) mod silero_fusion; +/// W13-4 sealed-span replay refusal + in-span loop fence (lane flag default OFF). +pub(crate) mod span_idempotence; /// Session stream-log sink (`CODESCRIBE_STREAM_LOG*` env contract). pub(crate) mod stream_log; /// Env-tunable runtime knobs shared across these modules. @@ -37,11 +41,15 @@ pub(crate) mod tuning; #[cfg(test)] mod tests; +pub use apple_live_session::APPLE_FINAL_OVERLAP_WARNING_CODE; pub use emitter::{BufferedEmitter, emitter_tick_loop}; pub use live_assembly::{LiveAssembly, assemble_live_from_events}; #[cfg(any(test, feature = "offline_eval"))] pub use offline::transcribe_streaming_samples; -pub use session::{SessionConfig, collect_buffered_engine_events, transcribe_buffered_samples}; +pub use session::{ + SessionConfig, collect_buffered_engine_events, collect_buffered_engine_events_with_config, + transcribe_buffered_samples, +}; #[cfg(test)] pub(crate) use quality_gate::should_drop_silence_chunk; diff --git a/core/pipeline/streaming/progressive_seal.rs b/core/pipeline/streaming/progressive_seal.rs index 152546cc..537595eb 100644 --- a/core/pipeline/streaming/progressive_seal.rs +++ b/core/pipeline/streaming/progressive_seal.rs @@ -17,6 +17,12 @@ use crate::pipeline::light_plus; use crate::pipeline::stream_postprocess; +use crate::stt::tail_provider::{ + TailEvidenceSource, TailEvidenceStability, TailProviderEvidence, TailRequestIdentity, + TailSampleRange, TailTimingQuality, TimedTailSegment, +}; + +use super::span_idempotence::{self, SpanIdempotenceLedger, SpanOffer}; /// Seconds after an Apple utterance commit during which the engine may still /// rewrite the open tail. Measured operator range ~2–3 s; pin the mid point. @@ -27,7 +33,7 @@ pub const APPLE_VOLATILE_WINDOW_SECS: f32 = 2.5; pub const SEAL_STARVATION_CEILING_SECS: f32 = 28.0; /// One byte-stable committed span after lexicon + (optional) Light+. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SealedSpan { /// Utterance / span identity (monotonic per session). pub id: u64, @@ -36,6 +42,21 @@ pub struct SealedSpan { pub text: String, /// Absolute session end of the sealed audio span, in seconds. pub end_secs_millis: u32, + /// Canonical half-open PCM range for this sealed Apple span. + pub range: TailSampleRange, + /// Apple word/segment evidence pinned to the same PCM clock. + pub words: Vec, + /// Typed Apple evidence recorded before any later fusion policy. + pub apple_evidence: TailProviderEvidence, + /// Typed Whisper evidence for the covering window, when Layer 1 ran. + pub whisper_evidence: Option, + /// Whisper segments mapped back to the capture PCM clock. + pub whisper_words: Vec, + /// Silero utterance this span was bound to, when the spectrum had an edge + /// enclosing it. `Some` is the evidence that [`Self::range`] came from the + /// VAD spectrum rather than from Apple's own segment boundaries; `None` + /// records the fail-open case, never a dropped span. + pub silero_utterance_id: Option, } impl SealedSpan { @@ -57,6 +78,38 @@ pub struct PendingSpan { pub end_secs: f32, /// Whisper window that fully covers this span, once known. pub covering_whisper_window_id: Option, + pub range: TailSampleRange, + pub words: Vec, + pub apple_evidence: TailProviderEvidence, + pub whisper_evidence: Option, + pub whisper_words: Vec, + /// Silero utterance this span was bound to. Carried to [`SealedSpan`]. + pub silero_utterance_id: Option, +} + +/// One Apple commit offered to the machine, with its PCM and identity +/// provenance. A record rather than a nine-argument call: every field is +/// provenance for the same span, and a positional list of that length is how +/// a range and an identity end up silently swapped. +#[derive(Debug, Clone, PartialEq)] +pub struct AppleCommit { + /// Span identity (monotonic per session; from the Silero ledger when the + /// fusion lane minted it, otherwise reserved from the same id space). + pub id: u64, + /// Raw engine text (or lexicon-ready canvas text) awaiting the seal pass. + pub raw_text: String, + /// Absolute end of the span in session audio seconds. + pub end_secs: f32, + /// Session time when Apple committed the utterance. + pub committed_at_secs: f32, + /// Canonical half-open PCM range for the span. + pub range: TailSampleRange, + /// Apple word/segment evidence pinned to the same PCM clock. + pub words: Vec, + /// Typed Apple evidence recorded before any later fusion policy. + pub apple_evidence: TailProviderEvidence, + /// Silero utterance the range was taken from, when one enclosed the span. + pub silero_utterance_id: Option, } /// One live-lane partial retained for residual stop-path fill. @@ -82,10 +135,15 @@ pub struct ProgressiveSealMachine { starvation_ceiling_hits: u64, /// Live lane health — when false, stop path may fall back to file inference. live_lane_alive: bool, + /// Session-captured W13-4 flag. Restart-only configuration must not change + /// underneath an active recording. + span_idempotence_enabled: bool, + /// W13-4 range-identity ledger. Consulted only when the lane flag is ON. + span_idempotence: SpanIdempotenceLedger, } /// Outcome of one seal evaluation pass. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SealTick { /// Spans that sealed on this tick (lexicon → Light+ applied). pub newly_sealed: Vec, @@ -112,6 +170,7 @@ impl ProgressiveSealMachine { pub fn new() -> Self { Self { live_lane_alive: true, + span_idempotence_enabled: span_idempotence::lane_enabled(), ..Self::default() } } @@ -135,9 +194,46 @@ impl ProgressiveSealMachine { end_secs: f32, committed_at_secs: f32, ) { - let raw_text = raw_text.into(); + let end_sample = (end_secs.max(0.0) * 1_000.0).round() as u64; + self.note_apple_commit_timed(AppleCommit { + id, + raw_text: raw_text.into(), + end_secs, + committed_at_secs, + range: TailSampleRange { + session: "legacy_progressive".to_string(), + capture_epoch: 0, + sample_start: 0, + sample_end: end_sample, + }, + words: Vec::new(), + apple_evidence: TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + silero_utterance_id: None, + }); + } + + /// Record an Apple commit together with canonical PCM, word and Silero + /// identity provenance. This is data-only: seal eligibility and text + /// transformation are the same as [`note_apple_commit`]. + pub fn note_apple_commit_timed(&mut self, commit: AppleCommit) -> bool { + let AppleCommit { + id, + raw_text, + end_secs, + committed_at_secs, + range, + words, + apple_evidence, + silero_utterance_id, + } = commit; if raw_text.trim().is_empty() { - return; + return false; } // Idempotent on id: a re-commit of the same utterance refreshes the // pending text but does not invent a second pending slot. @@ -145,11 +241,29 @@ impl ProgressiveSealMachine { existing.raw_text = raw_text; existing.end_secs = end_secs; existing.apple_committed_at_secs = committed_at_secs; - return; + existing.range = range; + existing.words = words; + existing.apple_evidence = apple_evidence; + existing.silero_utterance_id = silero_utterance_id; + return true; } if self.sealed.iter().any(|s| s.id == id) { // Already sealed — byte-stable fence: ignore re-commits. - return; + return false; + } + if self.span_idempotence_enabled { + let verdict = self.span_idempotence.offer(SpanOffer { + identity: TailRequestIdentity { + request_id: id, + range: range.clone(), + }, + text: raw_text.clone(), + timestamps_progressed: true, + decode_ok: true, + }); + if !verdict.lands_on_canvas() { + return false; + } } self.pending.push(PendingSpan { id, @@ -157,11 +271,40 @@ impl ProgressiveSealMachine { apple_committed_at_secs: committed_at_secs, end_secs, covering_whisper_window_id: None, + range, + words, + apple_evidence, + whisper_evidence: None, + whisper_words: Vec::new(), + silero_utterance_id, }); + true + } + + /// W13-4 receipts collected while the lane flag is ON. + pub fn span_idempotence_receipts(&self) -> &[span_idempotence::SpanIdempotenceReceipt] { + self.span_idempotence.receipts() } /// An elapsed Whisper window now covers audio through `covered_through_secs`. pub fn note_whisper_window_elapsed(&mut self, window_id: u64, covered_through_secs: f32) { + self.note_whisper_window_elapsed_with_provenance( + window_id, + covered_through_secs, + None, + Vec::new(), + ); + } + + /// Record elapsed coverage plus provider provenance without changing the + /// existing double-close decision. + pub fn note_whisper_window_elapsed_with_provenance( + &mut self, + window_id: u64, + covered_through_secs: f32, + evidence: Option, + words: Vec, + ) { if window_id > self.last_elapsed_whisper_window_id { self.last_elapsed_whisper_window_id = window_id; } @@ -171,6 +314,10 @@ impl ProgressiveSealMachine { for pending in &mut self.pending { if pending.end_secs <= self.whisper_covered_through_secs + f32::EPSILON { pending.covering_whisper_window_id = Some(window_id); + if pending.id == window_id { + pending.whisper_evidence = evidence.clone(); + pending.whisper_words = words.clone(); + } } } } @@ -249,12 +396,19 @@ impl ProgressiveSealMachine { id: span.id, text: sealed, end_secs_millis: (span.end_secs.max(0.0) * 1000.0).round() as u32, + range: span.range, + words: span.words, + apple_evidence: span.apple_evidence, + whisper_evidence: span.whisper_evidence, + whisper_words: span.whisper_words, + silero_utterance_id: span.silero_utterance_id, }; if !left_context.is_empty() && !sealed_span.text.is_empty() { left_context.push(' '); } left_context.push_str(&sealed_span.text); newly_sealed.push(sealed_span.clone()); + self.span_idempotence.mark_sealed(&sealed_span.range); self.sealed.push(sealed_span); } Some(SealBlockReason::StarvationCeiling) => { @@ -267,12 +421,19 @@ impl ProgressiveSealMachine { id: span.id, text: sealed, end_secs_millis: (span.end_secs.max(0.0) * 1000.0).round() as u32, + range: span.range, + words: span.words, + apple_evidence: span.apple_evidence, + whisper_evidence: span.whisper_evidence, + whisper_words: span.whisper_words, + silero_utterance_id: span.silero_utterance_id, }; if !left_context.is_empty() && !sealed_span.text.is_empty() { left_context.push(' '); } left_context.push_str(&sealed_span.text); newly_sealed.push(sealed_span.clone()); + self.span_idempotence.mark_sealed(&sealed_span.range); self.sealed.push(sealed_span); tracing::info!( span_id = span.id, @@ -350,6 +511,49 @@ impl ProgressiveSealMachine { } } + /// Seal every span still pending when the session itself ends. + /// + /// Both double-close gates are *vacuously* satisfied once capture is over: + /// no later Apple callback can revise a span, and no further Whisper window + /// can arrive. Holding a span past that point is not caution, it is a hang. + /// + /// # Why this exists as its own entry point + /// + /// The end-of-session caller used to reuse `try_seal(audio_secs + volatile + /// window + epsilon)`. That clock is derived from the PCM sample counter, + /// while `apple_committed_at_secs` comes from SFSpeech's own segment clock, + /// which can sit a few milliseconds *ahead* of it. Measured 2026-08-12: + /// audio clock 217.376s, last span committed at 217.378s → age 2.499s + /// against a 2.5s volatile window. The span missed by one millisecond, and + /// because the audio clock is frozen after EOF it could never age past the + /// gate — not even into the starvation ceiling. The worker then burned the + /// full 30s closure timeout waiting on a Whisper completion that would not + /// have unblocked it anyway (`rec_stop=36.701s` in the stop-path budget). + /// + /// Anchoring on the spans' own timestamps instead of the audio clock keeps + /// the volatile semantics exactly as written and removes the race. + pub fn seal_remaining_at_session_end(&mut self, force_raw: bool) -> SealTick { + let horizon = self + .pending + .iter() + .map(|span| span.apple_committed_at_secs.max(span.end_secs)) + .fold(0.0_f32, f32::max); + // No further window is coming, so everything recorded is as covered as + // it will ever be — satisfy `whisper_ready` without inventing an id. + self.whisper_covered_through_secs = self.whisper_covered_through_secs.max(horizon); + let tick = self.try_seal(horizon + APPLE_VOLATILE_WINDOW_SECS + 0.001, force_raw); + // The partial pool is spent once the end-of-session seal has run: every + // word it held is either inside a sealed span or inside the open + // partial the worker seals *before* calling this. Spans carry SFSpeech + // segment-clock ends while partials carry the receipt clock, which + // always runs slightly later — so a surviving partial reads as "past + // the last seal" to `compose_stop_path_residual` and re-appends text + // the seal already delivered. Live 2026-08-12 21:15: "Jaki chcesz. + // Kos." arrived twice in an 8s take exactly this way. + self.session_partials.clear(); + tick + } + /// Why a pending span is not yet sealable, or None when both engines closed it. fn seal_block_reason(&self, span: &PendingSpan, now_secs: f32) -> Option { let age = now_secs - span.apple_committed_at_secs; @@ -510,6 +714,100 @@ mod progressive_seal_tests { assert!(m.pending_spans().is_empty()); } + /// The 2026-08-12 stop-path hang, reduced to its arithmetic. + /// + /// SFSpeech committed the last span at 217.378s on its own segment clock + /// while the PCM counter had reached 217.376s. The end-of-session seal used + /// the audio clock, so the span's age came out at 2.499s against a 2.5s + /// volatile window — short by one millisecond, and frozen there forever + /// because the audio clock stops advancing at EOF. The worker then sat on + /// its closure timeout, costing the operator 30s on a stop that owed + /// nothing (`rec_stop=36.701s`). + #[test] + fn session_end_seals_the_span_a_frozen_audio_clock_holds_forever() { + let mut m = ProgressiveSealMachine::new(); + let audio_eof_secs = 217.376_f32; + let apple_commit_secs = 217.378_f32; + m.note_apple_commit(48, "ostatnie słowo", apple_commit_secs, apple_commit_secs); + // Whisper closed its side — the volatile gate is the only thing left. + m.note_whisper_window_elapsed(48, apple_commit_secs); + + let frozen = m.try_seal(audio_eof_secs + APPLE_VOLATILE_WINDOW_SECS + 0.001, false); + assert!( + frozen.newly_sealed.is_empty(), + "regression guard: this clock is exactly the one that hung, it must still miss" + ); + assert_eq!( + m.pending_spans().len(), + 1, + "the span the old end-of-session clock could never release" + ); + + let at_end = m.seal_remaining_at_session_end(false); + assert_eq!( + at_end.newly_sealed.len(), + 1, + "session end must seal on the span's own clock, not the audio counter" + ); + assert!( + m.pending_spans().is_empty(), + "no span may outlive the session that produced it" + ); + } + + /// The 2026-08-12 21:15 live duplicate ("Jaki chcesz. Kos." delivered + /// twice): spans sealed at session end carry SFSpeech segment-clock ends, + /// while session partials carry the receipt clock, which always runs a + /// little later. The stop-path residual then saw the freshest partial as + /// "past the last seal" and appended text the seal already delivered. + /// After an end-of-session seal the partial pool must be empty — every + /// word it held is either in a span or in the open partial the worker + /// seals first. + #[test] + fn session_end_seal_leaves_no_partial_for_the_residual_to_duplicate() { + let mut m = ProgressiveSealMachine::new(); + m.note_apple_commit(2, "jaki chcesz kos", 8.202, 8.202); + m.note_whisper_window_elapsed(2, 8.202); + // Receipt-clock partial restating the same tail, "later" than the seal. + m.note_session_partial("jaki chcesz kos", 8.4); + + let tick = m.seal_remaining_at_session_end(false); + assert_eq!(tick.newly_sealed.len(), 1); + + let residual = m.compose_stop_path_residual(); + assert_eq!( + residual.residual_tail, "", + "a partial restating sealed text must not ride the residual back in" + ); + assert_eq!( + residual.text.matches("chcesz").count(), + 1, + "the delivered text must carry the phrase exactly once: {:?}", + residual.text + ); + } + + /// Session end also closes the Whisper gate: once capture stops, no further + /// window can arrive, so holding a span for one is waiting on nothing. + #[test] + fn session_end_seals_span_that_never_got_a_whisper_window() { + let mut m = ProgressiveSealMachine::new(); + m.note_apple_commit(1, "bez lat ki", 10.0, 10.0); + + let mid_session = m.try_seal(10.0 + APPLE_VOLATILE_WINDOW_SECS + 0.1, false); + assert!( + mid_session.newly_sealed.is_empty(), + "mid-session the un-elapsed Whisper window must still block" + ); + + let at_end = m.seal_remaining_at_session_end(false); + assert_eq!( + at_end.newly_sealed.len(), + 1, + "at session end there is no window left to wait for" + ); + } + /// Ctrl-hold force_raw skips Light+ but still seals words. #[test] fn progressive_seal_force_raw_skips_light_plus_still_seals() { @@ -604,4 +902,61 @@ mod progressive_seal_tests { m.sealed_spans().len() ); } + + #[test] + fn w13_live_seal_refuses_replayed_range_identity_when_armed() { + let range = TailSampleRange { + session: "w13-4-live".into(), + capture_epoch: 1, + sample_start: 0, + sample_end: 8_000, + }; + let evidence = TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }; + let mut m = ProgressiveSealMachine::new(); + m.span_idempotence_enabled = true; + assert!(m.note_apple_commit_timed(AppleCommit { + id: 1, + raw_text: "fragment odzyskany".into(), + end_secs: 0.5, + committed_at_secs: 0.5, + range: range.clone(), + words: Vec::new(), + apple_evidence: evidence.clone(), + silero_utterance_id: Some(1), + })); + m.note_whisper_window_elapsed(1, 4.0); + let tick = m.try_seal(APPLE_VOLATILE_WINDOW_SECS + 1.0, true); + assert_eq!(tick.newly_sealed.len(), 1); + assert_eq!( + tick.newly_sealed[0].silero_utterance_id, + Some(1), + "the Silero identity a span was bound to must survive the seal" + ); + + let replayed = m.note_apple_commit_timed(AppleCommit { + id: 2, + raw_text: "fragment odzyskany".into(), + end_secs: 0.5, + committed_at_secs: 0.5, + range, + words: Vec::new(), + apple_evidence: evidence, + silero_utterance_id: Some(2), + }); + assert!(!replayed, "new Apple id on a sealed range must be refused"); + assert_eq!(m.pending_spans().len(), 0); + assert_eq!(m.sealed_spans().len(), 1); + assert_eq!(m.sealed_prefix(), "fragment odzyskany"); + assert!( + m.span_idempotence_receipts() + .iter() + .any(|r| r.code == "replayed_range_identity") + ); + } } diff --git a/core/pipeline/streaming/session.rs b/core/pipeline/streaming/session.rs index 59ad911a..16dc6b90 100644 --- a/core/pipeline/streaming/session.rs +++ b/core/pipeline/streaming/session.rs @@ -12,6 +12,10 @@ use tokio::sync::mpsc; use tokio::time::{Duration, Instant}; use tracing::{debug, error, info, warn}; +use crate::asr_session::recorder::{Layer1Decision, RecorderLifecycleEvents}; +use crate::audio::capture_receipt::{ + CaptureLevelAccumulator, CapturePathMeta, emit_capture_level_receipt, +}; use crate::audio::chunker::{SpeechEvent, SpeechSession}; use crate::pipeline::contracts::{ DropKind, EngineEvent, EventSink, LayerSource, LayerSummary, TranscriptSegment, @@ -19,7 +23,14 @@ use crate::pipeline::contracts::{ }; use crate::stt::scheduler::{SttLane, SttScheduler, SttTaskHandle}; use crate::stt::tail_patcher::{ - TailPatchConfig, TailPatchOutcome, compute_tail_patch, layered_phase, + TailPatchConfig, TailPatchOutcome, UnderCommit, compute_tail_patch_with_context, layered_phase, +}; +#[cfg(test)] +use crate::stt::tail_provider::{ + TailEvidenceSource, TailEvidenceStability, TailProviderId, TailTimingQuality, TimedTailSegment, +}; +use crate::stt::tail_provider::{ + TailProviderPayload, TailProviderRequest, TailRequestIdentity, TailSampleRange, }; use crate::vad; @@ -122,6 +133,15 @@ pub struct SessionConfig { pub stream_log_path: Option, /// VAD silence threshold for utterance boundary (None = use default). pub utterance_silence_sec: Option, + /// Injected, already-authorized Layer 1 refiner decision (C1). + /// + /// The pipeline only consumes this — construction, consent, and mode + /// persistence belong to the settings owner. [`Layer1Decision::Disarmed`] + /// is the stock product: canvas + lexicon, complete, never an error. + pub layer1: Layer1Decision, + /// Per-recording host lifecycle boundaries. Present only for a live + /// recorder; buffered/offline helpers have no system observer owner. + pub lifecycle_events: Option, } /// What happened to one enqueue attempt. @@ -226,14 +246,51 @@ fn record_semantic_gate_drop(counter: &mut u64, quality_gate_dropped: bool, is_f /// `UtteranceFinal.text`: the resulting `ReplaceRange` offsets are computed /// against it, so a differently-trimmed copy would produce patches that land at /// the wrong characters. The debug assertion pins that contract in test builds. +#[derive(Debug)] +pub(super) struct TailPatchJobResult { + pub utterance_id: u64, + pub outcome: TailPatchOutcome, + pub payload: TailProviderPayload, +} + +impl TailPatchJobResult { + pub fn into_outcome(self) -> (u64, TailPatchOutcome) { + (self.utterance_id, self.outcome) + } +} + pub(super) async fn compute_tail_patch_job( utterance_id: u64, committed_text: String, + neighbour_context: String, audio: Vec, - sample_rate: u32, - language: Option, + request: TailProviderRequest, config: TailPatchConfig, -) -> Result<(u64, TailPatchOutcome)> { +) -> Result { + compute_tail_patch_job_with( + utterance_id, + committed_text, + neighbour_context, + audio, + request, + config, + crate::stt::tail_provider::transcribe_configured, + ) + .await +} + +async fn compute_tail_patch_job_with( + utterance_id: u64, + committed_text: String, + neighbour_context: String, + audio: Vec, + request: TailProviderRequest, + config: TailPatchConfig, + transcribe: F, +) -> Result +where + F: FnOnce(&TailProviderRequest, &[f32]) -> Result + Send + 'static, +{ debug_assert_eq!( committed_text.trim(), committed_text, @@ -241,17 +298,54 @@ pub(super) async fn compute_tail_patch_job( (single trim owner: final_text at the emit site)" ); tokio::task::spawn_blocking(move || { - let retranscribed = - crate::stt::whisper_tail_patch_transcribe(&audio, sample_rate, language.as_deref())?; - Ok(( + let payload = transcribe(&request, &audio)?; + let outcome = compute_tail_patch_with_context( + &committed_text, + &payload.text, + &neighbour_context, utterance_id, - compute_tail_patch(&committed_text, &retranscribed.text, utterance_id, &config), - )) + &config, + ); + Ok(TailPatchJobResult { + utterance_id, + outcome, + payload, + }) }) .await .map_err(|e| anyhow!("tail patch worker task failed: {e}"))? } +/// Stable engine-event code carrying a Layer-1 under-commit outward. +/// +/// The stop path keys on this to require residual gap fill for a session whose +/// live canvas is known to be starved, independently of the committed-density +/// floor that judges the same session from the audio side. +pub const UNDER_COMMIT_WARNING_CODE: &str = "tail_patch_under_commit"; + +/// Build the outward escalation for an under-commit that could not be placed +/// live. +/// +/// Counts only — the message crosses the IPC boundary and reaches the log, and +/// the transcript is the user's speech. A `Warning` rather than a new event +/// variant on purpose: every sink, the IPC wire and the Swift bridge already +/// carry it, so the escalation costs no FFI surface. +fn under_commit_warning(under: &UnderCommit) -> EngineEvent { + EngineEvent::Warning { + code: UNDER_COMMIT_WARNING_CODE.to_string(), + message: format!( + "residual gap fill required: committed_chars={} retranscribed_chars={} \ + committed_tokens={} retranscribed_tokens={} commit_ratio={:.2} gap_appends={}", + under.committed_chars, + under.retranscribed_chars, + under.committed_tokens, + under.retranscribed_tokens, + under.commit_ratio, + under.appends.len(), + ), + } +} + /// Forward a tail-patch job's outcome to the sink and report how many /// replacements were emitted. /// @@ -285,8 +379,47 @@ pub(super) fn emit_tail_patch_result( debug!(utterance_id, "Tail patch found no changes"); 0 } - Ok((utterance_id, TailPatchOutcome::Skipped { reason })) => { - debug!(utterance_id, reason, "Tail patch skipped"); + Ok((utterance_id, TailPatchOutcome::UnderCommit(under))) => { + let mut emitted = 0u64; + for event in &under.appends { + if matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) { + emitted = emitted.saturating_add(1); + } + event_sink.on_event(event); + } + info!( + utterance_id, + reason = under.reason(), + committed_chars = under.committed_chars, + retranscribed_chars = under.retranscribed_chars, + committed_tokens = under.committed_tokens, + retranscribed_tokens = under.retranscribed_tokens, + gap_appends = emitted, + residual_required = under.residual_required, + "Layer 1 under-commit" + ); + if under.residual_required { + event_sink.on_event(&under_commit_warning(&under)); + } + emitted + } + Ok((utterance_id, TailPatchOutcome::Skipped { code, reason })) => { + // INFO, not debug: a skipped patch is text Whisper had in hand and + // the canvas never received. The counts belong to the receipt the + // patcher already logs; this line proves the sink saw the same + // verdict for this utterance. + info!( + utterance_id, + code = code.as_str(), + reason, + "Tail patch skipped" + ); 0 } Err(e) => { @@ -319,6 +452,39 @@ pub(super) fn emit_session_finalised( }); } +/// Per-session skip count at which a zero-application session is an alarm. +/// +/// One or two skips with nothing applied can be honest divergence (noise, a +/// throat-clear window). Three computed corrections all rejected is the gate +/// eating the lane's entire output — the 2026-08-12 audit found 116 skips and +/// 0 applied patches across the log's whole history, and not one line said so +/// out loud. +pub(super) const TAIL_PATCH_STARVED_MIN_SKIPS: u64 = 3; + +/// Whether this session's Layer 1 lane was starved: corrections were computed +/// and every single one was rejected. +pub(super) fn tail_patch_lane_starved(applied: u64, skipped: u64) -> bool { + applied == 0 && skipped >= TAIL_PATCH_STARVED_MIN_SKIPS +} + +/// One session-level receipt for the Layer 1 lane, emitted at finalise. +/// +/// The per-utterance skip receipts diagnose a single verdict; this line +/// diagnoses the lane. A starved session — Whisper burned inference on every +/// sealed utterance and the canvas received none of it — is a WARN, because +/// that is the lane not doing its one job, silently. +pub(super) fn log_tail_patch_session_receipt(applied: u64, skipped: u64) { + if tail_patch_lane_starved(applied, skipped) { + warn!( + applied, + skipped, + "tail_patch_lane_starved: every computed Whisper correction this session was rejected" + ); + } else if applied > 0 || skipped > 0 { + info!(applied, skipped, "tail_patch_session_receipt"); + } +} + // ── Unified transcription session (event-based) ───────────────────────────── /// Unified transcription session exposed as a single event-emitting pipeline. @@ -373,10 +539,25 @@ pub(crate) async fn vad_transcription_session( language, stream_log_path, utterance_silence_sec, + layer1, + lifecycle_events: _, } = config; + // C1 wires the live Layer 1 lane on the Apple progressive path only. On + // this canvas an armed decision is disarmed explicitly: a refiner that + // cannot run is a missing improvement, never an error, and never a reason + // to load anything heavier. + if layer1.is_armed() { + warn!( + "Layer 1 live lane is not wired on the VAD/scheduler path; \ + proceeding canvas + lexicon" + ); + } + drop(layer1); + info!("Transcription session started (event-based pipeline)"); let session_id = uuid::Uuid::new_v4().to_string(); + let mut capture_level = CaptureLevelAccumulator::new(); let mut session = if let Some(sec) = utterance_silence_sec { SpeechSession::new_utterance_with_silence(sample_rate, sec) @@ -404,6 +585,7 @@ pub(crate) async fn vad_transcription_session( let mut filtered_empty_drops: u64 = 0; let mut corrections_applied: u64 = 0; let mut tail_patch_replacements: u64 = 0; + let mut tail_patch_skips: u64 = 0; let mut partial_telemetry = PartialPassTelemetry::default(); let mut vad_started = false; let mut speech_activity_observed = false; @@ -477,7 +659,9 @@ pub(crate) async fn vad_transcription_session( "Phase 1 inference pipeline configured" ); let mut inference_pipeline = FuturesOrdered::new(); - let mut tail_patch_pipeline = FuturesUnordered::new(); + let mut tail_patch_pipeline: FuturesUnordered< + futures_util::future::BoxFuture<'static, Result>, + > = FuturesUnordered::new(); // Phase 2 (buffered correction) — request tracked for stale guards. let mut correction_in_flight: Option = None; @@ -646,6 +830,7 @@ pub(crate) async fn vad_transcription_session( maybe_data = chunk_receiver.recv(), if !audio_closed => { match maybe_data { Some(data) => { + capture_level.push_samples(&data); for event in session.feed(&data, sample_rate) { let speech_vad_samples = session.take_event_speech_vad_samples(); let max_speech_prob = session.segment_speech_prob(); @@ -1047,6 +1232,12 @@ pub(crate) async fn vad_transcription_session( // Drain the pipeline. FuturesOrdered guarantees results arrive in the order submitted. // This is critical for timestamp calculation and text accumulation. Some(result) = tail_patch_pipeline.next() => { + if matches!(&result, Ok(job) if matches!(job.outcome, TailPatchOutcome::Skipped { .. })) + || result.is_err() + { + tail_patch_skips = tail_patch_skips.saturating_add(1); + } + let result = result.map(TailPatchJobResult::into_outcome); tail_patch_replacements = tail_patch_replacements .saturating_add(emit_tail_patch_result(event_sink.as_ref(), result)); } @@ -1278,14 +1469,35 @@ pub(crate) async fn vad_transcription_session( if tail_patch_enabled && let Some(audio) = item.tail_patch_audio.take() { - tail_patch_pipeline.push(compute_tail_patch_job( + let sample_start = ((utterance_start_s.max(0.0) as f64) + * output_sample_rate as f64) + .round() as u64; + let sample_end = sample_start.saturating_add(audio.len() as u64); + let request = TailProviderRequest { + identity: TailRequestIdentity { + request_id: utterance_id, + range: TailSampleRange { + session: session_id.clone(), + capture_epoch: 0, + sample_start, + sample_end, + }, + }, + sample_rate: output_sample_rate, + language: pipeline.language.clone(), + }; + tail_patch_pipeline.push(Box::pin(compute_tail_patch_job( utterance_id, final_text, + // VAD lane: no sealed-prefix accumulator on this + // path, so the anti-duplication check falls back + // to the utterance's own canvas (pre-2026-08-14 + // behaviour, no regression). + String::new(), audio, - output_sample_rate, - pipeline.language.clone(), + request, tail_patch_config, - )); + ))); } } else { utterance_segments.clear(); @@ -1464,6 +1676,11 @@ pub(crate) async fn vad_transcription_session( partial_dropped_count: partial_telemetry.dropped_count, }); + log_tail_patch_session_receipt(tail_patch_replacements, tail_patch_skips); + emit_capture_level_receipt( + event_sink.as_ref(), + &capture_level.finalize(CapturePathMeta::resolve(sample_rate, 1, None)), + ); emit_session_finalised(event_sink.as_ref(), session_id, tail_patch_replacements); if dropped_utterances > 0 { @@ -1637,6 +1854,10 @@ pub async fn transcribe_buffered_samples( language, stream_log_path: None, utterance_silence_sec: None, + // Offline replay harness: Layer 1 arming is a live-recording + // decision owned elsewhere. + layer1: Layer1Decision::Disarmed, + lifecycle_events: None, }, )); @@ -1664,31 +1885,56 @@ pub async fn collect_buffered_engine_events( samples: &[f32], sample_rate: u32, language: Option, +) -> Result> { + collect_buffered_engine_events_with_config( + samples, + SessionConfig { + sample_rate, + language, + stream_log_path: None, + utterance_silence_sec: None, + // Offline replay harness: Layer 1 arming is a live-recording + // decision owned elsewhere. + layer1: Layer1Decision::Disarmed, + lifecycle_events: None, + }, + ) + .await +} + +/// Run buffered PCM through an explicitly supplied production session config. +/// +/// Unlike [`collect_buffered_engine_events`], this seam never invents or +/// hard-codes a Layer 1 decision. The recording owner must supply the complete +/// [`SessionConfig`], which makes this suitable for production-owned replay +/// witnesses while preserving the exact `transcription_session` implementation +/// used by live capture. +pub async fn collect_buffered_engine_events_with_config( + samples: &[f32], + config: SessionConfig, ) -> Result> { if samples.is_empty() { return Ok(Vec::new()); } - let chunk_size = ((sample_rate as f32) * 0.1).round().max(1.0) as usize; - + let chunk_size = ((config.sample_rate as f32) * 0.1).round().max(1.0) as usize; let (tx, rx) = mpsc::channel::>(8); let collector = Arc::new(SessionEventCollector::new()); let event_sink: Arc = collector.clone(); - let session = tokio::spawn(transcription_session( - rx, - event_sink, - SessionConfig { - sample_rate, - language, - stream_log_path: None, - utterance_silence_sec: None, - }, - )); + let session = tokio::spawn(transcription_session(rx, event_sink, config)); for chunk in samples.chunks(chunk_size) { if tx.send(chunk.to_vec()).await.is_err() { return Err(anyhow!("Transcription session dropped channel")); } + // `transcription_session` consumes a live capture stream. Preserve + // that temporal contract for replay: flooding an entire recording in + // one scheduler tick advances `audio_secs` ahead of Apple's result + // timestamps and turns otherwise valid phrase windows into unresolved + // seals. A 100 ms packet therefore occupies 100 ms of wall time, just + // like the production callback cadence this seam replaces at its only + // unavoidable boundary. + tokio::time::sleep(Duration::from_millis(100)).await; } drop(tx); @@ -1721,6 +1967,72 @@ mod session_tests { assert_eq!(drops, 1); } + #[tokio::test] + async fn w13_provenance_survives_tail_patch_job() { + let range = TailSampleRange { + session: "w13-replay-191351".to_string(), + capture_epoch: 4, + sample_start: 48_000, + sample_end: 48_320, + }; + let identity = TailRequestIdentity { + request_id: 73, + range: range.clone(), + }; + let evidence = crate::stt::tail_provider::TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: Some("fixture-r1".to_string()), + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: Some(-0.21), + }; + let payload = TailProviderPayload { + identity: identity.clone(), + text: "ala ma kota".to_string(), + segments: vec![TimedTailSegment { + text: "kota".to_string(), + range: TailSampleRange { + sample_start: 48_160, + sample_end: 48_300, + ..range.clone() + }, + }], + avg_logprob: Some(-0.21), + compression_ratio: Some(1.03), + quality_gate_dropped: false, + provider_id: TailProviderId::Fake, + elapsed_ms: 7, + evidence: evidence.clone(), + }; + let request = TailProviderRequest { + identity, + sample_rate: 16_000, + language: Some("pl-PL".to_string()), + }; + + let job = compute_tail_patch_job_with( + 73, + "ala ma kota".to_string(), + String::new(), + vec![0.0; 320], + request, + TailPatchConfig::default(), + move |request, pcm| { + request.validate_pcm(pcm)?; + Ok(payload) + }, + ) + .await + .expect("typed fake tail job"); + + assert!(matches!(job.outcome, TailPatchOutcome::NoChange)); + assert_eq!(job.payload.identity.range, range); + assert_eq!(job.payload.segments[0].range.sample_start, 48_160); + assert_eq!(job.payload.segments[0].range.sample_end, 48_300); + assert_eq!(job.payload.evidence, evidence); + assert_eq!(job.payload.provider_id, TailProviderId::Fake); + } + #[test] /// Successful tail-patch outcomes surface as `ReplaceRange` engine events. fn tail_patch_result_emits_replace_range_events() { @@ -1752,6 +2064,95 @@ mod session_tests { )); } + /// Build an under-commit outcome with `appends` gap-appends and the given + /// escalation, without needing Whisper or a diff. + fn under_commit_fixture(appends: usize, residual_required: bool) -> UnderCommit { + UnderCommit { + appends: (0..appends) + .map(|idx| EngineEvent::ReplaceRange { + utterance_id: 7, + start: 11 + idx, + end: 11 + idx, + text: " odzyskane".to_string(), + source: LayerSource::TailPatch, + }) + .collect(), + residual_required, + committed_tokens: 3, + retranscribed_tokens: 12, + committed_chars: 21, + retranscribed_chars: 84, + commit_ratio: 0.25, + } + } + + #[test] + /// W-C: recovered gap-appends reach the sink and are counted as Layer 1 + /// work — the outcome the bounded cap used to discard in silence. + fn under_commit_gap_appends_reach_the_sink_and_count() { + let collector = SessionEventCollector::new(); + let emitted = emit_tail_patch_result( + &collector, + Ok(( + 7, + TailPatchOutcome::UnderCommit(under_commit_fixture(1, false)), + )), + ); + + assert_eq!(emitted, 1, "an appended gap is Layer 1 work, not a skip"); + let events = collector.events(); + assert!(matches!( + events.as_slice(), + [EngineEvent::ReplaceRange { + start: 11, + end: 11, + source: LayerSource::TailPatch, + .. + }] + )); + assert!( + !events + .iter() + .any(|e| matches!(e, EngineEvent::Warning { .. })), + "nothing is owed to the stop path when everything landed live" + ); + } + + #[test] + /// W-C: an under-commit that could place nothing escalates outward instead + /// of leaving the stop path to call the starved canvas complete. + fn under_commit_without_safe_anchor_emits_residual_escalation() { + let collector = SessionEventCollector::new(); + let emitted = emit_tail_patch_result( + &collector, + Ok(( + 7, + TailPatchOutcome::UnderCommit(under_commit_fixture(0, true)), + )), + ); + + assert_eq!(emitted, 0); + let events = collector.events(); + let warning = events + .iter() + .find_map(|e| match e { + EngineEvent::Warning { code, message } if code == UNDER_COMMIT_WARNING_CODE => { + Some(message.clone()) + } + _ => None, + }) + .expect("residual escalation must be emitted"); + // Counts travel; transcript text never does. + assert!(warning.contains("committed_chars=21")); + assert!(warning.contains("retranscribed_chars=84")); + assert!( + !events + .iter() + .any(|e| matches!(e, EngineEvent::ReplaceRange { .. })), + "no anchor was safe, so no canvas may be touched" + ); + } + #[test] /// Trimmed final_text is the sole offset baseline for tail-patch apply. fn final_text_trim_contract_keeps_tail_patch_offsets_aligned() { @@ -1763,7 +2164,7 @@ mod session_tests { // Retranscribed side mimics real Whisper output shape: leading/trailing // whitespace and a newline. It must never skew offsets or get skipped. - let outcome = compute_tail_patch( + let outcome = crate::stt::tail_patcher::compute_tail_patch( &final_text, " ala ma psa \n", 1, @@ -1804,6 +2205,25 @@ mod session_tests { )); } + #[test] + /// The starvation verdict: zero applied with the skip floor reached is the + /// lane not doing its job. One landed patch — even against 116 skips — + /// proves the lane alive; a skip or two with nothing applied is honest + /// divergence, not starvation. + fn tail_patch_starvation_fires_only_on_all_rejected_sessions() { + assert!(tail_patch_lane_starved(0, TAIL_PATCH_STARVED_MIN_SKIPS)); + assert!(tail_patch_lane_starved(0, 116)); + assert!(!tail_patch_lane_starved(1, 116), "one landed patch = alive"); + assert!( + !tail_patch_lane_starved(0, TAIL_PATCH_STARVED_MIN_SKIPS - 1), + "a couple of honest divergences is not starvation" + ); + assert!( + !tail_patch_lane_starved(0, 0), + "an idle lane is not starved" + ); + } + #[test] /// Correction audio buffer drains oldest samples so length never exceeds the window. fn correction_buffer_window_cap() { diff --git a/core/pipeline/streaming/silero_fusion.rs b/core/pipeline/streaming/silero_fusion.rs new file mode 100644 index 00000000..8f53c4ba --- /dev/null +++ b/core/pipeline/streaming/silero_fusion.rs @@ -0,0 +1,884 @@ +//! W13-3B — Silero utterance identity + conservative per-word fusion. +//! +//! Lane flag [`SILERO_FUSION_ENV`] is **default OFF**. When armed: +//! Silero Supervisor edges mint utterance identity on the PCM sample clock; +//! Apple cumulative finals are sliced onto those ranges by time; Whisper and +//! Apple then fuse conservatively (agreements + clear gap fills). Unresolved +//! alternatives are receipted, never confidence-arbitrated. Every write into +//! a pending span goes through [`super::progressive_seal::ProgressiveSealMachine::try_rewrite`]. +//! +//! # One Silero per session +//! +//! [`SileroIngress`] is the session's **only** `SpeechSession`. Both consumers +//! of speech edges read it: the fusion ledger (utterance identity) and the +//! Apple engine lifecycle (`EpochGate` wake/sleep). Two independent VAD +//! sessions over the same PCM would mean two spectra and two sets of +//! boundaries, and "the same utterance" would then mean two different sample +//! ranges depending on which consumer was asked. [`SileroIngress::observe`] is +//! the single decision point that derives both from one observation. + +use crate::audio::chunker::{SpeechEvent, SpeechSession}; +use crate::stt::tail_patcher::SkipReasonCode; +use crate::stt::tail_provider::{TailSampleRange, TimedTailSegment}; + +/// Lane flag for Silero-identity conservative fusion. Unset / `0` / `false` / +/// `off` / `no` keep the existing production path bit-identical. +pub const SILERO_FUSION_ENV: &str = "CODESCRIBE_SILERO_FUSION"; + +/// Bounded-context A/B selector. Never crosses a long-silence cut. +pub const SILERO_FUSION_CONTEXT_ENV: &str = "CODESCRIBE_SILERO_FUSION_CONTEXT"; + +/// Silence longer than this (samples at the capture rate) is a hard context +/// fence — left-audio pad must not reach across it. +pub const LONG_SILENCE_FENCE_SECS: f32 = 0.55; + +/// Default left-audio pad when [`FusionContextMode::LeftAudioPad`] is armed. +pub const DEFAULT_LEFT_PAD_SECS: f32 = 0.40; + +/// Whether the W13-3B fusion lane is armed. Default OFF pending the operator's +/// live A/B decision required by the original engine roadmap. +pub fn lane_enabled() -> bool { + let raw = std::env::var(SILERO_FUSION_ENV).ok(); + lane_enabled_from_raw(raw.as_deref()) +} + +fn lane_enabled_from_raw(raw: Option<&str>) -> bool { + raw.is_some_and(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// One Silero-bounded utterance on the session PCM clock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SileroUtterance { + pub id: u64, + pub range: TailSampleRange, + pub closed: bool, +} + +/// Ledger of Silero-minted utterance identities. Pure data; the Supervisor +/// machine in [`SileroIngress`] is the only writer in production. +#[derive(Debug, Clone, Default)] +pub struct UtteranceLedger { + next_id: u64, + utterances: Vec, +} + +impl UtteranceLedger { + pub fn new() -> Self { + Self::default() + } + + /// Mint (or refresh) an open utterance covering `[sample_start, sample_end)`. + pub fn open_or_extend( + &mut self, + session: &str, + capture_epoch: u64, + sample_start: u64, + sample_end: u64, + ) -> u64 { + let sample_end = sample_end.max(sample_start); + if let Some(open) = self.utterances.iter_mut().rev().find(|u| !u.closed) { + open.range.sample_end = sample_end.max(open.range.sample_end); + return open.id; + } + self.next_id = self.next_id.saturating_add(1); + let id = self.next_id; + self.utterances.push(SileroUtterance { + id, + range: TailSampleRange { + session: session.to_string(), + capture_epoch, + sample_start, + sample_end, + }, + closed: false, + }); + id + } + + /// Close the open utterance so the next speech edge mints a new identity. + pub fn close_open(&mut self, sample_end: u64) -> Option { + let open = self.utterances.iter_mut().rev().find(|u| !u.closed)?; + open.range.sample_end = sample_end.max(open.range.sample_end); + open.closed = true; + Some(open.id) + } + + pub fn utterances(&self) -> &[SileroUtterance] { + &self.utterances + } + + /// Utterance whose range contains `sample` (half-open). Prefers the + /// tightest closed span; falls back to the open span. + pub fn utterance_covering(&self, sample: u64) -> Option<&SileroUtterance> { + self.utterances + .iter() + .filter(|u| u.range.sample_start <= sample && sample < u.range.sample_end) + .min_by_key(|u| u.range.sample_end.saturating_sub(u.range.sample_start)) + } + + /// Tightest utterance that fully **encloses** `[sample_start, sample_end)`. + /// + /// This is the seal-time binding query: an Apple span may adopt a Silero + /// range only when the spectrum edge already covers every sample Apple + /// claimed. Mere overlap is refused on purpose — adopting a range that + /// starts after Apple's first word would hand Layer 1 a window over audio + /// the utterance never contained, and the span would seal against a decode + /// of the wrong seconds. No enclosure ⇒ the caller keeps its own range + /// (fail-open; content is never dropped for want of an edge). + pub fn utterance_enclosing( + &self, + sample_start: u64, + sample_end: u64, + ) -> Option<&SileroUtterance> { + let sample_end = sample_end.max(sample_start); + self.utterances + .iter() + .filter(|u| u.range.sample_start <= sample_start && sample_end <= u.range.sample_end) + .min_by_key(|u| u.range.sample_end.saturating_sub(u.range.sample_start)) + } + + /// Burn one identity without minting an utterance. + /// + /// The Apple-boundary fallback still needs a span id, and it must not be an + /// id Silero will later mint for a real utterance: `note_apple_commit_timed` + /// is idempotent on id, so a collision would silently merge an Apple span + /// with an unrelated Silero one. One ledger, one id space. + pub fn reserve_id(&mut self) -> u64 { + self.next_id = self.next_id.saturating_add(1); + self.next_id + } +} + +/// What one observed capture chunk means to every consumer of the session's +/// single spectrum. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SileroIngest { + /// Utterance identities the Supervisor closed inside this chunk. + pub closed: Vec, + /// Identity of the utterance still open after this chunk. + pub open: Option, + /// Speech was live anywhere in this chunk — a segment is open, or one + /// closed inside it. This is the edge bit the Apple engine lifecycle + /// (`EpochGate`) reads instead of running a second Silero over the same + /// PCM; it is derived from the identical two facts the ledger is minted + /// from, in the same call, so wake/sleep and utterance identity cannot + /// disagree about where speech was. + pub speech_live: bool, +} + +/// Supervisor-mode Silero at the Apple PCM ingress. The session's only VAD. +pub struct SileroIngress { + session: String, + capture_epoch: u64, + vad: SpeechSession, + ledger: UtteranceLedger, +} + +impl SileroIngress { + pub fn new(sample_rate: u32, session: impl Into, capture_epoch: u64) -> Self { + Self { + session: session.into(), + capture_epoch, + vad: SpeechSession::new_utterance(sample_rate), + ledger: UtteranceLedger::new(), + } + } + + pub fn ledger(&self) -> &UtteranceLedger { + &self.ledger + } + + pub fn ledger_mut(&mut self) -> &mut UtteranceLedger { + &mut self.ledger + } + + /// Whether Silero actually loaded. `false` means every frame reads as + /// non-speech: no identity will ever be minted and no speech edge will ever + /// fire, so consumers that gate on edges must fail open instead of resting + /// forever. + pub fn vad_available(&self) -> bool { + self.vad.vad_available() + } + + /// Feed one capture chunk. `samples_seen` is the session cursor *after* + /// this chunk (same counter `apple_stream_worker` already owns). + pub fn ingest(&mut self, samples: &[f32], samples_seen: u64) -> SileroIngest { + if samples.is_empty() { + return SileroIngest::default(); + } + let events = self.vad.feed(samples, 0); + let closed_here = events + .iter() + .any(|event| matches!(event, SpeechEvent::UtteranceFinal(_))); + let open_range = self.vad.open_segment_raw_range(); + self.observe(open_range, closed_here, samples_seen) + } + + /// The whole decision, separated from the VAD read so it is testable on + /// synthetic edges (Silero loads from embedded bytes; a unit test that + /// silently degraded to "no model" would prove nothing). Production calls + /// this exactly once per chunk, from [`Self::ingest`]. + pub fn observe( + &mut self, + open_range: Option<(u64, u64)>, + closed_here: bool, + samples_seen: u64, + ) -> SileroIngest { + let mut out = SileroIngest { + speech_live: closed_here || open_range.is_some(), + ..SileroIngest::default() + }; + if let Some((start, end)) = open_range { + out.open = + Some( + self.ledger + .open_or_extend(&self.session, self.capture_epoch, start, end), + ); + } + if closed_here && let Some(id) = self.ledger.close_open(samples_seen) { + out.closed.push(id); + if out.open == Some(id) { + out.open = None; + } + } + out + } + + /// Seal any still-open Supervisor segment at capture EOF. + pub fn flush(&mut self, samples_seen: u64) -> Option { + let _ = self.vad.flush(); + self.ledger.close_open(samples_seen) + } +} + +/// How a Whisper window is cut relative to a Silero utterance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FusionContextMode { + /// Audio is exactly the Silero utterance. Default. + UtteranceOnly, + /// Small left pad, clipped at the last long-silence fence. + LeftAudioPad, + /// Same audio as utterance-only; the sealed prefix is the prompt (never + /// audio across a long silence). + StableTextPrompt, +} + +impl FusionContextMode { + pub fn from_env() -> Self { + match std::env::var(SILERO_FUSION_CONTEXT_ENV) { + Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "left_pad" | "left-pad" | "pad" => Self::LeftAudioPad, + "stable_prompt" | "stable-text" | "prompt" => Self::StableTextPrompt, + _ => Self::UtteranceOnly, + }, + Err(_) => Self::UtteranceOnly, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::UtteranceOnly => "utterance_only", + Self::LeftAudioPad => "left_audio_pad", + Self::StableTextPrompt => "stable_text_prompt", + } + } +} + +/// Cut the audio range a provider may see. Long silence is a hard fence. +pub fn bound_context_range( + utterance: &TailSampleRange, + last_long_silence_end: u64, + mode: FusionContextMode, + pad_samples: u64, +) -> TailSampleRange { + let mut range = utterance.clone(); + if mode == FusionContextMode::LeftAudioPad { + let want = utterance.sample_start.saturating_sub(pad_samples); + range.sample_start = want.max(last_long_silence_end); + } + if range.sample_start < last_long_silence_end + && last_long_silence_end < range.sample_end + && last_long_silence_end > utterance.sample_start.saturating_sub(pad_samples) + { + // Fence is inside the requested pad — clip, never cross. + range.sample_start = last_long_silence_end.max(utterance.sample_start); + } + if range.sample_start > range.sample_end { + range.sample_start = range.sample_end; + } + range +} + +/// One word pinned to a PCM range for fusion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FusionWord { + pub text: String, + pub sample_start: u64, + pub sample_end: u64, +} + +impl FusionWord { + pub fn from_timed(segment: &TimedTailSegment) -> Self { + Self { + text: segment.text.clone(), + sample_start: segment.range.sample_start, + sample_end: segment.range.sample_end, + } + } + + fn midpoint(&self) -> u64 { + self.sample_start + (self.sample_end.saturating_sub(self.sample_start) / 2) + } +} + +/// Unresolved Apple/Whisper pair — receipt only, no confidence pick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnresolvedAlternative { + pub apple: FusionWord, + pub whisper: FusionWord, +} + +/// Conservative fusion of one unsealed utterance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FusionDecision { + pub text: String, + pub agreements: usize, + pub gap_fills: usize, + pub unresolved: Vec, +} + +/// Content-free fusion receipt (no transcript text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FusionReceipt { + pub utterance_id: u64, + pub code: SkipReasonCode, + pub agreements: usize, + pub gap_fills: usize, + pub unresolved: usize, +} + +/// Case- and punctuation-folded token used only for agreement tests. +pub fn normalize_fusion_word(text: &str) -> String { + text.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +fn ranges_overlap(a_start: u64, a_end: u64, b_start: u64, b_end: u64) -> bool { + a_start < b_end && b_start < a_end +} + +/// Assign Apple words to Silero utterances by PCM overlap. Words that fall +/// in no utterance are returned as leftovers (caller receipts `no_time_overlap`). +pub fn slice_apple_words( + ledger: &UtteranceLedger, + words: &[FusionWord], +) -> (Vec<(u64, Vec)>, Vec) { + let mut leftover = Vec::new(); + let mut by_id: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for word in words { + match ledger.utterance_covering(word.midpoint()) { + Some(utterance) => by_id.entry(utterance.id).or_default().push(word.clone()), + None => leftover.push(word.clone()), + } + } + (by_id.into_iter().collect(), leftover) +} + +/// Conservative per-word fusion. Agreements and clear gap fills commit; +/// overlapping disagreements are receipted and Apple is kept. Confidence +/// never participates. +pub fn conservative_fuse(apple: &[FusionWord], whisper: &[FusionWord]) -> FusionDecision { + let mut committed: Vec = Vec::new(); + let mut unresolved = Vec::new(); + let mut used_whisper = vec![false; whisper.len()]; + let mut agreements = 0usize; + + for apple_word in apple { + let overlaps: Vec = whisper + .iter() + .enumerate() + .filter(|(_, whisper_word)| { + ranges_overlap( + apple_word.sample_start, + apple_word.sample_end, + whisper_word.sample_start, + whisper_word.sample_end, + ) + }) + .map(|(idx, _)| idx) + .collect(); + if overlaps.is_empty() { + committed.push(apple_word.clone()); + continue; + } + let apple_key = normalize_fusion_word(&apple_word.text); + let matching: Vec = overlaps + .iter() + .copied() + .filter(|&idx| normalize_fusion_word(&whisper[idx].text) == apple_key) + .collect(); + if matching.is_empty() { + let whisper_word = whisper[overlaps[0]].clone(); + used_whisper[overlaps[0]] = true; + unresolved.push(UnresolvedAlternative { + apple: apple_word.clone(), + whisper: whisper_word, + }); + committed.push(apple_word.clone()); + } else { + agreements += 1; + for idx in matching { + used_whisper[idx] = true; + } + committed.push(apple_word.clone()); + } + } + + let mut gap_fills = 0usize; + for (idx, whisper_word) in whisper.iter().enumerate() { + if used_whisper[idx] { + continue; + } + let overlaps_apple = apple.iter().any(|apple_word| { + ranges_overlap( + apple_word.sample_start, + apple_word.sample_end, + whisper_word.sample_start, + whisper_word.sample_end, + ) + }); + if overlaps_apple { + continue; + } + gap_fills += 1; + committed.push(whisper_word.clone()); + } + + committed.sort_by_key(|word| word.sample_start); + let text = committed + .iter() + .map(|word| word.text.as_str()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" "); + + FusionDecision { + text, + agreements, + gap_fills, + unresolved, + } +} + +pub fn fusion_receipt(utterance_id: u64, decision: &FusionDecision) -> FusionReceipt { + let code = if !decision.unresolved.is_empty() { + SkipReasonCode::UnresolvedAlternative + } else { + SkipReasonCode::NoTimeOverlap + }; + FusionReceipt { + utterance_id, + code, + agreements: decision.agreements, + gap_fills: decision.gap_fills, + unresolved: decision.unresolved.len(), + } +} + +/// One starved mid-phrase window used by the skip-table verifier. +#[cfg(test)] +#[derive(Debug, Clone)] +struct StarvedWindow { + pub committed: &'static str, + pub whisper: &'static str, + pub apple: Vec, + pub whisper_words: Vec, +} + +/// Synthetic reconstruction of the mid-phrase-window starvation class +/// (18 skips on build 614). Baseline LCS treats head-garbage as wholesale +/// divergence; time-sliced fusion commits the overlapping agreements. +#[cfg(test)] +fn starved_mid_phrase_windows() -> Vec { + fn word(text: &str, start: u64, end: u64) -> FusionWord { + FusionWord { + text: text.to_string(), + sample_start: start, + sample_end: end, + } + } + // 12 mid-phrase windows: Apple has the true phrase; Whisper window + // started in babble so the LCS head is garbage, but the overlapping + // tail agrees. 6 genuine unresolved pairs stay skipped. + let mut windows = Vec::new(); + for i in 0..12u64 { + let base = i * 48_000; + windows.push(StarvedWindow { + committed: "to jest fraza", + whisper: "babble noise to jest fraza", + apple: vec![ + word("to", base, base + 8_000), + word("jest", base + 8_000, base + 16_000), + word("fraza", base + 16_000, base + 24_000), + ], + whisper_words: vec![ + word("babble", base.saturating_sub(16_000), base), + word("noise", base.saturating_sub(8_000), base), + word("to", base, base + 8_000), + word("jest", base + 8_000, base + 16_000), + word("fraza", base + 16_000, base + 24_000), + ], + }); + } + for i in 0..6u64 { + let base = 600_000 + i * 16_000; + windows.push(StarvedWindow { + committed: "kot", + whisper: "pies", + apple: vec![word("kot", base, base + 8_000)], + whisper_words: vec![word("pies", base, base + 8_000)], + }); + } + windows +} + +/// Baseline (token LCS, fusion off) vs fusion-on skip/apply counts. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SkipTable { + pub baseline_skips: usize, + pub baseline_applied: usize, + pub fusion_skips: usize, + pub fusion_applied: usize, +} + +#[cfg(test)] +impl SkipTable { + pub fn skip_reduction_ratio(self) -> f64 { + if self.baseline_skips == 0 { + return 0.0; + } + 1.0 - (self.fusion_skips as f64 / self.baseline_skips as f64) + } +} + +/// Score the starved fixture. Baseline treats any Whisper head-garbage as a +/// skip (the production change-ratio class). Fusion commits agreements + +/// gap fills and only receipts unresolved alternatives. +#[cfg(test)] +fn score_starved_fixture(windows: &[StarvedWindow]) -> SkipTable { + let mut baseline_skips = 0usize; + let mut baseline_applied = 0usize; + let mut fusion_skips = 0usize; + let mut fusion_applied = 0usize; + for window in windows { + let committed: Vec<&str> = window.committed.split_whitespace().collect(); + let whisper: Vec<&str> = window.whisper.split_whitespace().collect(); + let committed_in_whisper = committed + .iter() + .filter(|token| { + whisper + .iter() + .any(|w| normalize_fusion_word(w) == normalize_fusion_word(token)) + }) + .count(); + let head_garbage = whisper.len() > committed.len() && committed_in_whisper < whisper.len(); + let identical = committed + .iter() + .zip(whisper.iter()) + .all(|(a, b)| normalize_fusion_word(a) == normalize_fusion_word(b)) + && committed.len() == whisper.len(); + if identical { + baseline_applied += 1; + } else if head_garbage || committed_in_whisper < committed.len() { + baseline_skips += 1; + } else { + baseline_applied += 1; + } + + let decision = conservative_fuse(&window.apple, &window.whisper_words); + if decision.unresolved.is_empty() && (decision.agreements > 0 || decision.gap_fills > 0) { + fusion_applied += 1; + } else if decision.unresolved.is_empty() && decision.agreements == 0 { + fusion_skips += 1; + } else { + // Unresolved alternatives are receipted, not applied as a rewrite. + fusion_skips += 1; + } + } + SkipTable { + baseline_skips, + baseline_applied, + fusion_skips, + fusion_applied, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn word(text: &str, start: u64, end: u64) -> FusionWord { + FusionWord { + text: text.to_string(), + sample_start: start, + sample_end: end, + } + } + + fn range(start: u64, end: u64) -> TailSampleRange { + TailSampleRange { + session: "s".into(), + capture_epoch: 0, + sample_start: start, + sample_end: end, + } + } + + #[test] + fn lane_defaults_off_until_operator_flip() { + assert!( + !lane_enabled_from_raw(None), + "unset must keep the experimental lane off" + ); + for off in ["0", "false", "no", "off", " OFF "] { + assert!( + !lane_enabled_from_raw(Some(off)), + "{off:?} must disarm the lane" + ); + } + for on in ["1", "true", "yes", "on"] { + assert!( + lane_enabled_from_raw(Some(on)), + "{on:?} must explicitly arm the lane" + ); + } + } + + /// The unification claim, stated as a test: **one** observation of the + /// spectrum produces both the ledger identity and the lifecycle edge bit. + /// Two speech segments split by a closing edge mint two identities, and the + /// `speech_live` the epoch gate reads is true exactly across those two + /// segments and false in the silence between them. + #[test] + fn one_observation_feeds_both_identity_and_the_lifecycle_edge() { + let mut ingress = SileroIngress::new(16_000, "s", 0); + + // Segment 1: open at 0, still open, then close inside the third chunk. + let a = ingress.observe(Some((0, 8_000)), false, 8_000); + assert_eq!(a.open, Some(1)); + assert!(a.speech_live, "an open segment is a live speech edge"); + let b = ingress.observe(Some((0, 16_000)), false, 16_000); + assert_eq!(b.open, Some(1), "an extending segment keeps its identity"); + let close = ingress.observe(None, true, 24_000); + assert_eq!(close.closed, vec![1]); + assert!( + close.speech_live, + "the chunk a segment closes in is still speech — the silence \ + counter starts after Silero's own hysteresis, never before it" + ); + + // Long silence: no edge, no identity. + for cursor in [32_000u64, 40_000, 48_000] { + let quiet = ingress.observe(None, false, cursor); + assert!(!quiet.speech_live, "silence is not a speech edge"); + assert!(quiet.closed.is_empty()); + assert_eq!(quiet.open, None); + } + + // Segment 2 past the long-silence fence: a NEW identity, not an extend. + let fence = (LONG_SILENCE_FENCE_SECS * 16_000.0) as u64; + let second_start = 24_000 + fence + 8_000; + let c = ingress.observe(Some((second_start, second_start + 8_000)), false, 56_000); + assert_eq!( + c.open, + Some(2), + "speech after a closing edge mints a second utterance" + ); + assert!(c.speech_live); + + let ledger = ingress.ledger(); + assert_eq!(ledger.utterances().len(), 2); + assert_eq!(ledger.utterances()[0].range.sample_start, 0); + assert_eq!(ledger.utterances()[0].range.sample_end, 24_000); + assert!(ledger.utterances()[0].closed); + assert_eq!(ledger.utterances()[1].range.sample_start, second_start); + assert!(!ledger.utterances()[1].closed); + assert!( + ledger.utterances()[1].range.sample_start - ledger.utterances()[0].range.sample_end + >= fence, + "fixture must actually clear the long-silence fence" + ); + } + + /// Enclosure, not overlap: a span may only adopt a Silero range that + /// already covers every sample it claimed. + #[test] + fn enclosure_is_required_before_a_span_adopts_a_silero_range() { + let mut ledger = UtteranceLedger::new(); + ledger.open_or_extend("s", 0, 10_000, 30_000); + ledger.close_open(30_000); + + let enclosed = ledger + .utterance_enclosing(12_000, 20_000) + .expect("a span inside the edge binds to it"); + assert_eq!(enclosed.id, 1); + assert_eq!(enclosed.range.sample_start, 10_000); + assert_eq!(enclosed.range.sample_end, 30_000); + + assert!( + ledger.utterance_enclosing(5_000, 20_000).is_none(), + "a span starting before the edge must NOT adopt it" + ); + assert!( + ledger.utterance_enclosing(20_000, 40_000).is_none(), + "a span ending after the edge must NOT adopt it" + ); + assert!( + ledger.utterance_enclosing(80_000, 90_000).is_none(), + "no edge at all is fail-open, not a panic" + ); + } + + /// One ledger, one id space: an id burnt by the Apple-boundary fallback is + /// never re-minted for a real utterance. + #[test] + fn reserved_ids_are_never_reused_by_a_minted_utterance() { + let mut ledger = UtteranceLedger::new(); + assert_eq!(ledger.reserve_id(), 1); + assert_eq!(ledger.reserve_id(), 2); + assert_eq!( + ledger.open_or_extend("s", 0, 0, 1_000), + 3, + "minting must continue past every reserved id" + ); + assert_eq!(ledger.utterances().len(), 1, "a reservation is not a span"); + } + + #[test] + fn w13_fusion_conservative_commits_agreements() { + let apple = vec![ + word("the", 0, 8_000), + word("cat", 8_000, 16_000), + word("sat", 16_000, 24_000), + ]; + let whisper_agree = vec![ + word("the", 0, 8_000), + word("cat", 8_000, 16_000), + word("sat", 16_000, 24_000), + ]; + let agreed = conservative_fuse(&apple, &whisper_agree); + assert_eq!(agreed.text, "the cat sat"); + assert_eq!(agreed.agreements, 3); + assert_eq!(agreed.gap_fills, 0); + assert!(agreed.unresolved.is_empty()); + + let mut whisper_gap = whisper_agree.clone(); + whisper_gap.push(word("here", 24_000, 32_000)); + let filled = conservative_fuse(&apple, &whisper_gap); + assert_eq!(filled.text, "the cat sat here"); + assert_eq!(filled.agreements, 3); + assert_eq!(filled.gap_fills, 1); + assert!(filled.unresolved.is_empty()); + + let whisper_conflict = vec![ + word("the", 0, 8_000), + word("dog", 8_000, 16_000), + word("sat", 16_000, 24_000), + ]; + let conflicted = conservative_fuse(&apple, &whisper_conflict); + assert_eq!(conflicted.text, "the cat sat"); + assert_eq!(conflicted.agreements, 2); + assert_eq!(conflicted.unresolved.len(), 1); + assert_eq!(conflicted.unresolved[0].apple.text, "cat"); + assert_eq!(conflicted.unresolved[0].whisper.text, "dog"); + let receipt = fusion_receipt(7, &conflicted); + assert_eq!(receipt.code, SkipReasonCode::UnresolvedAlternative); + assert_eq!(receipt.unresolved, 1); + } + + #[test] + fn apple_words_slice_onto_silero_edges() { + let mut ledger = UtteranceLedger::new(); + ledger.open_or_extend("s", 0, 0, 24_000); + ledger.close_open(24_000); + ledger.open_or_extend("s", 0, 32_000, 48_000); + let words = vec![ + word("alpha", 1_000, 8_000), + word("beta", 33_000, 40_000), + word("orphan", 80_000, 88_000), + ]; + let (sliced, leftover) = slice_apple_words(&ledger, &words); + assert_eq!(sliced.len(), 2); + assert_eq!(sliced[0].1[0].text, "alpha"); + assert_eq!(sliced[1].1[0].text, "beta"); + assert_eq!(leftover.len(), 1); + assert_eq!(leftover[0].text, "orphan"); + } + + #[test] + fn left_pad_never_crosses_long_silence() { + let utterance = range(48_000, 64_000); + let silence_end = 40_000; + let padded = bound_context_range( + &utterance, + silence_end, + FusionContextMode::LeftAudioPad, + 16_000, + ); + assert_eq!(padded.sample_start, silence_end); + assert_eq!(padded.sample_end, 64_000); + + let utterance_only = bound_context_range( + &utterance, + silence_end, + FusionContextMode::UtteranceOnly, + 16_000, + ); + assert_eq!(utterance_only.sample_start, 48_000); + + let prompt = bound_context_range( + &utterance, + silence_end, + FusionContextMode::StableTextPrompt, + 16_000, + ); + assert_eq!(prompt.sample_start, 48_000); + } + + #[test] + fn w13_fusion_starved_fixture_skip_table() { + let windows = starved_mid_phrase_windows(); + assert_eq!(windows.len(), 18); + let table = score_starved_fixture(&windows); + println!( + "starved fixture skip table: baseline skips={} applied={} | fusion skips={} applied={} | reduction={:.0}%", + table.baseline_skips, + table.baseline_applied, + table.fusion_skips, + table.fusion_applied, + table.skip_reduction_ratio() * 100.0 + ); + assert!( + table.skip_reduction_ratio() + f64::EPSILON >= 0.50, + "skip reduction {:.2} < 50% (baseline {} → fusion {})", + table.skip_reduction_ratio(), + table.baseline_skips, + table.fusion_skips + ); + assert!( + table.fusion_applied >= table.baseline_applied, + "applied dropped: baseline {} fusion {}", + table.baseline_applied, + table.fusion_applied + ); + } +} diff --git a/core/pipeline/streaming/span_idempotence.rs b/core/pipeline/streaming/span_idempotence.rs new file mode 100644 index 00000000..c257fb9f --- /dev/null +++ b/core/pipeline/streaming/span_idempotence.rs @@ -0,0 +1,469 @@ +//! W13-4 — sealed-span idempotence + in-span loop fence. +//! +//! Ports [`crate::asr_session::SessionIngest`] sealed-utterance rules onto the +//! live seal ledger. Identity is the canonical PCM range +//! (`session`, `capture_epoch`, `sample_start`, `sample_end`) plus an optional +//! provider `request_id`. Text is never a suppression key. +//! +//! Auto-removal is allowed only on non-content evidence (Amendment 3 / D2): +//! replayed request/range identity, non-progressing timestamps, or a decode +//! failure. Anything else is kept; a content-similar offer against a *new* +//! identity emits a WARN receipt and still lands on the canvas. +//! +//! Lane flag [`SPAN_IDEMPOTENCE_ENV`] is **default OFF**. + +use std::collections::BTreeSet; + +use crate::stt::tail_provider::{TailRequestIdentity, TailSampleRange}; + +/// Lane flag for sealed-span replay refusal. Unset / `0` / `false` / `off` / +/// `no` keep the pre-W13-4 seal path bit-identical. +pub const SPAN_IDEMPOTENCE_ENV: &str = "CODESCRIBE_SPAN_IDEMPOTENCE"; + +/// Whether the W13-4 idempotence lane is armed. Default OFF. +pub fn lane_enabled() -> bool { + let raw = std::env::var(SPAN_IDEMPOTENCE_ENV).ok(); + lane_enabled_from_raw(raw.as_deref()) +} + +fn lane_enabled_from_raw(raw: Option<&str>) -> bool { + raw.is_some_and(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// Non-content evidence that may auto-remove a delivery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NonContentEvidence { + /// Exact `(session, epoch, start, end)` already sealed or accepted. + ReplayedRangeIdentity, + /// Same `request_id` already consumed (provider re-submit). + ReplayedRequestIdentity, + /// Word/span clock did not advance on a re-offer of the same request. + NonProgressingTimestamps, + /// Provider reported a failed decode for this identity. + DecodeFailure, +} + +impl NonContentEvidence { + pub fn as_str(self) -> &'static str { + match self { + Self::ReplayedRangeIdentity => "replayed_range_identity", + Self::ReplayedRequestIdentity => "replayed_request_identity", + Self::NonProgressingTimestamps => "non_progressing_timestamps", + Self::DecodeFailure => "decode_failure", + } + } +} + +/// What the ledger decided about one offered span. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpanOfferVerdict { + /// New identity; canvas grows. + Accepted, + /// Same sealed/accepted identity — SessionIngest `RejectedSealedUtterance` + /// / `DuplicateIdempotent` ported onto range identity. + RejectedSealedReplay, + /// In-span loop fenced on non-content evidence (auto-removed). + FencedLoop { evidence: NonContentEvidence }, + /// Content looks like a duplicate but the identity is new — KEEP. + WarnPreserved, +} + +impl SpanOfferVerdict { + pub fn lands_on_canvas(&self) -> bool { + matches!(self, Self::Accepted | Self::WarnPreserved) + } + + pub fn as_token(&self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::RejectedSealedReplay => "rejected_sealed_replay", + Self::FencedLoop { .. } => "fenced_loop", + Self::WarnPreserved => "content_similar_preserved", + } + } +} + +/// Content-free receipt. Never carries transcript text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpanIdempotenceReceipt { + pub code: &'static str, + pub warn: bool, + pub request_id: u64, + pub range: TailSampleRange, +} + +/// One offered delivery. `text` is for canvas assembly only — never a key. +#[derive(Debug, Clone)] +pub struct SpanOffer { + pub identity: TailRequestIdentity, + pub text: String, + /// Caller-measured: did word/span timestamps advance vs the previous + /// offer of this `request_id`? Unused on a first offer. + pub timestamps_progressed: bool, + pub decode_ok: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AcceptedSpan { + identity: TailRequestIdentity, + text: String, +} + +/// Sealed-span ledger. Holds no audio and reads no wall clock. +#[derive(Debug, Clone, Default)] +pub struct SpanIdempotenceLedger { + accepted: Vec, + sealed_ranges: BTreeSet, + receipts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct RangeKey { + session: String, + capture_epoch: u64, + sample_start: u64, + sample_end: u64, +} + +impl RangeKey { + fn from_range(range: &TailSampleRange) -> Self { + Self { + session: range.session.clone(), + capture_epoch: range.capture_epoch, + sample_start: range.sample_start, + sample_end: range.sample_end, + } + } +} + +impl SpanIdempotenceLedger { + /// Apply one offer. Text never participates in the removal decision. + pub fn offer(&mut self, offer: SpanOffer) -> SpanOfferVerdict { + let range = &offer.identity.range; + let request_id = offer.identity.request_id; + + if !offer.decode_ok { + return self.fence(NonContentEvidence::DecodeFailure, request_id, range.clone()); + } + + // Sealed identity is immutable — SessionIngest rule 5, keyed by range. + if self.sealed_ranges.contains(&RangeKey::from_range(range)) { + return self.reject_replay( + NonContentEvidence::ReplayedRangeIdentity, + request_id, + range.clone(), + ); + } + + if let Some(previous) = self + .accepted + .iter() + .rev() + .find(|span| span.identity.request_id == request_id) + { + // In-span loop: same request, clock did not move. Not a content check. + if !offer.timestamps_progressed + || (range.sample_end <= previous.identity.range.sample_end + && range.sample_start <= previous.identity.range.sample_start) + { + return self.fence( + NonContentEvidence::NonProgressingTimestamps, + request_id, + range.clone(), + ); + } + return self.reject_replay( + NonContentEvidence::ReplayedRequestIdentity, + request_id, + range.clone(), + ); + } + + let content_similar = self + .accepted + .iter() + .any(|span| span.text == offer.text && !offer.text.trim().is_empty()); + + self.accepted.push(AcceptedSpan { + identity: offer.identity.clone(), + text: offer.text, + }); + + if content_similar { + self.receipts.push(SpanIdempotenceReceipt { + code: SpanOfferVerdict::WarnPreserved.as_token(), + warn: true, + request_id, + range: range.clone(), + }); + return SpanOfferVerdict::WarnPreserved; + } + + SpanOfferVerdict::Accepted + } + + /// Record that a range has sealed (immutable). Later exact-identity + /// offers are `RejectedSealedReplay` even if the Apple id is new. + pub fn mark_sealed(&mut self, range: &TailSampleRange) { + self.sealed_ranges.insert(RangeKey::from_range(range)); + } + + pub fn receipts(&self) -> &[SpanIdempotenceReceipt] { + &self.receipts + } + + #[cfg(test)] + fn canvas_texts(&self) -> Vec<&str> { + self.accepted + .iter() + .map(|span| span.text.as_str()) + .collect() + } + + #[cfg(test)] + fn canvas(&self) -> String { + self.canvas_texts().join(" ") + } + + #[cfg(test)] + fn warn_count(&self) -> usize { + self.receipts.iter().filter(|receipt| receipt.warn).count() + } + + #[cfg(test)] + fn suppressed_count(&self) -> usize { + self.receipts + .iter() + .filter(|receipt| { + matches!( + receipt.code, + "replayed_range_identity" + | "replayed_request_identity" + | "non_progressing_timestamps" + | "decode_failure" + ) + }) + .count() + } + + #[cfg(test)] + fn verdict_warns(&self) -> usize { + self.receipts + .iter() + .filter(|receipt| receipt.code == "content_similar_preserved") + .count() + } + + fn reject_replay( + &mut self, + evidence: NonContentEvidence, + request_id: u64, + range: TailSampleRange, + ) -> SpanOfferVerdict { + self.receipts.push(SpanIdempotenceReceipt { + code: evidence.as_str(), + warn: false, + request_id, + range, + }); + SpanOfferVerdict::RejectedSealedReplay + } + + fn fence( + &mut self, + evidence: NonContentEvidence, + request_id: u64, + range: TailSampleRange, + ) -> SpanOfferVerdict { + self.receipts.push(SpanIdempotenceReceipt { + code: evidence.as_str(), + warn: false, + request_id, + range, + }); + SpanOfferVerdict::FencedLoop { evidence } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SENTENCE: &str = "wpierdalało zabierało kradło"; + const RATE: u64 = 16_000; + /// Silero-sized pause used by the 5× fixture (above [`super::super::silero_fusion::LONG_SILENCE_FENCE_SECS`]). + const PAUSE_SAMPLES: u64 = RATE; // 1.0 s + const UTTERANCE_SAMPLES: u64 = 8_000; + + fn range(start: u64, end: u64) -> TailSampleRange { + TailSampleRange { + session: "w13-4".into(), + capture_epoch: 1, + sample_start: start, + sample_end: end, + } + } + + fn identity(request_id: u64, start: u64, end: u64) -> TailRequestIdentity { + TailRequestIdentity { + request_id, + range: range(start, end), + } + } + + fn offer( + request_id: u64, + start: u64, + end: u64, + text: &str, + timestamps_progressed: bool, + decode_ok: bool, + ) -> SpanOffer { + SpanOffer { + identity: identity(request_id, start, end), + text: text.to_string(), + timestamps_progressed, + decode_ok, + } + } + + #[test] + fn lane_defaults_off() { + assert!(!lane_enabled_from_raw(None)); + assert!(!lane_enabled_from_raw(Some("off"))); + assert!(lane_enabled_from_raw(Some("on"))); + } + + #[test] + fn decode_failure_is_fenced_without_canvas_write() { + let mut ledger = SpanIdempotenceLedger::default(); + let verdict = ledger.offer(offer(1, 0, 8_000, SENTENCE, true, false)); + assert_eq!( + verdict, + SpanOfferVerdict::FencedLoop { + evidence: NonContentEvidence::DecodeFailure + } + ); + assert!(ledger.canvas().is_empty()); + assert_eq!(ledger.receipts()[0].code, "decode_failure"); + } + + #[test] + fn w13_span_idempotence_preserves_repetition() { + // Fixture A — duplicate-once: same range identity replayed after seal. + let mut duplicate = SpanIdempotenceLedger::default(); + assert_eq!( + duplicate.offer(offer( + 10, + 0, + UTTERANCE_SAMPLES, + "fragment odzyskany", + true, + true + )), + SpanOfferVerdict::Accepted + ); + duplicate.mark_sealed(&range(0, UTTERANCE_SAMPLES)); + let replay = duplicate.offer(offer( + 11, + 0, + UTTERANCE_SAMPLES, + "fragment odzyskany", + true, + true, + )); + assert_eq!(replay, SpanOfferVerdict::RejectedSealedReplay); + assert_eq!(duplicate.canvas_texts(), ["fragment odzyskany"]); + assert_eq!(duplicate.suppressed_count(), 1); + assert_eq!( + duplicate.receipts().last().map(|r| r.code), + Some("replayed_range_identity") + ); + + // Same request_id re-submitted with a frozen clock is an in-span loop. + let mut looped = SpanIdempotenceLedger::default(); + assert!( + looped + .offer(offer(3, 0, UTTERANCE_SAMPLES, SENTENCE, true, true)) + .lands_on_canvas() + ); + let fenced = looped.offer(offer(3, 0, UTTERANCE_SAMPLES, SENTENCE, false, true)); + assert_eq!( + fenced, + SpanOfferVerdict::FencedLoop { + evidence: NonContentEvidence::NonProgressingTimestamps + } + ); + assert_eq!(looped.canvas_texts(), [SENTENCE]); + + // Fixture B — 5× paused deliberate repetition (Silero-sized gaps). + let mut paused = SpanIdempotenceLedger::default(); + let mut cursor = 0u64; + let mut paused_verdicts = Vec::new(); + for i in 0..5u64 { + let start = cursor; + let end = start + UTTERANCE_SAMPLES; + let verdict = paused.offer(offer(100 + i, start, end, SENTENCE, true, true)); + paused.mark_sealed(&range(start, end)); + paused_verdicts.push(verdict); + cursor = end + PAUSE_SAMPLES; + } + assert!( + paused_verdicts + .iter() + .all(SpanOfferVerdict::lands_on_canvas), + "paused 5× must all land: {paused_verdicts:?}" + ); + assert_eq!(paused.canvas_texts().len(), 5); + assert_eq!( + paused + .canvas_texts() + .iter() + .filter(|t| **t == SENTENCE) + .count(), + 5 + ); + assert_eq!(paused.verdict_warns(), 4); + + // Fixture C — continuous repetition, no Silero-sized gap, progressing clock. + let mut continuous = SpanIdempotenceLedger::default(); + let mut cursor = 0u64; + let mut continuous_verdicts = Vec::new(); + for i in 0..5u64 { + let start = cursor; + let end = start + UTTERANCE_SAMPLES; + let verdict = continuous.offer(offer(200 + i, start, end, SENTENCE, true, true)); + continuous.mark_sealed(&range(start, end)); + continuous_verdicts.push(verdict); + cursor = end; // abutting — no pause + } + assert!( + continuous_verdicts + .iter() + .all(SpanOfferVerdict::lands_on_canvas), + "continuous 5× must all land: {continuous_verdicts:?}" + ); + assert_eq!(continuous.canvas_texts().len(), 5); + assert_eq!( + continuous + .canvas_texts() + .iter() + .filter(|t| **t == SENTENCE) + .count(), + 5 + ); + assert_eq!(continuous.verdict_warns(), 4); + + // Choice rule: if content looks like a duplicate but identity is new, + // repetition wins and the would-be drop is a WARN receipt. + assert!(paused.warn_count() >= 4); + assert!(continuous.warn_count() >= 4); + assert_eq!(paused.suppressed_count(), 0); + assert_eq!(continuous.suppressed_count(), 0); + } +} diff --git a/core/pipeline/streaming/tests.rs b/core/pipeline/streaming/tests.rs index f3787bd9..225c88d4 100644 --- a/core/pipeline/streaming/tests.rs +++ b/core/pipeline/streaming/tests.rs @@ -8,6 +8,7 @@ use anyhow::Result; use tokio::sync::{Mutex, mpsc}; use tokio::time::{Duration, Instant}; +use crate::asr_session::recorder::Layer1Decision; use crate::pipeline::contracts::{EngineEvent, EventSink, RawTranscript, TranscriptSegment}; use crate::pipeline::sinks::CollectorEventSink; use crate::stt::scheduler::{SttLane, SttScheduler, SttTaskHandle}; @@ -974,6 +975,47 @@ fn test_correction_bootstraps_when_no_output_emitted_yet() { }); } +#[test] +fn test_transcript_buffer_has_single_writer_no_duplication() { + // Regression for 2026-08-14: the command worker snapshotted the full target + // into the shared buffer AND the tick loop appended the same suffix again, + // so repeated sentences tripled in the final RAW (a 264-char cumulative + // preview became a 791-char transcript). The buffer has exactly one writer: + // store_transcript_snapshot. Ticking must never grow it past the target. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let buf = Arc::new(Mutex::new(String::new())); + let mut emitter = BufferedEmitter::new(buf.clone(), None, None); + + // Cumulative targets, the shape the Apple lane delivers — each one is a + // superset of the previous, mirroring the command-worker snapshot flow. + for target in ["Ala ma", "Ala ma kota. ", "Ala ma kota. Ala ma kota. "] { + if let Some(snapshot) = emitter.set_target_text(target.to_string()) { + emitter.store_transcript_snapshot(snapshot).await; + } + } + emitter.finish(); + + // Generous deadline: the typing profile is env-tunable and an operator + // dotenv can slow the animation; the drain itself is sub-second. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !emitter.tick().await { + assert!( + std::time::Instant::now() < deadline, + "emitter failed to drain within 20s" + ); + tokio::time::sleep(Duration::from_millis(2)).await; + } + + let final_buffer = buf.lock().await.clone(); + assert_eq!(final_buffer, "Ala ma kota. Ala ma kota. "); + assert_eq!(emitter.emitted_text, "Ala ma kota. Ala ma kota. "); + }); +} + #[test] fn test_correction_delta() { let before = "This is a dratf."; @@ -1401,6 +1443,8 @@ async fn transcription_session_emits_no_speech_and_stats_for_empty_input() { language: Some("pl".to_string()), stream_log_path: None, utterance_silence_sec: None, + layer1: Layer1Decision::Disarmed, + lifecycle_events: None, }, ) .await; @@ -1635,6 +1679,8 @@ async fn bench_stt_scheduler_latency_probe_from_env() -> Result<()> { language: language.clone(), stream_log_path: None, utterance_silence_sec: None, + layer1: Layer1Decision::Disarmed, + lifecycle_events: None, }, )); @@ -1714,6 +1760,8 @@ async fn transcription_session_silent_callbacks_keep_no_speech_stats_coherent() language: Some("pl".to_string()), stream_log_path: None, utterance_silence_sec: None, + layer1: Layer1Decision::Disarmed, + lifecycle_events: None, }, ) .await; diff --git a/core/quality/mod.rs b/core/quality/mod.rs index e5575bbd..c02cc1ae 100644 --- a/core/quality/mod.rs +++ b/core/quality/mod.rs @@ -22,6 +22,6 @@ pub mod qube_report; pub mod teacher; pub use teacher::{ - MergeMode, MergedDelivery, TeacherInput, TeacherReport, merge_live_whisper, - merge_live_whisper_with_terms, report_to_html, teach, + Layer1MergeMode, Layer1MergedDelivery, MergeMode, MergedDelivery, TeacherInput, TeacherReport, + merge_live_layer1, merge_live_whisper, merge_live_whisper_with_terms, report_to_html, teach, }; diff --git a/core/quality/overlay_quality.rs b/core/quality/overlay_quality.rs index ddf7fb9c..70967d4a 100644 --- a/core/quality/overlay_quality.rs +++ b/core/quality/overlay_quality.rs @@ -1093,6 +1093,44 @@ pub fn commit_overlay_correction_with_confidence( }) } +/// One-click Teach from a highlighted canvas span. +/// +/// Lexicon-corrected spans upsert the known variant→canonical pair through +/// the existing Correction path. Speech-gap pustki are evidence-only: there +/// is no word to teach until a human supplies one in Voice Lab. +pub fn teach_span(variant: &str, canonical: &str, kind: &str) -> Result { + match kind { + "speech_gap" => commit_overlay_correction_with_confidence( + variant, + variant, + if canonical.trim().is_empty() { + "∅" + } else { + canonical + }, + "overlay-span", + None, + Some("teach-span-gap"), + Some(FormattingPolicy::Off.as_str()), + None, + None, + vec!["speech_gap".to_string()], + ), + _ => commit_overlay_correction_with_confidence( + variant, + variant, + canonical, + "overlay-span", + None, + Some("teach-span"), + Some(FormattingPolicy::Correction.as_str()), + None, + None, + vec!["lexicon_corrected".to_string()], + ), + } +} + /// Replay historical `corrections.jsonl` through the current extractor. /// Returns dry-run candidate rows; with `apply=true` upserts after backing up /// the custom lexicon to `.bak-replay-`. @@ -1619,6 +1657,24 @@ mod tests { ); } + #[test] + #[serial] + fn teach_span_lexicon_learns_pair_and_gap_is_evidence_only() { + let temp_dir = tempfile::tempdir().expect("temp"); + let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let temp_root = temp_dir.path().canonicalize().unwrap(); + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + } + let learned = super::teach_span("uni agentka", "Junie", "lexicon_corrected") + .expect("teach lexicon span"); + assert_eq!(learned.pairs_learned, 1); + assert!(!learned.evidence_only); + let gap = super::teach_span("", "", "speech_gap").expect("teach gap span"); + assert_eq!(gap.pairs_learned, 0); + assert!(gap.evidence_only); + } + /// E2E: long-dictation commit learns one pair and stamps correction provenance. #[test] #[serial] diff --git a/core/quality/teacher/merge.rs b/core/quality/teacher/merge.rs index 9a97498c..02f22dba 100644 --- a/core/quality/teacher/merge.rs +++ b/core/quality/teacher/merge.rs @@ -1,4 +1,4 @@ -//! Merged delivery: live floor + Whisper fill at weak loci. +//! Merged delivery: live floor + Layer 1 fill at weak loci. //! //! Product doctrine (operator 2026-07-24, 85% Apple×Whisper thesis): //! - **Live (Apple)** is the floor of truth where it spoke. @@ -41,10 +41,62 @@ pub struct MergedDelivery { pub whisper_won_substitutes: usize, } -/// Merge live (Apple/stream floor) with Whisper final into one delivery string. +/// Provider-neutral decision shape used by Layer 1 adjudication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layer1MergeMode { + /// Both live and provider results were empty. + Empty, + /// Only committed live content was available. + LiveOnly, + /// Only the Layer 1 provider had content. + ProviderOnly, + /// Live floor preserved with provider gap/tail additions. + LiveFloorGapFill, +} + +/// Provider-neutral result exposed to controller adjudication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layer1MergedDelivery { + pub text: String, + pub mode: Layer1MergeMode, + pub provider_fill_tokens: usize, + pub live_kept_substitutes: usize, + pub equal_tokens: usize, + pub provider_won_substitutes: usize, +} + +impl From for Layer1MergedDelivery { + fn from(delivery: MergedDelivery) -> Self { + let mode = match delivery.mode { + MergeMode::Empty => Layer1MergeMode::Empty, + MergeMode::LiveOnly => Layer1MergeMode::LiveOnly, + MergeMode::WhisperOnly => Layer1MergeMode::ProviderOnly, + MergeMode::LiveFloorWhisperFill => Layer1MergeMode::LiveFloorGapFill, + }; + Self { + text: delivery.text, + mode, + provider_fill_tokens: delivery.whisper_fill_tokens, + live_kept_substitutes: delivery.live_kept_substitutes, + equal_tokens: delivery.equal_tokens, + provider_won_substitutes: delivery.whisper_won_substitutes, + } + } +} + +/// Merge a live (Apple/stream) floor with a provider-neutral Layer 1 result. /// -/// Pure function — unit tests and controller adjudication both call this. -/// Legacy shape: no known-terms catalog, so substitutes always keep live. +/// Layer 1 may add aligned gaps and tail tokens, but substitutions always keep +/// the committed live token. Provider-specific lexicon evidence belongs in an +/// explicit extension such as [`merge_live_whisper_with_terms`]. +pub fn merge_live_layer1(live: &str, layer1: &str) -> Layer1MergedDelivery { + merge_live_whisper_with_terms(live, layer1, &[]).into() +} + +/// Backward-compatible local Whisper entrypoint. +/// +/// This preserves the existing no-catalog behavior while the controller uses +/// [`merge_live_layer1`] for provider-neutral cloud adjudication. pub fn merge_live_whisper(live: &str, whisper: &str) -> MergedDelivery { merge_live_whisper_with_terms(live, whisper, &[]) } @@ -230,6 +282,35 @@ pub fn merge_live_whisper_with_terms( } /// Doctrine guards: gap-fill, live floor, and empty-side modes. +#[cfg(test)] +mod layer1_contract_tests { + use super::{Layer1MergeMode, Layer1MergedDelivery, merge_live_layer1, merge_live_whisper}; + + #[test] + fn provider_neutral_layer1_preserves_live_substitutions_and_adds_tail() { + let merged = merge_live_layer1( + "live_token shared_token", + "provider_token shared_token tail_token", + ); + + assert_eq!(merged.mode, Layer1MergeMode::LiveFloorGapFill); + assert!(merged.text.starts_with("live_token shared_token")); + assert!(merged.text.ends_with("tail_token")); + assert_eq!(merged.provider_won_substitutes, 0); + } + + #[test] + fn local_legacy_entrypoint_retains_provider_neutral_default_behavior() { + let live = "live_token shared_token"; + let layer1 = "provider_token shared_token tail_token"; + + assert_eq!( + Layer1MergedDelivery::from(merge_live_whisper(live, layer1)), + merge_live_layer1(live, layer1) + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/quality/teacher/mod.rs b/core/quality/teacher/mod.rs index 74357f15..8caa7388 100644 --- a/core/quality/teacher/mod.rs +++ b/core/quality/teacher/mod.rs @@ -23,7 +23,10 @@ mod report; mod tokenize; pub use align::{AlignOp, align_words}; -pub use merge::{MergeMode, MergedDelivery, merge_live_whisper, merge_live_whisper_with_terms}; +pub use merge::{ + Layer1MergeMode, Layer1MergedDelivery, MergeMode, MergedDelivery, merge_live_layer1, + merge_live_whisper, merge_live_whisper_with_terms, +}; pub use report::{ AttentionKind, AttentionSpan, LexiconHint, TeacherInput, TeacherReport, report_to_html, teach, }; diff --git a/core/stt/apple_stt/codescribe-stt-bridge.swift b/core/stt/apple_stt/codescribe-stt-bridge.swift index 9076d9ae..d41e6858 100644 --- a/core/stt/apple_stt/codescribe-stt-bridge.swift +++ b/core/stt/apple_stt/codescribe-stt-bridge.swift @@ -28,6 +28,7 @@ struct BridgeRequest: Codable { let command: String let locale: String let audioPath: String? + let contextualStrings: [String]? let allowDownload: Bool } @@ -104,6 +105,10 @@ private func dictationTranscriberEnabled() -> Bool { } } +if CommandLine.arguments.contains("--phrase-restart-self-test") { + exit(runPhraseRestartVectorSelfTest()) +} + respawnSelfResponsibleIfRequested() Task { @@ -219,6 +224,7 @@ private func readRequest() throws -> BridgeRequest { command: command, locale: locale, audioPath: nil, + contextualStrings: nil, allowDownload: false ) } @@ -290,7 +296,10 @@ private func handle(request: BridgeRequest) async throws -> BridgeResponse { // {"rate":48000,"channels":1}, then raw interleaved f32le frames, EOF // ends the stream. // Speech TCC: requested inside transcribeStreaming (SF-only path). - let payload = try await transcribeStreaming(locale: locale) + let payload = try await transcribeStreaming( + locale: locale, + contextualStrings: request.contextualStrings + ) return BridgeResponse( ok: true, status: "ok", @@ -308,7 +317,11 @@ private func handle(request: BridgeRequest) async throws -> BridgeResponse { guard let audioPath = request.audioPath, !audioPath.isEmpty else { throw BridgeError.missingAudioPath } - let transcription = try await transcribeLiveBuffered(audioPath: audioPath, locale: locale) + let transcription = try await transcribeLiveBuffered( + audioPath: audioPath, + locale: locale, + contextualStrings: request.contextualStrings + ) return BridgeResponse( ok: true, status: "ok", @@ -809,7 +822,11 @@ private func transcribeWithSpeechTranscriber( /// /// WAV on disk is only a *source of samples* — same as a virtual CoreAudio device /// would deliver. Recognition uses the buffer API Apple designs for live mic. -private func transcribeLiveBuffered(audioPath: String, locale: Locale) async throws -> TranscriptionPayload { +private func transcribeLiveBuffered( + audioPath: String, + locale: Locale, + contextualStrings: [String]? +) async throws -> TranscriptionPayload { // Prefer SpeechTranscriber streaming when locale is installed; else SF buffer. let stSupported = await SpeechTranscriber.supportedLocales if let effectiveLocale = bestAvailableLocale(requested: locale, available: stSupported) { @@ -828,12 +845,35 @@ private func transcribeLiveBuffered(audioPath: String, locale: Locale) async thr ) } } - return try await transcribeWithSfSpeechAudioBuffer(audioPath: audioPath, locale: locale) + return try await transcribeWithSfSpeechAudioBuffer( + audioPath: audioPath, + locale: locale, + contextualStrings: contextualStrings + ) +} + +/// Defensive mirror of SFSpeech's 100-entry contextualStrings contract. Rust +/// already emits a deterministic, budgeted list; this keeps direct bridge +/// callers from smuggling empty or duplicate entries into the recognizer. +private func sanitizedContextualStrings(_ values: [String]?) -> [String] { + var seen = Set() + var result: [String] = [] + for raw in values ?? [] { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { continue } + let key = value.lowercased() + if seen.insert(key).inserted { + result.append(value) + } + if result.count == 100 { break } + } + return result } private func transcribeWithSfSpeechAudioBuffer( audioPath: String, - locale: Locale + locale: Locale, + contextualStrings: [String]? ) async throws -> TranscriptionPayload { // SFSpeech path only — Speech Recognition TCC required here (not on ST). try await ensureSpeechAuthorizedForSfSpeech() @@ -879,7 +919,8 @@ private func transcribeWithSfSpeechAudioBuffer( recognizer: recognizer, startFrame: 0, frameCount: AVAudioFramePosition(audioFile.length), - timeOffset: 0 + timeOffset: 0, + contextualStrings: contextualStrings ) } @@ -901,7 +942,8 @@ private func transcribeWithSfSpeechAudioBuffer( recognizer: recognizer, startFrame: frame, frameCount: windowFrames, - timeOffset: timeOffset + timeOffset: timeOffset, + contextualStrings: contextualStrings ) let trimmed = part.text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { @@ -931,7 +973,8 @@ private func recognizeSfSpeechBufferWindow( recognizer: SFSpeechRecognizer, startFrame: AVAudioFramePosition, frameCount: AVAudioFramePosition, - timeOffset: Double + timeOffset: Double, + contextualStrings: [String]? ) async throws -> TranscriptionPayload { let fileFormat = audioFile.processingFormat let rate = max(fileFormat.sampleRate, 1.0) @@ -948,6 +991,7 @@ private func recognizeSfSpeechBufferWindow( request.requiresOnDeviceRecognition = true request.shouldReportPartialResults = true request.taskHint = .dictation + request.contextualStrings = sanitizedContextualStrings(contextualStrings) // Isolate non-Sendable Speech request behind a serial-queue handle so // Dispatch/AVAudio @Sendable completions never capture it bare (S-4). let requestHandle = SfSpeechRequestHandle(request, label: "com.vetcoders.codescribe.stt.sf-buffer") @@ -1144,7 +1188,10 @@ private struct StreamHeader: Codable { /// replay. Partials become `partial` events, phrase finals become `final` /// events (with `SFTranscriptionSegment.confidence`), EOF ends audio and the /// accumulated text is returned as the summary payload. -private func transcribeStreaming(locale: Locale) async throws -> TranscriptionPayload { +private func transcribeStreaming( + locale: Locale, + contextualStrings: [String]? +) async throws -> TranscriptionPayload { // Stream command is SFSpeechAudioBuffer only today — gate Speech TCC here. try await ensureSpeechAuthorizedForSfSpeech() guard let headerData = readRawStdinLine(), @@ -1179,6 +1226,7 @@ private func transcribeStreaming(locale: Locale) async throws -> TranscriptionPa request.requiresOnDeviceRecognition = true request.shouldReportPartialResults = true request.taskHint = .dictation + request.contextualStrings = sanitizedContextualStrings(contextualStrings) // Stream PCM pump runs on a Dispatch queue (@Sendable); isolate the bare // request so append/endAudio never cross the Sendable boundary (L1068). let requestHandle = SfSpeechRequestHandle(request, label: "com.vetcoders.codescribe.stt.sf-stream") @@ -1372,14 +1420,9 @@ final class SfSpeechPhraseAccumulator: @unchecked Sendable { defer { lock.unlock() } let t = text.trimmingCharacters(in: .whitespacesAndNewlines) var frozen: FrozenPhrase? = nil - // Detect SFSpeech phrase restart without isFinal → freeze prior. - // - // A RESTART collapses the hypothesis to a few words; a REVISION keeps - // most of it and only rewords the middle. Measured on the parity - // fixture: all 13 restarts landed at ≤12 chars (from 47…191), while a - // revision shrank 95 → 79 — and the old "shorter by 12+" rule froze - // that revision as a phrase, sealing the same span twice (the residual - // duplication at similarity 0.872). + // Retain every prior hypothesis that the next hypothesis does not + // contain in full. Restart thresholds classify the transition for + // telemetry only; they never authorize overwriting speech. // // NAMED DROP MECHANISM (w1-b, 2026-08-10 three-way live): // `shared_opener_restart_suppresses_freeze`. Consecutive Polish @@ -1388,19 +1431,19 @@ final class SfSpeechPhraseAccumulator: @unchecked Sendable { // so a post-stressor collapse to the next sentence's short opener // OVERWROTE the open hypothesis without freezing it — s6/s8/s10 // vanished from committed raw while native dictation kept them. - // Same-phrase rewind only suppresses freeze when the short text is a - // TRUE substantial prefix (>15 chars) of the prior hypothesis. - if !partialText.isEmpty && !t.isEmpty { + // A same-phrase rewind is retained too: without a second retained copy, + // overwriting it would still discard the rewound suffix. + if !partialText.isEmpty { let prev = partialText - if Self.phraseRestartShouldFreezePrior(prev: prev, next: t) { + if let reason = Self.phraseRetentionReason(prev: prev, next: t) { finals.append(prev) finalSegments.append(contentsOf: partialSegments) frozen = FrozenPhrase(text: prev, segments: partialSegments) let ts = ISO8601DateFormatter().string(from: Date()) fputs( - "apple_lifecycle: freeze reason=shared_opener_restart_suppresses_freeze " + "INFO apple_lifecycle: freeze " + "ts=\(ts) prev_chars=\(prev.count) next_chars=\(t.count) " - + "prev_head=\(String(prev.prefix(40))) next_head=\(String(t.prefix(40)))\n", + + "reason=\(reason)\n", stderr ) } @@ -1413,15 +1456,19 @@ final class SfSpeechPhraseAccumulator: @unchecked Sendable { /// Pure freeze decision — kept in lockstep with /// `phrase_restart_should_freeze_prior` in `apple_live_session.rs`. fileprivate static func phraseRestartShouldFreezePrior(prev: String, next: String) -> Bool { + phraseRetentionReason(prev: prev, next: next) != nil + } + + /// Returns a telemetry classification only when the prior hypothesis must + /// be retained. Text safety depends solely on forward containment. + private static func phraseRetentionReason(prev: String, next: String) -> String? { let prev = prev.trimmingCharacters(in: .whitespacesAndNewlines) let next = next.trimmingCharacters(in: .whitespacesAndNewlines) - guard !prev.isEmpty, !next.isEmpty else { return false } + guard !prev.isEmpty else { return nil } + guard !next.contains(prev) else { return nil } + guard !next.isEmpty else { return "empty_collapse_retained" } let restarted = (next.count * 3 < prev.count) || (next.count <= 15 && prev.count >= 25) - guard restarted else { return false } - // Substantial true-prefix rewind of the SAME phrase — not a 1–2 word - // opener every "Zdanie N" sentence shares. - let samePhraseRewind = prev.hasPrefix(next) && next.count > 15 - return !samePhraseRewind + return restarted ? "restart_retained" : "revision_retained" } /// Freeze whatever hypothesis is still open (stream ended mid-phrase). @@ -1460,6 +1507,70 @@ final class SfSpeechPhraseAccumulator: @unchecked Sendable { } } +/// Test-only command path over the same accumulator decision used in live +/// streaming. It intentionally reads the exact TSV compiled into the Rust +/// mirror test, so fixture drift cannot make the two languages look aligned. +private func runPhraseRestartVectorSelfTest() -> Int32 { + let source = URL(fileURLWithPath: #filePath) + let root = source + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let fixture = root.appendingPathComponent("tests/fixtures/phrase_restart_vectors.tsv") + + do { + let contents = try String(contentsOf: fixture, encoding: .utf8) + let requiredIDs: Set = [ + "measured_restart_47_to_12", + "measured_revision_95_to_79", + "missed_collapse_40_to_20", + "shared_opener_sentence_restart", + "shared_opener_spoken_variant", + ] + var seenIDs: Set = [] + for line in contents.split(separator: "\n").map(String.init) + where !line.hasPrefix("#") + { + let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) + guard fields.count == 4, let expected = Bool(fields[1]) else { + fputs("phrase_restart_self_test malformed_vector=\(line)\n", stderr) + return 2 + } + seenIDs.insert(fields[0]) + if fields[0] == "missed_collapse_40_to_20" { + guard fields[2].count == 40, fields[3].count == 20 else { + fputs("phrase_restart_self_test invalid_40_to_20_lengths\n", stderr) + return 2 + } + } + let actual = SfSpeechPhraseAccumulator.phraseRestartShouldFreezePrior( + prev: fields[2], + next: fields[3] + ) + guard actual == expected else { + fputs( + "phrase_restart_self_test FAIL id=\(fields[0]) expected=\(expected) " + + "actual=\(actual) prev_chars=\(fields[2].count) " + + "next_chars=\(fields[3].count)\n", + stderr + ) + return 1 + } + } + let missingIDs = requiredIDs.subtracting(seenIDs).sorted() + guard missingIDs.isEmpty else { + fputs("phrase_restart_self_test missing=\(missingIDs.joined(separator: ","))\n", stderr) + return 2 + } + fputs("phrase_restart_self_test PASS\n", stderr) + return 0 + } catch { + fputs("phrase_restart_self_test fixture_error=\(error)\n", stderr) + return 2 + } +} + private func transcribeWithSfSpeech(audioPath: String, locale: Locale) async throws -> TranscriptionPayload { // SFSpeech URL path — Speech Recognition TCC required here (not on ST). try await ensureSpeechAuthorizedForSfSpeech() diff --git a/core/stt/apple_stt/live_stream.rs b/core/stt/apple_stt/live_stream.rs index f3426874..eceab1cb 100644 --- a/core/stt/apple_stt/live_stream.rs +++ b/core/stt/apple_stt/live_stream.rs @@ -31,6 +31,7 @@ pub enum LiveStreamEvent { Ready, Partial { text: String, + segments: Vec, }, /// Phrase-level seal (`isFinal` mid-stream). Text is utterance-local. PhraseFinal { @@ -119,16 +120,23 @@ impl LiveStreamSession { .stdin .take() .context("Apple STT bridge stdin unavailable")?; + // Every `write_pcm` below targets this pipe. Without this the first + // write after the bridge dies raises SIGPIPE, which is fatal in the + // Swift host and leaves no crash report — killing the bridge took the + // whole app down on 2026-08-12. See `util::pipes`. + crate::util::pipes::disable_sigpipe(&stdin); let stdout = child .stdout .take() .context("Apple STT bridge stdout unavailable")?; + let contextual_strings = crate::pipeline::stream_postprocess::apple_contextual_strings(); let request = BridgeRequest { protocol_version: 1, command: "stream", locale: &locale, audio_path: None, + contextual_strings: contextual_strings.as_deref(), allow_download: env_bool(ENV_ALLOW_DOWNLOAD, true), }; let req_payload = serde_json::to_vec(&request).context("serialize stream request")?; @@ -333,7 +341,27 @@ pub(crate) fn parse_stream_stdout_line(line: &str) -> Option { "ready" => Some(LiveStreamEvent::Ready), "partial" => { let text = parsed.text.unwrap_or_default().trim().to_string(); - Some(LiveStreamEvent::Partial { text }) + let segments = parsed + .segments + .unwrap_or_default() + .into_iter() + .filter_map(|s| { + let text = s.text.trim().to_string(); + if text.is_empty() + || !s.start_ts.is_finite() + || !s.end_ts.is_finite() + || s.end_ts < s.start_ts + { + return None; + } + Some(TranscriptSegment { + text, + start_ts: s.start_ts, + end_ts: s.end_ts, + }) + }) + .collect(); + Some(LiveStreamEvent::Partial { text, segments }) } "final" => { let text = parsed.text.unwrap_or_default().trim().to_string(); @@ -403,10 +431,18 @@ mod tests { /// Partial, phrase-final, and summary JSON lines map to the typed event enum. #[test] fn parse_partial_and_final_and_summary() { - let partial = - parse_stream_stdout_line(r#"{"event":"partial","text":"cześć"}"#).expect("partial"); + let partial = parse_stream_stdout_line( + r#"{"event":"partial","text":"cześć","segments":[{"text":"cześć","start_ts":0.0,"end_ts":0.4,"confidence":0.9}]}"#, + ) + .expect("partial"); match partial { - LiveStreamEvent::Partial { text } => assert_eq!(text, "cześć"), + LiveStreamEvent::Partial { text, segments } => { + assert_eq!(text, "cześć"); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].text, "cześć"); + assert_eq!(segments[0].start_ts, 0.0); + assert_eq!(segments[0].end_ts, 0.4); + } other => panic!("expected Partial, got {other:?}"), } diff --git a/core/stt/apple_stt/mod.rs b/core/stt/apple_stt/mod.rs index 93dc774f..b1985dee 100644 --- a/core/stt/apple_stt/mod.rs +++ b/core/stt/apple_stt/mod.rs @@ -110,6 +110,8 @@ struct BridgeRequest<'a> { locale: &'a str, #[serde(skip_serializing_if = "Option::is_none")] audio_path: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + contextual_strings: Option<&'a [String]>, allow_download: bool, } @@ -429,6 +431,7 @@ fn transcribe_file_with_backend( command: "transcribe", locale: &locale, audio_path: Some(audio_path.as_str()), + contextual_strings: None, allow_download: env_bool(ENV_ALLOW_DOWNLOAD, true), }; let response = run_bridge_with_timeout(&request, Some(BRIDGE_TRANSCRIBE_TIMEOUT)) @@ -491,11 +494,13 @@ fn transcribe_via_bridge_wav_live( let wav = TempWavFile::write(audio, sample_rate)?; let audio_path = wav.path().display().to_string(); let locale = resolved_locale(language); + let contextual_strings = crate::pipeline::stream_postprocess::apple_contextual_strings(); let request = BridgeRequest { protocol_version: 1, command: "transcribe_live", locale: &locale, audio_path: Some(audio_path.as_str()), + contextual_strings: contextual_strings.as_deref(), allow_download: env_bool(ENV_ALLOW_DOWNLOAD, true), }; let audio_secs = audio.len() as f64 / sample_rate.max(1) as f64; @@ -542,12 +547,17 @@ fn run_bridge_stream( .stdin .as_mut() .context("Apple STT bridge stdin unavailable")?; + // A dead bridge must surface as EPIPE, not as a fatal signal in the + // Swift host (see `util::pipes`). + crate::util::pipes::disable_sigpipe(stdin); + let contextual_strings = crate::pipeline::stream_postprocess::apple_contextual_strings(); let request = BridgeRequest { protocol_version: 1, command: "stream", locale, audio_path: None, + contextual_strings: contextual_strings.as_deref(), allow_download: env_bool(ENV_ALLOW_DOWNLOAD, true), }; let req_payload = serde_json::to_vec(&request).context("serialize stream request")?; @@ -713,6 +723,9 @@ fn run_bridge_with_timeout( .stdin .as_mut() .context("Apple STT bridge stdin unavailable")?; + // A dead bridge must surface as EPIPE, not as a fatal signal in the + // Swift host (see `util::pipes`). + crate::util::pipes::disable_sigpipe(stdin); let payload = serde_json::to_vec(request).context("serialize bridge request")?; stdin .write_all(&payload) @@ -854,6 +867,7 @@ fn probe_bridge(locale: &str, allow_download: bool) -> Result { command: "probe", locale, audio_path: None, + contextual_strings: None, allow_download, }; let response = run_bridge_with_timeout(&request, Some(BRIDGE_PROBE_TIMEOUT)) @@ -870,6 +884,7 @@ fn request_speech_auth_bridge(locale: &str, allow_download: bool) -> Result = inputs + .into_iter() + .map(|event| ingest.ingest(event)) + .collect(); + + assert_eq!( + verdicts, + vec![ + IngestVerdict::Accepted, + IngestVerdict::Accepted, + IngestVerdict::DuplicateIdempotent, + IngestVerdict::RejectedOutOfOrder, + IngestVerdict::Accepted, + ], + "partial/final/error events must carry session, utterance, and sequence identity; duplicate or out-of-order finals must be idempotent or rejected" + ); + let accepted: Vec<(&str, u64)> = ingest + .accepted() + .iter() + .map(|event| (event.as_token(), event.identity().sequence_id())) + .collect(); + assert_eq!(accepted, vec![("partial", 1), ("final", 2), ("error", 3)]); + assert_eq!( + ingest.duplicate_count(), + 1, + "duplicate final was idempotent" + ); + assert_eq!( + ingest.out_of_order_count(), + 1, + "out-of-order final was rejected" + ); + assert_eq!( + ingest.sealed_final(7).map(|event| event.text.as_str()), + Some("pacjent ma goraczke"), + "the stale final must not have rewritten committed text" + ); +} + +/// PROMOTED (C1, `c1-live-recorder-orchestration`): this probe no longer stops +/// at a `MissingContract` boundary. The seam it named exists — +/// `asr_session::recorder::RecorderLayer1Lane` — and the outcome below is +/// *measured* against that production state machine, not declared: two lanes +/// run against injected failing providers (sustained overflow, transport +/// disconnect), capture keeps offering frames throughout, both lanes land on +/// `RefinerMode::Off` (Apple + lexicon), and the heavyweight Whisper +/// initializer counters do not move. The depth (draft volatility, ingest +/// doctrine, sleep/wake, stop-drain, truth-seam adjudication) lives in +/// `asr_session::recorder::tests`; what stays here is the fleet-level witness +/// that the fixture the wave precommitted is the fixture production satisfies. +#[test] +fn fleet_red_cloud_backpressure_degrades_to_apple_only() { + use crate::asr_session::recorder::{ + FanOutVerdict, Layer1Decision, Layer1DegradeReason, Layer1LaneState, + OVERFLOW_DEGRADE_LIMIT, RecorderLayer1Lane, + }; + use crate::asr_session::{ + AsrErrorKind, FakeAsrSessionProvider, RefinerMode, SessionId, SessionInput, + }; + + // Saturating delta rather than a reset: the sibling M0 witness + // (`fleet_red_apple_prewarm_never_loads_whisper`) owns resets under + // `#[serial]`; a concurrent reset can only shrink this delta, never fake + // an init that did not happen. + let whisper_probe = || { + crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls() + }; + let whisper_before = whisper_probe(); + + let input = SessionInput { + session_id: SessionId::new("fleet-red-c1").expect("non-blank session id"), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + }; + let frame = [0.1f32; 320]; + + // Arm 1 — sustained overflow. Every offer must return without surfacing an + // error to capture, and the lane must degrade instead of blocking. + let overflow_provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Overflow); + let mut overflow_lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(overflow_provider)), &input); + let mut capture_continued = true; + for _ in 0..(OVERFLOW_DEGRADE_LIMIT * 2) { + capture_continued &= matches!( + overflow_lane.offer_pcm(&frame), + FanOutVerdict::Forwarded | FanOutVerdict::DroppedOverflow | FanOutVerdict::Inactive + ); + } + let overflow_degraded = matches!( + overflow_lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Overflow) + ); + + // Arm 2 — transport disconnect mid-recording. Capture keeps offering after + // the session dies; the lane absorbs the offers silently. + let disconnect_provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Transport); + let mut disconnect_lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(disconnect_provider)), &input); + for _ in 0..4 { + capture_continued &= matches!( + disconnect_lane.offer_pcm(&frame), + FanOutVerdict::Forwarded | FanOutVerdict::DroppedOverflow | FanOutVerdict::Inactive + ); + } + let disconnect_degraded = matches!( + disconnect_lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Disconnect(AsrErrorKind::Transport)) + ); + + // Both recordings finish on canvas + lexicon, and the bounded stop path + // never propagates the Layer 1 failure. + let apple_only = overflow_lane.refiner_mode() == RefinerMode::Off + && disconnect_lane.refiner_mode() == RefinerMode::Off; + let overflow_outcome = overflow_lane.stop(); + let disconnect_outcome = disconnect_lane.stop(); + assert!(overflow_outcome.finals().is_empty()); + assert!(disconnect_outcome.finals().is_empty()); + + let outcome: Result = Ok(BackpressureOutcome { + capture_continued, + apple_only, + overflow_degraded, + disconnect_degraded, + whisper_init_calls: whisper_probe().saturating_sub(whisper_before), + }); + assert_eq!( + outcome, + Ok(BackpressureOutcome { + capture_continued: true, + apple_only: true, + overflow_degraded: true, + disconnect_degraded: true, + whisper_init_calls: 0, + }), + "bounded overflow or disconnect must not block Apple capture or initialize Whisper" + ); +} + +/// PROMOTED (C2, `c2-cloud-mode-consent`): this probe no longer stops at a +/// `MissingContract` boundary. The seam it named exists — the consent gate in +/// `asr_session::consent` plus the mode/consent resolver in +/// `config::cloud_asr` — and the same rejection now comes from the production +/// factory. The depth (wire parsing, upgrade preservation, settings +/// round-trip, gateway mint validation) lives in those modules' tests; what +/// stays here is the fleet-level witness that cloud session construction +/// without explicit audio-egress consent is refused with a typed error, and +/// that the refusal never reaches for local weights. +#[test] +fn fleet_red_cloud_requires_explicit_consent() { + use crate::asr_session::bootstrap::{ + GatewaySessionAvailability, layer1_decision_for_recording, + }; + use crate::asr_session::cloud::{ + CloudGatewayTransport, CloudSessionLimits, GatewayPcmFrame, GatewaySessionConfig, + GatewayTransportPoll, LiveCloudAsrSession, + }; + use crate::asr_session::consent::{CloudSessionError, authorize_cloud_egress, refiner_for}; + use crate::asr_session::events::AsrErrorKind; + use crate::asr_session::provider::RefinerMode; + use crate::config::UserSettings; + use crate::config::cloud_asr::{ + AsrProductMode, AudioEgressConsent, ModeDerivation, resolve_asr_product_mode, + }; + + struct FleetConsentTransport; + + impl CloudGatewayTransport for FleetConsentTransport { + fn start(&mut self, _config: GatewaySessionConfig) -> Result<(), AsrErrorKind> { + Ok(()) + } + + fn try_send_pcm(&mut self, _frame: GatewayPcmFrame) -> Result<(), AsrErrorKind> { + Ok(()) + } + + fn poll(&mut self) -> GatewayTransportPoll { + GatewayTransportPoll::Pending + } + + fn begin_end(&mut self) -> Result<(), AsrErrorKind> { + Ok(()) + } + + fn abort(&mut self) {} + } + + fn construct( + consent: &AudioEgressConsent, + ) -> Result, CloudSessionError> { + let authorization = authorize_cloud_egress(consent)?; + Ok(LiveCloudAsrSession::new( + FleetConsentTransport, + CloudSessionLimits::default(), + authorization, + ) + .expect("default production limits must be valid")) + } + + // The real production constructor is unreachable without a witness, and + // this factory-shaped test path cannot obtain one from withheld consent. + for withheld in [AudioEgressConsent::Unanswered, AudioEgressConsent::Denied] { + assert_eq!( + construct(&withheld).err(), + Some(CloudSessionError::ConsentRequired), + "session construction without explicit audio-egress consent must be rejected by the production factory" + ); + } + + // A fresh install that persisted `cloud` but never answered the consent + // question resolves to Apple-only and arms no Layer 1 provider — and in + // particular never the local helper. + let unconsented = resolve_asr_product_mode(Some("cloud"), None, None); + assert_eq!(unconsented.mode, AsrProductMode::AppleOnly); + assert_eq!( + unconsented.derivation, + ModeDerivation::ConsentMissingFallback + ); + assert_eq!(refiner_for(&unconsented), RefinerMode::Off); + + // With the explicit grant recorded, the same request is authorized and + // arms the cloud session. + let consented = resolve_asr_product_mode(Some("cloud"), Some("granted"), None); + assert!(construct(&consented.consent).is_ok()); + assert_eq!(refiner_for(&consented), RefinerMode::CloudSession); + + // I3 integration witness: the same settings/consent truth now feeds the + // decision consumed by StreamingRecorder. No connection means offline + // Apple + lexicon; a validated minted session can arm only with consent. + let settings = |consent: Option<&str>| UserSettings { + asr_mode: Some("cloud".to_string()), + cloud_consent: consent.map(str::to_string), + ..UserSettings::default() + }; + let ready = || { + GatewaySessionAvailability::Ready( + crate::asr_session::GatewayConnection::new( + "wss://gateway.invalid/v1/stt/live", + "short-lived-token", + ) + .expect("normalized connection fixture"), + ) + }; + assert!(!layer1_decision_for_recording(&settings(None), ready()).is_armed()); + assert!(!layer1_decision_for_recording(&settings(Some("denied")), ready()).is_armed()); + assert!( + !layer1_decision_for_recording( + &settings(Some("granted")), + GatewaySessionAvailability::Unavailable, + ) + .is_armed() + ); + assert!( + layer1_decision_for_recording(&settings(Some("granted")), ready()).is_armed(), + "the recorder decision may arm only with consent plus a validated mint" + ); +} + +#[test] +#[serial_test::serial] +fn fleet_red_local_helper_exit_reclaims_process() { + use std::process::{Child, Command, Stdio}; + + use crate::asr_session::{ + AsrErrorKind, AsrSessionEvent, AsrSessionProvider, LocalHelperAsrSession, LocalHelperExit, + LocalHelperLauncher, LocalHelperLifecycle, LocalHelperProcess, SessionId, SessionInput, + }; + + struct FakeHelperProcess { + child: Child, + } + + impl LocalHelperProcess for FakeHelperProcess { + fn pid(&self) -> u32 { + self.child.id() + } + + fn start(&mut self, _input: &SessionInput) -> Result<(), AsrErrorKind> { + match self.child.try_wait() { + Ok(None) => Ok(()), + Ok(Some(_)) | Err(_) => Err(AsrErrorKind::Transport), + } + } + + fn push_audio(&mut self, _samples: &[f32]) -> Result<(), AsrErrorKind> { + match self.child.try_wait() { + Ok(None) => Ok(()), + Ok(Some(_)) | Err(_) => Err(AsrErrorKind::Transport), + } + } + + fn drain(&mut self) -> Vec { + Vec::new() + } + + fn request_shutdown(&mut self) -> Result<(), AsrErrorKind> { + self.child.kill().map_err(|_| AsrErrorKind::Transport) + } + + fn wait_for_exit(&mut self) -> Result { + self.child.wait().map_err(|_| AsrErrorKind::Transport)?; + Ok(LocalHelperExit { + pid: self.child.id(), + exited: true, + }) + } + + fn kill_and_wait(&mut self) -> Result { + let _ = self.child.kill(); + self.wait_for_exit() + } + } + + struct FakeHelperLauncher; + + impl LocalHelperLauncher for FakeHelperLauncher { + fn spawn(&mut self) -> Result, AsrErrorKind> { + let child = Command::new("/bin/sleep") + .arg("60") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| AsrErrorKind::Transport)?; + Ok(Box::new(FakeHelperProcess { child })) + } + } + + let whisper_probe = || { + crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls() + }; + let whisper_before = whisper_probe(); + let input = SessionInput { + session_id: SessionId::new("fleet-red-l0").expect("session id"), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + }; + let mut helper = LocalHelperAsrSession::new(Box::new(FakeHelperLauncher)); + helper.open(&input).expect("fake helper opens"); + let child_pid_observed = helper.child_pid().is_some_and(|pid| pid > 0); + helper.close().expect("fake helper exits and is reaped"); + let child_exited = helper.last_exit().is_some_and(|proof| proof.exited); + assert_eq!( + helper.transitions(), + [ + LocalHelperLifecycle::Stopped, + LocalHelperLifecycle::Starting, + LocalHelperLifecycle::Ready, + LocalHelperLifecycle::Cooling, + LocalHelperLifecycle::Stopped, + ] + ); + + let outcome: Result = Ok(HelperExitOutcome { + child_pid_observed, + child_exited, + gui_model_fallback_loaded: whisper_probe() != whisper_before, + }); + assert_eq!( + outcome, + Ok(HelperExitOutcome { + child_pid_observed: true, + child_exited: true, + gui_model_fallback_loaded: false, + }), + "fake helper shutdown must prove process exit and no hidden in-GUI model fallback" + ); +} diff --git a/core/stt/mod.rs b/core/stt/mod.rs index b8c5da51..cacfdcb3 100644 --- a/core/stt/mod.rs +++ b/core/stt/mod.rs @@ -37,9 +37,14 @@ pub mod punctuation_transplant; pub mod scheduler; /// Layer-1 on-the-go Whisper tail-patch helpers for append-only gap fill. pub mod tail_patcher; +/// Typed, time-ranged provider seam for Whisper tail-patch windows. +pub mod tail_provider; /// Candle Whisper engine, singleton, and file final-pass routes. pub mod whisper; +#[cfg(test)] +mod fleet_red_contracts; + use crate::pipeline::contracts::RawTranscript; use crate::pipeline::contracts::TranscriptionAdapter; use std::sync::OnceLock; @@ -332,11 +337,7 @@ pub(crate) fn whisper_tail_patch_transcribe( sample_rate: u32, language: Option<&str>, ) -> anyhow::Result { - let (speech, _) = crate::vad::extract_speech(audio, sample_rate); - if speech.is_empty() { - return Ok(RawTranscript::default()); - } - candle_transcribe_long_with_segments(&speech, sample_rate, language) + tail_provider::transcribe_legacy_window(audio, sample_rate, language) } /// First sample index of the uncommitted tail, clamped into `0..=total_samples`. @@ -830,6 +831,29 @@ mod tests { assert_eq!(preferred_engine_label(), "local_whisper"); } + /// Baseline guard: Apple is already the selected prewarm lane and the real + /// prewarm entrypoint must not cross the heavyweight Whisper initializer. + /// The initializer counter is compiled only for tests; no model is loaded. + #[test] + #[serial] + fn fleet_red_apple_prewarm_never_loads_whisper() { + let _guard = EnvGuard::set("apple"); + assert_eq!(selected_engine(), SttEngine::Apple); + + whisper::singleton::reset_test_init_calls(); + let _apple_probe = prewarm_active_engine(); + assert_eq!( + whisper::singleton::test_init_calls(), + 0, + "Apple prewarm must never initialize Whisper" + ); + assert_eq!( + whisper::singleton::test_load_calls(), + 0, + "Apple prewarm must never attempt to load Whisper weights" + ); + } + /// Live-only helper surfaces Apple bridge failures instead of silent swap. #[test] fn run_apple_live_only_surfaces_bridge_errors() { diff --git a/core/stt/tail_patcher/mod.rs b/core/stt/tail_patcher/mod.rs index 24e76e53..98d778ff 100644 --- a/core/stt/tail_patcher/mod.rs +++ b/core/stt/tail_patcher/mod.rs @@ -13,7 +13,7 @@ //! | Control | Env | Default | What it does | //! | --- | --- | --- | --- | //! | Final pass | `FINAL_PASS_MODE` | `smart` | Stop-path only: whether to run a full WAV Whisper re-pass after release | -//! | Layered / Layer 1 | `CODESCRIBE_LAYERED_TRANSCRIPTION` | **off** | During-hold gap-fill: Whisper tail patches on sealed utterances | +//! | Layered / Layer 1 | `CODESCRIBE_LAYERED_TRANSCRIPTION` | **phase1** | During-hold gap-fill: Whisper tail patches on sealed utterances. Unset → phase1; explicit `off`/`0`/`false` disarms. | //! //! - **Smart** = skip full stop re-pass when streaming completeness is //! adjudicated Complete. It does **not** enable layered transcription. @@ -21,9 +21,9 @@ //! - Layered phase ≥ 1 may run under any final-pass mode when the live session //! path actually wires Layer 1 (see below). //! -//! Product intent: Smart *works with* layered (completeness skip + optional -//! live gap-fill), but layered stays opt-in until Phase 1 is proven on the -//! default Apple progressive path. +//! Product intent: Smart *works with* layered (completeness skip + live +//! gap-fill). Phase 1 is the stock live default; W13 fusion / idempotence / +//! highlight flags remain the operator-flip surface, not this gate. //! //! # Where Layer 1 is wired today //! @@ -63,13 +63,34 @@ //! still count toward the change ratio so a wildly divergent re-transcription is //! skipped wholesale. //! -//! This module is a **pure** function of its inputs. It performs no audio -//! capture and no network calls. +//! # Under-commit is not divergence (W-C) +//! +//! The change-ratio cap above assumes the two texts describe the *same* speech +//! and merely disagree. When Layer 0 lost whole phrases — the measured +//! 104 s / 220 ch and 107 s / 118 ch Polish sessions — the re-transcription is +//! not a divergent opinion, it is the speech that never reached the canvas, and +//! the cap silently threw it away. [`TailPatchOutcome::UnderCommit`] separates +//! the two: a re-transcription that still *contains* the committed canvas +//! ([`UNDER_COMMIT_MIN_COVERAGE`]) while carrying substantially more of it +//! ([`UNDER_COMMIT_RATIO`]) is classified as under-commit, its recovered +//! material is emitted as bounded gap-**appends** where the anchor is a matched +//! committed token boundary, and anything that could only land by rewriting a +//! committed span escalates [`UnderCommit::residual_required`] instead. A +//! re-transcription that does *not* contain the canvas is still ordinary +//! divergence and still `Skipped`. +//! +//! This module is a **pure** function of its inputs in the sense that matters: +//! it performs no audio capture and no network calls, and its return value +//! depends only on its arguments. Its one side effect is a single INFO receipt +//! per non-patching outcome (counts and reason only — never transcript text), +//! because the discarded-truth bug was invisible precisely for want of one. //! //! Contract: the `committed` argument is byte-identical to the emitted //! `UtteranceFinal.text` and is already trimmed by the emitter (single trim //! owner: `final_text` at the session.rs emit site). +use tracing::info; + use crate::pipeline::contracts::{EngineEvent, LayerSource}; /// Env flag gating the layered transcription pipeline. @@ -91,6 +112,94 @@ const LAYERED_DEFAULT_PHASE: u8 = 1; /// Env override for [`TailPatchConfig::max_change_ratio`]. pub const TAIL_PATCH_MAX_CHANGE_RATIO_ENV: &str = "CODESCRIBE_TAIL_PATCH_MAX_CHANGE_RATIO"; +/// Committed/retranscribed **token** ratio below which Layer 0 is judged to have +/// under-committed rather than to have merely disagreed. +/// +/// The measured eaten sessions sat far under this (220 ch of a 104 s take), and +/// a healthy tail-patch — a word or two corrected in a phrase Layer 0 fully +/// heard — sits at ~1.0. `0.6` leaves ordinary Whisper verbosity (articles, +/// re-segmented compounds) on the normal path. +pub const UNDER_COMMIT_RATIO: f64 = 0.6; + +/// Minimum re-transcribed token count before under-commit is even considered, +/// for canvases of ≥3 tokens. +/// +/// A two-word Whisper burst against a one-word canvas satisfies any ratio while +/// carrying no recoverable speech; requiring a real sentence keeps the +/// escalation attached to material worth recovering. +/// +/// The floor SCALES DOWN for short canvases (see +/// [`under_commit_min_retranscribed`]): session a5623d55 (2026-08-12) lost the +/// utterance head three times in one minute because its live windows held only +/// 1-2 committed tokens and Whisper's 5-token recovery could never reach an +/// absolute 6. Coverage, not length, is the garbage discriminator. +pub const UNDER_COMMIT_MIN_RETRANSCRIBED_TOKENS: usize = 6; + +/// Hard floor of the scaled minimum: even a one-token canvas needs at least +/// this many recovered tokens before escalation is considered. +pub const UNDER_COMMIT_MIN_SHORT_WINDOW_TOKENS: usize = 4; + +/// Effective minimum re-transcribed token count for a given canvas size: +/// `min(6, max(4, 2 × committed))` — never stricter than the absolute +/// constant, looser only where the canvas itself is one or two tokens. +pub fn under_commit_min_retranscribed(committed_tokens: usize) -> usize { + (committed_tokens * 2).clamp( + UNDER_COMMIT_MIN_SHORT_WINDOW_TOKENS, + UNDER_COMMIT_MIN_RETRANSCRIBED_TOKENS, + ) +} + +/// Fraction of committed tokens that must be found inside the re-transcription +/// before its anchors may be trusted. +/// +/// **This is the discriminator, not the ratio.** Under-commit means Whisper +/// heard everything the canvas holds *plus* what Layer 0 lost, so the committed +/// tokens align and the gaps between them are addressable. A re-transcription +/// that does not contain the canvas is ordinary divergence: its offsets mean +/// nothing against the committed text, so it stays [`TailPatchOutcome::Skipped`] +/// and Layer 0 stands. +pub const UNDER_COMMIT_MIN_COVERAGE: f64 = 0.8; + +/// Canvas/re-transcription token ratio under which the canvas is treated as +/// *structurally starved* rather than merely incomplete: Whisper carries about +/// three times the material Layer 0 committed. +pub const UNDER_COMMIT_STARVED_CANVAS_RATIO: f64 = 0.35; + +/// Coverage required of a structurally starved canvas. +/// +/// # Why the full bar is the wrong instrument here +/// +/// Token coverage measures whether the two texts agree on *words*. That is a +/// sound proxy for "same speech" while Layer 0 hears well. It stops being one +/// exactly when this lane matters most: when SFSpeech mangles the phonetics, +/// its tokens stop matching anything ("zrób z loctree" committed as "gdzieś in. +/// zrope"), coverage collapses, and the full bar rejects the recovery — so the +/// worse Layer 0 heard, the more certain the rejection. Measured on the +/// operator's live log 2026-08-14: 295 change-ratio skips, 181 of them (61%) +/// carrying MORE material than the canvas, 5341 characters of speech discarded; +/// the sharpest single case committed 15 characters against 223 re-transcribed. +/// +/// What the full bar is really defending against — an unrelated decode pasting +/// its text onto the user's transcript — cannot happen on this seam: the window +/// handed to Whisper is cut from the sealed span's own PCM range +/// (`resolve_sealed_audio_window`), so both texts describe the same samples by +/// construction. Audio identity is the guarantee; token coverage was standing +/// in for it. A starved canvas therefore keeps a real but lower bar — enough +/// anchors to place appends honestly, not enough to demand agreement from a +/// canvas that is mostly noise. Everything unplaceable still escalates to +/// `residual_required`; nothing is ever rewritten. +pub const UNDER_COMMIT_STARVED_MIN_COVERAGE: f64 = 0.45; + +/// Coverage bar for this canvas: the full bar normally, the starved bar when +/// Whisper carries several times the canvas's material. +pub fn under_commit_min_coverage(commit_ratio: f64) -> f64 { + if commit_ratio <= UNDER_COMMIT_STARVED_CANVAS_RATIO { + UNDER_COMMIT_STARVED_MIN_COVERAGE + } else { + UNDER_COMMIT_MIN_COVERAGE + } +} + /// Parse a layered-transcription phase token (`phase1`..`phase4` or bare `1`..`4`). /// /// Returns `None` for off/empty/garbage — including final-pass tokens @@ -113,12 +222,20 @@ pub fn parse_layered_phase_value(raw: &str) -> Option { /// /// Independent of `FINAL_PASS_MODE` / Smart completeness skip. pub fn layered_phase() -> Option { - match std::env::var(LAYERED_TRANSCRIPTION_ENV) { - Ok(raw) => parse_layered_phase_value(&raw), - Err(_) => Some(LAYERED_DEFAULT_PHASE), - } + let raw = std::env::var(LAYERED_TRANSCRIPTION_ENV).ok(); + layered_phase_from_raw(raw.as_deref()) } +/// Resolve the layered phase from an optional raw override without touching +/// process-global environment state. `None` carries the production default. +pub fn layered_phase_from_raw(raw: Option<&str>) -> Option { + raw.map(parse_layered_phase_value) + .unwrap_or(Some(LAYERED_DEFAULT_PHASE)) +} + +/// Env override for [`TailPatchConfig::small_edit_token_floor`]. +pub const TAIL_PATCH_SMALL_EDIT_FLOOR_ENV: &str = "CODESCRIBE_TAIL_PATCH_SMALL_EDIT_FLOOR"; + /// Tuning for the tail-patch diff. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TailPatchConfig { @@ -126,13 +243,27 @@ pub struct TailPatchConfig { /// patch is skipped. `0.5` means: if more than half the utterance would be /// touched, leave Layer 0 output untouched. pub max_change_ratio: f64, + /// Absolute change budget under which a substitution-shaped fix bypasses + /// the ratio cap. + /// + /// The ratio alone starves short utterances structurally: on a 1-3-token + /// commit any real correction is ≥50% change, so the lane could never fix + /// a single misheard word — the very job it exists for. Measured on the + /// 2026-08-12 log: 116 skips, 0 applied patches, 38 of them at exactly + /// ratio 1.00 ("Kos" → "kombos" class). A substitution of this many tokens + /// or fewer is bounded by definition — wholesale divergence, which the + /// ratio guards against, always touches more. Pure insertions never use + /// this budget; they keep their under-commit / noise routing. + pub small_edit_token_floor: usize, } impl Default for TailPatchConfig { - /// Conservative default: skip the patch if more than half the tokens would change. + /// Conservative default: skip the patch if more than half the tokens would + /// change — unless the whole change fits inside the small-edit budget. fn default() -> Self { Self { max_change_ratio: 0.5, + small_edit_token_floor: 3, } } } @@ -148,10 +279,54 @@ impl TailPatchConfig { { cfg.max_change_ratio = value; } + if let Some(value) = std::env::var(TAIL_PATCH_SMALL_EDIT_FLOOR_ENV) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + { + cfg.small_edit_token_floor = value; + } cfg } } +/// Stable skip-reason code on a tail-patch or fusion receipt. +/// +/// The string [`TailPatchOutcome::Skipped::reason`] stays human-readable; this +/// code is what a later starvation diagnosis greps. W13-3B adds the time-slice +/// and fusion tokens; the LCS tokens keep the v1 receipts diagnosable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReasonCode { + EmptyCommitted, + EmptyRetranscription, + NoCommittedTokens, + ChangeRatio, + HeadGarbage, + NoTimeOverlap, + LowConfidence, + UnresolvedAlternative, + SealedFence, + Divergence, + ProviderError, +} + +impl SkipReasonCode { + pub fn as_str(self) -> &'static str { + match self { + Self::EmptyCommitted => "empty_committed", + Self::EmptyRetranscription => "empty_retranscription", + Self::NoCommittedTokens => "no_committed_tokens", + Self::ChangeRatio => "change_ratio", + Self::HeadGarbage => "head_garbage", + Self::NoTimeOverlap => "no_time_overlap", + Self::LowConfidence => "low_confidence", + Self::UnresolvedAlternative => "unresolved_alternative", + Self::SealedFence => "sealed_fence", + Self::Divergence => "divergence", + Self::ProviderError => "provider_error", + } + } +} + /// Result of a tail-patch diff. #[derive(Debug, Clone, PartialEq)] pub enum TailPatchOutcome { @@ -163,7 +338,95 @@ pub enum TailPatchOutcome { NoChange, /// Diff exceeded the safety threshold (or there was nothing to patch /// against); Layer 0 output stands unchanged. - Skipped { reason: String }, + Skipped { + code: SkipReasonCode, + reason: String, + }, + /// Layer 0 committed substantially less than the audio carried, and the + /// committed canvas is still contained in the re-transcription. Recovered + /// speech, not a diff to clamp — see [`UnderCommit`]. + UnderCommit(UnderCommit), +} + +impl TailPatchOutcome { + /// Construct a skipped outcome with a stable reason code. + pub fn skipped(code: SkipReasonCode, reason: impl Into) -> Self { + Self::Skipped { + code, + reason: reason.into(), + } + } +} + +/// A Layer-0 under-commit: what was recovered, what could be placed live, and +/// whether the stop path still owes the session a residual gap fill. +/// +/// Append-plus-gap-fill is the whole contract here. `appends` never touches a +/// committed span — every event is a zero-width `ReplaceRange` anchored on a +/// matched committed token boundary (or on buffer start). Material that could +/// only land by rewriting committed text is *not* emitted; it raises +/// `residual_required` instead, because the asymmetry is settled doctrine: lost +/// speech is unrecoverable, duplication is filterable downstream. +#[derive(Debug, Clone, PartialEq)] +pub struct UnderCommit { + /// Bounded gap-append events, descending by `start` so sequential + /// application against the committed text is offset-stable. May be empty. + pub appends: Vec, + /// Recovered speech that no safe anchor could place. The stop path must + /// treat this session as owing a residual gap fill rather than as a + /// complete streaming transcript. + pub residual_required: bool, + /// Whitespace-delimited token count of the committed canvas. + pub committed_tokens: usize, + /// Whitespace-delimited token count of the re-transcription. + pub retranscribed_tokens: usize, + /// Char count of the trimmed committed canvas (log/telemetry only). + pub committed_chars: usize, + /// Char count of the trimmed re-transcription (log/telemetry only). + pub retranscribed_chars: usize, + /// `committed_tokens / retranscribed_tokens` — under [`UNDER_COMMIT_RATIO`]. + pub commit_ratio: f64, +} + +impl UnderCommit { + /// Stable log/wire tag naming what this escalation asks of the stop path. + pub fn reason(&self) -> &'static str { + if self.residual_required { + "under_commit_residual_required" + } else { + "under_commit_gap_append" + } + } +} + +impl TailPatchOutcome { + /// Bounded events this outcome contributes to the committed canvas. + /// + /// One accessor for both event-bearing arms so a sink cannot forward + /// `Patches` while silently dropping recovered gap-appends — the exact + /// shape of the bug this cut repairs. + pub fn events(&self) -> &[EngineEvent] { + match self { + Self::Patches(events) => events, + Self::UnderCommit(under) => &under.appends, + Self::NoChange | Self::Skipped { .. } => &[], + } + } + + /// Same events, owned, for sinks that consume the outcome. + pub fn into_events(self) -> Vec { + match self { + Self::Patches(events) => events, + Self::UnderCommit(under) => under.appends, + Self::NoChange | Self::Skipped { .. } => Vec::new(), + } + } + + /// Whether the stop path must run residual gap fill because recovered + /// speech could not be placed on the live canvas. + pub fn residual_required(&self) -> bool { + matches!(self, Self::UnderCommit(under) if under.residual_required) + } } /// A whitespace-delimited token with char-offset span inside the source string. @@ -226,17 +489,98 @@ struct EditGroup { has_prev_match: bool, } -/// Longest-common-subsequence alignment over token text (exact match). +/// Alignment key for LCS matching: casefolded, stripped of leading/trailing +/// non-alphanumerics. +/// +/// The two texts come from different normalization worlds — the committed +/// canvas carries Apple's casing, punctuation and the lexicon's rewrites, +/// while the Whisper re-transcription is bare lowercase. Compared byte-exact, +/// "Jaki chcesz. Kos." vs "jaki chcesz kombos" shares zero tokens and reads +/// as wholesale divergence; every skip ratio in the starved 2026-08-12 log +/// was inflated this way. Matching on the key aligns what a listener would +/// call the same word; matched tokens are never patched, so the canvas keeps +/// its casing and punctuation. Diacritics stay significant — they are +/// content, not decoration. A token that is all punctuation falls back to its +/// lowercased raw form so it can only match another such token. +fn alignment_key(token: &str) -> String { + let stripped = token.trim_matches(|c: char| !c.is_alphanumeric()); + if stripped.is_empty() { + token.to_lowercase() + } else { + stripped.to_lowercase() + } +} + +/// Consecutive matching tokens that mark a recovery as already carried. +/// +/// Four is the shortest run that is not ordinary Polish repetition: "nam na +/// zrobienie" (3) recurs naturally, "która pozwoli nam na" (4) does not. +pub const DUPLICATE_RUN_TOKENS: usize = 4; + +/// Whether `canvas` already carries the words in `candidate`. +/// +/// Public seam for the presentation layer, which applies a patch against the +/// canvas as it stands NOW — not the canvas the patch was computed against. +/// Measured 2026-08-14: Layer 1 computed an append for a 15-character canvas +/// while SFSpeech went on to restate the SAME utterance at 47 characters, +/// already delivering the words the append recovered; the append landed on the +/// restatement and duplicated the phrase. +pub fn text_already_carries(canvas: &str, candidate: &str) -> bool { + let canvas_tokens = tokenize(canvas); + let candidate_tokens = tokenize(candidate); + let refs: Vec<&Token> = candidate_tokens.iter().collect(); + canvas_already_carries(&canvas_tokens, &refs) +} + +/// Whether the canvas already carries this recovered run of words. +/// +/// Compared on [`alignment_key`] — the same key the aligner matches on — so a +/// phrase the canvas holds in mangled casing/punctuation still counts as +/// present. Single-token runs are exempt: one repeated short word ("i", "no") +/// is ordinary speech, not a duplicated recovery, and refusing those would +/// starve the lane again. +fn canvas_already_carries(canvas: &[Token], recovered: &[&Token]) -> bool { + let needle: Vec = recovered + .iter() + .map(|token| alignment_key(&token.text)) + .collect(); + let hay: Vec = canvas + .iter() + .map(|token| alignment_key(&token.text)) + .collect(); + // A run counts as already carried when a long-enough CONTIGUOUS stretch of + // it appears in the canvas — not when the whole run matches end to end. + // Requiring the full run is defeated by exactly the defect this guards + // against: Layer 0 mangles a word at the edge ("hard Pru" against the + // recovered "hard pruna"), one key differs, and the duplicate is placed + // anyway. Four consecutive content words repeating verbatim is speech the + // canvas already holds; three or fewer is ordinary Polish repetition. + let span = DUPLICATE_RUN_TOKENS.min(needle.len()); + if span < DUPLICATE_RUN_TOKENS { + return false; + } + needle + .windows(span) + .any(|run| hay.windows(span).any(|window| window == run)) +} + +/// Longest-common-subsequence alignment over normalized token keys +/// ([`alignment_key`]). /// /// Returns pairs `(committed_idx, retranscribed_idx)` of matched tokens, in order. fn lcs_matches(committed: &[Token], retranscribed: &[Token]) -> Vec<(usize, usize)> { + let c_keys: Vec = committed.iter().map(|t| alignment_key(&t.text)).collect(); + let r_keys: Vec = retranscribed + .iter() + .map(|t| alignment_key(&t.text)) + .collect(); let m = committed.len(); let n = retranscribed.len(); // dp[i][j] = LCS length of committed[i..] and retranscribed[j..]. let mut dp = vec![vec![0usize; n + 1]; m + 1]; for i in (0..m).rev() { for j in (0..n).rev() { - dp[i][j] = if committed[i].text == retranscribed[j].text { + dp[i][j] = if c_keys[i] == r_keys[j] { dp[i + 1][j + 1] + 1 } else { dp[i + 1][j].max(dp[i][j + 1]) @@ -247,7 +591,7 @@ fn lcs_matches(committed: &[Token], retranscribed: &[Token]) -> Vec<(usize, usiz let mut matches = Vec::new(); let (mut i, mut j) = (0, 0); while i < m && j < n { - if committed[i].text == retranscribed[j].text { + if c_keys[i] == r_keys[j] { matches.push((i, j)); i += 1; j += 1; @@ -260,6 +604,194 @@ fn lcs_matches(committed: &[Token], retranscribed: &[Token]) -> Vec<(usize, usiz matches } +/// One INFO receipt for a tail-patch outcome that put nothing on the canvas. +/// +/// Counts and reason only. The transcript is the user's speech and never enters +/// a log line; the counts are what makes a starved session diagnosable, which +/// is exactly what was missing when `Skipped` was a `debug!` and the recovered +/// text vanished without trace. +fn log_skipped_receipt( + utterance_id: u64, + reason: &str, + committed: &str, + retranscribed: &str, + committed_tokens: usize, + retranscribed_tokens: usize, +) { + info!( + utterance_id, + reason, + committed_chars = committed.trim().chars().count(), + retranscribed_chars = retranscribed.trim().chars().count(), + committed_tokens, + retranscribed_tokens, + "tail_patch_skipped" + ); +} + +/// Everything the under-commit classifier reads about one alignment. +struct UnderCommitScan<'a> { + c_tokens: &'a [Token], + r_tokens: &'a [Token], + matched: &'a [(usize, usize)], + groups: &'a [EditGroup], + /// Canvas sealed before this utterance; read-only duplicate guard. + neighbour_tokens: &'a [Token], +} + +/// Decide whether a change-ratio rejection is really an under-commit, and if so +/// what of the recovered speech can be appended safely. +/// +/// Returns `None` for ordinary divergence, leaving the caller to skip. +/// +/// Three gates, in order of how much they can hurt if wrong: +/// 1. the re-transcription must be substantial ([`UNDER_COMMIT_MIN_RETRANSCRIBED_TOKENS`]); +/// 2. it must carry substantially more than the canvas ([`UNDER_COMMIT_RATIO`]); +/// 3. it must still *contain* the canvas ([`UNDER_COMMIT_MIN_COVERAGE`]) — without +/// this an unrelated decode would append its whole text to the user's transcript. +fn classify_under_commit( + scan: UnderCommitScan<'_>, + utterance_id: u64, + committed_chars: usize, + retranscribed_chars: usize, +) -> Option { + let UnderCommitScan { + c_tokens, + r_tokens, + matched, + groups, + neighbour_tokens, + } = scan; + let committed_tokens = c_tokens.len(); + let retranscribed_tokens = r_tokens.len(); + if retranscribed_tokens < under_commit_min_retranscribed(committed_tokens) { + return None; + } + let commit_ratio = committed_tokens as f64 / retranscribed_tokens as f64; + if commit_ratio >= UNDER_COMMIT_RATIO { + return None; + } + let coverage = matched.len() as f64 / committed_tokens as f64; + let min_coverage = under_commit_min_coverage(commit_ratio); + // Two different decisions were fused into this one bar, and fusing them is + // what discarded speech: + // + // 1. May recovered material be PLACED into the canvas? That needs trusted + // anchors — a matched committed token to append beside — so it keeps the + // coverage bar. + // 2. May the recovery be ESCALATED to the stop path? That touches nothing + // live, so a failed coverage check is no argument for dropping it. Under + // the old rule the answer to both was "no", and 5341 measured characters + // left as a `change_ratio` receipt. + // + // Below the bar the canvas is too mangled to anchor against, so nothing is + // placed inline — but the material is owed to the stop path, not to /dev/null. + // Placing on weak anchors was tried and MEASURED DOWN, 2026-08-14: five + // takes, delivered text against the lbrx reference, mean WER 0.604 -> 0.612 + // (worst case 01_no-to-dobra 0.356 -> 0.425). Recovered words placed beside + // an anchor the canvas cannot really vouch for land in the wrong part of the + // sentence more often than they fill a real gap, and the last-mile duplicate + // guard cannot catch that — it only catches repeats, not misplacement. + // The bar stays; the recovery that cannot be anchored is owed elsewhere. + let anchors_trusted = coverage >= min_coverage; + if !anchors_trusted && commit_ratio > UNDER_COMMIT_STARVED_CANVAS_RATIO { + // Not starved and not alignable: ordinary divergence, Layer 0 stands. + return None; + } + + let mut appends: Vec = Vec::new(); + let mut residual_required = false; + for g in groups.iter().filter(|_| anchors_trusted) { + if g.retranscribed.is_empty() { + // Deletion: Whisper heard less here. Nothing was recovered, and v1 + // never removes text the user already saw. + continue; + } + if !g.committed.is_empty() { + // A committed span sits under this material, so placing it would + // rewrite the canvas — forbidden. Only escalate when the group + // actually carries more than the canvas holds; an equal-or-smaller + // substitution is a re-hearing, not lost speech. + if g.retranscribed.len() > g.committed.len() { + residual_required = true; + } + continue; + } + let replacement: String = g + .retranscribed + .clone() + .map(|idx| r_tokens[idx].text.as_str()) + .collect::>() + .join(" "); + // Anti-duplication: a gap the aligner found is not always a gap in the + // SPEECH. When Layer 0 mangled the words around a phrase, the aligner + // cannot match them, so a phrase the canvas already carries reads as + // missing and gets appended a second time. Measured 2026-08-14 on the + // operator's take, the moment recoveries first reached the canvas: + // "…która pozwoli nam na zrobienie hard pruna I road która pozwoli nam + // na zrobienie hard Pru." — three repeated 4-grams, and a WER worse + // than before the recovery landed. A recovery whose words already sit + // in the canvas is a re-hearing of mangled text, not lost speech: it + // belongs to substitution (which this lane does not do) or to the stop + // path, never to a second copy in front of the user. + let recovered: Vec<&Token> = g.retranscribed.clone().map(|idx| &r_tokens[idx]).collect(); + if canvas_already_carries(c_tokens, &recovered) + || canvas_already_carries(neighbour_tokens, &recovered) + { + residual_required = true; + continue; + } + // Zero-width range: a pure append at a boundary between committed + // tokens, never a replacement of one. + let (start, text) = if g.has_prev_match { + (g.anchor, format!(" {replacement}")) + } else { + (0usize, format!("{replacement} ")) + }; + appends.push(EngineEvent::ReplaceRange { + utterance_id, + start, + end: start, + text, + source: LayerSource::TailPatch, + }); + } + + if appends.is_empty() { + // Under-commit with nothing placeable: the whole recovery is owed to + // the stop path. + residual_required = true; + } + appends.sort_by_key(|e| std::cmp::Reverse(event_start(e))); + + let under = UnderCommit { + appends, + residual_required, + committed_tokens, + retranscribed_tokens, + committed_chars, + retranscribed_chars, + commit_ratio, + }; + info!( + utterance_id, + reason = under.reason(), + committed_chars, + retranscribed_chars, + committed_tokens, + retranscribed_tokens, + commit_ratio, + coverage, + min_coverage, + starved_canvas = min_coverage < UNDER_COMMIT_MIN_COVERAGE, + anchors_trusted, + gap_appends = under.appends.len(), + residual_required = under.residual_required, + "tail_patch_under_commit" + ); + Some(under) +} + /// Compute bounded tail-patch events from a Layer-0 committed utterance and a /// Whisper re-transcription of the same audio slice. /// @@ -269,25 +801,65 @@ pub fn compute_tail_patch( retranscribed: &str, utterance_id: u64, cfg: &TailPatchConfig, +) -> TailPatchOutcome { + compute_tail_patch_with_context(committed, retranscribed, "", utterance_id, cfg) +} + +/// As [`compute_tail_patch`], plus the canvas already sealed BEFORE this +/// utterance. +/// +/// Layer 1 sees one utterance at a time. A phrase the PREVIOUS utterance +/// already carries therefore reads as a gap here and is appended a second +/// time — measured 2026-08-14 the moment recoveries first reached the canvas: +/// three repeated 4-grams and a WER worse than before the recovery landed. +/// The context is read-only; it is never patched, only consulted so a +/// duplicate escalates to the stop path instead of being placed. +pub fn compute_tail_patch_with_context( + committed: &str, + retranscribed: &str, + neighbour_context: &str, + utterance_id: u64, + cfg: &TailPatchConfig, ) -> TailPatchOutcome { // Layer 0 owns the first commit: nothing to patch against an empty buffer. if committed.trim().is_empty() { - return TailPatchOutcome::Skipped { - reason: "empty_committed".to_string(), - }; + log_skipped_receipt( + utterance_id, + "empty_committed", + committed, + retranscribed, + 0, + 0, + ); + return TailPatchOutcome::skipped(SkipReasonCode::EmptyCommitted, "empty_committed"); } if retranscribed.trim().is_empty() { - return TailPatchOutcome::Skipped { - reason: "empty_retranscription".to_string(), - }; + log_skipped_receipt( + utterance_id, + "empty_retranscription", + committed, + retranscribed, + tokenize(committed).len(), + 0, + ); + return TailPatchOutcome::skipped( + SkipReasonCode::EmptyRetranscription, + "empty_retranscription", + ); } let c_tokens = tokenize(committed); let r_tokens = tokenize(retranscribed); if c_tokens.is_empty() { - return TailPatchOutcome::Skipped { - reason: "no_committed_tokens".to_string(), - }; + log_skipped_receipt( + utterance_id, + "no_committed_tokens", + committed, + retranscribed, + 0, + r_tokens.len(), + ); + return TailPatchOutcome::skipped(SkipReasonCode::NoCommittedTokens, "no_committed_tokens"); } let matches = lcs_matches(&c_tokens, &r_tokens); @@ -324,18 +896,58 @@ pub fn compute_tail_patch( } // Safety gate: count changed tokens against the committed token budget. + // The ratio is a wholesale-divergence guard; a SUBSTITUTION fix that fits + // inside the small-edit budget is bounded by definition and bypasses it — + // otherwise a short utterance can never be corrected at all (any one-word + // fix on a 1-3-token commit is ≥50% change, and the lane sat at 116 skips / + // 0 applied patches on the 2026-08-12 log because of exactly this). + // + // Substitution-shaped only, on purpose: every group must replace committed + // tokens with retranscribed ones. Pure insertions keep their deliberate + // routing from the under-commit work — head-loss recovery escalates as + // `UnderCommit`, tiny bursts ("tak" → "no tak") stay noise-skipped — and a + // blanket floor was measured to swallow all three of those contracts. let changed: usize = groups .iter() .map(|g| g.committed.len().max(g.retranscribed.len())) .sum(); + let small_substitution_fix = changed <= cfg.small_edit_token_floor + && groups + .iter() + .all(|g| !g.committed.is_empty() && !g.retranscribed.is_empty()); let ratio = changed as f64 / c_tokens.len() as f64; - if ratio > cfg.max_change_ratio { - return TailPatchOutcome::Skipped { - reason: format!( - "change_ratio {:.2} exceeds max {:.2}", - ratio, cfg.max_change_ratio - ), - }; + if ratio > cfg.max_change_ratio && !small_substitution_fix { + // Before the cap discards this: is the canvas starved rather than + // wrong? The bounded diff was never an instrument for measuring lost + // speech, and using it as one is what threw the recovered 104 s / 107 s + // Polish takes away. + if let Some(under) = classify_under_commit( + UnderCommitScan { + c_tokens: &c_tokens, + r_tokens: &r_tokens, + matched: &matches, + groups: &groups, + neighbour_tokens: &tokenize(neighbour_context), + }, + utterance_id, + committed.trim().chars().count(), + retranscribed.trim().chars().count(), + ) { + return TailPatchOutcome::UnderCommit(under); + } + let reason = format!( + "change_ratio {:.2} exceeds max {:.2}", + ratio, cfg.max_change_ratio + ); + log_skipped_receipt( + utterance_id, + &reason, + committed, + retranscribed, + c_tokens.len(), + r_tokens.len(), + ); + return TailPatchOutcome::skipped(SkipReasonCode::ChangeRatio, reason); } let mut events: Vec = Vec::new(); @@ -375,14 +987,35 @@ pub fn compute_tail_patch( }); } } else { - // Substitution: replace the committed span with the W text. + // Substitution: replace the committed span with the W text. The + // canvas span owns its trailing punctuation (Whisper emits bare + // words); carry it onto the replacement so fixing a word never + // eats the sentence boundary Apple already placed. let start = c_tokens[g.committed.start].char_start; let end = c_tokens[g.committed.end - 1].char_end; + let last_committed = c_tokens[g.committed.end - 1].text.as_str(); + let trailing: String = last_committed + .chars() + .rev() + .take_while(|c| !c.is_alphanumeric()) + .collect::>() + .into_iter() + .rev() + .collect(); + let keeps_own_boundary = replacement + .chars() + .last() + .is_some_and(|c| !c.is_alphanumeric()); + let text = if trailing.is_empty() || keeps_own_boundary { + replacement + } else { + format!("{replacement}{trailing}") + }; events.push(EngineEvent::ReplaceRange { utterance_id, start, end, - text: replacement, + text, source: LayerSource::TailPatch, }); } @@ -415,15 +1048,33 @@ mod tests { /// return the resulting buffer. Mirrors how a sink folds the events. fn apply_all(committed: &str, outcome: &TailPatchOutcome) -> String { let mut buf = committed.to_string(); - if let TailPatchOutcome::Patches(events) = outcome { - for ev in events { - ev.apply_to_committed_text(&mut buf) - .expect("bounded range must be valid against committed text"); - } + // `events()` folds both event-bearing arms, so an under-commit's + // gap-appends are exercised by the same helper as ordinary patches. + for ev in outcome.events() { + ev.apply_to_committed_text(&mut buf) + .expect("bounded range must be valid against committed text"); } buf } + /// Every event an under-commit emits must be a zero-width append. + fn assert_all_zero_width(under: &UnderCommit) { + for event in &under.appends { + match event { + EngineEvent::ReplaceRange { + start, end, source, .. + } => { + assert_eq!( + start, end, + "under-commit may only append; a non-empty range rewrites committed text" + ); + assert_eq!(*source, LayerSource::TailPatch); + } + other => panic!("under-commit emitted a non-ReplaceRange event: {other:?}"), + } + } + } + /// Exact re-transcription match must yield `NoChange` (no empty ReplaceRange). #[test] fn identical_text_is_no_change() { @@ -525,6 +1176,203 @@ mod tests { assert_eq!(apply_all(committed, &outcome), committed); } + /// RED: once Whisper recovers substantially more non-trivial text than + /// Layer 0 committed, a bounded-diff rejection must escalate rather than + /// silently returning the same `Skipped` outcome as ordinary divergence. + #[test] + fn fleet_red_under_commit_escalates() { + let cfg = TailPatchConfig::default(); + let committed = "pierwsza krótka fraza"; + let retranscribed = "pierwsza krótka fraza oraz cały odzyskany dalszy fragment wypowiedzi z wieloma słowami"; + let under_commit = compute_tail_patch(committed, retranscribed, 41, &cfg); + + let normal = compute_tail_patch("ala ma kota", "ala ma psa", 42, &cfg); + assert!(matches!(normal, TailPatchOutcome::Patches(_))); + + let empty = compute_tail_patch("ala ma kota", "", 43, &cfg); + assert_eq!( + empty, + TailPatchOutcome::skipped( + SkipReasonCode::EmptyRetranscription, + "empty_retranscription", + ) + ); + + assert!( + !matches!(under_commit, TailPatchOutcome::Skipped { .. }), + "committed/retranscribed below 0.6 must escalate, got {under_commit:?}" + ); + } + + /// Recovered tail is appended, not discarded, and the canvas is untouched. + #[test] + fn under_commit_appends_recovered_tail_without_rewriting_canvas() { + // The measured shape: Layer 0 kept one phrase of a long take, Whisper + // returned that phrase plus everything Apple's partial-collapse ate. + let cfg = TailPatchConfig::default(); + let committed = "pierwsza krótka fraza"; + let retranscribed = "pierwsza krótka fraza oraz cały odzyskany dalszy fragment wypowiedzi z wieloma słowami"; + let outcome = compute_tail_patch(committed, retranscribed, 41, &cfg); + + let TailPatchOutcome::UnderCommit(under) = &outcome else { + panic!("expected UnderCommit, got {outcome:?}"); + }; + assert_eq!(under.committed_tokens, 3); + assert_eq!(under.retranscribed_tokens, 12); + assert!(under.commit_ratio < UNDER_COMMIT_RATIO); + assert_eq!(under.appends.len(), 1, "one bounded gap-append"); + assert_all_zero_width(under); + assert!( + !under.residual_required, + "everything recovered landed live; nothing is owed to the stop path" + ); + // Append-plus-gap-fill: the committed prefix survives byte-identical. + let applied = apply_all(committed, &outcome); + assert!(applied.starts_with(committed)); + assert_eq!(applied, retranscribed); + } + + /// Session a5623d55 (2026-08-12): the first live windows are SHORT — the + /// canvas held 2 tokens, Whisper recovered 5 (the eaten utterance head). + /// An absolute 6-token floor threw that recovery away three times in one + /// minute. Short-window under-commits must escalate too; the coverage + /// gate, not a length floor, is the garbage discriminator. + #[test] + fn short_window_under_commit_escalates_instead_of_skipping() { + let cfg = TailPatchConfig::default(); + let committed = "zmienili zobacz"; + let retranscribed = "coś się tutaj zmienili zobacz"; + let outcome = compute_tail_patch(committed, retranscribed, 1, &cfg); + + let TailPatchOutcome::UnderCommit(under) = &outcome else { + panic!("short-window head recovery must escalate, got {outcome:?}"); + }; + assert_eq!(under.committed_tokens, 2); + assert_eq!(under.retranscribed_tokens, 5); + // The head sits before the first match: a prepend, canvas untouched. + let applied = apply_all(committed, &outcome); + assert!( + applied.ends_with(committed), + "canvas must survive: {applied:?}" + ); + assert!( + applied.contains("coś się tutaj"), + "recovered head must land: {applied:?}" + ); + } + + /// The floor still exists for genuinely tiny bursts: a two-word decode + /// against a one-word canvas carries nothing worth escalating. + #[test] + fn tiny_burst_still_skips() { + let cfg = TailPatchConfig::default(); + let outcome = compute_tail_patch("tak", "no tak", 1, &cfg); + assert!( + matches!(outcome, TailPatchOutcome::Skipped { .. }), + "two-word burst must not escalate: {outcome:?}" + ); + } + + /// Recovered speech that would have to overwrite a committed span is never + /// emitted — it escalates to the stop path instead. + #[test] + fn under_commit_without_safe_anchor_requires_residual() { + let cfg = TailPatchConfig::default(); + // "piec" is committed but absent from the re-transcription, so the whole + // recovered tail sits under a committed token: no safe anchor exists. + let committed = "raz dwa trzy cztery piec"; + let retranscribed = "raz dwa trzy cztery szesc siedem osiem dziewiec dziesiec"; + let outcome = compute_tail_patch(committed, retranscribed, 42, &cfg); + + let TailPatchOutcome::UnderCommit(under) = &outcome else { + panic!("expected UnderCommit, got {outcome:?}"); + }; + assert!(under.appends.is_empty(), "no anchor was demonstrably safe"); + assert!(under.residual_required); + assert_eq!(under.reason(), "under_commit_residual_required"); + assert!(outcome.residual_required()); + // Layer 0 stands exactly as the user saw it. + assert_eq!(apply_all(committed, &outcome), committed); + } + + /// Mixed under-commit: the addressable gap is filled live, the unplaceable + /// remainder escalates, and no committed token is rewritten either way. + #[test] + fn under_commit_fills_safe_gap_and_still_escalates_remainder() { + let cfg = TailPatchConfig::default(); + let committed = "raz dwa trzy cztery piec szesc"; + let retranscribed = + "raz dwa trzy alfa beta gamma cztery piec siedem osiem dziewiec dziesiec"; + let outcome = compute_tail_patch(committed, retranscribed, 43, &cfg); + + let TailPatchOutcome::UnderCommit(under) = &outcome else { + panic!("expected UnderCommit, got {outcome:?}"); + }; + assert_eq!(under.appends.len(), 1); + assert_all_zero_width(under); + assert!( + under.residual_required, + "the tail under the committed span could not be placed" + ); + assert_eq!( + apply_all(committed, &outcome), + "raz dwa trzy alfa beta gamma cztery piec szesc", + "gap filled in place; every committed token survives" + ); + } + + /// Coverage — not the length ratio — is what separates under-commit from + /// divergence. This decode is *shorter-ratio* than the escalation threshold + /// yet shares no token with the canvas, so its offsets mean nothing. + #[test] + fn divergence_without_canvas_coverage_never_escalates() { + let cfg = TailPatchConfig::default(); + let committed = "ala ma kota"; + let retranscribed = "zupełnie inny tekst o czymś innym"; + assert!( + (tokenize(committed).len() as f64 / tokenize(retranscribed).len() as f64) + < UNDER_COMMIT_RATIO, + "fixture must sit below the ratio gate so coverage is the deciding gate" + ); + let outcome = compute_tail_patch(committed, retranscribed, 44, &cfg); + assert!(matches!(outcome, TailPatchOutcome::Skipped { .. })); + assert_eq!(apply_all(committed, &outcome), committed); + } + + /// A short Whisper burst satisfies any ratio while recovering nothing worth + /// appending; it stays on the ordinary skip path. + #[test] + fn short_retranscription_never_escalates() { + let cfg = TailPatchConfig::default(); + let outcome = compute_tail_patch("tak", "tak jest dobrze", 45, &cfg); + assert!( + matches!(outcome, TailPatchOutcome::Skipped { .. }), + "under {UNDER_COMMIT_MIN_RETRANSCRIBED_TOKENS} tokens is noise, not recovered speech" + ); + } + + /// The escalation must not disturb the ordinary lane: a small diff still + /// patches, and an empty re-transcription still skips with its exact reason. + #[test] + fn small_diff_and_empty_retranscription_are_unchanged() { + let cfg = TailPatchConfig::default(); + assert!(matches!( + compute_tail_patch("ala ma kota", "ala ma psa", 46, &cfg), + TailPatchOutcome::Patches(_) + )); + assert_eq!( + compute_tail_patch("ala ma kota", "", 47, &cfg), + TailPatchOutcome::skipped( + SkipReasonCode::EmptyRetranscription, + "empty_retranscription", + ) + ); + assert!(matches!( + compute_tail_patch("", "cokolwiek dłuższego tu jest naprawdę sporo", 48, &cfg), + TailPatchOutcome::Skipped { .. } + )); + } + /// Multiple substitutions emit descending-by-start for offset-stable apply. #[test] fn multiple_edits_apply_offset_stable() { @@ -550,6 +1398,234 @@ mod tests { ); } + /// The 2026-08-12 live case: a short utterance can be corrected at all. + /// + /// "Kos" for "kombos" is a one-word fix on a three-token commit — ratio + /// 0.33? No: with re-segmentation the changed-token count hits the ratio + /// cap, and before the small-edit floor existed the lane logged + /// `change_ratio 1.50 exceeds max 0.50` and threw Whisper's correct + /// hearing away. Measured: 116 skips, 0 applied patches in the whole log. + #[test] + fn small_edit_on_short_utterance_bypasses_the_ratio_cap() { + let cfg = TailPatchConfig::default(); + let committed = "Jaki chcesz. Kos."; + let retranscribed = "jaki chcesz kombos"; + let outcome = compute_tail_patch(committed, retranscribed, 2, &cfg); + match &outcome { + TailPatchOutcome::Patches(_) => {} + other => panic!("a bounded one-word fix must patch, got {other:?}"), + } + assert!( + apply_all(committed, &outcome) + .to_lowercase() + .contains("kombos"), + "the corrected word must land on the canvas" + ); + } + + /// A canvas that is mostly mangled must still be recoverable. + /// + /// The operator's live log 2026-08-14: SFSpeech committed a fragment while + /// Whisper heard the whole sentence, and the full coverage bar rejected the + /// recovery precisely because Layer 0 had mangled the words it did commit — + /// the worse it heard, the fewer tokens matched, the more certain the + /// rejection. 5341 characters of speech went to the receipt that way. + /// The canvas here mirrors the measured shape (a short mangled fragment + /// against a re-transcription carrying several times the material). + #[test] + fn starved_canvas_recovers_instead_of_being_rejected_for_low_coverage() { + let cfg = TailPatchConfig::default(); + // Two canvas tokens survive inside the re-transcription; the rest is + // mangled — coverage 0.5, under the full 0.8 bar, over the starved one. + let committed = "no gdzieś in zrope analizę"; + let retranscribed = "zrób z loctree analizę pełną martwego kodu bo mamy nową wersję \ + która pozwoli nam zrobić hard pruna przed wydaniem"; + let outcome = compute_tail_patch(committed, retranscribed, 7, &cfg); + let under = match &outcome { + TailPatchOutcome::UnderCommit(under) => under, + other => panic!("a starved canvas must classify as under-commit, got {other:?}"), + }; + assert!( + under.residual_required, + "a canvas too mangled to anchor against owes its recovery to the stop \ + path — it must never be dropped as a change-ratio receipt" + ); + // Nothing is placed inline: the anchors are not trustworthy, so the + // canvas the user is watching is left exactly as it was. + assert!( + under.appends.is_empty(), + "untrusted anchors must not place text into the live canvas" + ); + assert_eq!(apply_all(committed, &outcome), committed); + } + + /// The same lane, with a canvas Layer 0 heard well enough to anchor against: + /// here the recovery lands inline, append-only, and the canvas grows. + #[test] + fn starved_canvas_with_trusted_anchors_places_the_recovery_inline() { + let cfg = TailPatchConfig::default(); + let committed = "mamy nową wersję"; + let retranscribed = "mamy nową wersję pełną analizę martwego kodu która pozwoli nam zrobić \ + hard pruna przed wydaniem"; + let outcome = compute_tail_patch(committed, retranscribed, 9, &cfg); + let under = match &outcome { + TailPatchOutcome::UnderCommit(under) => under, + other => panic!("under-commit expected, got {other:?}"), + }; + assert!( + !under.appends.is_empty(), + "trusted anchors must place the recovered material" + ); + let applied = apply_all(committed, &outcome); + assert!( + applied.len() > committed.len(), + "canvas must grow: {applied:?}" + ); + for token in ["mamy", "nową", "wersję"] { + assert!( + applied.contains(token), + "committed text must never be rewritten, missing {token:?} in {applied:?}" + ); + } + } + + /// The exact duplication measured on the operator's take 2026-08-14, the + /// first run where recoveries reached the canvas at all. + /// + /// Whisper's window for a short utterance ("Zrób.") carried the phrase the + /// NEXT utterance already holds, so the aligner saw a gap and appended a + /// second copy: "…hard pruna I road która pozwoli nam na zrobienie hard + /// Pru." — three repeated 4-grams, WER 0.463 → 0.610. With the neighbour + /// canvas in hand the recovery escalates instead of duplicating. + #[test] + fn recovery_already_carried_by_the_neighbour_utterance_is_not_appended() { + let cfg = TailPatchConfig::default(); + let committed = "Zrób."; + let retranscribed = "zrób która pozwoli nam na zrobienie hard pruna"; + let neighbour = "I road która pozwoli nam na zrobienie hard Pru."; + + // Without the neighbour context this lane duplicates the phrase. + let blind = compute_tail_patch(committed, retranscribed, 5, &cfg); + let blind_text = apply_all(committed, &blind); + assert!( + blind_text.to_lowercase().contains("pozwoli nam na"), + "fixture must reproduce the duplication when the neighbour is unknown: {blind_text:?}" + ); + + // With it, nothing is placed and the recovery is owed to the stop path. + let outcome = compute_tail_patch_with_context(committed, retranscribed, neighbour, 5, &cfg); + match &outcome { + TailPatchOutcome::UnderCommit(under) => assert!( + under.appends.is_empty() && under.residual_required, + "a phrase the neighbour carries must escalate, not duplicate: {under:?}" + ), + TailPatchOutcome::Skipped { .. } => {} + other => panic!("must not place a duplicate, got {other:?}"), + } + assert_eq!( + apply_all(committed, &outcome), + committed, + "the canvas must be byte-identical when the recovery is a duplicate" + ); + } + + /// The live canvas stays protected: a re-transcription that shares almost + /// nothing with the canvas may still be owed to the stop path (which has its + /// own hallucination and semantic gates), but it must never place a single + /// character into the text the user is watching. + #[test] + fn low_agreement_recovery_never_touches_the_live_canvas() { + let cfg = TailPatchConfig::default(); + let committed = "spotkanie o dziesiątej"; + let retranscribed = "całkiem inne zdanie o zupełnie innych sprawach które nigdy nie padło \ + w tym nagraniu ani razu w żadnej formie"; + let outcome = compute_tail_patch(committed, retranscribed, 8, &cfg); + match &outcome { + TailPatchOutcome::Skipped { .. } => {} + TailPatchOutcome::UnderCommit(under) => assert!( + under.appends.is_empty() && under.residual_required, + "low agreement may only escalate, never place: {under:?}" + ), + other => panic!("a low-agreement decode must not patch inline, got {other:?}"), + } + assert_eq!( + apply_all(committed, &outcome), + committed, + "the canvas the user is watching must be byte-identical" + ); + } + + /// Ordinary divergence on a HEALTHY canvas keeps the old contract: Layer 0 + /// stands, nothing escalates. The starved path must not become a blanket + /// amnesty for every disagreement. + #[test] + fn divergence_on_a_healthy_canvas_still_skips() { + let cfg = TailPatchConfig::default(); + let committed = "spotkanie o dziesiątej rano w poniedziałek"; + let retranscribed = "zupełnie inne słowa nie mające z tym wspólnego"; + match compute_tail_patch(committed, retranscribed, 10, &cfg) { + TailPatchOutcome::Skipped { .. } => {} + other => panic!("healthy-canvas divergence must stay skipped, got {other:?}"), + } + } + + /// Casing and punctuation are alignment facts, not edits. The canvas comes + /// from Apple + lexicon (capitalized, punctuated); the re-transcription is + /// bare-lowercase Whisper. Compared byte-exact they shared zero tokens and + /// every healthy sentence read as wholesale divergence — the structural + /// half of the 116-skips/0-applied starvation (live ratios 0.56-2.00 on + /// ordinary Polish takes, 2026-08-12 21:20 session). The one genuinely + /// different word must be the only patch, and it must keep the sentence + /// boundary Apple already placed. + #[test] + fn casing_and_punctuation_align_instead_of_counting_as_changes() { + let cfg = TailPatchConfig::default(); + let committed = "Jaki chcesz. Kos."; + let retranscribed = "jaki chcesz kombos"; + let outcome = compute_tail_patch(committed, retranscribed, 21, &cfg); + match &outcome { + TailPatchOutcome::Patches(events) => { + assert_eq!(events.len(), 1, "only the truly different word patches"); + } + other => panic!("expected a single bounded patch, got {other:?}"), + } + assert_eq!(apply_all(committed, &outcome), "Jaki chcesz. kombos."); + } + + /// The same words in Whisper's bare normalization are NoChange — the + /// canvas keeps its casing and punctuation and nothing moves. + #[test] + fn same_words_in_bare_normalization_are_no_change() { + let cfg = TailPatchConfig::default(); + let committed = "To jest zdanie, które Apple dobrze usłyszało."; + let retranscribed = "to jest zdanie które apple dobrze usłyszało"; + let outcome = compute_tail_patch(committed, retranscribed, 22, &cfg); + assert_eq!(outcome, TailPatchOutcome::NoChange); + } + + /// The floor is a small-edit budget, not a hole in the divergence guard: a + /// wholesale rewrite of a short utterance still skips. + #[test] + fn wholesale_divergence_on_short_utterance_still_skips() { + let cfg = TailPatchConfig::default(); + let committed = "dobra nara"; + let retranscribed = "zupełnie inne zdanie o niczym wcale niepodobne do tamtego"; + let outcome = compute_tail_patch(committed, retranscribed, 3, &cfg); + match &outcome { + TailPatchOutcome::Skipped { reason, .. } => { + assert!( + reason.contains("change_ratio"), + "unexpected reason: {reason}" + ); + } + TailPatchOutcome::UnderCommit(_) => { + // Also acceptable: classified as recovered speech, which is a + // deliberate, bounded append path — never a rewrite. + } + other => panic!("wholesale divergence must not rewrite, got {other:?}"), + } + } + /// Polish diacritics: offsets are char-based so apply never corrupts UTF-8. #[test] fn unicode_offsets_are_char_based() { @@ -577,6 +1653,8 @@ mod tests { #[test] fn layered_phase_parses_phase_prefix() { // Pure parse — no process env (suite stays deterministic under parallel exec). + assert_eq!(layered_phase_from_raw(None), Some(LAYERED_DEFAULT_PHASE)); + assert_eq!(layered_phase_from_raw(Some("off")), None); assert_eq!(parse_layered_phase_value("phase1"), Some(1)); assert_eq!(parse_layered_phase_value("phase2"), Some(2)); assert_eq!(parse_layered_phase_value("4"), Some(4)); diff --git a/core/stt/tail_provider.rs b/core/stt/tail_provider.rs new file mode 100644 index 00000000..814f48e6 --- /dev/null +++ b/core/stt/tail_provider.rs @@ -0,0 +1,1314 @@ +//! Typed provider contract for bounded Whisper tail-patch windows. +//! +//! Time on this seam is always an integer PCM sample range. Floating-point +//! seconds exist only in the adapter back to the legacy [`TranscriptSegment`] +//! surface. The in-process implementation, localhost WebSocket sidecar, and +//! remote multipart client all terminate on this one seam. Sidecar/remote +//! failures fall back without changing the caller's append-only contract. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use rand::RngCore; +use reqwest::Url; +use reqwest::blocking::Client; +use reqwest::blocking::multipart::{Form, Part}; +use serde::{Deserialize, Serialize}; +use tokio_tungstenite::tungstenite::{Message, accept, client}; + +use crate::pipeline::contracts::{RawTranscript, TranscriptSegment}; + +/// Environment key selecting the tail-patch provider. +pub const STT_TAIL_PROVIDER_ENV: &str = "STT_TAIL_PROVIDER"; +/// Optional development override for the sidecar executable. +pub const STT_SIDECAR_BIN_ENV: &str = "CODESCRIBE_STT_SIDECAR_BIN"; +/// Child-only authentication token; never read from operator config. +pub const STT_SIDECAR_TOKEN_ENV: &str = "CODESCRIBE_STT_SIDECAR_TOKEN"; + +const SIDECAR_PROTOCOL_VERSION: u8 = 1; +const SIDECAR_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +const SIDECAR_IO_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_LOCAL_REMOTE_ENDPOINT: &str = "http://127.0.0.1:8000/v1/audio/transcriptions"; +const MAX_TAIL_PROVIDER_PCM_BYTES: usize = 32 * 1024 * 1024; + +/// Maximum transcript bytes accepted across the provider seam. +pub const MAX_TAIL_PROVIDER_TEXT_BYTES: usize = 64 * 1024; +/// Maximum timed segments accepted across the provider seam. +pub const MAX_TAIL_PROVIDER_SEGMENTS: usize = 2_048; +/// Maximum bytes accepted for an identity/session or evidence revision token. +pub const MAX_TAIL_PROVIDER_ID_BYTES: usize = 256; + +/// Compatibility request counter until W13-3A threads capture identity into +/// the live call site. It prevents unrelated legacy windows from sharing an +/// idempotency key; explicit callers should supply their own identity. +static LEGACY_REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +/// Provider incarnation chosen for a tail-patch request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TailProviderId { + InProcess, + Sidecar, + Remote, + Fake, +} + +impl TailProviderId { + pub fn as_str(self) -> &'static str { + match self { + Self::InProcess => "inprocess", + Self::Sidecar => "sidecar", + Self::Remote => "remote", + Self::Fake => "fake", + } + } + + /// Parse the provider selector without changing the default. + pub fn parse(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "inprocess" | "in_process" => Ok(Self::InProcess), + "sidecar" => Ok(Self::Sidecar), + "remote" => Ok(Self::Remote), + other => bail!( + "invalid {STT_TAIL_PROVIDER_ENV} value {other:?}; expected inprocess, sidecar, or remote" + ), + } + } +} + +/// Canonical identity of one PCM range in one capture epoch. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TailSampleRange { + pub session: String, + pub capture_epoch: u64, + pub sample_start: u64, + pub sample_end: u64, +} + +impl TailSampleRange { + pub fn sample_len(&self) -> Result { + self.sample_end + .checked_sub(self.sample_start) + .ok_or_else(|| { + anyhow!( + "tail range ends before it starts: {}..{}", + self.sample_start, + self.sample_end + ) + }) + } + + fn contains(&self, other: &Self) -> bool { + self.session == other.session + && self.capture_epoch == other.capture_epoch + && self.sample_start <= other.sample_start + && other.sample_end <= self.sample_end + } +} + +/// Idempotency key plus the exact audio range it names. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TailRequestIdentity { + pub request_id: u64, + pub range: TailSampleRange, +} + +/// Typed stability of the provider evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TailEvidenceStability { + Final, +} + +/// Honesty label for the timestamp mapping carried by a payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TailTimingQuality { + /// Segment ranges are exact on the capture PCM clock. + ExactSampleRange, + /// Current in-process Whisper timestamps refer to VAD-compacted speech. + CompactedSpeechRelative, + /// Deterministic test evidence, not a measured engine timestamp. + Synthetic, +} + +impl TailTimingQuality { + pub fn as_str(self) -> &'static str { + match self { + Self::ExactSampleRange => "exact_sample_range", + Self::CompactedSpeechRelative => "compacted_speech_relative", + Self::Synthetic => "synthetic", + } + } +} + +/// Engine family that produced the evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TailEvidenceSource { + AppleSpeech, + Whisper, +} + +impl TailEvidenceSource { + pub fn as_str(self) -> &'static str { + match self { + Self::AppleSpeech => "apple_speech", + Self::Whisper => "whisper", + } + } +} + +/// Provenance fields that must exist before confidence participates in fusion. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TailProviderEvidence { + pub source: TailEvidenceSource, + pub revision: Option, + pub stability: TailEvidenceStability, + pub timing_quality: TailTimingQuality, + /// Raw engine confidence; never calibrated or promoted on this seam. + pub avg_logprob: Option, +} + +/// One provider segment pinned to the canonical sample clock. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TimedTailSegment { + pub text: String, + pub range: TailSampleRange, +} + +/// Bounded, typed result returned by every provider incarnation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TailProviderPayload { + pub identity: TailRequestIdentity, + pub text: String, + pub segments: Vec, + pub avg_logprob: Option, + pub compression_ratio: Option, + pub quality_gate_dropped: bool, + pub provider_id: TailProviderId, + pub elapsed_ms: u64, + pub evidence: TailProviderEvidence, +} + +impl TailProviderPayload { + /// Enforce transport bounds and range identity before the payload can reach + /// fusion code. + pub fn validate(&self) -> Result<()> { + if self.identity.range.session.trim().is_empty() { + bail!("tail provider session identity must be non-empty"); + } + if self.identity.range.session.len() > MAX_TAIL_PROVIDER_ID_BYTES { + bail!("tail provider session identity is too long"); + } + if self.text.len() > MAX_TAIL_PROVIDER_TEXT_BYTES { + bail!("tail provider text exceeds {MAX_TAIL_PROVIDER_TEXT_BYTES} bytes"); + } + if self.segments.len() > MAX_TAIL_PROVIDER_SEGMENTS { + bail!("tail provider returned too many segments"); + } + self.identity.range.sample_len()?; + let mut segment_text_bytes = 0usize; + for segment in &self.segments { + segment.range.sample_len()?; + if !self.identity.range.contains(&segment.range) { + bail!("tail provider segment range escapes request range"); + } + segment_text_bytes = segment_text_bytes + .checked_add(segment.text.len()) + .ok_or_else(|| anyhow!("tail provider segment text size overflow"))?; + if segment_text_bytes > MAX_TAIL_PROVIDER_TEXT_BYTES { + bail!("tail provider segment text exceeds bounded payload size"); + } + } + if self + .evidence + .revision + .as_ref() + .is_some_and(|revision| revision.len() > MAX_TAIL_PROVIDER_ID_BYTES) + { + bail!("tail provider evidence revision is too long"); + } + if self.avg_logprob.is_some_and(|value| !value.is_finite()) + || self + .evidence + .avg_logprob + .is_some_and(|value| !value.is_finite()) + { + bail!("tail provider avg_logprob must be finite"); + } + if self.avg_logprob != self.evidence.avg_logprob { + bail!("tail provider confidence disagrees with typed evidence"); + } + Ok(()) + } + + /// Adapter back to the legacy seconds-based pipeline contract. + pub fn into_raw_transcript(self, sample_rate: u32) -> Result { + if sample_rate == 0 { + bail!("tail provider sample_rate must be non-zero"); + } + self.validate()?; + let request_start = self.identity.range.sample_start; + let rate = sample_rate as f64; + Ok(RawTranscript { + text: self.text, + segments: self + .segments + .into_iter() + .map(|segment| TranscriptSegment { + text: segment.text, + start_ts: ((segment.range.sample_start - request_start) as f64 / rate) as f32, + end_ts: ((segment.range.sample_end - request_start) as f64 / rate) as f32, + }) + .collect(), + avg_logprob: self.avg_logprob, + compression_ratio: self.compression_ratio, + quality_gate_dropped: self.quality_gate_dropped, + }) + } +} + +/// Metadata for one bounded transcription request. PCM stays borrowed and is +/// passed separately so the in-process refit does not copy every live window. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TailProviderRequest { + pub identity: TailRequestIdentity, + pub sample_rate: u32, + pub language: Option, +} + +impl TailProviderRequest { + pub fn validate_pcm(&self, pcm: &[f32]) -> Result<()> { + if self.sample_rate == 0 { + bail!("tail provider sample_rate must be non-zero"); + } + if self + .language + .as_ref() + .is_some_and(|language| language.len() > MAX_TAIL_PROVIDER_ID_BYTES) + { + bail!("tail provider language token is too long"); + } + let expected = self.identity.range.sample_len()?; + if expected != pcm.len() as u64 { + bail!( + "tail request range length {expected} does not match PCM length {}", + pcm.len() + ); + } + Ok(()) + } +} + +/// One transport-neutral tail-patch provider. +pub trait TailProvider: Send + Sync { + fn provider_id(&self) -> TailProviderId; + fn transcribe(&self, request: &TailProviderRequest, pcm: &[f32]) + -> Result; +} + +/// Existing local Whisper refit behind the W13 provider contract. +#[derive(Debug, Default)] +pub struct InProcessTailProvider; + +impl TailProvider for InProcessTailProvider { + fn provider_id(&self) -> TailProviderId { + TailProviderId::InProcess + } + + fn transcribe( + &self, + request: &TailProviderRequest, + pcm: &[f32], + ) -> Result { + request.validate_pcm(pcm)?; + let started = Instant::now(); + let (speech, _, speech_index) = + crate::vad::extract_speech_indexed(pcm, request.sample_rate); + let raw = if speech.is_empty() { + RawTranscript::default() + } else { + super::candle_transcribe_long_with_segments( + &speech, + request.sample_rate, + request.language.as_deref(), + )? + }; + let request_range = &request.identity.range; + let max_compacted = speech.len() as u64; + let to_sample = |seconds: f32| -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + ((seconds as f64 * request.sample_rate as f64).round() as u64).min(max_compacted) + }; + let segments = raw + .segments + .into_iter() + .filter_map(|segment| { + let compacted_start = to_sample(segment.start_ts); + let compacted_end = to_sample(segment.end_ts).max(compacted_start); + let (source_start, source_end) = crate::vad::map_compacted_sample_range( + &speech_index, + compacted_start, + compacted_end, + )?; + Some(TimedTailSegment { + text: segment.text, + range: TailSampleRange { + session: request_range.session.clone(), + capture_epoch: request_range.capture_epoch, + sample_start: request.range_end_for(source_start), + sample_end: request.range_end_for(source_end), + }, + }) + }) + .collect(); + let payload = TailProviderPayload { + identity: request.identity.clone(), + text: raw.text, + segments, + avg_logprob: raw.avg_logprob, + compression_ratio: raw.compression_ratio, + quality_gate_dropped: raw.quality_gate_dropped, + provider_id: self.provider_id(), + elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + evidence: TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: raw.avg_logprob, + }, + }; + payload.validate()?; + Ok(payload) + } +} + +impl TailProviderRequest { + fn range_end_for(&self, relative_sample: u64) -> u64 { + (self.identity.range.sample_start + relative_sample).min(self.identity.range.sample_end) + } +} + +/// Deterministic fake: an idempotent re-submit of the same request returns the +/// exact same payload, including elapsed time. A different identity is refused. +#[derive(Debug, Clone)] +pub struct FakeTailProvider { + payload: TailProviderPayload, +} + +impl FakeTailProvider { + pub fn new(payload: TailProviderPayload) -> Result { + payload.validate()?; + if payload.provider_id != TailProviderId::Fake { + bail!("fake tail provider payload must identify provider_id=fake"); + } + Ok(Self { payload }) + } +} + +impl TailProvider for FakeTailProvider { + fn provider_id(&self) -> TailProviderId { + TailProviderId::Fake + } + + fn transcribe( + &self, + request: &TailProviderRequest, + pcm: &[f32], + ) -> Result { + request.validate_pcm(pcm)?; + if request.identity != self.payload.identity { + bail!("fake tail provider request identity mismatch"); + } + Ok(self.payload.clone()) + } +} + +/// Normalized reason why the selected transport yielded to its fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TailProviderFailureKind { + Unavailable, + RemoteRequest, +} + +impl TailProviderFailureKind { + fn as_str(self) -> &'static str { + match self { + Self::Unavailable => "unavailable", + Self::RemoteRequest => "remote_request", + } + } +} + +/// Content-free proof of which provider actually served one PCM range. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TailProviderReceipt { + pub identity: TailRequestIdentity, + pub requested_provider: TailProviderId, + pub served_provider: TailProviderId, + pub fallback_used: bool, + pub primary_failure: Option, + pub elapsed_ms: u64, +} + +/// Result plus its routing receipt. Keeping the receipt typed lets the real +/// kill-mid-take harness assert fallback without scraping log prose. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TailProviderOutcome { + pub payload: TailProviderPayload, + pub receipt: TailProviderReceipt, +} + +/// Run one selected provider and fall back exactly once. The error itself is +/// deliberately collapsed into a safe category before it reaches telemetry. +pub fn transcribe_with_fallback( + primary: &dyn TailProvider, + fallback: &dyn TailProvider, + primary_failure: TailProviderFailureKind, + request: &TailProviderRequest, + pcm: &[f32], +) -> Result { + let started = Instant::now(); + let requested_provider = primary.provider_id(); + match primary.transcribe(request, pcm) { + Ok(payload) => Ok(TailProviderOutcome { + receipt: TailProviderReceipt { + identity: request.identity.clone(), + requested_provider, + served_provider: payload.provider_id, + fallback_used: false, + primary_failure: None, + elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + }, + payload, + }), + Err(_primary_error) => { + let payload = fallback.transcribe(request, pcm).with_context(|| { + format!( + "tail provider {} and fallback {} both failed (primary category {})", + requested_provider.as_str(), + fallback.provider_id().as_str(), + primary_failure.as_str() + ) + })?; + tracing::debug!( + requested_provider = requested_provider.as_str(), + primary_failure = primary_failure.as_str(), + "tail provider yielded to fallback" + ); + Ok(TailProviderOutcome { + receipt: TailProviderReceipt { + identity: request.identity.clone(), + requested_provider, + served_provider: payload.provider_id, + fallback_used: true, + primary_failure: Some(primary_failure), + elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + }, + payload, + }) + } + } +} + +struct UnavailableTailProvider(TailProviderId); + +impl TailProvider for UnavailableTailProvider { + fn provider_id(&self) -> TailProviderId { + self.0 + } + + fn transcribe( + &self, + _request: &TailProviderRequest, + _pcm: &[f32], + ) -> Result { + bail!("configured tail provider is unavailable") + } +} + +#[derive(Serialize, Deserialize)] +struct SidecarWireRequest { + protocol_version: u8, + token: String, + request: TailProviderRequest, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SidecarWireError { + Unauthorized, + Protocol, + Provider, +} + +#[derive(Serialize, Deserialize)] +struct SidecarWireResponse { + protocol_version: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + payload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// WebSocket client for one already-running localhost sidecar. +pub struct SidecarTailProvider { + endpoint: String, + token: String, +} + +impl std::fmt::Debug for SidecarTailProvider { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SidecarTailProvider") + .field("endpoint", &self.endpoint) + .field("token", &"[redacted]") + .finish() + } +} + +impl SidecarTailProvider { + pub fn new(endpoint: impl Into, token: impl Into) -> Result { + let endpoint = endpoint.into(); + sidecar_socket_address(&endpoint)?; + let token = token.into(); + if token.len() < 32 || token.len() > MAX_TAIL_PROVIDER_ID_BYTES { + bail!("sidecar token must be 32..={MAX_TAIL_PROVIDER_ID_BYTES} bytes"); + } + Ok(Self { endpoint, token }) + } +} + +impl TailProvider for SidecarTailProvider { + fn provider_id(&self) -> TailProviderId { + TailProviderId::Sidecar + } + + fn transcribe( + &self, + request: &TailProviderRequest, + pcm: &[f32], + ) -> Result { + request.validate_pcm(pcm)?; + let pcm_bytes = pcm_f32le(pcm)?; + let address = sidecar_socket_address(&self.endpoint)?; + let stream = TcpStream::connect_timeout(&address, SIDECAR_CONNECT_TIMEOUT) + .context("sidecar unavailable")?; + stream + .set_read_timeout(Some(SIDECAR_IO_TIMEOUT)) + .context("set sidecar read timeout")?; + stream + .set_write_timeout(Some(SIDECAR_IO_TIMEOUT)) + .context("set sidecar write timeout")?; + let (mut socket, _) = client(self.endpoint.as_str(), stream) + .map_err(|_| anyhow!("sidecar WebSocket handshake failed"))?; + let header = SidecarWireRequest { + protocol_version: SIDECAR_PROTOCOL_VERSION, + token: self.token.clone(), + request: request.clone(), + }; + socket + .send(Message::Text(serde_json::to_string(&header)?.into())) + .map_err(|_| anyhow!("sidecar request header send failed"))?; + socket + .send(Message::Binary(pcm_bytes.into())) + .map_err(|_| anyhow!("sidecar PCM send failed"))?; + let response_message = socket + .read() + .map_err(|error| anyhow!("sidecar response read failed: {error}"))?; + let response_text = response_message + .into_text() + .map_err(|_| anyhow!("sidecar response was not JSON text"))?; + let response: SidecarWireResponse = + serde_json::from_str(&response_text).context("sidecar response JSON was invalid")?; + if response.protocol_version != SIDECAR_PROTOCOL_VERSION { + bail!("sidecar protocol version mismatch"); + } + if let Some(error) = response.error { + bail!("sidecar returned normalized error {error:?}"); + } + let payload = response + .payload + .ok_or_else(|| anyhow!("sidecar response omitted payload"))?; + if payload.identity != request.identity || payload.provider_id != TailProviderId::Sidecar { + bail!("sidecar response identity/provider mismatch"); + } + payload.validate()?; + Ok(payload) + } +} + +fn sidecar_socket_address(endpoint: &str) -> Result { + let url = Url::parse(endpoint).context("invalid sidecar endpoint")?; + if url.scheme() != "ws" || url.path() != "/tail" || url.query().is_some() { + bail!("sidecar endpoint must be ws://127.0.0.1:/tail"); + } + let host = url + .host_str() + .ok_or_else(|| anyhow!("sidecar endpoint has no host"))?; + let ip: IpAddr = host + .trim_matches(['[', ']']) + .parse() + .map_err(|_| anyhow!("sidecar endpoint must use a numeric loopback host"))?; + if !ip.is_loopback() { + bail!("sidecar endpoint must stay on loopback"); + } + let port = url + .port() + .ok_or_else(|| anyhow!("sidecar endpoint has no port"))?; + Ok(SocketAddr::new(ip, port)) +} + +fn pcm_f32le(pcm: &[f32]) -> Result> { + let byte_len = pcm + .len() + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| anyhow!("sidecar PCM byte length overflow"))?; + if byte_len > MAX_TAIL_PROVIDER_PCM_BYTES { + bail!("sidecar PCM exceeds bounded request size"); + } + let mut bytes = Vec::with_capacity(byte_len); + for sample in pcm { + if !sample.is_finite() { + bail!("sidecar PCM contains a non-finite sample"); + } + bytes.extend_from_slice(&sample.to_le_bytes()); + } + Ok(bytes) +} + +fn decode_pcm_f32le(bytes: &[u8]) -> Result> { + if bytes.len() > MAX_TAIL_PROVIDER_PCM_BYTES || !bytes.len().is_multiple_of(4) { + bail!("sidecar PCM frame has an invalid bounded length"); + } + bytes + .chunks_exact(4) + .map(|chunk| { + let sample = f32::from_le_bytes(chunk.try_into().expect("four-byte chunk")); + if sample.is_finite() { + Ok(sample) + } else { + bail!("sidecar PCM contains a non-finite sample") + } + }) + .collect() +} + +/// Serve the sidecar protocol on an explicitly loopback address. This API owns +/// no capture device: its only audio input is the binary PCM WebSocket frame. +pub fn serve_sidecar( + bind: SocketAddr, + token: String, + provider: &dyn TailProvider, + parent_pid: Option, +) -> Result<()> { + if !bind.ip().is_loopback() || token.len() < 32 { + bail!("sidecar requires loopback bind and a process token"); + } + let listener = TcpListener::bind(bind).context("bind sidecar loopback listener")?; + listener + .set_nonblocking(true) + .context("set sidecar listener nonblocking")?; + loop { + if parent_pid.is_some_and(|pid| !parent_process_alive(pid)) { + return Ok(()); + } + match listener.accept() { + Ok((stream, _)) => { + let _ = handle_sidecar_connection(stream, &token, provider); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(20)); + } + Err(error) => return Err(error).context("accept sidecar connection"), + } + } +} + +fn handle_sidecar_connection( + stream: TcpStream, + token: &str, + provider: &dyn TailProvider, +) -> Result<()> { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(SIDECAR_IO_TIMEOUT))?; + stream.set_write_timeout(Some(SIDECAR_IO_TIMEOUT))?; + let mut socket = accept(stream).map_err(|_| anyhow!("sidecar handshake rejected"))?; + let header_text = socket + .read() + .map_err(|error| anyhow!("sidecar header read failed: {error}"))? + .into_text() + .map_err(|_| anyhow!("sidecar header was not JSON text"))?; + let header: SidecarWireRequest = match serde_json::from_str(&header_text) { + Ok(header) => header, + Err(_) => return send_sidecar_error(&mut socket, SidecarWireError::Protocol), + }; + if header.protocol_version != SIDECAR_PROTOCOL_VERSION || header.token != token { + return send_sidecar_error(&mut socket, SidecarWireError::Unauthorized); + } + let pcm_message = socket + .read() + .map_err(|_| anyhow!("sidecar PCM read failed"))?; + if !pcm_message.is_binary() { + return send_sidecar_error(&mut socket, SidecarWireError::Protocol); + } + let pcm = match decode_pcm_f32le(&pcm_message.into_data()) { + Ok(pcm) => pcm, + Err(_) => return send_sidecar_error(&mut socket, SidecarWireError::Protocol), + }; + if header.request.validate_pcm(&pcm).is_err() { + return send_sidecar_error(&mut socket, SidecarWireError::Protocol); + } + let started = Instant::now(); + let mut payload = match provider.transcribe(&header.request, &pcm) { + Ok(payload) => payload, + Err(_) => return send_sidecar_error(&mut socket, SidecarWireError::Provider), + }; + payload.provider_id = TailProviderId::Sidecar; + payload.elapsed_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64; + payload.validate()?; + let response = SidecarWireResponse { + protocol_version: SIDECAR_PROTOCOL_VERSION, + payload: Some(payload), + error: None, + }; + socket + .send(Message::Text(serde_json::to_string(&response)?.into())) + .map_err(|_| anyhow!("sidecar response send failed"))?; + Ok(()) +} + +fn send_sidecar_error( + socket: &mut tokio_tungstenite::tungstenite::WebSocket, + error: SidecarWireError, +) -> Result<()> { + let response = SidecarWireResponse { + protocol_version: SIDECAR_PROTOCOL_VERSION, + payload: None, + error: Some(error), + }; + socket + .send(Message::Text(serde_json::to_string(&response)?.into())) + .map_err(|_| anyhow!("sidecar error response send failed"))?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn parent_process_alive(pid: u32) -> bool { + // SAFETY: signal 0 performs an existence/permission probe and does not + // deliver a signal to the parent process. + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } +} + +#[cfg(not(target_os = "macos"))] +fn parent_process_alive(_pid: u32) -> bool { + true +} + +struct SupervisedSidecar { + child: Child, + endpoint: String, + token: String, +} + +impl Drop for SupervisedSidecar { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[derive(Default)] +struct SidecarSupervisor { + process: Mutex>, +} + +impl SidecarSupervisor { + fn transcribe( + &self, + request: &TailProviderRequest, + pcm: &[f32], + ) -> Result { + let mut guard = self + .process + .lock() + .map_err(|_| anyhow!("sidecar supervisor lock poisoned"))?; + let needs_spawn = guard + .as_mut() + .map(|process| process.child.try_wait().ok().flatten().is_some()) + .unwrap_or(true); + if needs_spawn { + *guard = Some(spawn_sidecar()?); + } + let process = guard.as_ref().expect("spawned sidecar"); + let client = SidecarTailProvider::new(&process.endpoint, &process.token)?; + match client.transcribe(request, pcm) { + Ok(payload) => Ok(payload), + Err(error) => { + *guard = None; + Err(error) + } + } + } +} + +impl TailProvider for SidecarSupervisor { + fn provider_id(&self) -> TailProviderId { + TailProviderId::Sidecar + } + + fn transcribe( + &self, + request: &TailProviderRequest, + pcm: &[f32], + ) -> Result { + self.transcribe(request, pcm) + } +} + +fn spawn_sidecar() -> Result { + let reservation = + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).context("reserve sidecar loopback port")?; + let address = reservation.local_addr()?; + drop(reservation); + + let mut token_bytes = [0_u8; 32]; + rand::thread_rng().fill_bytes(&mut token_bytes); + let token = token_bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let binary = resolve_sidecar_binary()?; + let mut child = Command::new(&binary) + .arg("--bind") + .arg(address.to_string()) + .arg("--parent-pid") + .arg(std::process::id().to_string()) + .env(STT_SIDECAR_TOKEN_ENV, &token) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("spawn sidecar helper {}", binary.display()))?; + + let deadline = Instant::now() + SIDECAR_CONNECT_TIMEOUT; + loop { + if child.try_wait()?.is_some() { + bail!("sidecar helper exited before becoming ready"); + } + if TcpStream::connect_timeout(&address, Duration::from_millis(50)).is_ok() { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!("sidecar helper readiness timed out"); + } + std::thread::sleep(Duration::from_millis(20)); + } + Ok(SupervisedSidecar { + child, + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- numeric loopback is enforced by both server and client; TLS adds no trust inside this one-host authenticated channel. + endpoint: format!("ws://{address}/tail"), + token, + }) +} + +fn resolve_sidecar_binary() -> Result { + if let Some(path) = std::env::var_os(STT_SIDECAR_BIN_ENV).filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + let executable = std::env::current_exe().context("resolve current executable")?; + let parent = executable + .parent() + .ok_or_else(|| anyhow!("current executable has no parent"))?; + let sibling = parent.join("codescribe-stt-sidecar"); + if sibling.is_file() { + return Ok(sibling); + } + if parent.file_name().is_some_and(|name| name == "deps") { + let test_sibling = parent + .parent() + .ok_or_else(|| anyhow!("test executable has no target parent"))? + .join("codescribe-stt-sidecar"); + if test_sibling.is_file() { + return Ok(test_sibling); + } + } + Ok(PathBuf::from("codescribe-stt-sidecar")) +} + +#[derive(Debug)] +pub struct RemoteTailProvider { + endpoint: String, + api_key: String, +} + +impl RemoteTailProvider { + pub fn new(endpoint: impl Into, api_key: impl Into) -> Result { + let endpoint = endpoint.into(); + validate_remote_endpoint(&endpoint)?; + let api_key = api_key.into(); + if api_key.trim().is_empty() { + bail!("STT_API_KEY is required for remote tail provider"); + } + Ok(Self { endpoint, api_key }) + } + + fn from_config() -> Result { + let config = crate::config::Config::load(); + let endpoint = config + .stt_endpoint + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_LOCAL_REMOTE_ENDPOINT.to_string()); + let api_key = config + .stt_api_key + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("STT_API_KEY is required for remote tail provider"))?; + Self::new(endpoint, api_key) + } +} + +impl TailProvider for RemoteTailProvider { + fn provider_id(&self) -> TailProviderId { + TailProviderId::Remote + } + + fn transcribe( + &self, + request: &TailProviderRequest, + pcm: &[f32], + ) -> Result { + request.validate_pcm(pcm)?; + let started = Instant::now(); + let wav = pcm16_wav(pcm, request.sample_rate)?; + let language = request.language.as_deref().unwrap_or("pl"); + let model = std::env::var("WHISPER_MODEL") + .unwrap_or_else(|_| "mlx-community/whisper-large-v3-mlx".to_string()); + let file = Part::bytes(wav) + .file_name("tail-window.wav") + .mime_str("audio/wav")?; + let form = Form::new() + .part("file", file) + .text("model", model.clone()) + .text("language", language.to_string()) + .text("response_format", "verbose_json"); + let response = Client::builder() + .timeout(REMOTE_REQUEST_TIMEOUT) + .connect_timeout(SIDECAR_CONNECT_TIMEOUT) + .build()? + .post(&self.endpoint) + .header("x-api-key", &self.api_key) + .multipart(form) + .send() + .context("remote tail request failed")?; + if !response.status().is_success() { + bail!("remote tail endpoint returned status {}", response.status()); + } + let response: RemoteTailResponse = response + .json() + .context("remote tail response was not compatible JSON")?; + let to_absolute = |seconds: f64| -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return request.identity.range.sample_start; + } + request + .identity + .range + .sample_start + .saturating_add((seconds * request.sample_rate as f64).round() as u64) + .min(request.identity.range.sample_end) + }; + let segments = response + .segments + .into_iter() + .map(|segment| TimedTailSegment { + text: segment.text, + range: TailSampleRange { + session: request.identity.range.session.clone(), + capture_epoch: request.identity.range.capture_epoch, + sample_start: to_absolute(segment.start), + sample_end: to_absolute(segment.end).max(to_absolute(segment.start)), + }, + }) + .collect(); + let payload = TailProviderPayload { + identity: request.identity.clone(), + text: response.text, + segments, + avg_logprob: response.avg_logprob, + compression_ratio: response.compression_ratio, + quality_gate_dropped: false, + provider_id: TailProviderId::Remote, + elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + evidence: TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: Some(model), + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: response.avg_logprob, + }, + }; + payload.validate()?; + Ok(payload) + } +} + +#[derive(Deserialize)] +struct RemoteTailResponse { + text: String, + #[serde(default)] + segments: Vec, + #[serde(default)] + avg_logprob: Option, + #[serde(default)] + compression_ratio: Option, +} + +#[derive(Deserialize)] +struct RemoteTailSegment { + text: String, + start: f64, + end: f64, +} + +pub(crate) fn validate_remote_endpoint(endpoint: &str) -> Result<()> { + let url = Url::parse(endpoint).context("invalid remote STT endpoint")?; + let host = url + .host_str() + .ok_or_else(|| anyhow!("remote STT endpoint has no host"))? + .trim_matches(['[', ']']); + let loopback = host == "localhost" || host.parse::().is_ok_and(|ip| ip.is_loopback()); + if url.scheme() != "https" && !(url.scheme() == "http" && loopback) { + bail!("remote STT endpoint requires HTTPS except on loopback"); + } + if !url.username().is_empty() || url.password().is_some() { + bail!("remote STT endpoint must not contain credentials"); + } + Ok(()) +} + +pub(crate) fn pcm16_wav(pcm: &[f32], sample_rate: u32) -> Result> { + if sample_rate == 0 { + bail!("remote tail sample rate must be non-zero"); + } + let data_len = pcm + .len() + .checked_mul(2) + .and_then(|bytes| u32::try_from(bytes).ok()) + .ok_or_else(|| anyhow!("remote tail PCM is too large for WAV"))?; + let mut wav = Vec::with_capacity(44 + data_len as usize); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36_u32 + data_len).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16_u32.to_le_bytes()); + wav.extend_from_slice(&1_u16.to_le_bytes()); + wav.extend_from_slice(&1_u16.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + wav.extend_from_slice(&2_u16.to_le_bytes()); + wav.extend_from_slice(&16_u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_len.to_le_bytes()); + for sample in pcm { + if !sample.is_finite() { + bail!("remote tail PCM contains a non-finite sample"); + } + let value = (sample.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16; + wav.extend_from_slice(&value.to_le_bytes()); + } + Ok(wav) +} + +/// Resolve and run the configured provider, emitting only content-free receipt +/// fields. New transports remain opt-in; their failure returns through the +/// in-process provider instead of starving the tail lane. +pub fn transcribe_configured( + request: &TailProviderRequest, + pcm: &[f32], +) -> Result { + let provider_id = match std::env::var(STT_TAIL_PROVIDER_ENV) { + Ok(value) => TailProviderId::parse(&value)?, + Err(std::env::VarError::NotPresent) => TailProviderId::InProcess, + Err(error) => return Err(error.into()), + }; + let inprocess = InProcessTailProvider; + let outcome = match provider_id { + TailProviderId::InProcess => { + let started = Instant::now(); + let payload = inprocess.transcribe(request, pcm)?; + TailProviderOutcome { + receipt: TailProviderReceipt { + identity: request.identity.clone(), + requested_provider: TailProviderId::InProcess, + served_provider: TailProviderId::InProcess, + fallback_used: false, + primary_failure: None, + elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + }, + payload, + } + } + TailProviderId::Sidecar => { + static SIDECAR: OnceLock = OnceLock::new(); + transcribe_with_fallback( + SIDECAR.get_or_init(SidecarSupervisor::default), + &inprocess, + TailProviderFailureKind::Unavailable, + request, + pcm, + )? + } + TailProviderId::Remote => match RemoteTailProvider::from_config() { + Ok(remote) => transcribe_with_fallback( + &remote, + &inprocess, + TailProviderFailureKind::RemoteRequest, + request, + pcm, + )?, + Err(_) => transcribe_with_fallback( + &UnavailableTailProvider(TailProviderId::Remote), + &inprocess, + TailProviderFailureKind::RemoteRequest, + request, + pcm, + )?, + }, + TailProviderId::Fake => unreachable!("fake is injectable, never selected from config"), + }; + let payload = outcome.payload; + tracing::info!( + requested_provider = outcome.receipt.requested_provider.as_str(), + served_provider = outcome.receipt.served_provider.as_str(), + fallback_used = outcome.receipt.fallback_used, + primary_failure = outcome + .receipt + .primary_failure + .map(TailProviderFailureKind::as_str), + request_id = payload.identity.request_id, + capture_epoch = payload.identity.range.capture_epoch, + sample_start = payload.identity.range.sample_start, + sample_end = payload.identity.range.sample_end, + segment_count = payload.segments.len(), + evidence_source = payload.evidence.source.as_str(), + timing_quality = payload.evidence.timing_quality.as_str(), + avg_logprob = payload.evidence.avg_logprob, + provider_elapsed_ms = payload.elapsed_ms, + routing_elapsed_ms = outcome.receipt.elapsed_ms, + "tail_provider_receipt" + ); + Ok(payload) +} + +/// Compatibility adapter for current call sites. W13-3A replaces this local +/// identity with the real capture session/epoch and absolute window range. +pub(crate) fn transcribe_legacy_window( + pcm: &[f32], + sample_rate: u32, + language: Option<&str>, +) -> Result { + let request = TailProviderRequest { + identity: TailRequestIdentity { + request_id: LEGACY_REQUEST_ID.fetch_add(1, Ordering::Relaxed), + range: TailSampleRange { + session: "legacy_tail_patch".to_string(), + capture_epoch: 0, + sample_start: 0, + sample_end: pcm.len() as u64, + }, + }, + sample_rate, + language: language.map(str::to_owned), + }; + transcribe_configured(&request, pcm)?.into_raw_transcript(sample_rate) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn w13_tail_provider_contract_typed_payload() { + let identity = TailRequestIdentity { + request_id: 17, + range: TailSampleRange { + session: "session-typed".to_string(), + capture_epoch: 3, + sample_start: 48_000, + sample_end: 48_320, + }, + }; + let payload = TailProviderPayload { + identity: identity.clone(), + text: "typed result".to_string(), + segments: vec![TimedTailSegment { + text: "typed".to_string(), + range: TailSampleRange { + session: "session-typed".to_string(), + capture_epoch: 3, + sample_start: 48_040, + sample_end: 48_200, + }, + }], + avg_logprob: Some(-0.21), + compression_ratio: Some(1.12), + quality_gate_dropped: false, + provider_id: TailProviderId::Fake, + elapsed_ms: 7, + evidence: TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: Some("fake-r1".to_string()), + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: Some(-0.21), + }, + }; + let fake = FakeTailProvider::new(payload.clone()).unwrap(); + let request = TailProviderRequest { + identity, + sample_rate: 16_000, + language: Some("pl-PL".to_string()), + }; + let pcm = vec![0.0; 320]; + + let first = fake.transcribe(&request, &pcm).unwrap(); + let retry = fake.transcribe(&request, &pcm).unwrap(); + + assert_eq!(first, payload); + assert_eq!(retry, first, "same request identity must be idempotent"); + assert_eq!(first.segments[0].range.sample_start, 48_040); + assert_eq!(first.segments[0].range.sample_end, 48_200); + assert_eq!(first.avg_logprob, Some(-0.21)); + assert_eq!(first.provider_id, TailProviderId::Fake); + assert_eq!(first.elapsed_ms, 7); + first.validate().unwrap(); + assert_eq!( + TailProviderId::parse("sidecar").unwrap(), + TailProviderId::Sidecar + ); + assert_eq!( + TailProviderId::parse("remote").unwrap(), + TailProviderId::Remote + ); + assert_eq!( + TailProviderId::parse("").unwrap(), + TailProviderId::InProcess + ); + + let mut different_request = request.clone(); + different_request.identity.request_id += 1; + assert!(fake.transcribe(&different_request, &pcm).is_err()); + } +} diff --git a/core/stt/whisper/singleton.rs b/core/stt/whisper/singleton.rs index a4641260..d3e68cf0 100644 --- a/core/stt/whisper/singleton.rs +++ b/core/stt/whisper/singleton.rs @@ -23,12 +23,13 @@ //! inference. The reaper therefore forces that prune right after the drop //! (`memory::reclaim_metal_buffer_pool`) so RSS actually falls while idle. //! -//! Default TTL is 45 minutes. Set `CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=0` to -//! keep weights resident for the whole process life. +//! Default TTL is five minutes. Set `CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=0` to +//! explicitly keep weights resident for the whole process life. // This entire module is a public API for library consumers use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -38,7 +39,6 @@ use tracing::{info, warn}; use crate::config::models::resolve_runtime_whisper_model_path; use crate::config::{Config, UserSettings}; use crate::pipeline::contracts::{FileTranscriptionOptions, RawTranscript, TranscriptionVerdict}; -use crate::pipeline::stream_postprocess::whisper_initial_prompt; use super::engine::LocalWhisperEngine; use super::params::DecodingParams; @@ -48,11 +48,11 @@ pub use crate::config::models::DEFAULT_MODEL; /// Default idle period after which Whisper **weights** are unloaded. /// -/// 45 minutes balances the operator-measured ~7.5 GB resident floor against -/// reload cost. Metal `Device` stays process-cached (see engine module), so -/// reloads after TTL reuse the same device. Override with -/// `CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS` (`0` disables unload). -const DEFAULT_IDLE_UNLOAD_SECS: u64 = 2700; +/// Five minutes bounds the operator-measured multi-GB resident floor while +/// leaving an explicit `0` override for power users who choose keep-warm. +/// Metal `Device` stays process-cached (see engine module), so reloads after +/// TTL reuse the same device. +const DEFAULT_IDLE_UNLOAD_SECS: u64 = 300; /// How often the reaper wakes to check for idleness. const REAPER_TICK: Duration = Duration::from_secs(30); @@ -75,6 +75,21 @@ static MODEL_PATH: OnceLock = OnceLock::new(); /// Guard so the idle reaper thread is spawned at most once. static REAPER_STARTED: OnceLock<()> = OnceLock::new(); +/// Process-lifetime residency transition counters. These deliberately record +/// lifecycle only: no audio, transcript, or model-path content enters them. +static RESIDENCY_LOAD_COUNT: AtomicU64 = AtomicU64::new(0); +static RESIDENCY_UNLOAD_COUNT: AtomicU64 = AtomicU64::new(0); +static RESIDENCY_RECLAIM_COUNT: AtomicU64 = AtomicU64::new(0); + +/// Test-only witness for callers that would initialize the heavyweight local +/// engine. It lets routing tests exercise the real selected-engine seam without +/// loading a model or inferring from source text. +#[cfg(test)] +static TEST_INIT_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +#[cfg(test)] +static TEST_LOAD_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// Access the engine slot, creating it (unloaded) on first use. fn slot() -> &'static Mutex { SLOT.get_or_init(|| { @@ -85,13 +100,33 @@ fn slot() -> &'static Mutex { }) } -/// Resolve the configured idle-unload period, or `None` when disabled (0). -fn idle_unload_after() -> Option { +/// The effective residency policy at this instant. +/// +/// `effective_ttl_secs=0` is intentionally a meaningful, explicit keep-warm +/// value rather than an absent configuration. Every residency lifecycle log +/// emits both fields so an operator dotenv override cannot masquerade as the +/// shipped default. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WhisperResidencyPolicy { + effective_ttl_secs: u64, + keep_warm: bool, +} + +fn whisper_residency_policy() -> WhisperResidencyPolicy { let secs = std::env::var("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS") .ok() .and_then(|s| s.trim().parse::().ok()) .unwrap_or(DEFAULT_IDLE_UNLOAD_SECS); - (secs > 0).then(|| Duration::from_secs(secs)) + WhisperResidencyPolicy { + effective_ttl_secs: secs, + keep_warm: secs == 0, + } +} + +/// Resolve the configured idle-unload period, or `None` for keep-warm. +fn idle_unload_after() -> Option { + let policy = whisper_residency_policy(); + (!policy.keep_warm).then(|| Duration::from_secs(policy.effective_ttl_secs)) } /// Resolve the model path for runtime Whisper fallback loading. @@ -148,6 +183,9 @@ pub fn get_model_path() -> Result<&'static PathBuf> { /// Build a fresh engine, embedded-first with a runtime-path fallback. fn load_engine() -> Result { + #[cfg(test)] + TEST_LOAD_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // 1. Primary shipped path: embedded Whisper payload. if let Some(embedded) = super::embedded::get_embedded_data() { let engine = LocalWhisperEngine::from_embedded(&embedded) @@ -164,17 +202,49 @@ fn load_engine() -> Result { Ok(engine) } +/// Emit the load transition without leaking transcription content. +fn record_residency_load(model_load_ms: u64) { + let policy = whisper_residency_policy(); + let load_count = RESIDENCY_LOAD_COUNT.fetch_add(1, Ordering::Relaxed) + 1; + info!( + event = "whisper_residency_load", + load_count, + model_load_ms, + effective_ttl_secs = policy.effective_ttl_secs, + keep_warm = policy.keep_warm, + "Whisper residency load complete" + ); +} + /// Spawn the idle reaper once (only when idle-unload is enabled). fn ensure_reaper() { - if idle_unload_after().is_none() { + let policy = whisper_residency_policy(); + if policy.keep_warm { + info!( + event = "whisper_residency_policy", + effective_ttl_secs = policy.effective_ttl_secs, + keep_warm = true, + "Whisper residency keep-warm selected; idle reaper is disabled" + ); return; } REAPER_STARTED.get_or_init(|| { + info!( + event = "whisper_residency_policy", + effective_ttl_secs = policy.effective_ttl_secs, + keep_warm = false, + "Whisper residency idle reaper armed" + ); let spawned = std::thread::Builder::new() .name("whisper-idle-reaper".into()) .spawn(reaper_loop); if let Err(e) = spawned { - warn!("Failed to spawn Whisper idle reaper: {e}"); + warn!( + event = "whisper_residency_policy", + effective_ttl_secs = policy.effective_ttl_secs, + keep_warm = false, + "Failed to spawn Whisper idle reaper: {e}" + ); } }); } @@ -190,24 +260,47 @@ fn reaper_loop() { Ok(g) => g, Err(_) => continue, }; - if guard.engine.is_some() && guard.last_used.elapsed() >= threshold { + let idle_for = guard.last_used.elapsed(); + let idle_ms = idle_for.as_millis().min(u128::from(u64::MAX)) as u64; + if guard.engine.is_some() && idle_for >= threshold { // Drop weights only (LocalWhisperEngine). The process-cached Metal // Device in engine::process_device stays alive so the next cold load // reuses it — no Device::new_metal churn / port leak. + let unload_started = Instant::now(); guard.engine = None; + let unload_drop_ms = unload_started.elapsed().as_millis() as u64; // Dropped weight buffers only return to the MetalDevice free-buffer // pool; force candle's prune or the multi-GB stays resident until // the NEXT inference. Done under the slot lock so a concurrent // reload cannot interleave with the pool sweep. - if let Some(device) = super::engine::cached_process_device() { - crate::memory::reclaim_metal_buffer_pool(&device); - } + let metal_reclaim_started = Instant::now(); + let metal_reclaim_attempted = + if let Some(device) = super::engine::cached_process_device() { + crate::memory::reclaim_metal_buffer_pool(&device); + true + } else { + false + }; + let metal_reclaim_ms = metal_reclaim_started.elapsed().as_millis() as u64; drop(guard); + let heap_release_started = Instant::now(); + crate::memory::release_freed_heap(); + let heap_release_ms = heap_release_started.elapsed().as_millis() as u64; + let unload_count = RESIDENCY_UNLOAD_COUNT.fetch_add(1, Ordering::Relaxed) + 1; + let reclaim_count = RESIDENCY_RECLAIM_COUNT.fetch_add(1, Ordering::Relaxed) + 1; info!( - "Whisper weights unloaded after {}s idle (Metal device retained, buffer pool pruned); releasing host heap", - threshold.as_secs() + event = "whisper_residency_reclaim", + unload_count, + reclaim_count, + effective_ttl_secs = threshold.as_secs(), + keep_warm = false, + idle_ms, + unload_drop_ms, + metal_reclaim_attempted, + metal_reclaim_ms, + heap_release_ms, + "Whisper residency unload and reclaim complete" ); - crate::memory::release_freed_heap(); } } } @@ -225,8 +318,8 @@ fn with_engine(f: impl FnOnce(&mut LocalWhisperEngine) -> Result) -> Resul let load_started = Instant::now(); guard.engine = Some(load_engine()?); model_load_ms = load_started.elapsed().as_millis() as u64; - info!("whisper_engine_cold_load model_load_ms={model_load_ms}"); ensure_reaper(); + record_residency_load(model_load_ms); } super::timing::record_engine_acquire(lock_wait_ms, model_load_ms, cold_load); guard.last_used = Instant::now(); @@ -255,12 +348,11 @@ fn with_engine_initial_prompt( }) } -/// Initial prompt used by file transcription, or `None` when the feature is off. -/// -/// Opt-in: the underlying builder returns `None` unless the initial-prompt env -/// flag is set, so file transcription stays prompt-free by default. +/// Full-file decoding is deliberately prompt-free. The live A/B measured a +/// vocabulary prompt deleting roughly half the file; lexicon voice belongs to +/// bounded tail/utterance windows only. fn file_transcription_initial_prompt() -> Option { - whisper_initial_prompt() + None } /// Like [`with_engine`] but never blocks: if the engine is busy, return an error @@ -275,8 +367,8 @@ fn try_with_engine(f: impl FnOnce(&mut LocalWhisperEngine) -> Result) -> R let load_started = Instant::now(); guard.engine = Some(load_engine()?); model_load_ms = load_started.elapsed().as_millis() as u64; - info!("whisper_engine_cold_load model_load_ms={model_load_ms}"); ensure_reaper(); + record_residency_load(model_load_ms); } // try_lock never waits, so lock_wait is 0 by construction. super::timing::record_engine_acquire(0, model_load_ms, cold_load); @@ -294,9 +386,27 @@ fn try_with_engine(f: impl FnOnce(&mut LocalWhisperEngine) -> Result) -> R /// deliberate fallback for no-embed builds and local recovery. Idempotent: a /// no-op if the engine is already loaded. pub fn init() -> Result<()> { + #[cfg(test)] + TEST_INIT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); with_engine(|_| Ok(())) } +#[cfg(test)] +pub(crate) fn reset_test_init_calls() { + TEST_INIT_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); + TEST_LOAD_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); +} + +#[cfg(test)] +pub(crate) fn test_init_calls() -> usize { + TEST_INIT_CALLS.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(test)] +pub(crate) fn test_load_calls() -> usize { + TEST_LOAD_CALLS.load(std::sync::atomic::Ordering::SeqCst) +} + /// Check if the engine is currently loaded. /// /// Note: with idle-unload enabled this can become `false` again after a period @@ -417,7 +527,7 @@ mod tests { } } - /// Initial prompt for file transcription stays off unless explicitly opted in. + /// File transcription stays prompt-free by contract. #[test] #[serial] fn file_transcription_initial_prompt_defaults_off() { @@ -439,10 +549,10 @@ mod tests { assert_eq!(file_transcription_initial_prompt(), None); } - /// Opt-in env builds the file-transcription initial prompt (contains lexicon terms). + /// Window opt-in must never leak into full-file transcription. #[test] #[serial] - fn file_transcription_initial_prompt_is_opt_in() { + fn file_transcription_initial_prompt_stays_off_when_window_prompt_is_opted_in() { let _data_dir = EnvRestore::capture("CODESCRIBE_DATA_DIR"); let _env_path = EnvRestore::capture("CODESCRIBE_ENV_PATH"); let _prompt_enabled = EnvRestore::capture( @@ -459,21 +569,67 @@ mod tests { ); } - let prompt = file_transcription_initial_prompt().expect("opt-in prompt should be built"); - assert!(prompt.contains("Loctree")); + assert_eq!( + file_transcription_initial_prompt(), + None, + "full-file prompting is forbidden even when window prompting is enabled" + ); + } + + /// RED: normal default is five minutes rather than the current 45 minutes. + #[test] + #[serial] + fn fleet_red_whisper_default_ttl_is_300() { + let _ttl = EnvRestore::capture("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS"); + + unsafe { std::env::remove_var("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS") }; + assert_eq!( + idle_unload_after(), + Some(Duration::from_secs(300)), + "normal Whisper residency must default to 300 seconds" + ); } - /// `0` disables idle unload; explicit secs win; default is 2700s weight unload. + /// Supporting GREEN guard: runtime overrides remain effective, including + /// explicit zero as the power-user keep-warm setting. #[test] - fn idle_unload_disabled_when_zero() { - // SAFETY: single-threaded test mutating a process env var it owns. + #[serial] + fn fleet_red_whisper_effective_ttl_overrides_include_zero_keep_warm() { + let _ttl = EnvRestore::capture("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS"); + + unsafe { std::env::set_var("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS", "17") }; + assert_eq!(idle_unload_after(), Some(Duration::from_secs(17))); + unsafe { std::env::set_var("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS", "0") }; - assert!(idle_unload_after().is_none()); - unsafe { std::env::set_var("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS", "120") }; - assert_eq!(idle_unload_after(), Some(Duration::from_secs(120))); + assert_eq!( + idle_unload_after(), + None, + "explicit zero is the power-user keep-warm override" + ); + } + + #[test] + #[serial] + fn whisper_residency_policy_exposes_effective_ttl_and_keep_warm() { + let _ttl = EnvRestore::capture("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS"); + unsafe { std::env::remove_var("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS") }; - // Default is 45 min weight-only unload (Metal device stays process-cached). - assert_eq!(idle_unload_after(), Some(Duration::from_secs(2700))); + assert_eq!( + whisper_residency_policy(), + WhisperResidencyPolicy { + effective_ttl_secs: 300, + keep_warm: false, + } + ); + + unsafe { std::env::set_var("CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS", "0") }; + assert_eq!( + whisper_residency_policy(), + WhisperResidencyPolicy { + effective_ttl_secs: 0, + keep_warm: true, + } + ); } /// LOCAL_MODEL precedence: process env > UserSettings > env file on disk. diff --git a/core/tests/w13_sidecar_fallback.rs b/core/tests/w13_sidecar_fallback.rs new file mode 100644 index 00000000..416c8478 --- /dev/null +++ b/core/tests/w13_sidecar_fallback.rs @@ -0,0 +1,202 @@ +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, TcpListener, TcpStream}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use codescribe_core::stt::tail_provider::{ + FakeTailProvider, RemoteTailProvider, STT_SIDECAR_TOKEN_ENV, SidecarTailProvider, + TailEvidenceSource, TailEvidenceStability, TailProvider, TailProviderEvidence, + TailProviderFailureKind, TailProviderId, TailProviderPayload, TailProviderRequest, + TailRequestIdentity, TailSampleRange, TailTimingQuality, TimedTailSegment, + transcribe_with_fallback, +}; + +fn request(request_id: u64, sample_start: u64) -> TailProviderRequest { + TailProviderRequest { + identity: TailRequestIdentity { + request_id, + range: TailSampleRange { + session: "sidecar-take".to_string(), + capture_epoch: 9, + sample_start, + sample_end: sample_start + 320, + }, + }, + sample_rate: 16_000, + language: Some("pl-PL".to_string()), + } +} + +fn fake_payload(request: &TailProviderRequest, text: &str) -> TailProviderPayload { + TailProviderPayload { + identity: request.identity.clone(), + text: text.to_string(), + segments: vec![TimedTailSegment { + text: text.to_string(), + range: request.identity.range.clone(), + }], + avg_logprob: Some(-0.1), + compression_ratio: Some(1.0), + quality_gate_dropped: false, + provider_id: TailProviderId::Fake, + elapsed_ms: 3, + evidence: TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: Some("fake-sidecar-r1".to_string()), + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: Some(-0.1), + }, + } +} + +#[test] +fn w13_sidecar_fallback_receipts() { + let remote_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("remote listener"); + let remote_address = remote_listener.local_addr().expect("remote address"); + let remote_server = std::thread::spawn(move || { + let (mut stream, _) = remote_listener.accept().expect("accept remote request"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("remote read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = stream.read(&mut buffer).expect("read multipart request"); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if bytes.windows(4).any(|window| window == b"\r\n\r\n") + && String::from_utf8_lossy(&bytes).contains("response_format") + { + break; + } + } + let request_text = String::from_utf8_lossy(&bytes); + assert!(request_text.starts_with("POST /v1/audio/transcriptions HTTP/1.1")); + assert!( + request_text + .to_ascii_lowercase() + .contains("x-api-key: test-key") + ); + assert!(request_text.contains("verbose_json")); + let body = r#"{"text":"remote-window","segments":[{"text":"remote-window","start":0.0,"end":0.02}],"avg_logprob":-0.2,"compression_ratio":1.1}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write remote response"); + }); + let remote_request = request(0, 0); + let remote = RemoteTailProvider::new( + format!("http://{remote_address}/v1/audio/transcriptions"), + "test-key", + ) + .expect("remote provider"); + let remote_payload = remote + .transcribe(&remote_request, &vec![0.0; 320]) + .expect("multipart remote window"); + assert_eq!(remote_payload.provider_id, TailProviderId::Remote); + assert_eq!(remote_payload.text, "remote-window"); + assert_eq!(remote_payload.segments[0].range.sample_end, 320); + remote_server.join().expect("remote server"); + + let reservation = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("reserve port"); + let address = reservation.local_addr().expect("reserved address"); + drop(reservation); + let token = "a".repeat(64); + let first_request = request(1, 0); + let first_payload = fake_payload(&first_request, "sidecar-window"); + let fixture = tempfile::NamedTempFile::new().expect("fixture file"); + std::fs::write( + fixture.path(), + serde_json::to_vec(&first_payload).expect("serialize fixture"), + ) + .expect("write fixture"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_codescribe-stt-sidecar")) + .arg("--bind") + .arg(address.to_string()) + .arg("--parent-pid") + .arg(std::process::id().to_string()) + .arg("--fake-payload") + .arg(fixture.path()) + .env(STT_SIDECAR_TOKEN_ENV, &token) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn real sidecar binary"); + + let deadline = Instant::now() + Duration::from_secs(5); + while TcpStream::connect_timeout(&address, Duration::from_millis(50)).is_err() { + assert!(Instant::now() < deadline, "sidecar did not become ready"); + std::thread::sleep(Duration::from_millis(20)); + } + + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- test exercises the production loopback-only exception. + let endpoint = format!("ws://{address}/tail"); + let sidecar = SidecarTailProvider::new(endpoint, token).expect("sidecar client"); + let first_fallback = FakeTailProvider::new(first_payload).expect("first fallback"); + let pcm = vec![0.0; 320]; + let direct = sidecar + .transcribe(&first_request, &pcm) + .expect("real sidecar transport serves the first window"); + assert_eq!(direct.provider_id, TailProviderId::Sidecar); + let first = transcribe_with_fallback( + &sidecar, + &first_fallback, + TailProviderFailureKind::Unavailable, + &first_request, + &pcm, + ) + .expect("first sidecar window"); + assert_eq!(first.receipt.requested_provider, TailProviderId::Sidecar); + assert_eq!(first.receipt.served_provider, TailProviderId::Sidecar); + assert!(!first.receipt.fallback_used); + assert!( + !first.payload.text.is_empty(), + "sidecar must return an applied candidate" + ); + assert!( + first.receipt.elapsed_ms < 1_000, + "fake sidecar window should stay below the delivery bar" + ); + println!( + "sidecar_receipt provider={} elapsed_ms={} applied_candidates=1", + first.receipt.served_provider.as_str(), + first.receipt.elapsed_ms + ); + + child.kill().expect("kill sidecar mid-take"); + child.wait().expect("reap killed sidecar"); + + let second_request = request(2, 320); + let second_payload = fake_payload(&second_request, "fallback-window"); + let second_fallback = FakeTailProvider::new(second_payload).expect("second fallback"); + let second = transcribe_with_fallback( + &sidecar, + &second_fallback, + TailProviderFailureKind::Unavailable, + &second_request, + &pcm, + ) + .expect("take continues through fallback"); + assert_eq!(second.receipt.requested_provider, TailProviderId::Sidecar); + assert_eq!(second.receipt.served_provider, TailProviderId::Fake); + assert!(second.receipt.fallback_used); + assert_eq!( + second.receipt.primary_failure, + Some(TailProviderFailureKind::Unavailable) + ); + assert_eq!(second.payload.text, "fallback-window"); + println!( + "fallback_receipt requested={} served={} fallback_used={} take_completed=true", + second.receipt.requested_provider.as_str(), + second.receipt.served_provider.as_str(), + second.receipt.fallback_used + ); +} diff --git a/core/util/mod.rs b/core/util/mod.rs index 02532477..3a8d7437 100644 --- a/core/util/mod.rs +++ b/core/util/mod.rs @@ -1,6 +1,8 @@ //! Cross-cutting helpers with no home in a domain module: filesystem path -//! safety and the process-wide status signal channel. +//! safety, child-pipe signal hygiene, and the process-wide status channel. +/// Per-fd SIGPIPE suppression — required because the core runs inside a Swift host. +pub mod pipes; pub mod safe_path; /// Process-wide status signal channel (Thinking/Error) for tray/bridge surfaces. pub mod status; diff --git a/core/util/pipes.rs b/core/util/pipes.rs new file mode 100644 index 00000000..9823b1bc --- /dev/null +++ b/core/util/pipes.rs @@ -0,0 +1,93 @@ +//! Per-fd SIGPIPE suppression for pipes we write to child processes. +//! +//! # Why this is not optional here +//! +//! Rust's runtime sets `SIGPIPE` to `SIG_IGN` before `main`, so a broken-pipe +//! write in an ordinary Rust binary surfaces as an `EPIPE` error. That setup +//! **never runs** in this product: the core ships as `crate-type = ["staticlib", +//! "cdylib"]` and is loaded into a Swift host, which keeps the default +//! disposition. A write to a dead child's stdin therefore kills the entire +//! application — and Darwin does not file a ReportCrash entry for it, so the app +//! simply vanishes with nothing in the logs to explain it. +//! +//! This was first diagnosed for the MCP stdio client (U14, `a35a64b`), where a +//! server that died at exec took the app down with it on the farewell write. The +//! same hazard exists for every child we pipe into; observed again 2026-08-12 +//! when killing the Apple STT bridge terminated the host app. +//! +//! `F_SETNOSIGPIPE` is deliberately per-fd rather than a process-wide +//! `signal(SIGPIPE, SIG_IGN)`: the core is a guest inside someone else's +//! process and must not mutate the host's signal table. + +/// Mark a pipe so writes to a dead peer return `EPIPE` instead of raising +/// `SIGPIPE`. +/// +/// Best-effort by design: a failure leaves the previous behaviour in place, so +/// callers keep whatever liveness check they already had. Accepts anything with +/// a raw fd, so both `std::process::ChildStdin` and its async equivalents work. +#[cfg(target_os = "macos")] +pub fn disable_sigpipe(pipe: &F) { + // Darwin `sys/fcntl.h`: `#define F_SETNOSIGPIPE 73`. The libc crate does not + // export this per-fd fcntl command (only the socket-level `SO_NOSIGPIPE`), + // so the value is pinned here. + /// Darwin fcntl command: mark a fd so broken-pipe writes return EPIPE, not SIGPIPE. + const F_SETNOSIGPIPE: libc::c_int = 73; + + // SAFETY: fcntl on a fd the caller owns; `F_SETNOSIGPIPE` only flips a + // per-fd flag and cannot invalidate the descriptor. + let _ = unsafe { libc::fcntl(pipe.as_raw_fd(), F_SETNOSIGPIPE, 1) }; +} + +/// No-op outside macOS: `F_SETNOSIGPIPE` is a Darwin-specific fcntl. +#[cfg(not(target_os = "macos"))] +pub fn disable_sigpipe(_pipe: &F) {} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + use std::process::{Command, Stdio}; + + /// The flag must actually be set on the pipe. + /// + /// A Rust test cannot reproduce the failure itself — the harness is an + /// ordinary Rust binary, so its runtime already ignores SIGPIPE and a + /// broken-pipe write returns `EPIPE` with or without this call. The part + /// that can silently rot is the pinned command number: `F_SETNOSIGPIPE` is + /// not exported by the libc crate, so `73` is a hand-copied constant. Read + /// it back with `F_GETNOSIGPIPE` (74) and the constant is pinned by + /// evidence rather than by comment. + #[test] + fn disable_sigpipe_sets_the_flag_the_swift_host_depends_on() { + use std::os::fd::AsRawFd; + + /// Darwin fcntl command: read back the per-fd no-SIGPIPE flag. + const F_GETNOSIGPIPE: libc::c_int = 74; + + let mut child = Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("spawn /bin/cat"); + let stdin = child.stdin.as_ref().expect("child stdin"); + + // SAFETY: reading a per-fd flag on a descriptor owned by this test. + let before = unsafe { libc::fcntl(stdin.as_raw_fd(), F_GETNOSIGPIPE) }; + assert_eq!( + before, 0, + "pipes start with SIGPIPE live — that is the hazard" + ); + + disable_sigpipe(stdin); + + // SAFETY: same descriptor, same read-only command. + let after = unsafe { libc::fcntl(stdin.as_raw_fd(), F_GETNOSIGPIPE) }; + assert_eq!( + after, 1, + "F_SETNOSIGPIPE must take effect; a wrong command number fails silently \ + and only shows up as the whole app vanishing without a crash report" + ); + + let _ = child.kill(); + let _ = child.wait(); + } +} diff --git a/core/vad/mod.rs b/core/vad/mod.rs index 2db25524..be9b01fe 100644 --- a/core/vad/mod.rs +++ b/core/vad/mod.rs @@ -69,6 +69,48 @@ pub struct VadExtractStats { pub probabilities: Vec, } +/// One retained slice of the original PCM timeline inside a compacted speech +/// buffer. All bounds are half-open sample indices. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpeechIndexRange { + pub compacted_start: u64, + pub compacted_end: u64, + pub source_start: u64, + pub source_end: u64, +} + +/// Translate a half-open range on the compacted speech buffer back to the +/// original PCM clock. A range spanning a removed pause expands across that +/// pause instead of pretending the compacted clock was contiguous source time. +pub fn map_compacted_sample_range( + index: &[SpeechIndexRange], + compacted_start: u64, + compacted_end: u64, +) -> Option<(u64, u64)> { + if compacted_end < compacted_start || index.is_empty() { + return None; + } + let start_range = index + .iter() + .find(|range| compacted_start < range.compacted_end) + .or_else(|| index.last())?; + let end_range = index + .iter() + .find(|range| compacted_end <= range.compacted_end) + .or_else(|| index.last())?; + let source_start = start_range.source_start.saturating_add( + compacted_start + .saturating_sub(start_range.compacted_start) + .min(start_range.source_end - start_range.source_start), + ); + let source_end = end_range.source_start.saturating_add( + compacted_end + .saturating_sub(end_range.compacted_start) + .min(end_range.source_end - end_range.source_start), + ); + Some((source_start, source_end.max(source_start))) +} + /// Window size for VAD analysis: 500ms of audio. const EXTRACT_WINDOW_MS: u32 = 500; @@ -107,6 +149,16 @@ fn return_extract_vad(sample_rate: u32, vad: AccumulatingVad) { /// /// Returns an empty vector when no speech is detected or VAD is unavailable. pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtractStats) { + let (speech, stats, _) = extract_speech_indexed(samples, sample_rate); + (speech, stats) +} + +/// Extract speech and retain the exact original-sample map for every copied +/// region. This is the timestamp-safe form used by timed STT providers. +pub fn extract_speech_indexed( + samples: &[f32], + sample_rate: u32, +) -> (Vec, VadExtractStats, Vec) { if samples.is_empty() { return ( Vec::new(), @@ -118,6 +170,7 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac sparkline: String::new(), probabilities: Vec::new(), }, + Vec::new(), ); } if sample_rate == 0 { @@ -131,6 +184,7 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac sparkline: String::new(), probabilities: Vec::new(), }, + Vec::new(), ); } @@ -146,6 +200,7 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac sparkline: String::new(), probabilities: Vec::new(), }, + Vec::new(), ); } @@ -166,6 +221,7 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac sparkline: String::new(), probabilities: Vec::new(), }, + Vec::new(), ); } }; @@ -183,9 +239,11 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac let mut total_windows = 0usize; let mut sparkline = String::new(); let mut probabilities = Vec::new(); + let mut index_map = Vec::new(); let mut last_window_was_speech = false; - for window in samples.chunks(window_size) { + for (window_index, window) in samples.chunks(window_size).enumerate() { + let source_start = window_index.saturating_mul(window_size) as u64; if window.len() < window_size / 2 { // Keep very short trailing tails only when they clearly continue speech. if should_include_trailing_fragment( @@ -194,7 +252,14 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac speech_windows > 0, last_window_was_speech, ) { + let compacted_start = speech_samples.len() as u64; speech_samples.extend_from_slice(window); + index_map.push(SpeechIndexRange { + compacted_start, + compacted_end: speech_samples.len() as u64, + source_start, + source_end: source_start + window.len() as u64, + }); } break; } @@ -221,7 +286,14 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac }); if prob >= threshold { + let compacted_start = speech_samples.len() as u64; speech_samples.extend_from_slice(window); + index_map.push(SpeechIndexRange { + compacted_start, + compacted_end: speech_samples.len() as u64, + source_start, + source_end: source_start + window.len() as u64, + }); speech_windows += 1; last_window_was_speech = true; } else { @@ -255,6 +327,7 @@ pub fn extract_speech(samples: &[f32], sample_rate: u32) -> (Vec, VadExtrac sparkline, probabilities, }, + index_map, ) } @@ -489,4 +562,33 @@ mod tests { ); assert!(stats.probabilities.is_empty()); } + + /// A decoded span crossing a removed interior pause expands back onto the + /// original PCM clock instead of keeping compacted offsets. + #[test] + fn compacted_range_maps_across_removed_pcm_gap() { + let index = [ + SpeechIndexRange { + compacted_start: 0, + compacted_end: 8_000, + source_start: 0, + source_end: 8_000, + }, + SpeechIndexRange { + compacted_start: 8_000, + compacted_end: 16_000, + source_start: 24_000, + source_end: 32_000, + }, + ]; + + assert_eq!( + map_compacted_sample_range(&index, 4_000, 12_000), + Some((4_000, 28_000)) + ); + assert_eq!( + map_compacted_sample_range(&index, 8_000, 9_000), + Some((24_000, 25_000)) + ); + } } diff --git a/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md b/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md index c1c2a9a2..0e887eb7 100644 --- a/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md +++ b/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md @@ -319,13 +319,21 @@ Four phases. Each ships as an independent machete cut behind a feature flag ### Phase delivery status (2026-08-08) -What this ADR proposed vs. what the runtime actually executes today. Everything below -`phase1` is still **off by default** — `CODESCRIBE_LAYERED_TRANSCRIPTION=off` is the shipped -value and no default flip has been taken (see the W3-B default-flip memo). +What this ADR proposed vs. what the runtime actually executes today. + +**Superseded on the Layer 1 default (2026-08-09 / polarized 2026-08-14).** +`CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1` (`LAYERED_DEFAULT_PHASE = 1`). +Explicit `off`/`0`/`false` is the only disarm. The 2026-08-08 sentence +"`phase1` is still off by default" is historical. Everything **above** +phase1 (Layers 2–4 in this table, plus W13 fusion / idempotence / +highlights) is still off by default. + +The 2026-08-08 table below is inventory, not the default. Read the +"Delivered?" column with that amendment. | Phase | Proposed module | Delivered? | Where it actually lives | | ------------------------------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **1 — Layer 1 tail patch** | `core/stt/tail_patcher/` | ✅ **delivered, opt-in** | `core/stt/tail_patcher/` exists as proposed; wired into `core/pipeline/streaming/session.rs` (VAD/scheduler) and `core/pipeline/streaming/apple_live_session.rs` (Apple progressive, W2-A `a6b1233d`) | +| **1 — Layer 1 tail patch** | `core/stt/tail_patcher/` | ✅ **delivered, default phase1** | `core/stt/tail_patcher/` exists as proposed; wired into `core/pipeline/streaming/session.rs` (VAD/scheduler) and `core/pipeline/streaming/apple_live_session.rs` (Apple progressive, W2-A `a6b1233d`). Unset env → phase1 since 2026-08-09. | | **1 — overlay `ReplaceRange` render** | `app/ui/overlay/mod.rs` | ✅ delivered, moved | `OverlayState.applyReplaceRange` → `OverlayTranscriptSegment.replaceRange` (Swift) | | **1 — orchestrator** | `app/controller/layered_orchestrator.rs` | ❌ **not built — and not needed so far** | Both live paths call the shared `tail_patch_enabled` / `compute_tail_patch_job` / `emit_tail_patch_result` primitives directly. One gate, one `LayerSummary` shape, no separate state machine. Revisit only when Layers 2–4 need a single audio cursor (see Consequences) | | **2 — Lexicon** | `core/lexicon/` | ⚠️ **partial, different shape** | No `core/lexicon/` module. Lexicon substitution lives in `core/pipeline/stream_postprocess.rs::apply_lexicon` and runs **at seal time** on the Apple progressive path (W1-A `d180add9`) — as the doctrine's final automated layer, not as a debounced Layer 2 sub-pass | diff --git a/docs/ADR/2026-08-14-PROVIDER_CREDENTIAL_TOPOLOGY.md b/docs/ADR/2026-08-14-PROVIDER_CREDENTIAL_TOPOLOGY.md new file mode 100644 index 00000000..ca6cbcd8 --- /dev/null +++ b/docs/ADR/2026-08-14-PROVIDER_CREDENTIAL_TOPOLOGY.md @@ -0,0 +1,107 @@ +# ADR 2026-08-14 — Provider credential topology (keys and endpoints live together) + +Status: ACCEPTED (operator directive, 2026-08-14, verbatim shape below) +Owner: operator; implementation pending as a dedicated cut (W14 candidate) + +## Problem — the drift maker + +Today credentials and endpoints live in two unrelated panels: + +- `Settings → Providers` holds bare key slots (`LLM_API_KEY`, + `LLM_FORMATTING_API_KEY`, `LLM_ASSISTIVE_API_KEY`, `STT_API_KEY`) plus the + ChatGPT OAuth block, with no endpoint in sight; +- `Settings → Agent → LLM lanes` holds free-text endpoints and models per + lane, with no credential in sight. + +Nothing binds a key to the endpoint it authenticates. Measured consequences +(2026-08-12/14 field evidence, one morning): + +1. **Chain poisoning** — Responses ids are scoped to the minting credential; + a Keychain key swap left the stored chain id invisible to the new key + (three `previous_response_not_found` failures, transcript delivered raw). + Nothing invalidates conversation state on credential change + (`reset_conversation*` has zero callers in config/keychain/account_auth). +2. **Two auth identities in one lane** — the OAuth-account-wins-over-key + rule plus key-as-fallback lets one lane mint a chain under identity A and + continue under identity B: `not_found` by construction. +3. **Slot asymmetry** — formatting/main resolve fixed slots while assistive + resolves per-provider (`api_key_env_key`); onboarding wrote only the + assistive slot and stale keys rotted silently in the others (silent 401, + "no punctuation" class). +4. **Contradictory surface** — the assistive lane shows endpoint `api.x.ai`, + a slot labeled "Assistive API key (OpenAI)", and a discovery banner from + a third, dead xAI key. Three truths in one window. + +## Decision — the operator's target shape (zero deviations) + +One `Providers` surface with three tiers; **credential and endpoint are one +object, always**: + +### Tier 1 — Vendors (endpoints PINNED, no URL field at all) + +| Provider | Credentials offered | Endpoint | +| --------- | ---------------------------- | --------------------------------------- | +| OpenAI | API key AND/OR ChatGPT OAuth | official, pinned, not shown as editable | +| xAI | API key AND/OR xAI OAuth | official, pinned | +| Anthropic | API key | official, pinned | + +"Zero samowolki w urlach": a vendor row never exposes an endpoint editor. +If both credentials are present, the row states which one the runtime will +use (and the chain fingerprint — see Invariants). + +### Tier 2 — Custom compatible providers + +A list of user-defined rows, each an ATOMIC `{name, wire, endpoint, api_key}`: + +- `wire = openai-responses | anthropic-messages` +- endpoint and key are entered, stored, tested and deleted TOGETHER; +- this is the only place a custom URL can exist (e.g. api.libraxis.cloud). + +### Tier 3 — STT lanes + +STT providers with explicit transports, same atomic credential+endpoint rule: + +- `ws` (streaming sidecar/remote, W13-2B slot), +- `ndjson` (stt-jsonl-v1 class), +- `file` (multipart transcriptions). + +### Lanes consume references, never raw URLs + +`assistive / formatting / main / stt` each select a **provider reference** +(vendor or custom row) + model. The lane panel shows resolved truth only: +provider ref, credential KIND (key/account), model — no free endpoint field. + +## Invariants (these close the measured failure classes) + +- **I1**: a credential is never stored, tested, or deleted apart from its + endpoint (atomic row). +- **I2**: conversation chain state is keyed by + `(provider_ref, credential_fingerprint)`; any credential change resets the + affected lanes' chains at write time (root fix for class 1/2; runtime + self-heal `65e578e2` stays as backstop). +- **I3**: one resolution path for every lane (kills the fixed-slot vs + per-provider split). +- **I4**: vendor endpoints are compile-time constants; only Tier-2 rows carry + URLs. +- **I5**: the UI never shows a credential slot a lane cannot actually use. + +## Migration sketch + +1. Config model: provider registry v2 (`vendor` rows + `custom` rows + `stt` + rows) with env/Keychain back-compat mapping from today's slots. +2. `lane_truth` resolves lane → provider ref → (endpoint, credential) in one + step; delete the per-lane endpoint envs from the UI surface (env override + stays for ops, marked as such). +3. Settings UI: single Providers screen with the three tiers; LLM lanes + screen loses endpoint editors, keeps provider picker + model + resolved + truth. +4. Chain fingerprint in `state::conversation` + reset hooks in + keychain/account_auth writes. + +## Out of scope here + +Implementation. This ADR freezes the shape so no agent re-derives a +different one. Anti-pattern to reject on sight: "add one more key slot" or +"add an endpoint override field" — both re-open the drift maker. + +𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by VetCoders (c)2024-2026 LibraxisAI diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1471b379..a52c16b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -312,9 +312,10 @@ embedding is disabled with `CODESCRIBE_NO_EMBED=1` or the build cannot embed the model: 1. `CODESCRIBE_MODEL_PATH` environment variable -2. `~/.codescribe/models/whisper-large-v3-turbo-mlx-q8/` -3. `./models/whisper-large-v3-turbo-mlx-q8/` in repo -4. Hugging Face cache snapshots for `LibraxisAI/whisper-large-v3-turbo-mlx-q8` +2. `~/.codescribe/models/whisper-large-v3-turbo/` (fp16 default) +3. Hugging Face cache snapshots for `mlx-community/whisper-large-v3-turbo` +4. Legacy fallback: `whisper-large-v3-turbo-mlx-q8` dir or + `LibraxisAI/whisper-large-v3-turbo-mlx-q8` snapshots ## Related Documentation diff --git a/docs/ENV_REGISTRY.toml b/docs/ENV_REGISTRY.toml index dbfac88d..0a9d416b 100644 --- a/docs/ENV_REGISTRY.toml +++ b/docs/ENV_REGISTRY.toml @@ -23,6 +23,11 @@ updated = "2026-07-16" # ═══════════════════════════════════════════════════════════════════════════════ # VAD internals are hardcoded in `core/vad/config.rs` based on Silero defaults. # No user-configurable VAD env vars. +# +# Dead folklore (never read; leftover .env lines, not product knobs): +# CODESCRIBE_VAD_SILENCE_SEC, CODESCRIBE_VAD_PRE_ROLL_MS, CODESCRIBE_VAD_SILENCE_DB +# Product silence on the live Apple lane is TOGGLE_SILENCE_SEC → EpochGate +# (speech opens an SFSpeech epoch, silence past the slider closes it). # ═══════════════════════════════════════════════════════════════════════════════ # Audio / Recording @@ -71,6 +76,13 @@ category = "audio" description = "Sound volume (0.0-1.0)" range = "0.0-1.0" +[vars.CODESCRIBE_CAPTURE_LEVEL_LOW_DB] +default = "-52" +type = "f32" +reload = "hot" +category = "audio" +description = "W13-5 capture-level WARN floor in dBFS, keyed on active-speech median RMS (not all-audio). Corpus: golden era ≈ −38, break ≈ −44; −52 leaves headroom. Must stay a non-terminal quality receipt." + # ═══════════════════════════════════════════════════════════════════════════════ # Streaming / Buffering # ═══════════════════════════════════════════════════════════════════════════════ @@ -390,7 +402,7 @@ category = "stt" description = "Use local Whisper model (vs cloud)" [vars.LOCAL_MODEL] -default = "whisper-large-v3-turbo-mlx-q8" +default = "whisper-large-v3-turbo" type = "string" reload = "restart" category = "stt" @@ -424,12 +436,40 @@ reload = "hot" category = "stt" description = "Opt-in gate for Candle Whisper runtime-lexicon initial_prompt. Default OFF after W2-F measured a 100% WER regression with the active prompt." +[vars.CODESCRIBE_OVERLAY_HIGHLIGHTS] +default = "0" +type = "bool" +reload = "hot" +category = "stt" +description = "W13-6B span-based overlay highlight layer (lexicon-corrected words + Silero speech-gap pustki) and one-click send-span-to-Teach. Default OFF. No new permission prompts; consumes ReplaceRange/on_final/VAD data already crossing the bridge after W13-3A." + +[vars.CODESCRIBE_SILERO_FUSION] +default = "0" +type = "bool" +reload = "restart" +category = "stt" +description = "W13-3B Silero-identity seal authority. Default OFF pending the operator live A/B required by the original THE_ENGINE_ROADMAP. Armed: Supervisor edges mint utterance ids at Apple PCM ingress, cumulative Apple finals are sliced by time onto those ranges, sealed spans carry silero_utterance_id, and Whisper/Apple fuse by span overlap (agreements + clear gap fills; unresolved alternatives are receipted). The session Silero still runs whenever hands-free silence arms the engine lifecycle." + +[vars.CODESCRIBE_SILERO_FUSION_CONTEXT] +default = "utterance" +type = "string" +reload = "restart" +category = "stt" +description = "Bounded-context A/B for W13-3B Whisper windows: utterance (default), left_pad, or stable_prompt. Left pad never crosses a Silero-sized silence fence." + +[vars.CODESCRIBE_SPAN_IDEMPOTENCE] +default = "0" +type = "bool" +reload = "restart" +category = "stt" +description = "W13-4 sealed-span replay refusal + in-span loop fence. Default OFF. When 1/true/on: live seal path refuses a replayed PCM-range / request identity (SessionIngest RejectedSealedUtterance ported onto ledger identity) and fences non-progressing timestamps or decode failure. Content-similar offers on a new identity are WARN-preserved. No default flip." + [vars.CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS] -default = "2700" +default = "300" type = "u64" reload = "hot" category = "stt" -description = "Unload idle Whisper weights after this many seconds (default 2700 = 45 min). Metal Device is process-cached and retained; the Metal free-buffer pool is force-pruned on unload so RSS actually drops. 0 disables unload (weights stay resident)." +description = "Unload idle Whisper weights after this many seconds (default 300 = 5 min). Metal Device is process-cached and retained; the Metal free-buffer pool is force-pruned on unload so RSS actually drops. 0 is the explicit keep-warm override (weights stay resident). INFO-only residency events expose the effective TTL plus load/unload/reclaim counts and durations without audio or transcript content." [vars.CANDLE_METAL_COMPUTE_PER_BUFFER] default = "50" @@ -488,12 +528,96 @@ reload = "restart" category = "stt" description = "STT engine: auto (Apple on supported macOS, Candle fallback), candle, onnx, or apple" +[vars.CODESCRIBE_ASR_MODE] +default = "" +type = "string" +reload = "restart" +category = "stt" +description = "Layer 1 ASR product mode: cloud, local_power, or apple_only (empty = derive from legacy use_local_stt, else apple_only). Promoted: settings.json is the source of truth; cloud additionally requires CODESCRIBE_CLOUD_CONSENT=granted" + +[vars.CODESCRIBE_CLOUD_CONSENT] +default = "" +type = "string" +reload = "restart" +category = "stt" +description = "Explicit audio-egress consent record: granted or denied (empty = never asked; anything else reads as unanswered). Without granted, cloud mode resolves to apple_only and never loads local weights. Promoted: settings.json is the source of truth" + +[vars.CODESCRIBE_ASR_GATEWAY_URL] +default = "" +type = "string" +reload = "restart" +category = "stt" +description = "Libraxis gateway session-mint endpoint (https; loopback http for dev). Endpoint only — URLs with user-info or query material are refused; vendor keys never live in config. Promoted: settings.json is the source of truth" + [vars.CODESCRIBE_LAYERED_TRANSCRIPTION] default = "phase1" type = "string" reload = "hot" category = "stt" -description = "Layered incremental transcription phase gate: phase1 (DEFAULT since 2026-08-09 — operator directive: the live tail patch is a core element of the triangulation, not an opt-in; explicit off/0/false disables) or phase1..phase4 (bare 1..4 accepted). Orthogonal to FINAL_PASS_MODE/Smart — Smart never enables this. Phase ≥ 1 turns on Layer 1 Whisper tail-patch during hold on BOTH live paths: the VAD/scheduler session and the default Apple progressive live session (wired W2-A, a6b1233d). phase1 = Apple Layer 0 + Whisper ReplaceRange patches. Power-user key: not promoted to settings.json, so a stale ~/.codescribe/.env line arms it in every process that loads the core." +description = "Layered incremental transcription phase gate: phase1 (DEFAULT since 2026-08-09 — operator directive: the live tail patch is a core element of the triangulation, not an opt-in; explicit off/0/false disables) or phase1..phase4 (bare 1..4 accepted). Orthogonal to FINAL_PASS_MODE/Smart — Smart never enables this. Phase ≥ 1 turns on Layer 1 Whisper tail-patch during hold on BOTH live paths: the VAD/scheduler session and the default Apple progressive live session (wired W2-A, a6b1233d). phase1 = Apple Layer 0 + Whisper ReplaceRange patches. Promoted 2026-08-10 to settings.json (single-brain; a stale ~/.codescribe/.env line must not lottery the live path)." + +[vars.CODESCRIBE_SEAL_ATLAS_DUMP] +default = "" +type = "string" +reload = "hot" +category = "stt" +description = "Operator evidence path. When set to a non-empty filesystem path, the Apple progressive worker writes one JSON file of sealed spans (id, text, PCM-pinned words, Apple/Whisper evidence) at session end. Empty = no-op. Diagnostic only — not a product setting." + +[vars.CODESCRIBE_INLINE_FORMAT] +default = "0" +type = "bool" +reload = "hot" +category = "llm" +description = "W13-1 live inline-format buffer master switch. Default OFF; a default flip is an operator decision." + +[vars.CODESCRIBE_INLINE_FORMAT_CHUNK_TIMEOUT_MS] +default = "10000" +type = "int" +reload = "hot" +category = "llm" +description = "Per-chunk inline-format LLM timeout in milliseconds; timeout keeps the raw chunk." + +[vars.CODESCRIBE_INLINE_FORMAT_FLUSH_TIMEOUT_MS] +default = "2500" +type = "int" +reload = "hot" +category = "llm" +description = "Stop-path budget in milliseconds for draining queued inline-format chunks." + +[vars.CODESCRIBE_INLINE_FORMAT_TAIL_TIMEOUT_MS] +default = "15000" +type = "int" +reload = "hot" +category = "llm" +description = "Stop-path budget in milliseconds for the final inline-format tail request." + +[vars.CODESCRIBE_RESPONSES_PROBE_URL] +default = "" +type = "string" +reload = "hot" +category = "llm" +description = "Override target for the sign-in Responses authorization probe (empty = provider's official endpoint). Hermetic tests point it at a mock; production leaves it unset." + +[vars.STT_TAIL_PROVIDER] +default = "inprocess" +type = "string" +reload = "hot" +category = "stt" +description = "Tail-patch provider selector: inprocess | sidecar | remote. Sidecar and remote fall back to inprocess with typed receipts. Default remains inprocess; flipping it is an operator decision." + +[vars.CODESCRIBE_STT_SIDECAR_BIN] +default = "" +type = "path" +reload = "restart" +category = "stt" +description = "Development override for the codescribe-stt-sidecar executable; packaged apps resolve the bundled sibling automatically." + +[vars.CODESCRIBE_STT_SIDECAR_TOKEN] +default = "" +type = "secret" +reload = "restart" +category = "internal" +description = "Internal per-process sidecar authentication token injected only into the supervised child. Never configure or persist this value." [vars.CODESCRIBE_TAIL_PATCH_MAX_CHANGE_RATIO] default = "0.5" @@ -502,6 +626,13 @@ reload = "hot" category = "stt" description = "Layer 1 safety threshold: a tail-patch whose changed-character ratio against the committed utterance exceeds this is dropped whole rather than applied, so a divergent re-transcription can never overwrite the live canvas" +[vars.CODESCRIBE_TAIL_PATCH_SMALL_EDIT_FLOOR] +default = "3" +type = "usize" +reload = "hot" +category = "stt" +description = "Layer 1 small-edit budget: a substitution-shaped tail patch touching at most this many tokens bypasses the MAX_CHANGE_RATIO cap. The ratio alone structurally starves short utterances (any 1-word fix on a 1-3-token commit is >=50% change — measured 2026-08-12: 116 skips, 0 applied). Pure insertions never use this budget" + [vars.CODESCRIBE_STT_THERMAL_GOVERNOR_ENABLED] default = "1" type = "bool" @@ -689,7 +820,7 @@ default = "" type = "string" reload = "restart" category = "stt" -description = "Cloud STT API endpoint (when USE_LOCAL_STT=0)" +description = "Multipart STT API endpoint used by cloud final pass and STT_TAIL_PROVIDER=remote; remote tail defaults to the loopback endpoint when unset" [vars.STT_API_KEY] default = "" @@ -703,7 +834,7 @@ default = "smart" type = "string" reload = "restart" category = "stt" -description = "Stop-path full re-pass routing only: always | smart (default) | off. Always = full WAV Whisper re-pass every stop; smart = skip full re-pass when streaming completeness is adjudicated Complete (incomplete may still re-pass); off = never full re-pass (does not force Whisper at stop). Does NOT enable CODESCRIBE_LAYERED_TRANSCRIPTION — live gap-fill via Layer 1 tail-patch is a separate opt-in. Live engine never rewrites the mode. Dictionary/lexicon always applies in postprocess regardless of mode. Seeds from Settings → Final pass. Alias: CODESCRIBE_FINAL_PASS_MODE." +description = "Stop-path full re-pass routing only: always | smart (default) | off. Always = full WAV Whisper re-pass every stop; smart = skip full re-pass when streaming completeness is adjudicated Complete (incomplete may still re-pass); off = never full re-pass (does not force Whisper at stop). Does NOT enable or disable CODESCRIBE_LAYERED_TRANSCRIPTION — live gap-fill via Layer 1 tail-patch is a separate gate (default phase1). Live engine never rewrites the mode. Dictionary/lexicon always applies in postprocess regardless of mode. Seeds from Settings → Final pass. Alias: CODESCRIBE_FINAL_PASS_MODE." [vars.CODESCRIBE_FINAL_PASS_MODE] default = "smart" @@ -1076,9 +1207,9 @@ description = "Double-tap interval for toggle detection (ms)" [vars.TOGGLE_SILENCE_SEC] default = "5.0" type = "f32" -reload = "restart" -category = "hotkeys" -description = "Silence duration before sending a toggle utterance (seconds)" +reload = "hot" +category = "stt" +description = "Hands-free silence on the live Apple lane: seconds of Silero-detected quiet that close the current SFSpeech epoch (seal + rest) and let Layer 1 Whisper patch the sealed span. Next speech edge wakes a fresh epoch with pre-roll. Next recording session. Not a wav-only chunker knob and not CODESCRIBE_VAD_*." # ═══════════════════════════════════════════════════════════════════════════════ # UI / Overlay diff --git a/docs/HOTKEYS_CONTRACT.md b/docs/HOTKEYS_CONTRACT.md index c6b7dbc2..ce5c341b 100644 --- a/docs/HOTKEYS_CONTRACT.md +++ b/docs/HOTKEYS_CONTRACT.md @@ -37,6 +37,16 @@ capture. Assistive never enters `RecordingController` and never shows the transcription overlay. A single process-wide capture owner makes Agent and overlay recording mutually exclusive; a competing start fails closed. +**Thread routing (operator contract 2026-08-13).** An assistive turn always +lands in the thread the Agent rail currently has selected — the thread the +user is looking at. The rail publishes every selection change through +`CodescribeHotkeys.set_assistive_target_thread`; the controller rebinds its +runtime (rejoin + rehydrate) when the target differs from the bound thread. A +new thread is only ever minted by an explicit "+ New thread" (published as a +`nil` target, consumed once — one press mints one thread, not one per +utterance). If the Agent UI never published a selection (window never +opened), the lane continues its bound conversation as before. + ```mermaid flowchart TB subgraph Input["🎹 Input Layer"] @@ -123,8 +133,10 @@ recent committed utterance; it must never create a second delivered utterance. **Trigger:** Double-tap Option key within `DOUBLE_TAP_INTERVAL_MS` (default **200ms**, range 100–450ms) **Behavior:** First tap starts recording, second tap toggles send/stop -**VAD:** ENABLED – finalized utterances append to the active draft; `TOGGLE_SILENCE_SEC` of silence -(default 5s) sends the accumulated draft without stopping recording +**Silence:** ENABLED – `TOGGLE_SILENCE_SEC` (default 5s) is the Apple engine lifecycle on the live +lane (`EpochGate` in `apple_live_session.rs`): Silero watches the mic, speech opens an SFSpeech +epoch, silence past the slider seals the span and rests the engine, the next speech edge wakes a +fresh epoch. Recording does not stop. This is not a wav-only chunker knob and not `CODESCRIBE_VAD_*`. | Mode binding | Keys | Mode | | ----------------------------- | ---------------------------- | ----------------- | @@ -285,12 +297,12 @@ flowchart LR Bindings themselves are persisted in `settings.json`. The remaining runtime env surface only tunes detector behavior: -| Variable | Default | Options | Reload | -| ------------------------ | ------- | --------------- | ------- | -| `HOLD_EXCLUSIVE` | `false` | `true`, `false` | RESTART | -| `HOLD_START_DELAY_MS` | `800` | 0-1000 | RESTART | -| `DOUBLE_TAP_INTERVAL_MS` | `200` | 100-450 | RESTART | -| `TOGGLE_SILENCE_SEC` | `5.0` | 0.5-10.0 | RESTART | +| Variable | Default | Options | Reload | +| ------------------------ | ------- | --------------- | -------------------- | +| `HOLD_EXCLUSIVE` | `false` | `true`, `false` | RESTART | +| `HOLD_START_DELAY_MS` | `800` | 0-1000 | RESTART | +| `DOUBLE_TAP_INTERVAL_MS` | `200` | 100-450 | RESTART | +| `TOGGLE_SILENCE_SEC` | `5.0` | 0.5-30.0 | HOT (next recording) | ### VAD Configuration @@ -349,7 +361,8 @@ sequenceDiagram participant HotkeyDetector participant Controller as RecordingController participant VAD as Silero VAD - participant Whisper + participant Apple as SFSpeech epoch + participant Whisper as Layer 1 Whisper User->>CGEventTap: Double-tap Left Option CGEventTap->>HotkeyDetector: kCGEventFlagsChanged (x4) @@ -358,9 +371,9 @@ sequenceDiagram rect rgb(200, 255, 200) Note over Controller: State: IDLE → REC_TOGGLE - loop Recording with VAD - VAD->>VAD: Monitor speech probability - VAD-->>Recorder: Utterance boundary on silence + loop Recording with EpochGate + VAD->>VAD: Watch speech edges (mic stays open) + VAD-->>Apple: Speech opens SFSpeech epoch / silence past slider closes it end end @@ -368,11 +381,11 @@ sequenceDiagram User->>HotkeyDetector: Double-tap Option HotkeyDetector->>Controller: ToggleNormal Note over Controller: State: REC_TOGGLE → BUSY - Controller->>Whisper: Finalize + format + Controller->>Whisper: Stop-path residual + format Note over Controller: State: BUSY → IDLE else Silence > TOGGLE_SILENCE_SEC - VAD->>Recorder: Utterance boundary (auto-send, recording continues) - Note over Controller: State stays REC_TOGGLE + Apple->>Apple: Seal open span, rest SFSpeech (Layer 1 can patch) + Note over Controller: State stays REC_TOGGLE — mic + Silero keep watching end ``` diff --git a/docs/STT_CONTRACT.md b/docs/STT_CONTRACT.md index cfc2ab04..31d65332 100644 --- a/docs/STT_CONTRACT.md +++ b/docs/STT_CONTRACT.md @@ -7,10 +7,12 @@ > Whisper transcribing **partials on the go** to fill canvas gaps — NOT final-pass-only. > Lexicon substitution is the FINAL automated layer, after Whisper. > -> **Status (2026-08-08):** on-the-go gap-fill now **exists** as Layer 1 tail-patch on both live -> paths (`a6b1233d`), but it is **opt-in and off by default** — `CODESCRIBE_LAYERED_TRANSCRIPTION` -> ships as `off` and no default flip has been taken. So for a stock install the stop-time file -> final-pass described below is still what runs. It is the shipped path, not the destination. +> **Status (2026-08-14):** on-the-go gap-fill **exists and is the stock live +> default** as Layer 1 tail-patch on both live paths (`a6b1233d`, default +> flip 2026-08-09). `CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1`; +> explicit `off`/`0`/`false` disarms. Stop-path `FINAL_PASS_MODE=smart` +> remains the residual file pass, never a live full-replace. W13 fusion / +> idempotence / highlights stay OFF until an operator flip. > Planning report: internal plan `stt-apple-must-have` (operator artifact store, 2026-07-24). --- diff --git a/docs/TEAM_SETUP.md b/docs/TEAM_SETUP.md index 392f2d7d..5ea43f0e 100644 --- a/docs/TEAM_SETUP.md +++ b/docs/TEAM_SETUP.md @@ -59,7 +59,7 @@ Grant in: System Settings > Privacy & Security ## Model -**Embedded-first Whisper policy**: `whisper-large-v3-turbo-mlx-q8` +**Embedded-first Whisper policy**: `whisper-large-v3-turbo` (mlx-community fp16; legacy q8 fallback) **Embedded Embedder**: `paraphrase-multilingual-MiniLM-L12-v2` (for semantic gating) - `core/build.rs` embeds Whisper by default when a complete model is available at build time. diff --git a/docs/THE_ENGINE_ROADMAP.md b/docs/THE_ENGINE_ROADMAP.md new file mode 100644 index 00000000..a7571938 --- /dev/null +++ b/docs/THE_ENGINE_ROADMAP.md @@ -0,0 +1,553 @@ +# THE ENGINE ROADMAP + +**Codescribe STT engine — current state vs. target, sealed.** + +| | | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Status | SEALED — direction decided by the operator; execution tracked per cut | +| Date | 2026-08-13 (polarized 2026-08-14) | +| Plan pack | `~/.vibecrafted/artifacts/vetcoders/codescribe/2026_0813/plans/w13-tail-and-format/` (ATLAS + 6 briefs + DRIVER + de-risk recon) | +| Branch | `fix/the-tail-patches` (Living Tree) | +| Evidence | Every claim in this document is backed by a measurement or a `file:line` citation from the 2026-08-12/13 sessions. No aspirational numbers. | + +--- + +## 1. Introduction + +This document exists because the design was ahead of the runtime — and the +gap kept being re-derived instead of closed. + +The layered transcription model (fast on-device words + deep-context +correction + lexicon + AI formatting) was designed by the operator and +repeated, in his own count, ~50 times. Every element of it exists in this +repository. Almost none of it is connected the way the design draws it. +The system today is a set of healthy organs that are not wired into one +bloodstream. + +This roadmap freezes three things: + +1. **The doctrine** — the decisions that are settled and must not be + re-litigated by future sessions or agents (§3). +2. **The gap** — current runtime state vs. target state, side by side, + with evidence (§5). +3. **The work** — every implementation point of every cut, enumerated, + with its non-fakeable acceptance measurement (§6). + +If you are an agent entering this repo to work on the engine: read §3 +before proposing anything. The direction questions are closed. + +## 2. Executive summary + +**One organizing idea:** the canvas's primary key changes from _token +position_ to **TIME**. One session clock (the PCM sample counter), words +pinned to seconds, utterances bounded by Silero silence edges, and the +transcript maintained as an append-only ledger of sealed (immutable) +utterances. Everything else in this roadmap is a consequence of that +inversion. + +**Two engines, equal and complementary — this is settled.** Apple +SFSpeech delivers certain words instantly; Whisper supplements and deepens +them in flight with what needs wider context. History proves neither can +work alone: today Apple-alone starves words on degraded input; in early +2026 Whisper-alone produced hallucination and repetition storms on this +same engine. The layered model is not a compromise between them — it is +the invention that fuses them. The wave's goal is to FINISH the fusion, +not to crown either engine. + +**Six cuts (W13-1 … W13-6)** deliver the finish. Code for W13-2…6 is on +`HEAD` behind default-OFF flags (`13b1eed8`). Empty `[ ]` here was a lie +after settlement — do not re-open those cuts as unstarted work. + +| Cut | One line | State | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| W13-0 | Clock falsification + frozen golden replay (171939 / 191351 / 193523) | landed | +| W13-1 | Inline-format buffer: sealed chunks stream to the formatting LLM during dictation (`previous_response_id` chain); stop pays only the tail | `[~]` in progress — `CODESCRIBE_INLINE_FORMAT` default OFF | +| W13-2 | Tail-patch behind a provider seam: local ws sidecar (default target), remote opt-in, in-process fallback | landed OFF — 2A contract + 2B sidecar slot | +| W13-3 | **Keystone**: time-pinned canvas — Silero-bounded utterances, words pinned to seconds, sealed ledger | landed OFF — 3A provenance + 3B `CODESCRIBE_SILERO_FUSION=0` | +| W13-4 | Gap-append dedup by time-span (shrinks to a corollary of W13-3) + in-span hallucination fence | landed OFF — `CODESCRIBE_SPAN_IDEMPOTENCE=0` | +| W13-5 | Capture-level receipt + Audio menu truth (level, device, quality) | landed — WARN is non-terminal | +| W13-6 | Lexicon gets a voice (Whisper `initial_prompt`, Apple `contextualStrings`) + word/gap highlighting feeding Teach | landed OFF — 6A voice + 6B `CODESCRIBE_OVERLAY_HIGHLIGHTS=0` | + +**Current truth (2026-08-14, polarize):** the live product already runs +Apple canvas + Layer 1 Whisper tail-patch by default (`unset` → +`phase1`, `core/stt/tail_patcher/mod.rs::LAYERED_DEFAULT_PHASE`). W13 +fusion / idempotence / highlights stay OFF until an operator flip. +`lbrx-stt-engine` file-mode is a **bench**, not a replacement engine. +Single-writer emitter landed in `75c89f56`. Next field cut is take-614 +fusion A/B. Closing bar: layered-ON ≥ lbrx file-mode on U-WER vs human, +at live latency, on all three golden takes. + +**What the user feels when this lands:** words stop vanishing; corrections +actually arrive; stop is near-instant; deliberate repetition is never +eaten; duplicated fragments disappear; the transcript shows what was +corrected and where speech was lost; and the engine hears project +vocabulary before it errs instead of being spell-checked after. + +## 3. Doctrine (settled — do not re-litigate) + +1. **Both engines are equal.** Apple = certain words now. Whisper = deeper + context in flight. Any proposal shaped "make X primary and demote Y" + is wrong by construction. (Operator, 2026-08-13, correcting BOTH + directions of pendulum swing.) +2. **Time is the primary key.** PCM sample counter is the session clock; + SFSpeech span clock is mapped onto it explicitly at ingestion (a 2 ms + divergence is measured and documented at `progressive_seal.rs:360–373`). +3. **Append-only overlay, layer order preserved:** Apple → Whisper → + lexicon → human. Sealed spans are immutable; the human layer stays on + top after seal. +4. **Live-first; the stop path budget is sacred.** Work happens during + dictation; stop pays only for the unsealed tail. +5. **Silero is a filter, not a microphone.** It detects words, not noise; + its silence edges define utterance identity. ("Fundament stabilności" + — operator, 2026-08-13.) +6. **The lexicon has a voice, not only an eraser.** Vocabulary reaches the + decoders _before_ they err (initial prompt / contextual strings); + post-hoc rewrite remains as the second line. +7. **Content is never destroyed.** A failure with a non-empty draft ends + the session and keeps the transcript (the 282-characters incident rule, + generalized on 2026-08-13 by `8bc1cc37`). +8. **Default flips, DMG publication, pushes and merges are operator + buttons.** Agents deliver measurements for those decisions, never press + them. + +## 4. Evidence base (why this roadmap is shaped like this) + +All measured 2026-08-12/13 unless noted. + +- **The decisive A/B/C** (same take `2026-08-13/171939`, RMS −42.1 dB — + degraded input): (A) live canvas: word salad — "maszynę", hallucinated + "Dziękuję", "RIPOS", "Edyta", a 3× duplicated fragment; (B) the SAME + in-app local Whisper (turbo fp16) on the whole file with proper + boundaries: API-class output, every sentence intact; (C) + whisper-v3-large via api.libraxis.cloud: same class as B. **Machine, + model and resources are not the bottleneck; role assignment and window + feeding are.** +- **Tail-patch lane history:** a month of 116 rejected / 0 applied + (starvation, invisible until the session receipt was added, `c3933f42`); + after the word-aligned LCS fix (`f224effd`) the lane applies 32/12/24 + per take but still logs up to 18 skips/take — the mid-phrase-window + class. +- **External window cost:** a 3 s window through + `/v1/audio/transcriptions` = **0.34–0.38 s** total; local model is + native fp16 (dequantize 0.00 s), cold load 3.9 s after the 300 s TTL + reaper; warm RTF ≈ 0.06. The historical "Whisper = 20–30 s" that + justified demoting it was a one-time Metal-compile misdiagnosis. +- **Stop cost today:** full-text LLM format at stop = 8.6–13.8 s; nano + via Responses streaming formats the same text in 8.5 s total with + ~1–2 s chunks, and `previous_response_id` chaining works on both + OpenAI and api.libraxis.cloud endpoints. +- **Capture drift:** full corpus (662 takes): weekly median RMS broke in + week 30 (Jul 20–26: −38.3 → −43.9 dB) and slid to −46.5 by W33; monthly + SNR 16.7 → 10.3 dB with a rock-stable noise floor. Invisible for three + weeks because only per-event logs existed — the same telemetry class as + the 116/0 starvation. +- **Duplication vs. theft (both measured on the operator's canvas):** + gap-appends double text when the engine later re-delivers the same + span; naive content dedup ATE a deliberately repeated sentence + ("wpierdalało = zabierało"). Scope by time, never by similarity. +- **Early-2026 mirror disease:** Whisper-alone produced + over-hallucination and repetition storms (window overlap, decode + derailment — see `whisper-window-alignment-fragility` incident record). + +## 5. Current state vs. target — the master table + +| Subsystem | Current (evidence) | Target (cut) | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Utterance identity | **Engine lifecycle landed** (`7d163d58`): `TOGGLE_SILENCE_SEC` arms `EpochGate` on the Apple progressive lane — Silero watches PCM, speech opens an SFSpeech epoch, silence past the slider seals + rests the engine, next speech wakes with 0.40 s pre-roll. Phrase ids inside an epoch still mint on `PhraseFinal`/frozen partials. Independent of `CODESCRIBE_SILERO_FUSION`. | W13-3 fusion (still OFF) still owns _slicing Apple cumulative finals by time_ and per-word ledger identity — that is not the same cut as the epoch rest/wake | +| Word timestamps | Apple per-word segments cross the bridge into `EngineEvent::UtteranceFinal.segments` — then die outbound in `CsEventSink::on_event` `..` destructure (recording.rs:686–707); Whisper segments dropped at `compute_tail_patch_job` (session.rs:258 takes `.text` only) | Words pinned to spans end-to-end; segments survive into the ledger and the UI (W13-3, W13-6 highlighting) | +| Sealing | `ProgressiveSealMachine` IS production seal authority on the Apple lane (`AppleSealState.progressive`, wired `8d65f610`/`d64c3876`) — but `SealedSpan` has end-only time, no per-word payload; the `try_rewrite` fence has zero callers (patches bypass it via `ReplaceRange`) | Ledger of sealed spans with `[start,end)` + per-word payload; ALL rewrites go through the fence; sealed span = immutable (W13-3) | +| Whisper windows | Epoch close now gives Layer 1 a sealed span + retained-PCM window (`7d163d58` + clamp `bdd6eacb`). Mid-phrase 3 s shards and `extract_speech` clock-lies remain on the VAD/scheduler path; fusion-sliced utterance windows stay behind `CODESCRIBE_SILERO_FUSION=0`. | Fusion-ON: utterance-bounded windows cut at Silero edges, exact offsets by construction (operator A/B on take-614 — not a default flip) | +| Replacement authority | Small-edit floors + conservative gates veto Whisper's better truth (18 skips/take) | Per-word fusion by span overlap inside the unsealed utterance; corrections land; skips carry a reason code (W13-3) | +| Duplicates / repetition | Gap-append doubles raw; dedup once ate deliberate 5× repetition | Sealed span cannot be re-delivered (structural); deliberate repetition = different span, always survives; in-span loop fence for engine hallucinations (W13-4) | +| Whisper hosting | In-process; RAM/battery in app; cold 3.9 s after TTL | Provider seam: local ws sidecar default target (qube-ws pattern — ends the SIGPIPE class), remote opt-in via STT_API_KEY slot, in-process fallback; per-window latency receipts (W13-2) | +| AI formatting | One-shot full text at stop: 8.6–13.8 s; `LayerSource::InlineLlm` exists unwired | Sealed chunks stream to the LLM during dictation; `previous_response_id` chain; stop formats only the tail and closes coherently with full-chain context; fail-open per chunk; anti-invention guard (W13-1) | +| Lexicon | seed.jsonl (2401) + programming.jsonl (155) compiled in and applied ONLY as post-hoc whole-word rewrites on enumerated variants — loses to generative mangling; `build_whisper_initial_prompt` ("Vocabulary:", 224-token budget) fully wired into the scheduler and file mode but **default OFF** (`loader.rs:2296`) and drawing from protected+custom only | The voice ON (operator flips with WER A/B numbers in hand), budget-aware selection incl. domain picks; Apple `contextualStrings` recon/wire; corrections + speech-gaps highlighted on canvas, one click to Teach (W13-6) | +| Capture telemetry | None (drift found 3 weeks late by archaeology) | `capture_level_receipt` per session (median RMS + device), WARN below floor registered as NON-terminal, Audio menu section with input truth (W13-5) | +| Failure UX | Fixed 2026-08-13 (`8bc1cc37`): terminal failure with draft ends session, keeps transcript, honest toast | Keep; regression-pinned | + +## 6. The cuts — every implementation point + +State alphabet: `[ ]` todo · `[~]` running · `[?]` done-unverified · +`[!]` blocked · `[x]` verifier-green. **Only the delivery-verifier flips +`[~]`→`[x]`; an agent's claim never does.** + +### W13-1 — Inline-format buffer (Backspace Magic) — `[~]` + +Current: formatting is a single full-text LLM pass at stop (8.6–13.8 s +measured). `LayerSource::InlineLlm` exists with no producer. + +Implementation points: + +1. Sealed sentence/segment triggers an async format request for that chunk + while recording continues; results keyed by chunk span. +2. Chunks chain via `previous_response_id` (Responses API; both OpenAI and + api.libraxis.cloud proven); chain resets per session. +3. Stop composes formatted chunks + freshly formatted tail only; the final + link closes coherently with full-chain context. +4. Fail-open per chunk: LLM error/timeout ⇒ raw text + receipt; session + never blocked. +5. Anti-invention guard: a formatted chunk whose word-set materially + exceeds its input is rejected (raw kept + receipt) — the formatter may + punctuate and case, never add words (a content-adding formatter was + observed live 2026-08-13). +6. Feature flag, default OFF (operator button). +7. Reuse the existing formatter LLM client — no new HTTP client. + +Verifier: on a ≥60 s dictation with the flag ON, measured stop-to-paste +< 3 s and output equal to full-text formatting modulo chunk-boundary +punctuation; receipts show chunks formatted in flight. + +### W13-2 — Tail-patch provider seam (sidecar / remote / in-process) — `[ ]` + +Current: one in-process path behind `whisper_tail_patch_transcribe` +(core/stt/mod.rs); STT_API_KEY slot reports "Unsupported" in +key_liveness; cold 3.9 s after TTL inside the app process. + +Implementation points: + +1. One seam, three incarnations selected by config + (`STT_TAIL_PROVIDER=sidecar|remote|inprocess`); no call-site branching. +2. Local sidecar: ws transport (qube-ws pattern from vista-kernel — ws + ends the pipe/SIGPIPE class), localhost only, spawned and supervised by + the app; PCM window in → text + **timestamped segments** out; model + loading reuses the fp16 path. Sidecar never touches the mic (PCM over + ws) — verify the TCC-disclaim class does not apply. +3. Remote: multipart `/v1/audio/transcriptions` client, localhost-first + URL resolution, STT_API_KEY slot made supported; never a hard + dependency. Measured: 0.34–0.38 s per 3 s window. +4. Fallback: sidecar dead/unreachable ⇒ in-process takes the window with a + receipt; the lane never silently starves (the `c3933f42` receipts keep + working). +5. Per-window receipt: provider + elapsed ms. +6. Local-only remains a first-class product mode (operator hard + requirement). + +Verifier: offline replay per incarnation; sidecar run shows per-window +latency < 1 s and applied > 0; killing the sidecar mid-take completes the +take via fallback with receipts proving the switch. + +### W13-3 — Time-pinned canvas (KEYSTONE) — `[ ]` + +Current: token position is the primary key; time is dropped at three +located points (see §5 rows 2–5). De-risk recon (evidence-grade, in the +plan pack: `recon-w13-3-derisk.md`) settles the build-vs-reuse questions. + +Implementation points: + +1. **One clock:** PCM sample counter as session timeline; map the SFSpeech + span clock at ingestion (divergence documented at + progressive_seal.rs:360–373). Silero edges computed on the PCM counter. +2. **Silero-bounded utterances on the Apple lane:** feed the existing + Supervisor-mode boundary machine (`VadGateMode::Supervisor` + + `VadIterState`, chunker.rs:119/:1400, embedded model) at the + `apple_stream_worker` PCM ingress (apple_live_session.rs:1114–1135); + mint utterance ids from silence edges in `seal_utterance_final` + (:1040); slice Apple's cumulative finals by time using their + `TranscriptSegment` spans. **Hardest part — plan for it explicitly:** + Apple's restart/freeze/novel-suffix guards (:1225, :940–1005) assume + Apple's own boundaries. +3. **Extend, don't rewrite, the seal machinery:** `ProgressiveSealMachine` + is the production authority — add span start + per-word payload to + `SealedSpan` (additive); wire ALL patch writes through the currently + orphaned `may_rewrite`/`try_rewrite` fence; port the idempotence rules + of the dormant `SessionIngest` ledger (typed `AudioRange`, + `RejectedSealedUtterance`) or re-parent it to the local lanes. +4. **Thread Whisper timestamps:** carry `RawTranscript.segments` through + `compute_tail_patch_job` (today session.rs:258 takes `.text` only); add + window start to `TailPatchRequest`; either skip `extract_speech` + compaction for anchored windows or emit a kept-window index map so + timestamps survive it. +5. **Per-word fusion at seal:** inside one utterance span, match by span + overlap + normalized word key; agreements confirm; disagreements + resolve by evidence (confidence, degraded-input bias measured in §4); + lexicon applies once to the fused text; then SEAL. +6. Skip receipts gain a reason field (`head_garbage`, `no_time_overlap`, + `low_confidence`, …). +7. **First execution step:** replay a real pl-PL take and histogram Apple + per-word span sanity — only synthetic timestamp fixtures exist today + (live_stream.rs:433–448). + +Verifier: the starved fixture (18 skips on build 614) re-run: skip count +down ≥50% with applied same-or-higher and zero regressions in existing +tail-patcher tests; sealed ledger replay shows immutable spans and +committed text trailing live preview by ≤1 s. + +### W13-4 — Duplicates die structurally; hallucinations get a fence — `[ ]` + +Current: gap-appends double raw text; content-similarity dedup is banned +(it stole deliberate repetition). + +Implementation points (post-W13-3 re-scope — most of the original cut +falls out of the ledger): + +1. Sealed span cannot be re-delivered: incoming text overlapping a sealed + span is suppressed once with receipt `gap_append_superseded` — + append-only holds (drop the duplicate, never rewrite the canvas). +2. Deliberate repetition = different time span ⇒ passes untouched, always. +3. In-span engine-loop fence: repetition-loop detection + (`has_repetition_loop` candidate from qube-ws) flags and truncates + engine-hallucinated loops _within_ one span; receipt-only, no silent + drops. +4. Two regression fixtures decide: the operator's duplicated-canvas take + (segment appears exactly once) and a deliberate 5× repetition take + (all five present). + +Verifier: both fixtures green, archived. + +### W13-5 — Capture truth (receipt + Audio menu) — `[ ]` + +Current: zero aggregate capture telemetry; the W30 break was found three +weeks late; the take that produced the decisive A/B/C ran at −42 dB +unnoticed. + +Implementation points: + +1. Running RMS accumulated per buffer on the capture path (follow the + `AUDIO_INPUT_DEVICE` env contract, 4 files). +2. `capture_level_receipt` at finalization next to the tail-patch session + receipt: median RMS dB, peak dB, device, sample rate, channels. +3. WARN `capture_level_low` below ~−52 dB (configurable; floor derived + from the corpus: golden era ≈ −38, break ≈ −44) — registered in the + NON-terminal warning class; must never kill the dictation UI (the + `28881bdd` class) and never touch `USER_TERMINAL_WARNING_CODES`. +4. Audio menu section: current device, native rate/channels, last-session + level, coarse quality verdict; read-only from the open capture path — + zero new permission prompts. +5. Feeds forward: Silero thresholds on degraded input (W13-3) calibrate + against this receipt. + +Verifier: normal-level replay ⇒ receipt, no WARN; attenuated take +(< −52 dB) ⇒ WARN fires, dictation UI stays alive, mic released at stop; +Audio menu screenshot on a live device. + +### W13-6 — Lexicon voice + highlighting — `[ ]` + +Current: 2 585 lexicon rows (seed 2401 + programming 155 + operator 8 + +protected 21) compiled into the binary and applied ONLY as post-hoc +enumerated rewrites; the strong mechanism — +`build_whisper_initial_prompt` (stream_postprocess.rs:399, "Vocabulary:", +224-token budget) wired into `scheduler.rs:476` (per-lane) and +`singleton.rs:357` (file mode) — sits behind `stt_initial_prompt_enabled` +whose **default is false** (pinned by loader.rs:2296), drawing from +protected+custom only. Loader gates (case-equal skip; custom plain +word→word rejection after the function-word poisoning incident) are +healthy and stay. + +Implementation points: + +1. Budget-aware deterministic prompt selection: protected > custom > + session-relevant seed/programming picks; receipt logs selected terms + + token count. +2. WER A/B on ≥3 real fixtures (bench-stt probe already exists, + scripts/bench-stt.sh:615/:632): prompt ON vs OFF — the default flip + goes to the operator WITH numbers. (The "custom LM for pl is dead" + verdict does not cover this mechanism — initial_prompt is a decoder + hint on vanilla weights.) +3. Apple lane voice: recon SFSpeech `contextualStrings` in the bridge; + wire behind the same config if cheap, else a written verdict. +4. Highlighting on the time-pinned canvas: lexicon-corrected words marked; + "pustki" — spans where Silero detected speech but no words landed — + marked as gaps; a highlighted span can be sent to Teach as a lexicon + candidate in one click. +5. No weakening of loader gates; no function-word poisoning regression. + +Verifier: prompt-ON replay of the `171939` take yields canonical terms +("reports", "editors") in engine output BEFORE any rewrite, receipt +proving the prompt was sent; WER A/B table archived; highlight + gap +marker screenshot. + +## 7. Sequencing + +``` +W13-1 ──────────────────────────────────────┐ +W13-2 ──► W13-3 (keystone) ──► W13-4 ───────┼──► ⛔ operator buttons +W13-5 ──────────────────────────────────────┤ (default flips, DMG, PR) +W13-6 voice-half ───────┐ │ +W13-6 highlight-half ◄──┴── after W13-3 ────┘ +``` + +- W13-1 ∥ W13-2 ∥ W13-5 ∥ W13-6(voice): disjoint file domains. +- W13-2 → W13-3: shared `core/stt` domain; W13-3 consumes W13-2's + timestamped payloads. +- W13-3 → W13-4: the ledger deletes most of W13-4; re-scope before + dispatch. +- W13-3 → W13-6(highlight): highlighting needs the time-pinned canvas. + +## 8. Risks + +| Risk | Grounding | Mitigation | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Apple-boundary machinery vs. Silero identity (THE hard part) | All freeze/restart/novel-suffix guards assume Apple boundaries (recon Q2) | Slice cumulative finals by time; land behind a lane flag; replay harness before live | +| pl-PL per-word timestamp fidelity unverified | Only synthetic fixtures test it (live_stream.rs:433–448) | First execution step of W13-3 = histogram on a real take; fallback: proportional span distribution | +| Seal latency vs. live feel | Seal waits for the utterance's Whisper pass (~0.4–1 s after silence edge) | Preview lane unaffected; only committed status trails; stop pays one utterance max | +| Continuous speech without silence | Silero finds no edge | Max-length cut at the weakest Silero dip | +| Degraded input starves Silero too | Corpus: current sessions run ~−45 dB | W13-5 receipt calibrates thresholds; operator fixes gain with data | +| LLM formatter invents content | Observed live 2026-08-13 | W13-1 anti-invention guard; fail-open to raw | +| Living Tree concurrency | Concurrent sessions clobbered a shared test log 2026-08-13 (false "0 tests") | Isolated `SWIFT_TEST_LOG` per session; re-read before edit; commit in small packs | +| Sidecar supervision scope creep | — | Seam + remote + fallback land first; sidecar may follow (brief §10) | +| Stale memory as false ground truth | The "seal machine orphan" memory survived 3 days past its wiring | Verify wiring claims via `loct who-imports` on HEAD before acting | + +## 9. Acceptance discipline + +Every cut carries a delivery-verifier (listed per cut above). Gates for +every commit: `cargo check --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` (pin env — the +operator dotenv leaks into tests), and for Swift surfaces `make app-bindings` + `make test-swift` with an isolated `SWIFT_TEST_LOG`. +Green gates are necessary, not sufficient: the verifier measurement flips +the state, not the CI color. + +## 10. Operator buttons (open decisions, delivered with numbers) + +1. Inline-format buffer default ON/OFF — after the W13-1 stop-to-paste + measurement. +2. Tail provider default (sidecar vs in-process) — after W13-2 latency + receipts. +3. `stt_initial_prompt_enabled` default — after the W13-6 WER A/B. +4. Input gain / device decision — after W13-5 receipts confirm the W30 + drift on live sessions (and the operator's answer to "what changed + Jul 20–26"). +5. DMG publication and merges — as always. + +## 11. Glossary + +- **Canvas / ledger** — the transcript as an append-only sequence of + sealed utterance records keyed by time spans. +- **Seal** — the transition making an utterance immutable after fusion + + lexicon; also the "format now" signal for W13-1. +- **Span** — `[start, end)` in session time (PCM sample clock). +- **Gap-append** — recovered text appended to fill a hole in delivery. +- **Pustka** — a span where Silero detected speech but no words landed; + prime Teach candidate. +- **Voice (of the lexicon)** — vocabulary delivered to a decoder before + transcription (Whisper `initial_prompt`, SFSpeech `contextualStrings`), + as opposed to post-hoc rewriting. +- **Best-truth swap** — per-word fusion inside an unsealed utterance, + replacing weaker evidence with stronger regardless of which engine + produced it. + +## 12. Independent feasibility verdict (2026-08-13 evening) + +A triple-agent research study (grok + claude + codex, independent lanes, +adversarial synthesis; run `rese-260813-190311-53919`) reviewed this +roadmap against the codebase and world SoTA. Verdict: **GO WITH +AMENDMENTS** — the doctrine is confirmed and SoTA-aligned (the same shape +appears in Apple time-ranged finality, streaming-Whisper stable prefixes, +WhisperX segmentation and two-pass ASR); all amendments target contracts, +not direction. Binding contract amendments (full text: plan-pack ATLAS, +Amendment 3): + +- new **W13-0** first: real pl-PL clock/timestamp falsification + frozen + golden replay fixtures before any fusion; +- canonical time = **integer sample ranges** + (`session, capture_epoch, sample_start, sample_end`); seconds only at + adapters; +- **typed evidence** (source, revision, stability, timing quality, raw + confidence) precedes any confidence-based fusion; until calibration, + commit agreements and clear gap fills, receipt the rest; +- **W13-2 → 2A/2B** (timed provider contract gates W13-3; sidecar hosting + follows), **W13-3 → 3A/3B** (provenance before conservative fusion, with + a bounded-context A/B), **W13-6 → 6A/6B** (voice early, highlighting + after provenance); +- W13-4 auto-removal only on **non-content evidence**; continuous + repetition protected; +- W13-5 warnings keyed to **active-speech** level (+ clipping/dropout/ + noise/SNR), not all-audio medians; +- no Apple backend migration this wave; vocabulary A/B must report + U-WER + false insertions. + +Rejected unanimously: full-file replacement, single-engine authority, +content-similarity dedup, unbounded prompting, and +unit-green-as-delivery-proof. + +## 13. Comparative bench vs lbrx-stt-engine (2026-08-14) — the bar is low and the live lane is still under it + +Same-host comparison against `lbrx-stt-engine` (whisper-large-v3-mlx-q8, +MLX; three transports: HTTP :8444, NDJSON :8445 `stt-jsonl-v1`, +WS :8446 `stt-ws-v1`). Grading context, stated plainly: that API is a +**neglected, low-intensity** file-mode service — slow-moving, with its own +hallucinations — not a state-of-the-art target. Codescribe receives an +order of magnitude more engineering. That is exactly what makes this bench +binding: **even a neglected engine beats our live canvas on content.** + +### Measured (W13-0 golden takes, warm engine) + +| Take | Audio | lbrx wall | RTF | word-sim canvas↔lbrx | Decisive content deltas | +| ------ | ------: | --------: | ----: | -------------------: | ----------------------------------------------------------------------------------------- | +| 171939 | 135.7 s | 4.02 s | 0.030 | 0.660 | canvas tail garbled ("czytą lebymiałą", "Pt. River ton"); lbrx tail clean and grammatical | +| 191351 | 337.4 s | 9.60 s | 0.028 | 0.686 | lbrx catches "voice isolation" 3×; canvas 0× (pl-PL code-switching blindness) | +| 193523 | 27.9 s | 1.27 s | 0.045 | 0.789 | lbrx "WorkTrees" 3× correct; canvas 0× (Workplace/Warp3s manglings) | + +lbrx word counts track the canvas (136/149, 364/344, 36/35) — no mass +hallucination, no mass loss; the delta is concentrated exactly in the +classes this roadmap names: vocabulary, code-switching, tail integrity. + +### Honest defects of the reference engine (measured, not assumed) + +- **Its segment timestamps lie by compaction**: reported coverage ends at + 70.6 s of 135.7 s and 199.6 s of 337.4 s while the transcribed content + demonstrably reaches the end of both takes. This is the same clock-lie + class W13-0 froze (compact drop 0.377 / 0.311). Any integration maps by + **integer sample ranges, never by its reported seconds** — Amendment 3 + confirmed by a second, independent engine. +- Its own manglings exist ("Wipeshotted", "WordLine", "konkurencji" for + "równoległości") — file-mode Whisper is a ruler with scratches, not + truth. The only truth reference remains the human transcript (U-WER). +- File-mode warm RTF 0.028–0.045 is a batch number, not a live-latency + claim. + +### Diagnosis (holistic) + +The gap is **not model capability** — we embed the same Whisper family, +and Layer 1 is already the stock live default (`phase1`). The gap is the +live lane: window feeding, patch authority, and (until this morning) +buffer integrity. Field evidence, same morning (Monika, 2026-08-14): 42% +of Layer-1 tail patches rejected (80 applied / 59 rejected across 10 +sessions), and a dual-writer `transcript_buffer` split-brain (reducer 228 +chars, final Apple partial 264, RAW 791 ≈ 3×264 — the same sentence +delivered almost three times). No ledger can save a buffer with two +writers. + +That dual-writer cut **landed** in `75c89f56`: +`store_transcript_snapshot` is the shared buffer's only writer; the tick +loop animates the `DeltaSink` only. Do not re-open it as "field P0". + +The W13 close-the-gap machinery **already landed** in the settlement +(`13b1eed8`). "All defaults OFF" names the **W13 flip flags** +(`CODESCRIBE_SILERO_FUSION`, `CODESCRIBE_SPAN_IDEMPOTENCE`, +`CODESCRIBE_OVERLAY_HIGHLIGHTS`, `CODESCRIBE_INLINE_FORMAT`) — it does +**not** describe Layer 1, which has been `phase1` since the 2026-08-09 +operator directive. Silero-boundary fusion (3B, synthetic starvation +−67%), span idempotence (4), typed tail providers (2A/2B), and lexicon +voice (6A) sit behind those OFF flags. "Catching up" is therefore not +new architecture — it is measurement and the operator's flip matrix, in +this order: + +1. ~~single-writer emitter + final snapshot barrier~~ **done** + (`75c89f56`); +2. ~~hands-free silence as Apple engine lifecycle~~ **done** + (`7d163d58`) — the Settings slider is no longer a no-op on the + progressive lane; it rests SFSpeech so Layer 1 can be fed sealed + spans. Do not re-open as "switch the canvas to candle"; +3. real take-614 fusion A/B → `CODESCRIBE_SILERO_FUSION` decision; +4. span idempotence observed on a real session → flip decision; +5. optional: lbrx as a **remote tail provider** — its + `hello/ack/vad/transcript.final` stream protocol is shape-compatible + with the W13-2B remote slot, moving ~4 GB of Whisper RSS out of the + app on hosts where the service runs anyway. lbrx is not a product + engine and must not replace the Apple canvas. + +### Bench discipline going forward + +The golden-take bench gains an lbrx column. The bar that ends this +section's shame: **layered-ON ≥ lbrx file-mode on U-WER vs human, at live +latency, on all three golden takes.** Apple-similarity stays a ruler, +never the gate (§12). + +--- + +_Provenance: distilled from the 2026-08-12/13 measurement sessions, the +W13 plan pack (ATLAS incl. Amendments 1–3, briefs W13-1…W13-6, DRIVER, +de-risk recon with file:line evidence), the triple-agent feasibility +study `rese-260813-190311-53919`, and the operator's engine doctrine as +recorded in the session registry._ + +𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by VetCoders (c)2024-2026 LibraxisAI diff --git a/docs/TRANSCRIPT_LANES.md b/docs/TRANSCRIPT_LANES.md new file mode 100644 index 00000000..8b482967 --- /dev/null +++ b/docs/TRANSCRIPT_LANES.md @@ -0,0 +1,216 @@ +# TRANSCRIPT LANES — every transcription path, linearized + +**One line per path: where it starts → what it passes through → what it +crosses → what the user sees.** Anchors are `file :: symbol` (symbols survive +the Living Tree better than line numbers). Verified against +`fix/the-tail-patches` HEAD `16e0b9c3`, 2026-08-14. + +How to read: + +- **LINE** — one transcription path, with stations in order. +- **`[Jn]`** — a junction: the station where lines cross (full list in §9). +- **⚑ OFF** — machinery that exists and is verifier-green but sits behind a + default-OFF flag (an operator button). Drawn as a dashed line. +- **since** — the commit/wave that put the station into service. + +--- + +## 0. The map at a glance + +``` + ┌──────────────────────────── LIVE ────────────────────────────┐ +MIC ▶ recorder ▶ [J1 PCM ring+spill] + │ + ┌───────────────┴────────────────┐ + │ LINE A (DEFAULT) │ LINE B (CODESCRIBE_STT_ENGINE=whisper/onnx) + │ Apple SFSpeech progressive │ Silero VAD ▶ scheduler ▶ Whisper + │ partials+finals │ utterance finals + Refine corrections + │ ▼ │ ▼ + │ ProgressiveSealMachine │ stream_postprocess (lexicon, gates) + │ ▼ │ ▼ + └──▶ [J2 CANVAS: reducer+emitter] ◀────┘ + ▲ │ + LINE L1 ───┘ ▼ + (tail-patch, OVERLAY live text ▶ ...user watches letters land + rides A and B) + │ stop + ▼ + LINE S: [J3 truth adjudication] ▶ [J4 postprocess+lexicon] + ▼ + ┌────────────┴─────────────┐ + ▼ ▼ + LINE F formatting LLM LINE G assistive → To Agent + ([J5 response chain]) (thread rail conversation) + ▼ + [J6 history files] + [J7 overlay final: Copy/Insert/Revert/Format/To Agent/Auto-Paste] + +LINE C (no mic): audio file ▶ cloud or local Whisper ▶ same [J4]→[J6] tail +``` + +--- + +## 1. LINE A — Apple progressive live (the instant + +**preview** lane) + +``` +mic ▶ recorder ▶ [J1] ▶ apple_stream_worker PCM ingress ▶ SFSpeech bridge + ▶ partial callbacks (volatile) + phrase finals ▶ ProgressiveSealMachine + ▶ seal adjudication + guards ▶ [J2 canvas] ▶ emitter ▶ OVERLAY (live letters) +``` + +| # | station | code | what happens | since | +| --- | ------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| A1 | capture | `core/audio/recorder.rs`, `streaming_recorder.rs` | cpal stream from `AUDIO_INPUT_DEVICE` (native rate/channels) feeds the ring; `CODESCRIBE_AUDIO_SPILL` keeps the FULL take on disk while the RAM ring caps at 300 s | spill: W-A retention | +| A2 | engine choice | `core/stt/mod.rs :: selected_engine` | `CODESCRIBE_STT_ENGINE` (`apple` / `whisper` / `onnx`); unset ⇒ Apple when available, otherwise Candle Whisper | W11 respec | +| A3 | lane routing | `core/pipeline/streaming/session.rs :: transcription_session` | Apple engine + progressive mode branches into `apple_live_session` BEFORE the VAD path | W2-A | +| A4 | PCM ingress | `apple_live_session.rs :: apple_stream_worker` | PCM frames go to the bridge; the same frames feed L1 windows — the **sample counter minted here is the session clock** (doctrine §3.2) | W2-A | +| A5 | recognition | `core/stt/apple_stt/` + `codescribe-stt-bridge` (Swift) | SFSpeechRecognizer streams volatile partials and cumulative phrase finals; pl-PL on-device ~0.24 s; TCC is owned by the app process | W11 | +| A6 | sealing | `progressive_seal.rs :: ProgressiveSealMachine` | finals seal utterances; the SFSpeech span clock maps onto the PCM clock (2 ms divergence measured); `may_rewrite`/`try_rewrite` is the future time-fence | wired W2-B (`8d65f610`/`d64c3876`) | +| A7 | guards | `apple_live_session.rs` (`phrase final adjudicated`, `novel final suffix rescued`, `freeze open partial`) | cumulative finals are adjudicated against sealed state; novel suffixes are rescued with synthesized windows; restart-retained partials are frozen — the anti-duplication front line | W1-B/W2-A | +| A8 | canvas | **[J2]** `emitter.rs :: TranscriptReducer` | committed utterances + one active preview; **single writer** `store_transcript_snapshot` (the tick loop only animates) | `75c89f56` | +| A9 | user | overlay live view | letters land as spoken; corrections arrive live as backspace magic (append + gap-fill only — full-replace is a doctrine violation) | pre-0.8 → doctrine | + +## 2. LINE B — VAD/scheduler live (Whisper-first lane) + +``` +mic ▶ recorder ▶ [J1] ▶ Silero VAD chunker ▶ utterance boundaries + ▶ SttScheduler (Fast lane) ▶ Whisper singleton ▶ stream_postprocess + ▶ [J2 canvas] ▶ emitter ▶ OVERLAY + ↑ Refine lane: correction.rs partial passes (VAD-aligned windows) +``` + +| # | station | code | what happens | since | +| --- | ------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| B1 | VAD filter | `core/audio/chunker.rs` (Silero, embedded, zero-I/O) | detects WORDS, not noise; silence edges close utterances — “fundament stabilności” | doctrine §3.5 | +| B2 | scheduling | `core/stt/scheduler.rs :: SttScheduler` | Fast lane = utterance decode; Refine lane = correction re-decodes; per-lane `initial_prompt_for_lane` (⚑ OFF, W13-6A) | — | +| B3 | decode | `core/stt/whisper/singleton.rs` | in-process Whisper (turbo fp16 native; q8 composition for tokenizer+mel); TTL reaper unloads after idle (`whisper_residency_reclaim`) | fp16 default | +| B4 | corrections | `streaming/correction.rs` | Phase-2 Refine: partial passes triggered by finals/speech-ms, **VAD-aligned windows** (`plan_vad_aligned_windows_with_config`) so windows never begin mid-phrase | W1-A | +| B5 | postprocess | `core/pipeline/stream_postprocess.rs` | lexicon rewrite table (compiled-in seed/programming/operator/protected), hallucination + SemanticGate + empty-drop gates | — | +| B6 | canvas + user | **[J2]** → overlay | same reducer/emitter contract as LINE A | — | + +## 3. LINE L1 — Layer 1 tail-patch (rides on top of A **and** B) + +``` +[J1 stopped PCM window] ▶ compute_tail_patch_job ▶ TailProvider + ▶ Whisper re-decode of the window ▶ word-aligned LCS diff + ▶ ReplaceRange on [J2 canvas] (gap-fill, never full-replace) +``` + +| # | station | code | what happens | since | +| ---- | ------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| L1.1 | gate | `CODESCRIBE_LAYERED_TRANSCRIPTION` (default **phase1**) | orthogonal to engine choice; `off` disarms it | default `phase1` 2026-08-09; one-truth `afbb604a` | +| L1.2 | window | `session.rs :: compute_tail_patch_job` (called from BOTH live sessions) | takes settled PCM behind the live head; max one job in flight; unresolved windows never reach Whisper | W2-A | +| L1.3 | provider seam | `core/stt/tail_provider.rs :: TailProvider` | typed payload with **integer sample identity** `(session, capture_epoch, sample_start, sample_end)` + evidence + receipts; `STT_TAIL_PROVIDER=inprocess` is the default; `sidecar`/`remote` ⚑ built (W13-2B `4a9fc3fd`), falling back to inprocess with receipts | W13-2A `16ffe025` | +| L1.4 | diff + apply | `core/stt/tail_patcher/` | word-aligned LCS (`alignment_key`), `MAX_CHANGE_RATIO=0.5` safety (small-edit floor exempts short commits), applies as `ReplaceRange` events on the canvas | `f224effd` + `0e77a1e4` | +| L1.5 | receipts | `tail_patch_session_receipt applied=/skipped=` + per-request `tail_provider_receipt` | the starvation-alarm class (a month of 116/0 was invisible before it) | `c3933f42` | + +Field truth, 2026-08-14: ~42% of patches were rejected in Monika's sessions — +the number the ⚑ Silero-fusion flip (§8) exists to fix. + +## 4. LINE S — Stop path (every live line terminates here) + +``` +stop ▶ recorder.stop (drain + WAV) ▶ [J3 truth adjudication] + ▶ residual-from-partials (live-first) ▶ [J4 postprocess + lexicon] + ▶ LINE F (format) or LINE G (agent) ▶ [J6 history] + [J7 delivery] +``` + +| # | station | code | what happens | since | +| --- | ----------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| S1 | drain | `stop_toggle_inner` PHASE 0–4 (`app/controller/mod.rs`) | serialized stop; `stop_path_budget` log line prices every phase — the budget is sacred (doctrine §3.4) | — | +| S2 | truth | **[J3]** `app/controller/truth.rs :: adjudicate_recording_truth` | `merge_live_whisper`: live canvas is the FLOOR, provider fills gaps, never full-replace; length-regression guard | doctrine | +| S3 | residual | `app/controller/final_pass.rs` + `final_pass_residual_from_partials` | `FINAL_PASS_MODE=smart` (default): full-file re-pass only when streaming is incomplete; residual composed from partials, `seal_source=live_session`, no file re-decode | live-first, stop-residual | +| S4 | postprocess | **[J4]** same `stream_postprocess` gates + lexicon | applied to the ADJUDICATED text (`Post-processed transcript … lexicon_rewrites=n`) | — | +| S5 | fork | mode decision (hotkey held) | raw → LINE F (formatting) and/or LINE G (assistive); AUTO format may fire on the overlay | — | + +## 5. LINE F — Formatting LLM lane + +``` +raw transcript ▶ ai_formatting (per-lane endpoint/model/key) + ▶ [J5 Responses chain: previous_response_id per mode] + ▶ semantic guard ▶ [J7 overlay formatted] + [J6 history] +``` + +| # | station | code | what happens | since | +| --- | ---------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| F1 | lane truth | `core/llm/lane_truth.rs` | endpoint/model/key resolved per lane (Formatting ≠ Assistive ≠ Main — separate key slots, separate chains) | — | +| F2 | request | `core/llm/ai_formatting.rs :: build_responses_input` | wire contract: `instructions` param on the FIRST turn only; chained turns re-carry the prompt as a leading `developer` item (the chain does NOT persist instructions server-side) | `26d0982d` + `5d62aacb` | +| F3 | chain | **[J5]** `core/state/conversation.rs` | per-mode `previous_response_id`; the chain is REAL memory (2026-08-14: a stored id answered “what was this about” with a full recall of the take, hours later); ids are org/key-scoped — stale after key rotation ⇒ self-heal drops the id and retries unchained | self-heal in flight | +| F4 | guard | `Action quality guardrail` + `semantic_cosine` (`app/controller/quality_delivery.rs`) | divergence (< 0.86) vetoes auto-paste; RAW is always preserved beside the draft | — | +| F5 | user | **[J7]** overlay formatted view | Copy / Insert→alacritty / **Revert** (armed with the raw first version after AUTO format, `16e0b9c3`) / Format / To Agent; Auto Paste toggle | `16e0b9c3` | + +## 6. LINE G — Assistive → To Agent lane + +``` +dictation ▶ same LINE A/B live ▶ stop ▶ assistive delivery + ▶ agent thread (rail) ▶ provider (account-auth aware) ▶ reply in thread +``` + +| # | station | code | what happens | since | +| --- | -------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| G1 | routing | `app/controller/assistive_delivery.rs` | dictation goes to the SELECTED thread; a new thread is created only through explicit New thread (routing contract) | contract | +| G2 | provider | `app/agent/openai_provider.rs` | Responses + tools + SSE; chained turns re-carry the system prompt as a developer item (same wire contract as F2); account tokens fetched fresh per request | `e591d514` | +| G3 | auth | `core/llm/account_auth/` | OAuth sign-in verifies `api.responses.write` BEFORE saving (“connected” is never a lie); scope-starved tokens are rejected at login | `230443fc` | +| G4 | user | thread rail conversation | agent replies land in the thread; permission gate is risk-based, and native side-effectful tools fail closed in the voice lane | — | + +## 7. LINE C — File/cloud mode (no microphone) + +``` +audio file ▶ `codescribe transcribe` CLI / cloud final pass + ▶ local Whisper full-file OR multipart STT endpoint (STT_ENDPOINT) + ▶ [J4 postprocess] ▶ stdout / files [J6] +``` + +- Local full-file: same in-app Whisper, proper boundaries ⇒ **API-class + output** (the decisive A/B/C proof — the machine and model were never the + bottleneck; window feeding was). +- Cloud: `core/llm/client.rs` multipart (`[Multipart STT]` retries), also the + target class of `STT_TAIL_PROVIDER=remote`. +- Bench ruler: the same-host `lbrx-stt-engine` column (§13 of the roadmap) — + its timestamps compress silence (clock-lie class), so any integration maps + by sample ranges, never by its reported seconds. +- `streaming/offline.rs` is **tests/offline_eval only** — not a runtime lane. + +## 8. Dashed lines — built, verifier-green, ⚑ default-OFF (operator buttons) + +| flag | line it arms | code | evidence | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- | +| `CODESCRIBE_INLINE_FORMAT` | W13-1 buforek: sealed spans stream to the formatter DURING dictation (`previous_response_id` chain per span), so stop pays only the tail | `core/llm/inline_format.rs` | seam stop 0.398 s vs 8.6–13.8 s; needs live ≥60 s take | +| `CODESCRIBE_SILERO_FUSION` | ⚠ **default ON since 2026-08-14** — this row is now the OFF switch. Silero boundary identity + conservative fusion feeding L1 windows | `streaming/silero_fusion.rs` | synthetic starvation skips 18→6 (−67%); take-614 A/B still owed | +| `CODESCRIBE_SPAN_IDEMPOTENCE` | ledger-keyed replay rejection — a sealed span cannot be delivered twice (kills gap-append “×4” dupes; NEVER content similarity) | `streaming/span_idempotence.rs` | named repetition tests green; real-session receipts pending | +| `CODESCRIBE_OVERLAY_HIGHLIGHTS` | lexicon-corrected words + VAD-speech-no-words gaps marked on canvas; highlighted span → Teach | W13-6B (bridge + Swift) | Rust+Swift tests green | +| `CODESCRIBE_STT_INITIAL_PROMPT_ENABLED` | lexicon VOICE: `Vocabulary:` prompt to Whisper per window | `stream_postprocess.rs :: build_whisper_initial_prompt` | A/B: U-WER −1.5 pp but false inserts 1→7 — flip not justified yet | +| `STT_TAIL_PROVIDER=sidecar\|remote` | tail decode out of process / off host | `tail_provider.rs` + `codescribe-stt-sidecar` | fake-provider receipt 22 ms; production supervision unmeasured | + +## 9. Junctions — where lines cross + +| J | place | who meets whom | contract | +| --- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| J1 | PCM ring + spill (`recorder`/`live_audio_buffer`) | mic capture × Apple ingress × L1 windows × stop WAV × crash recovery | the sample counter minted here is the ONE session clock | +| J2 | canvas (`TranscriptReducer` + `BufferedEmitter` + `app/presentation/emitter.rs`) | Apple finals × L1 `ReplaceRange` × gap-appends × preview | append + gap-fill only; ONE writer (`store_transcript_snapshot`); tick loop animates, never writes | +| J3 | truth adjudication (`truth.rs`) | live canvas × Whisper residual | live floor + gap fill, never full-replace | +| J4 | postprocess (`stream_postprocess`) | every text × lexicon × gates | lexicon is the FINAL automated layer; the human layer stays on top | +| J5 | Responses chain (`state/conversation.rs`) | formatting turns × assistive turns (separate streams) | per-mode ids; first-turn `instructions`, chained developer item; chain = recoverable session memory | +| J6 | history (`core/state/history.rs`) | every take | `_raw.txt` + `_formatted.txt` (or `formatting-failed`) + `.m4a` + `.truth.json` — content is never destroyed (doctrine §3.7) | +| J7 | overlay delivery (`overlay_paste.rs`, OverlayState.swift) | formatted draft × auto-paste × manual commit | semantic guard vetoes auto-paste only; Revert holds the raw first version | + +## 10. What the user sees, surface by surface + +| surface | fed by | truth it shows | +| ------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------- | +| overlay LIVE | LINE A/B via J2 | letters as spoken; live backspace corrections (L1); never a rewrite of committed text | +| overlay FINAL | LINE S→F via J7 | formatted draft + buttons (Copy / Insert / Revert / Format / To Agent); Auto Paste when guard allows | +| paste target | J7 | formatted text into the frontmost app; `CopyTargetUnavailable` ⇒ clipboard fallback | +| thread rail | LINE G | agent conversation, chained turn by turn | +| history dir | J6 | `~/.codescribe/transcriptions//` — raw, formatted, m4a, truth receipts | +| menu/tray | controller state | recording state; Audio truth section (W13-5: device, level, quality verdict) | +| warnings | `contracts.rs` warning classes | ONLY `transcription_failed` is terminal/UI; receipts (capture level, tail patch, seal) are log-only | + +--- + +_Provenance: distilled from `docs/THE_ENGINE_ROADMAP.md` (§3 doctrine, §5 gap +map, §12 verdict, §13 bench), the W13 settlement ledger (`13b1eed8`), and +symbol-level verification on HEAD `16e0b9c3`, 2026-08-14._ + +𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by VetCoders (c)2024-2026 LibraxisAI diff --git a/docs/WHISPER_LIVE.md b/docs/WHISPER_LIVE.md index b25e8b38..d22de86f 100644 --- a/docs/WHISPER_LIVE.md +++ b/docs/WHISPER_LIVE.md @@ -15,11 +15,14 @@ events that visibly patch tokens Apple missed — mixed-language inserts, rare t nouns. The legacy "Whisper-as-primary" path stays as automatic fallback when Apple Speech is unavailable (no permission, no macOS Speech framework). -> **Delivery status (2026-08-08).** Layer 1 is wired on **both** live paths — VAD/scheduler and -> the default Apple progressive live (`a6b1233d`) — but it is **opt-in and off by default**: -> `CODESCRIBE_LAYERED_TRANSCRIPTION` ships as `off`. On a stock install Whisper still earns its -> keep at **stop time** (`FINAL_PASS_MODE`, Smart by default), not as a live tail-patcher. -> Layer 2's inline LLM, Layer 3 and Layer 4 have no producer at all — see the ADR's +> **Delivery status (2026-08-14).** Layer 1 is wired on **both** live paths — VAD/scheduler and +> the default Apple progressive live (`a6b1233d`) — and is **on by default**: +> `CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1` (operator directive +> 2026-08-09). Explicit `off`/`0`/`false` disarms. Whisper also still earns +> its keep at **stop time** (`FINAL_PASS_MODE`, Smart by default) as the +> residual file pass, never as a live full-replace. W13 fusion / +> idempotence / highlights stay OFF. Layer 2's inline LLM, Layer 3 and +> Layer 4 have no producer at all — see the ADR's > [Phase delivery status](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md#phase-delivery-status-2026-08-08). **Hard invariant that gates every Whisper write:** _NEVER REWRITE FROM ZERO._ Tail Patch may @@ -31,7 +34,7 @@ See the ADR for the full contract. Codescribe’s Whisper layer power-ups: -1. **Embedded-first Whisper model** (`whisper-large-v3-turbo-mlx-q8` by default) +1. **Embedded-first Whisper model** (`whisper-large-v3-turbo`, mlx-community fp16, by default; legacy q8 as fallback) - build policy embeds Whisper whenever the model is available at build time - runtime lookup from `CODESCRIBE_MODEL_PATH`, configured model dirs, bundled app resources, or the Hugging Face cache is a fallback path for `CODESCRIBE_NO_EMBED=1` builds or recovery 2. **Live (streaming) transcription** while the user is recording diff --git a/docs/architecture/local-helper-asr.md b/docs/architecture/local-helper-asr.md new file mode 100644 index 00000000..12b6beab --- /dev/null +++ b/docs/architecture/local-helper-asr.md @@ -0,0 +1,91 @@ +# Local ASR helper and Qwen evaluation contract + +Codescribe's stock GUI process owns Apple Speech and the lexicon. Optional local +weights belong to a separate, killable Layer 1 helper. `LocalHelperAsrSession` +is the provider-compatible boundary: the power-user runtime injects a launcher, +and process exit plus wait/reap is the only reclaim proof. + +The lifecycle is `Stopped -> Starting -> Ready -> Cooling -> Stopped`. A failed +spawn, handshake, PCM push, or shutdown is a Layer 1 failure. The recorder keeps +Apple + lexicon and never loads Candle/Whisper as a surprise fallback. There is +no local helper selected by default and no model runtime linked by this cut. + +## Opt-in Qwen3-ASR-0.6B q5/q8 benchmark + +The harness downloads nothing and retains no audio, reference, or hypothesis in +its output: + +```bash +python3 scripts/bench-qwen-local-helper.py \ + --models /private/path/qwen-models.json \ + --corpus /private/path/pl-vet-corpus.json \ + --runner /private/path/codescribe-qwen-runner \ + --out /private/path/qwen-local-helper-result.json +``` + +The models manifest contains exactly q5 and q8 entries: + +```json +{ + "models": [ + { + "model_id": "Qwen/Qwen3-ASR-0.6B", + "quantization": "q5", + "revision": "exact-revision", + "sha256": "optional-expected-sha256", + "license": "verified-license", + "artifact_path": "/private/q5" + }, + { + "model_id": "Qwen/Qwen3-ASR-0.6B", + "quantization": "q8", + "revision": "exact-revision", + "sha256": "optional-expected-sha256", + "license": "verified-license", + "artifact_path": "/private/q8" + } + ] +} +``` + +The private corpus manifest points to external WAV and reference files; it is +never copied into this repository: + +```json +{ + "cases": [ + { + "id": "vet-pl-01", + "audio_path": "/private/01.wav", + "reference_path": "/private/01.txt", + "terms": ["term weterynaryjny"], + "tags": ["pl_vet", "pl_en_code_switch"] + } + ] +} +``` + +For each case the injected runner accepts `--model PATH --audio PATH --json` +and returns one JSON object on stdout: + +```json +{ + "transcript": "in-memory only", + "cold_load_seconds": 1.2, + "audio_seconds": 8.0, + "segments": [{ "start_ms": 0, "end_ms": 8000 }] +} +``` + +The result schema is +`scripts/schemas/qwen-local-helper-bench-result.schema.json`. It records exact +revision, computed checksum, declared license, PL/veterinary WER and CER, term +recall, PL-EN code-switch WER, timestamp presence/monotonicity, cold load, RTF, +peak RSS, post-exit RSS, confirmed process exit, and bundle size. A missing +private corpus, runner, or model is an unverified host bench, never a fabricated +number and never a hermetic gate failure. + +Qwen3-ASR-0.6B remains an unproven candidate until both quantizations complete +this bench. Parakeet remains plan B. Cohere 2B remains batch-only until measured +evidence establishes a live timestamp and code-switch contract. No default is +selected here. diff --git a/docs/config-generator.html b/docs/config-generator.html index 1b7f384c..7fcabaaf 100644 --- a/docs/config-generator.html +++ b/docs/config-generator.html @@ -379,7 +379,7 @@

🎤 Speech-to-Text

- +

@@ -819,8 +819,7 @@

📄 Generated .env

document.getElementById("toggle-local-stt").classList.add("on"); document.getElementById("toggle-ai").classList.remove("on"); document.getElementById("toggle-beep").classList.add("on"); - document.getElementById("local-model").value = - "whisper-large-v3-turbo-mlx-q8"; + document.getElementById("local-model").value = "whisper-large-v3-turbo"; document.getElementById("hold-delay").value = "800"; document.getElementById("double-tap-interval").value = "200"; document.getElementById("toggle-silence").value = "5.0"; diff --git a/docs/env.md b/docs/env.md index c8765849..20cedb5b 100644 --- a/docs/env.md +++ b/docs/env.md @@ -34,7 +34,7 @@ Poniższe działają „same z siebie” — jeśli ich nie ustawisz, aplikacja - `HOLD_ARM_MODIFIER` – domyślnie `shift` — `cmd` ustawia Command jako modifier armujący assistive (W10-B) - `HOLD_START_DELAY_MS` – domyślnie `800` (RESTART NEEDED) - `DOUBLE_TAP_INTERVAL_MS` – domyślnie `200` (RESTART NEEDED) -- `TOGGLE_SILENCE_SEC` – domyślnie `5.0` (RESTART NEEDED) +- `TOGGLE_SILENCE_SEC` – domyślnie `5.0` (HOT — next recording): cisza zamyka epokę Apple (silnik odpoczywa, Layer 1 dostaje zapieczętowany span). Nie `CODESCRIBE_VAD_*` (te nazwy nigdy nie były czytane). - `SHOW_TRAY_GLYPH` – domyślnie `1` (RESTART NEEDED) - `HOLD_INDICATOR` – domyślnie `1` (HOT — next badge show; K3 W10-E, no live redraw of visible badge) - `HOLD_BADGE_SIZE` – domyślnie `8` (HOT — next badge show; K3 W10-E) @@ -155,21 +155,26 @@ i runtime nie może znaleźć Whispera przez cache / config: ### Audio - `AUDIO_INPUT_DEVICE` – nazwa urządzenia wejściowego (RESTART NEEDED) +- `CODESCRIBE_CAPTURE_LEVEL_LOW_DB` (HOT RELOADED; default `-52`) — próg WARN `capture_level_low` w dBFS, liczony od **mediany RMS mowy aktywnej** (nie od mediany całego nagrania). Cyfrowe zera ciszy na macOS 27 nie mogą zaniżać progu. Receipt nigdy nie jest terminalny. - `AUTO_SILENCE` (RESTART NEEDED) ### Transkrypcja (local/cloud) +- `CODESCRIBE_INLINE_FORMAT` (HOT RELOADED; default `0`) — uzbraja bufor formatowania chunków W13-1; timeouty `CODESCRIBE_INLINE_FORMAT_{CHUNK,FLUSH,TAIL}_TIMEOUT_MS` mają domyślne budżety odpowiednio 10000/2500/15000 ms. Default pozostaje guzikiem operatora. - `USE_LOCAL_STT` (RESTART NEEDED) - `LOCAL_MODEL`, `WHISPER_MODEL` (RESTART NEEDED) - `WHISPER_LANGUAGE` (HOT RELOADED; default `auto`; applies to the next capture. `auto` leaves language detection to Whisper for mixed-language dictation.) - `CODESCRIBE_WHISPER_INITIAL_PROMPT` (RESTART NEEDED; alias legacy: `WHISPER_INITIAL_PROMPT`; ignorowane przez ONNX) - `STT_ENDPOINT`, `STT_API_KEY` (RESTART NEEDED) -- `FINAL_PASS_MODE` (HOT RELOADED; `always|smart|off`; default `smart`; alias `CODESCRIBE_FINAL_PASS_MODE`) — **tylko** routing pełnego re-passu na stopie: `always`/`on` = zawsze pełny Whisper re-pass WAV, `smart` = pomija pełny re-pass gdy streaming completeness jest Complete (incomplete może jeszcze odpalić re-pass), `off` = nigdy pełnego re-passu (nie wymusza Whisper na stopie). **Nie włącza** layered/tail-patch. Live gap-fill = osobny opt-in `CODESCRIBE_LAYERED_TRANSCRIPTION`. Słownik/lexicon **zawsze** w postprocess. Settings → Dictation → "Final pass". Legacy `CODESCRIBE_TOGGLE_FINAL_PASS`: falsey→`off`, truthy→`always`. -- `CODESCRIBE_LAYERED_TRANSCRIPTION` (HOT RELOADED; default `off`; `phase1`..`phase4` lub bare `1`..`4`) — ortogonalny gate warstwowej transkrypcji. `phase1+` = Layer 1 Whisper tail-patch (`ReplaceRange`) na **obu** ścieżkach live: VAD/scheduler oraz domyślnym Apple progressive live (W2-A — gap-fill z zatrzymanego PCM, max jeden job w locie, nierozwiązane okno nigdy nie trafia do Whispera). Smart final-pass **nie** ustawia tej flagi. +- `FINAL_PASS_MODE` (HOT RELOADED; `always|smart|off`; default `smart`; alias `CODESCRIBE_FINAL_PASS_MODE`) — **tylko** routing pełnego re-passu na stopie: `always`/`on` = zawsze pełny Whisper re-pass WAV, `smart` = pomija pełny re-pass gdy streaming completeness jest Complete (incomplete może jeszcze odpalić re-pass), `off` = nigdy pełnego re-passu (nie wymusza Whisper na stopie). **Nie włącza i nie wyłącza** layered/tail-patch — to osobny gate (domyślnie `phase1`). Słownik/lexicon **zawsze** w postprocess. Settings → Dictation → "Final pass". Legacy `CODESCRIBE_TOGGLE_FINAL_PASS`: falsey→`off`, truthy→`always`. +- `CODESCRIBE_LAYERED_TRANSCRIPTION` (HOT RELOADED; default `phase1`; `phase1`..`phase4` lub bare `1`..`4`; jawne `off`/`0`/`false` rozbraja) — ortogonalny gate warstwowej transkrypcji. Unset → `phase1` od 2026-08-09. `phase1+` = Layer 1 Whisper tail-patch (`ReplaceRange`) na **obu** ścieżkach live: VAD/scheduler oraz domyślnym Apple progressive live (W2-A — gap-fill z zatrzymanego PCM, max jeden job w locie, nierozwiązane okno nigdy nie trafia do Whispera). Promoted do `settings.json`. Smart final-pass **nie** ustawia tej flagi. +- `STT_TAIL_PROVIDER` (HOT RELOADED; default `inprocess`) — wybiera implementację kontraktu tail-patch: `inprocess`, nadzorowany lokalny `sidecar` po WebSocket albo `remote` po multipart. Awaria sidecara/remote przechodzi do in-process z typed receiptem; zmiana defaultu pozostaje guzikiem operatora. +- `CODESCRIBE_STT_SIDECAR_BIN` (RESTART NEEDED; dev only) — jawna ścieżka do helpera; aplikacja dystrybucyjna automatycznie znajduje `codescribe-stt-sidecar` obok własnego executable. - `CODESCRIBE_TAIL_PATCH_MAX_CHANGE_RATIO` (HOT RELOADED; default `0.5`) — próg bezpieczeństwa Layer 1: jeśli udział zmienionych znaków wobec zatwierdzonej wypowiedzi przekracza tę wartość, cała łatka jest **odrzucana** zamiast nałożona. Dzięki temu rozbieżna re-transkrypcja nigdy nie nadpisze żywego płótna. +- `CODESCRIBE_TAIL_PATCH_SMALL_EDIT_FLOOR` (HOT RELOADED; default `3`) — budżet małych poprawek Layer 1: łatka o kształcie substytucji dotykająca co najwyżej tylu tokenów omija próg `MAX_CHANGE_RATIO`. Sam próg względny strukturalnie głodzi krótkie wypowiedzi (każda realna poprawka 1 słowa na 1-3-tokenowym commicie to ≥50% zmiany — zmierzone 2026-08-12: 116 skipów, 0 nałożonych). Czyste insercje nigdy nie korzystają z tego budżetu. - `CODESCRIBE_APPLE_DICTATION_TRANSCRIBER` (RESTART NEEDED; default `0`) — uzbraja ścieżkę PoC `DictationTranscriber` (W4-A): moduł SpeechAnalyzer stojący za SYSTEMOWYM dyktowaniem, jedyny analizator Apple, którego katalog zawiera pl-PL. Domyślnie **wyłączone** — nieuzbrojony rung nie istnieje, a kolejność backendów jest bajt w bajt taka jak w wersji wydanej. Klucz czytają obie strony (proces Rust i dziedziczący go bridge), więc jeden wpis uzbraja całą ścieżkę. **To nie jest domyślna ścieżka produktu** — przełączenie jest decyzją operatora. - `CODESCRIBE_MODEL_PATH`, `CODESCRIBE_MODELS_DIR` (RESTART NEEDED) -- `CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS` (HOT RELOADED dla wartości progu; default `2700` = 45 min; `0` wyłącza — włączenie z `0` wymaga restartu) — po N s bezczynności silnik Whisper jest zwalniany z pamięci (GPU/host) i ładowany ponownie przy następnym użyciu. **Cena przeładowania jest realna:** zmierzone `total 6.79 s`, z czego `5.19 s` to sama dekwantyzacja q8→F32 liczona od nowa (`cargo run --release --example whisper_load_probe`). `0` kupuje zerową latencję kosztem trzymanego RSS +- `CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS` (HOT RELOADED dla wartości progu; default `300` = 5 min; `0` jest jawnym keep-warm — włączenie reaper'a z `0` wymaga restartu) — po N s bezczynności silnik Whisper jest zwalniany z pamięci (GPU/host) i ładowany ponownie przy następnym użyciu. Każdy cold-load loguje `whisper_residency_load`, a reaper `whisper_residency_reclaim`: zdarzenia INFO mają efektywny TTL, liczniki oraz czasy load/unload/reclaim, lecz nigdy audio ani treść transkrypcji. **Cena przeładowania jest realna:** zmierzone `total 6.79 s`, z czego `5.19 s` to sama dekwantyzacja q8→F32 liczona od nowa (`cargo run --release --example whisper_load_probe`). `0` kupuje zerową latencję kosztem trzymanego RSS. ### Streaming / VAD / buffer @@ -199,6 +204,7 @@ i runtime nie może znaleźć Whispera przez cache / config: - `AI_MAX_TOKENS`, `AI_ASSISTIVE_MAX_TOKENS` (RESTART NEEDED) - `TRANSCRIPT_SEND_MODE` (RESTART NEEDED) - `CODESCRIBE_AI_MAX_RETRIES`, `CODESCRIBE_AI_RETRY_DELAY_MS`, `CODESCRIBE_AI_ATTEMPT_TIMEOUT_MS`, `CODESCRIBE_AI_OLLAMA_ATTEMPT_TIMEOUT_MS` (HOT RELOADED) +- `CODESCRIBE_RESPONSES_PROBE_URL` (HOT RELOADED; default pusty = oficjalny endpoint providera) — cel sondy autoryzacyjnej przy logowaniu kontem: świeży token musi umieć pisać do Responses API **zanim** zostanie zapisany jako „połączono" (pole: 5×401 `Missing scopes: api.responses.write`, 2026-08-14). Sonda nie wydaje tokenów (puste body ⇒ 400 przy zdrowym tokenie, 401 przy okrojonym). Testy hermetyczne celują w mock; produkcja zostawia unset. ### Hotkeys @@ -206,7 +212,7 @@ i runtime nie może znaleźć Whispera przez cache / config: - `HOLD_EXCLUSIVE` (RESTART NEEDED) - `HOLD_START_DELAY_MS` (RESTART NEEDED) - `DOUBLE_TAP_INTERVAL_MS` (RESTART NEEDED) -- `TOGGLE_SILENCE_SEC` (RESTART NEEDED) +- `TOGGLE_SILENCE_SEC` (HOT — next recording; Apple epoch lifecycle, not wav-VAD auto-send) ### UI / Overlay / Feedback diff --git a/examples/README.md b/examples/README.md index 52b8228d..5b7539c0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,7 +21,7 @@ cargo run --release --example demo_full_pipeline -- cargo run --release --example demo_full_pipeline -- --assistive ``` -Requires a local Whisper model (default `~/.codescribe/models/whisper-large-v3-turbo-mlx-q8`, override with `--model`) and `LLM_ENDPOINT`/`LLM_MODEL` (or `LLM_FORMATTING_*` overrides) for the formatting step. +Requires a local Whisper model (default `~/.codescribe/models/whisper-large-v3-turbo`, legacy q8 dir as fallback, override with `--model`) and `LLM_ENDPOINT`/`LLM_MODEL` (or `LLM_FORMATTING_*` overrides) for the formatting step. ### `e2e_stt.rs` diff --git a/examples/demo_full_pipeline.rs b/examples/demo_full_pipeline.rs index 1dd8cab1..ec302153 100644 --- a/examples/demo_full_pipeline.rs +++ b/examples/demo_full_pipeline.rs @@ -10,7 +10,7 @@ //! cargo run --release --example demo_full_pipeline -- --assistive //! //! Requires: -//! - Model at ~/.codescribe/models/whisper-large-v3-turbo-mlx-q8 (or set --model) +//! - Model at ~/.codescribe/models/whisper-large-v3-turbo (or set --model) //! - LLM_ENDPOINT + LLM_MODEL (or LLM_FORMATTING_* overrides) for formatting use anyhow::Result; @@ -35,7 +35,7 @@ async fn main() -> Result<()> { println!(); println!("Options:"); println!( - " --model PATH Model directory (default: ~/.codescribe/models/whisper-large-v3-turbo-mlx-q8)" + " --model PATH Model directory (default: ~/.codescribe/models/whisper-large-v3-turbo)" ); println!(" --assistive Use assistive mode (kurier/enhancer) instead of formatting"); println!(" --raw Skip AI formatting, show raw transcription only"); @@ -50,7 +50,12 @@ async fn main() -> Result<()> { // Parse args // Model path: ~/.codescribe/models/ (unified standard) let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let mut model = PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo-mlx-q8"); + // fp16 default first, legacy q8 as fallback — mirrors runtime precedence. + let mut model = ["whisper-large-v3-turbo", "whisper-large-v3-turbo-mlx-q8"] + .iter() + .map(|name| PathBuf::from(&home).join(".codescribe/models").join(name)) + .find(|p| p.join("config.json").exists()) + .unwrap_or_else(|| PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo")); let mut assistive = false; let mut raw_only = false; let mut audio_file: Option = None; diff --git a/examples/e2e_stt.rs b/examples/e2e_stt.rs index f4a87f25..0379bc31 100644 --- a/examples/e2e_stt.rs +++ b/examples/e2e_stt.rs @@ -22,7 +22,12 @@ async fn main() -> Result<()> { // Model path: ~/.codescribe/models/ (unified standard) let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let model_path = PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo-mlx-q8"); + // fp16 default first, legacy q8 as fallback — mirrors runtime precedence. + let model_path = ["whisper-large-v3-turbo", "whisper-large-v3-turbo-mlx-q8"] + .iter() + .map(|name| PathBuf::from(&home).join(".codescribe/models").join(name)) + .find(|p| p.join("config.json").exists()) + .unwrap_or_else(|| PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo")); // Supply your own sample files via env vars (or edit the placeholder paths below). let audio_medium = PathBuf::from( std::env::var("CODESCRIBE_E2E_AUDIO_MEDIUM") diff --git a/examples/test_audio.rs b/examples/test_audio.rs index b3aa1274..a9145737 100644 --- a/examples/test_audio.rs +++ b/examples/test_audio.rs @@ -6,7 +6,12 @@ use std::path::PathBuf; fn main() -> Result<()> { // Model path: ~/.codescribe/models/ (unified standard) let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let model = PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo-mlx-q8"); + // fp16 default first, legacy q8 as fallback — mirrors runtime precedence. + let model = ["whisper-large-v3-turbo", "whisper-large-v3-turbo-mlx-q8"] + .iter() + .map(|name| PathBuf::from(&home).join(".codescribe/models").join(name)) + .find(|p| p.join("config.json").exists()) + .unwrap_or_else(|| PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo")); println!("Loading model..."); let mut engine = LocalWhisperEngine::new(&model)?; println!("Model loaded.\n"); diff --git a/examples/test_audio_long.rs b/examples/test_audio_long.rs index 149d1723..c06e238b 100644 --- a/examples/test_audio_long.rs +++ b/examples/test_audio_long.rs @@ -14,8 +14,12 @@ fn main() -> Result<()> { // Model path: ~/.codescribe/models/ (unified standard) let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let default_model = - PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo-mlx-q8"); + // fp16 default first, legacy q8 as fallback — mirrors runtime precedence. + let default_model = ["whisper-large-v3-turbo", "whisper-large-v3-turbo-mlx-q8"] + .iter() + .map(|name| PathBuf::from(&home).join(".codescribe/models").join(name)) + .find(|p| p.join("config.json").exists()) + .unwrap_or_else(|| PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo")); let (model, files): (PathBuf, Vec) = if args[0] == "--model" { ( diff --git a/examples/transcribe_file.rs b/examples/transcribe_file.rs index 2dad33ee..d3bff224 100644 --- a/examples/transcribe_file.rs +++ b/examples/transcribe_file.rs @@ -36,6 +36,7 @@ fn main() -> anyhow::Result<()> { let home = env::var("HOME").unwrap_or_else(|_| ".".to_string()); let model_candidates = [ env::var("CODESCRIBE_MODEL_PATH").ok().map(PathBuf::from), + Some(PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo")), Some(PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo-mlx-q8")), ]; diff --git a/macos/Codescribe/App.swift b/macos/Codescribe/App.swift index 8f928615..1f3c4263 100644 --- a/macos/Codescribe/App.swift +++ b/macos/Codescribe/App.swift @@ -6,15 +6,15 @@ import SwiftUI // via UniFFI. AppKit owns the menu-bar status item/popover; SwiftUI owns the // Settings scene and the content hosted inside AppKit windows. private let appLogger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "App" + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "App" ) // Breadcrumbs for the tray Notes actions. Inspect with: // log show --predicate 'subsystem == "com.vetcoders.codescribe" && category == "notes"' --info private let notesLog = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "notes" + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "notes" ) /// Testable seam for the no-payload Agent command. AppDelegate remains the @@ -22,715 +22,758 @@ private let notesLog = Logger( /// requesting composer focus on the existing store. @MainActor final class AgentSummonAction { - private let store: AgentChatStore - private let showAgent: @MainActor () -> Void - private let capture: @MainActor (ComposerCaptureCommand) -> Void - - init( - store: AgentChatStore, - showAgent: @escaping @MainActor () -> Void, - capture: @escaping @MainActor (ComposerCaptureCommand) -> Void = { _ in } - ) { - self.store = store - self.showAgent = showAgent - self.capture = capture - } - - func perform() { - showAgent() - store.requestComposerFocus() - } - - func performCapture(_ command: ComposerCaptureCommand) { - perform() - capture(command) - } + private let store: AgentChatStore + private let showAgent: @MainActor () -> Void + private let capture: @MainActor (ComposerCaptureCommand) -> Void + + init( + store: AgentChatStore, + showAgent: @escaping @MainActor () -> Void, + capture: @escaping @MainActor (ComposerCaptureCommand) -> Void = { _ in } + ) { + self.store = store + self.showAgent = showAgent + self.capture = capture + } + + func perform() { + showAgent() + store.requestComposerFocus() + } + + func performCapture(_ command: ComposerCaptureCommand) { + perform() + capture(command) + } } /// UniFFI callbacks arrive off-main. This listener performs exactly one hop to /// the AppDelegate-owned action and carries no recording/model payload. final class AgentAppActionListener: CsAppActionListener, @unchecked Sendable { - private let summonAgent: @MainActor () -> Void - private let captureAgent: @MainActor (CsAgentCaptureCommand) -> Void - - init( - summonAgent: @escaping @MainActor () -> Void, - captureAgent: @escaping @MainActor (CsAgentCaptureCommand) -> Void = { _ in } - ) { - self.summonAgent = summonAgent - self.captureAgent = captureAgent - } - - func onShowAgent() { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.summonAgent() - } - } - } - - func onAgentCapture(command: CsAgentCaptureCommand) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.captureAgent(command) - } - } - } + private let summonAgent: @MainActor () -> Void + private let captureAgent: @MainActor (CsAgentCaptureCommand) -> Void + + init( + summonAgent: @escaping @MainActor () -> Void, + captureAgent: @escaping @MainActor (CsAgentCaptureCommand) -> Void = { _ in } + ) { + self.summonAgent = summonAgent + self.captureAgent = captureAgent + } + + func onShowAgent() { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.summonAgent() + } + } + } + + func onAgentCapture(command: CsAgentCaptureCommand) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.captureAgent(command) + } + } + } } @main struct CodescribeApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - - init() { - FontLoader.register() - } - - var body: some Scene { - Settings { - SettingsView(model: SettingsViewModel( - engine: RealSettingsEngine(), - agentStatus: RealAgentStatusEngine(), - mcpAdmin: RealMCPAdminEngine(), - hotkeys: RealHotkeysEngine(), - licenseService: LicenseService.shared - )) - } - // Make the Settings window user-resizable: the content's `.frame` floor - // becomes the window minimum, and it can grow from there (default is a - // fixed content-sized window). SwiftUI restores the frame across launches. - .windowResizability(.contentMinSize) - } + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + init() { + FontLoader.register() + } + + var body: some Scene { + Settings { + SettingsView( + model: SettingsViewModel( + engine: RealSettingsEngine(), + agentStatus: RealAgentStatusEngine(), + mcpAdmin: RealMCPAdminEngine(), + hotkeys: RealHotkeysEngine(), + licenseService: LicenseService.shared + )) + } + // Make the Settings window user-resizable: the content's `.frame` floor + // becomes the window minimum, and it can grow from there (default is a + // fixed content-sized window). SwiftUI restores the frame across launches. + .windowResizability(.contentMinSize) + } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - private static let showAgentNotification = Notification.Name("com.vetcoders.codescribe.showAgent") - private static let notificationObject = Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe" - - private static let helpURL = URL(string: "https://vetcoders.github.io/codescribe/")! - private static let privacyURL = URL(string: "https://vetcoders.github.io/codescribe/privacy")! - private static let termsURL = URL(string: "https://vetcoders.github.io/codescribe/terms")! - - // Every core-touching handle below is `lazy` for correctness, not for launch - // cost. The XCTest bundle uses this app as its host, so `AppDelegate` is - // instantiated inside the test process. Stored properties are initialised at - // *instantiation*, which happens before `applicationWillFinishLaunching` — - // so the `Self.isRunningTests` guards on the lifecycle methods below are - // structurally unable to stop them, no matter what they check. - // - // Constructed eagerly, they boot a second Rust core beside the test: - // `AppModel.shared` builds the chat/overlay/tray engines, and - // `TrayStatusStore.init` goes further and registers a live listener on the - // core (`TrayStatusStore.swift:56`). That listener answers - // `ConfigChangeBus.holdBadgeDidChange`, so a pure-logic settings unit test - // running entirely on a mock engine drove real core work. - // - // Measured 2026-08-08 on this host, whole suite (317 tests), same binary - // except for these keywords: - // eager 86.7 s — of which 82.3 s is one test, - // SettingsTruthTests.testHoldBadgeControlRoundTrips…, - // which costs 0.009 s when run alone - // lazy 4.1-4.5 s (n=4, app running or not) - // The gap is fan-out: XCTest holds every test-case instance for the whole - // run, so each view model an earlier test built is still a live observer - // when this one posts. Removing the app's own core-backed observers from the - // host is what collapses it. - // - // Deferring to first access costs production nothing: the earliest use is - // `applicationDidFinishLaunching` (line ~179), already behind the guard. - private lazy var model = AppModel.shared - private lazy var trayStatus = TrayStatusStore() - private lazy var hotkeys = CodescribeHotkeys() - // Stateless bridge handles backing the tray's app-level actions (notes, - // config paths, transcript history). Each call reads/writes live on-disk truth. - private lazy var notes = CodescribeNotes() - private lazy var config = CodescribeConfig() - private lazy var threads = CodescribeThreads() - private var agentWindow: NSWindow? - // Strong ref to the voice-assistive delivery listener: UniFFI releases the - // foreign callback the moment Swift drops its reference, which would silently - // kill live voice-reply rendering. Held for the app's lifetime. - private var voiceDeliveryListener: VoiceDeliveryListener? - private var appActionListener: AgentAppActionListener? - private lazy var agentSummonAction = AgentSummonAction( - store: model.chat, - showAgent: { [weak self] in self?.showAgent() }, - capture: { [weak self] command in - guard let self else { return } - // A stopped overlay may remain visible for post-capture actions. It - // has no authority in Agent mode and is closed before composer capture. - if !self.model.chat.dictationBlocked { self.model.overlay.hide() } - self.model.chat.handleAssistiveCapture(command) - } + private static let showAgentNotification = Notification.Name("com.vetcoders.codescribe.showAgent") + private static let notificationObject = Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe" + + private static let helpURL = URL(string: "https://vetcoders.github.io/codescribe/")! + private static let privacyURL = URL(string: "https://vetcoders.github.io/codescribe/privacy")! + private static let termsURL = URL(string: "https://vetcoders.github.io/codescribe/terms")! + + // Every core-touching handle below is `lazy` for correctness, not for launch + // cost. The XCTest bundle uses this app as its host, so `AppDelegate` is + // instantiated inside the test process. Stored properties are initialised at + // *instantiation*, which happens before `applicationWillFinishLaunching` — + // so the `Self.isRunningTests` guards on the lifecycle methods below are + // structurally unable to stop them, no matter what they check. + // + // Constructed eagerly, they boot a second Rust core beside the test: + // `AppModel.shared` builds the chat/overlay/tray engines, and + // `TrayStatusStore.init` goes further and registers a live listener on the + // core (`TrayStatusStore.swift:56`). That listener answers + // `ConfigChangeBus.holdBadgeDidChange`, so a pure-logic settings unit test + // running entirely on a mock engine drove real core work. + // + // Measured 2026-08-08 on this host, whole suite (317 tests), same binary + // except for these keywords: + // eager 86.7 s — of which 82.3 s is one test, + // SettingsTruthTests.testHoldBadgeControlRoundTrips…, + // which costs 0.009 s when run alone + // lazy 4.1-4.5 s (n=4, app running or not) + // The gap is fan-out: XCTest holds every test-case instance for the whole + // run, so each view model an earlier test built is still a live observer + // when this one posts. Removing the app's own core-backed observers from the + // host is what collapses it. + // + // Deferring to first access costs production nothing: the earliest use is + // `applicationDidFinishLaunching` (line ~179), already behind the guard. + private lazy var model = AppModel.shared + private lazy var trayStatus = TrayStatusStore() + private lazy var hotkeys = CodescribeHotkeys() + // Stateless bridge handles backing the tray's app-level actions (notes, + // config paths, transcript history). Each call reads/writes live on-disk truth. + private lazy var notes = CodescribeNotes() + private lazy var config = CodescribeConfig() + private lazy var threads = CodescribeThreads() + private var agentWindow: NSWindow? + // Strong ref to the voice-assistive delivery listener: UniFFI releases the + // foreign callback the moment Swift drops its reference, which would silently + // kill live voice-reply rendering. Held for the app's lifetime. + private var voiceDeliveryListener: VoiceDeliveryListener? + private var appActionListener: AgentAppActionListener? + private lazy var agentSummonAction = AgentSummonAction( + store: model.chat, + showAgent: { [weak self] in self?.showAgent() }, + capture: { [weak self] command in + guard let self else { return } + // A stopped overlay may remain visible for post-capture actions. It + // has no authority in Agent mode and is closed before composer capture. + if !self.model.chat.dictationBlocked { self.model.overlay.hide() } + self.model.chat.handleAssistiveCapture(command) + } + ) + private var statusItem: NSStatusItem! + private var hasUnreadAgentUpdate = false + // Local key monitor for ⌘+ / ⌘- / ⌘0 text scaling, routed to the key window's + // surface (overlay panel vs agent window). Held so it can be removed on quit. + private var textScaleMonitor: Any? + // NSWorkspace sleep/wake bridge. The observer itself only coalesces one + // next-tick callback; the async Rust hop happens outside AppKit's callout. + private var sleepWakeObserver: SystemSleepWakeObserver? + private let popover = NSPopover() + private var shouldExitForDuplicate = false + // First-run onboarding wizard host. Presented at launch when the core gate + // (`shouldShowOnboarding`) reports setup is due. + private lazy var onboarding = OnboardingWindowController(engine: RealOnboardingEngine()) + // Sparkle update channel. Created in didFinishLaunching (after the + // duplicate-instance/test-host guard) so the XCTest host never starts a + // scheduled updater alongside the live app. + private var updater: UpdaterService? + + /// True when the process is the XCTest host, not a user launch. The unit-test + /// runner reuses this app as its host: without this gate the duplicate-instance + /// check would `terminate` the runner whenever the real app is running, and + /// the host would start hotkeys + engine prewarm alongside the live instance + /// (fighting it for the CGEventTap and the microphone). + private static let isRunningTests = QualityCaptureHost.isRunningTests + + func applicationWillFinishLaunching(_ notification: Notification) { + guard !Self.isRunningTests, Self.isDuplicateInstance else { return } + shouldExitForDuplicate = true + DistributedNotificationCenter.default().postNotificationName( + Self.showAgentNotification, + object: Self.notificationObject, + userInfo: nil, + deliverImmediately: true + ) + NSApp.terminate(nil) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + guard !shouldExitForDuplicate, !Self.isRunningTests else { return } + // Honour the persisted "Show Dock Icon" toggle at launch. LSUIElement + // makes us an accessory by default; promote to .regular when enabled so + // the launch state matches the tray toggle. + NSApp.setActivationPolicy(config.trayToggles().showDockIcon ? .regular : .accessory) + + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(showAgentFromExternalLaunch), + name: Self.showAgentNotification, + object: Self.notificationObject, + suspensionBehavior: .deliverImmediately ) - private var statusItem: NSStatusItem! - private var hasUnreadAgentUpdate = false - // Local key monitor for ⌘+ / ⌘- / ⌘0 text scaling, routed to the key window's - // surface (overlay panel vs agent window). Held so it can be removed on quit. - private var textScaleMonitor: Any? - private let popover = NSPopover() - private var shouldExitForDuplicate = false - // First-run onboarding wizard host. Presented at launch when the core gate - // (`shouldShowOnboarding`) reports setup is due. - private lazy var onboarding = OnboardingWindowController(engine: RealOnboardingEngine()) - // Sparkle update channel. Created in didFinishLaunching (after the - // duplicate-instance/test-host guard) so the XCTest host never starts a - // scheduled updater alongside the live app. - private var updater: UpdaterService? - - /// True when the process is the XCTest host, not a user launch. The unit-test - /// runner reuses this app as its host: without this gate the duplicate-instance - /// check would `terminate` the runner whenever the real app is running, and - /// the host would start hotkeys + engine prewarm alongside the live instance - /// (fighting it for the CGEventTap and the microphone). - private static let isRunningTests = QualityCaptureHost.isRunningTests - - func applicationWillFinishLaunching(_ notification: Notification) { - guard !Self.isRunningTests, Self.isDuplicateInstance else { return } - shouldExitForDuplicate = true - DistributedNotificationCenter.default().postNotificationName( - Self.showAgentNotification, - object: Self.notificationObject, - userInfo: nil, - deliverImmediately: true - ) - NSApp.terminate(nil) - } - - func applicationDidFinishLaunching(_ notification: Notification) { - guard !shouldExitForDuplicate, !Self.isRunningTests else { return } - // Honour the persisted "Show Dock Icon" toggle at launch. LSUIElement - // makes us an accessory by default; promote to .regular when enabled so - // the launch state matches the tray toggle. - NSApp.setActivationPolicy(config.trayToggles().showDockIcon ? .regular : .accessory) - - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(showAgentFromExternalLaunch), - name: Self.showAgentNotification, - object: Self.notificationObject, - suspensionBehavior: .deliverImmediately - ) - - popover.behavior = .transient - popover.contentSize = NSSize(width: 300, height: 460) - popover.contentViewController = NSHostingController( - rootView: TrayMenuView(viewModel: model.tray, trayStatus: trayStatus) - ) - - model.tray.onIntent = { intent in - switch intent { - case .openChat: - self.showAgent() - case .revealChat: - self.revealAgentForDelivery() - } - } - model.tray.onDictationStartRequested = { [model] in - model.overlay.prepareForRecordingStart() - model.overlay.showForRecording() - } - updater = UpdaterService() - wireTrayActions() - installStatusItem() - installTextScaleMonitor() - registerAppActions() - startHotkeys() - registerVoiceDelivery() - prewarmRecordingController() - // Speech Recognition TCC must be requested from THIS process - // (com.vetcoders.codescribe). The bridge child is co-located under - // Contents/MacOS and inherits the app's responsible identity; granting - // only via Terminal/CLI leaves the app as speech_auth_not_determined. - ensureSpeechRecognitionAtLaunch() - // Show the first-run wizard on top of the freshly-installed tray when the - // core reports onboarding is still due (no setup_done marker, or a stale - // one invalidated because a required permission is missing). - onboarding.presentIfNeeded() - } - - /// Prompt Speech Recognition while undetermined so Apple live dictation can - /// start on first hotkey without a Settings detour. Denied/restricted is a - /// no-op here — Settings › Dictation surfaces the deep link. - /// - /// Accessory (LSUIElement) apps often fail to surface TCC dialogs unless the - /// process is briefly activation-eligible — promote to regular for the - /// request window, then restore the dock policy. - private func ensureSpeechRecognitionAtLaunch() { - let probe = NativePermissionProbe().snapshot() - guard probe.speechRecognition == .notDetermined else { - appLogger.info( - "Speech Recognition at launch already \(String(describing: probe.speechRecognition), privacy: .public)" - ) - return - } - let priorPolicy = NSApp.activationPolicy() - if priorPolicy == .accessory { - NSApp.setActivationPolicy(.regular) - } - NSApp.activate(ignoringOtherApps: true) - SpeechRecognitionPermission.request { [weak self] state in - guard let self else { return } - if priorPolicy == .accessory { - // Restore accessory only when the user has not enabled Dock icon. - if !self.config.trayToggles().showDockIcon { - NSApp.setActivationPolicy(.accessory) - } - } - appLogger.info("Speech Recognition at launch → \(String(describing: state), privacy: .public)") - } - } - - /// Bind the tray's app-level action closures (Help / About / Notes / - /// Diagnostics) to real behaviour. Navigation intents are wired separately via - /// `onIntent`; these are the non-navigation actions the tray view invokes. - private func wireTrayActions() { - model.tray.onAbout = { - NSApp.activate(ignoringOtherApps: true) - // Build provenance in the standard About panel (Pensieve-style): - // version/build come from Info.plist keys stamped by scripts/build-app.sh; - // commit + built-at land in the credits block below them. Privacy / - // Terms links open the public trust pages (MoR buyer requirement). - let info = Bundle.main.infoDictionary ?? [:] - let commit = info["CSBuildCommit"] as? String ?? "dev" - let builtAt = info["CSBuiltAt"] as? String ?? "unknown" - let mono: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), - .foregroundColor: NSColor.secondaryLabelColor, - ] - let credits = NSMutableAttributedString( - string: "Commit: \(commit)\nBuilt: \(builtAt)\n\n", - attributes: mono - ) - let privacy = NSAttributedString( - string: "Privacy Policy", - attributes: mono.merging([.link: Self.privacyURL]) { _, new in new } - ) - let terms = NSAttributedString( - string: "Terms of Use & EULA", - attributes: mono.merging([.link: Self.termsURL]) { _, new in new } - ) - credits.append(privacy) - credits.append(NSAttributedString(string: "\n", attributes: mono)) - credits.append(terms) - NSApp.orderFrontStandardAboutPanel(options: [.credits: credits]) - } - model.tray.onHelp = { - NSWorkspace.shared.open(Self.helpURL) - } - model.tray.onCheckForUpdates = { [weak self] in - self?.updater?.checkForUpdates() - } - // Re-open the setup wizard on demand. Unlike `presentIfNeeded()` (launch - // gate), `present()` always fronts the window — resume when onboarding is - // still due, or re-run from Welcome after completion. - model.tray.onOpenSetupWizard = { [onboarding] in - onboarding.present() - } - - // ── Notes ── - model.tray.onOpenNotesFolder = { [notes] in - NSWorkspace.shared.open(URL(fileURLWithPath: notes.notesDir())) - } - model.tray.onOpenTodayNote = { [notes] in - let path = notes.todayNotePath() - if FileManager.default.fileExists(atPath: path) { - NSWorkspace.shared.open(URL(fileURLWithPath: path)) - } else { - // No note captured today yet — reveal the notes folder instead. - NSWorkspace.shared.open(URL(fileURLWithPath: notes.notesDir())) - } - } - // One-shot: append the most recent transcript to the daily note. No paste - // — Notes is a brain-dump destination. Result is surfaced in the popover - // (and the bridge's OS toast) so the action is never a silent no-op. - model.tray.onSaveLastTranscript = { [weak self, notes, threads, model] in - let text = Self.latestTranscriptText(threads) ?? "" - self?.saveToNote(tray: model.tray, emptyMessage: "No transcript to save") { - try notes.saveText(text: text) - } - } - // One-shot: capture the current selection into the daily note. The tray - // popover steals key focus and SwiftUI `Text.textSelection` doesn't expose - // `AXSelectedText`, so the system-wide AX read can't see a selection made - // in our own agent window — harvest it from that window's responder chain - // first, then fall back to the AX/clipboard path for other apps. - model.tray.onSaveSelection = { [weak self, notes, model] in - guard let self else { return } - self.saveToNote(tray: model.tray, emptyMessage: "No text selected") { - if let own = self.harvestAgentWindowSelection() { - notesLog.info("save selection: harvested \(own.count, privacy: .public) chars from agent window") - return try notes.saveText(text: own) - } - notesLog.info("save selection: no own-window selection; trying AX/clipboard path") - return try notes.saveSelection() - } - } - - // ── Diagnostics ── - model.tray.onOpenLogFolder = { [config] in - // stream.log + .env + notes/transcriptions all live under the data dir. - NSWorkspace.shared.open(URL(fileURLWithPath: config.configDir())) - } - model.tray.onCopyDebugInfo = { [config, notes, hotkeys] in - Task { @MainActor in - let recording = await hotkeys.isRecording() - let settings = config.loadSettings() - let info = Bundle.main.infoDictionary - let version = info?["CFBundleShortVersionString"] as? String ?? "?" - let build = info?["CFBundleVersion"] as? String ?? "?" - let stt = settings.useLocalStt - ? "local (\(settings.localModel))" - : "cloud (\(settings.sttEndpoint ?? "default"))" - let text = [ - "codescribe debug info", - "app version: \(version) (\(build))", - "macOS: \(ProcessInfo.processInfo.operatingSystemVersionString)", - "recording: \(recording)", - "STT engine: \(stt)", - "config dir: \(config.configDir())", - "notes dir: \(notes.notesDir())", - ].joined(separator: "\n") - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - } - } - - /// Text of the most recent transcript artifact, mirroring the tray engine's - /// `latestTranscriptText` (newest history entry → its file contents). - private static func latestTranscriptText(_ threads: CodescribeThreads) -> String? { - // Skip failure / no-speech markers so "Save last transcript" never writes a - // "failed" placeholder into the daily note (see RealTrayEngine). - guard let path = threads.recentHistory(limit: 32) - .first(where: { $0.kind.isCopyableTranscript })?.path else { return nil } - return try? threads.readHistoryText(path: path) - } - - /// Run a Notes save and reflect the outcome in the still-open popover. The - /// bridge returns the saved payload (non-nil) on success, nil when there was - /// nothing to save, and throws on a write error — every branch gets a banner - /// so the action is fail-loud, never a silent no-op. - private func saveToNote( - tray: TrayViewModel, - emptyMessage: String, - _ perform: () throws -> String? - ) { - do { - let saved = try perform() - if let saved, !saved.isEmpty { - notesLog.info("note saved (\(saved.count, privacy: .public) chars)") - tray.showNoteStatus(.init(kind: .success, message: "Saved to daily note")) - } else { - notesLog.info("note save: nothing to save") - tray.showNoteStatus(.init(kind: .failure, message: emptyMessage)) - } - } catch { - notesLog.error("note save failed: \(error.localizedDescription, privacy: .public)") - tray.showNoteStatus(.init(kind: .failure, message: "Could not save note")) - } - } - - /// Best-effort harvest of the live text selection from our own agent window. - /// - /// The system-wide AX read used by the bridge can't see it: the tray popover - /// has stolen key focus and SwiftUI `Text.textSelection` doesn't expose - /// `AXSelectedText`. Instead we ask the agent window's responder chain to - /// `copy:`, snapshotting and restoring the real pasteboard so the user's - /// clipboard is left untouched. Returns nil when the window is absent/hidden - /// or holds no selection (a `copy:` on an empty selection leaves the - /// pasteboard `changeCount` unmoved). - private func harvestAgentWindowSelection() -> String? { - guard let window = agentWindow, window.isVisible else { - notesLog.info("harvest: no visible agent window") - return nil - } - let pasteboard = NSPasteboard.general - let changeCountBefore = pasteboard.changeCount - // Snapshot the ENTIRE pasteboard (every item, every type) so restoring it - // can't clobber images/files the user had copied — a string-only snapshot - // would drop them. Items read from the pasteboard are owned by it, so each - // is deep-copied into a fresh NSPasteboardItem before we overwrite them. - let savedItems: [NSPasteboardItem] = (pasteboard.pasteboardItems ?? []).map { item in - let copy = NSPasteboardItem() - for type in item.types { - if let data = item.data(forType: type) { - copy.setData(data, forType: type) - } - } - return copy - } - - let handled = window.firstResponder? - .tryToPerform(#selector(NSText.copy(_:)), with: nil) ?? false - guard handled, pasteboard.changeCount != changeCountBefore else { - notesLog.info("harvest: responder copy produced no selection") - return nil - } - - let harvested = pasteboard.string(forType: .string)? - .trimmingCharacters(in: .whitespacesAndNewlines) - - // Restore the user's full clipboard — Save selection must not clobber it. - pasteboard.clearContents() - if !savedItems.isEmpty { pasteboard.writeObjects(savedItems) } - - guard let harvested, !harvested.isEmpty else { return nil } - return harvested - } - - func applicationWillTerminate(_ notification: Notification) { - // Mirrors the launch guards: the test host never started hotkeys, and - // touching the lazy handle here would construct the bridge at teardown - // purely to stop something that was never running. - guard !Self.isRunningTests else { return } - hotkeys.stop() - if let textScaleMonitor { NSEvent.removeMonitor(textScaleMonitor) } - DistributedNotificationCenter.default().removeObserver(self) - } - - // MARK: - Text scaling (⌘+ / ⌘- / ⌘0) - - /// Install one local key monitor that routes text-scale shortcuts to the SURFACE - /// under focus: the key window decides which scale you adjust. Handled events are - /// swallowed (return nil); anything else passes through untouched. - private func installTextScaleMonitor() { - textScaleMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard let self else { return event } - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - // Require ⌘ with no other command modifiers; Shift is allowed because - // "+" is Shift-"=" on most layouts. - guard flags.contains(.command), - !flags.contains(.option), !flags.contains(.control), - let controller = self.textScaleController(for: NSApp.keyWindow) else { - return event - } - switch event.charactersIgnoringModifiers { - case "+", "=": controller.increase(); return nil - case "-", "_": controller.decrease(); return nil - case "0": controller.reset(); return nil - default: return event - } - } - } - - /// The text-scale controller for a window, or nil when the key window is not a - /// scalable surface (Settings, tray popover, panels). The overlay is discriminated - /// by its `FloatingOverlayPanel` type; the chat by identity. - private func textScaleController(for window: NSWindow?) -> TextScaleController? { - guard let window else { return nil } - if window is FloatingOverlayPanel { return model.overlay.textScale } - if window == agentWindow { return model.chatTextScale } - return nil - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } - - private func installStatusItem() { - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - if let button = item.button { - button.imagePosition = .imageOnly - button.title = "" - button.action = #selector(toggleTray) - button.target = self - } - statusItem = item - trayStatus.onChange = { [weak self] status in - guard let self else { return } - self.model.overlay.handleIndicatorModeChange(status.indicatorMode) - self.applyStatusItemStatus() - } - applyStatusItemStatus() - } - - private func applyStatusItemStatus() { - guard let button = statusItem?.button else { return } - // The glyph never changes with status — it is always the brand mark. - // Mode is conveyed by the status dot composited into the glyph's - // bottom-right corner, 1:1 with the tray status feed: green ready / - // red recording / orange processing / purple assistive. - button.imagePosition = .imageOnly - button.title = "" - if let dot = trayStatus.menuBarDotColor, let base = statusItemImage() { - // The composite is a flattened, non-template image, so the - // unread-agent tint rides the glyph tint, not contentTintColor. - button.image = TrayStatusDotIcon.composite( - base: base, - dot: NSColor(dot), - glyphTint: hasUnreadAgentUpdate ? .systemYellow : nil - ) - button.contentTintColor = nil - } else { - button.image = statusItemImage() - button.contentTintColor = hasUnreadAgentUpdate ? NSColor.systemYellow : nil - } - button.toolTip = hasUnreadAgentUpdate - ? "\(trayStatus.status.tooltip) - agent reply ready" - : trayStatus.status.tooltip - } - - private func statusItemImage() -> NSImage? { - // Brand mark from Assets.xcassets (template image → auto-tints for - // light/dark menu bars). Status is signaled by a colored dot composited - // into this icon's corner, never by swapping the glyph. A missing asset - // is a build bug to surface (empty item), not something to paper over. - let image = NSImage(named: "MenuBarIcon") - image?.isTemplate = true - return image - } - - private func ensureAgentWindow() -> NSWindow { - if let agentWindow { return agentWindow } - // Wrap in TextScaleRoot so ⌘+/-/0 on the chat window scale the message - // bodies + composer via `\.csTextScale`, independently of the overlay. - let root = TextScaleRoot(controller: model.chatTextScale) { - AgentChatView(store: model.chat) - .preferredColorScheme(.dark) - } - let hosting = NSHostingController(rootView: root) - let window = NSWindow(contentViewController: hosting) - window.title = "codescribe — Agent" - window.setContentSize(NSSize(width: 1120, height: 720)) - window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] - window.titlebarAppearsTransparent = true - window.isReleasedWhenClosed = false - // LSUIElement accessory: join the active Space so a passive - // `orderFrontRegardless` is actually visible during voice delivery. - window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] - window.center() - agentWindow = window - return window - } - - /// Show the agent chat window. - /// - Parameter activating: when true (tray/menu/summon), steal focus. When - /// false (voice TurnStarted / end-of-turn fallback), order front without - /// `NSApp.activate` so the user's frontmost app stays frontmost. - private func showAgent(activating: Bool = true) { - let window = ensureAgentWindow() - let revealIntent = AgentRevealPolicy.intent(activating: activating) - let shouldActivate = AgentRevealPolicy.shouldActivate(for: revealIntent) - // Keep Space-join behavior even if the window was created earlier on an - // older build of this method before collectionBehavior was set. - window.collectionBehavior.formUnion([.moveToActiveSpace, .fullScreenAuxiliary]) - if shouldActivate { - hasUnreadAgentUpdate = false - applyStatusItemStatus() - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - appLogger.info( - "w10a_agent_show activating=true isVisible=\(window.isVisible, privacy: .public)" - ) - } else if AgentRevealPolicy.shouldReorderEvenIfVisible(for: revealIntent) { - // Passive path: always re-order. Early-return on isVisible alone hid - // windows that were "visible" on another Space or occluded, so live - // voice turns never painted until the activating end-of-turn open. - hasUnreadAgentUpdate = true - applyStatusItemStatus() - window.orderFrontRegardless() - appLogger.info( - "w10a_agent_show activating=false isVisible=\(window.isVisible, privacy: .public) isKey=\(window.isKeyWindow, privacy: .public)" - ) - } - } - - /// Passive reveal for voice→agent delivery. Must never call NSApp.activate. - /// Structured log line is the runtime receipt anchor: reveal_ts < done_ts. - private func revealAgentForDelivery() { - appLogger.info("w10a_reveal_begin") - showAgent(activating: false) - appLogger.info("w10a_reveal_done") - } - - private func showTray() { - guard let button = statusItem.button else { return } - NSApp.activate(ignoringOtherApps: true) - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - popover.contentViewController?.view.window?.makeKey() - } - - @objc private func toggleTray() { - if popover.isShown { - popover.performClose(nil) - } else { - showTray() - } - } - @objc private func showAgentFromExternalLaunch() { - showAgent() - } + popover.behavior = .transient + popover.contentSize = NSSize(width: 300, height: 460) + popover.contentViewController = NSHostingController( + rootView: TrayMenuView(viewModel: model.tray, trayStatus: trayStatus) + ) - /// Wire the voice-assistive agent reply stream into the chat window. The - /// hotkey / hands-off send path streams the reply from the core runtime; this - /// listener renders those events live (opening the chat window on turn start). - /// Registration is process-global on the bridge side, so it stands independent - /// of the `hotkeys.start()` Task above. - private func registerVoiceDelivery() { - let listener = VoiceDeliveryListener(store: model.chat) { [weak self] in - self?.revealAgentForDelivery() - } - voiceDeliveryListener = listener - hotkeys.setAgentDeliveryListener(listener: listener) - } - - private func registerAppActions() { - let action = agentSummonAction - let listener = AgentAppActionListener( - summonAgent: { [weak action] in - action?.perform() - appLogger.info("Agent summon command handled: window fronted and composer focus requested") - }, - captureAgent: { [weak action] command in - let mapped: ComposerCaptureCommand - switch command { - case .start: mapped = .startAssistive - case .stop: mapped = .stopAssistive - case .toggle: mapped = .toggleAssistive - } - action?.performCapture(mapped) - appLogger.info("Assistive hotkey handled by Agent composer microphone") - } + model.tray.onIntent = { intent in + switch intent { + case .openChat: + self.showAgent() + case .revealChat: + self.revealAgentForDelivery() + } + } + model.tray.onDictationStartRequested = { [model] in + model.overlay.prepareForRecordingStart() + model.overlay.showForRecording() + } + updater = UpdaterService() + wireTrayActions() + installStatusItem() + installTextScaleMonitor() + registerAppActions() + startHotkeys() + installSystemSleepWakeObserver() + registerVoiceDelivery() + prewarmRecordingController() + // Speech Recognition TCC must be requested from THIS process + // (com.vetcoders.codescribe). The bridge child is co-located under + // Contents/MacOS and inherits the app's responsible identity; granting + // only via Terminal/CLI leaves the app as speech_auth_not_determined. + ensureSpeechRecognitionAtLaunch() + // Show the first-run wizard on top of the freshly-installed tray when the + // core reports onboarding is still due (no setup_done marker, or a stale + // one invalidated because a required permission is missing). + onboarding.presentIfNeeded() + } + + /// Prompt Speech Recognition while undetermined so Apple live dictation can + /// start on first hotkey without a Settings detour. Denied/restricted is a + /// no-op here — Settings › Dictation surfaces the deep link. + /// + /// Accessory (LSUIElement) apps often fail to surface TCC dialogs unless the + /// process is briefly activation-eligible — promote to regular for the + /// request window, then restore the dock policy. + private func ensureSpeechRecognitionAtLaunch() { + let probe = NativePermissionProbe().snapshot() + guard probe.speechRecognition == .notDetermined else { + appLogger.info( + "Speech Recognition at launch already \(String(describing: probe.speechRecognition), privacy: .public)" + ) + return + } + let priorPolicy = NSApp.activationPolicy() + if priorPolicy == .accessory { + NSApp.setActivationPolicy(.regular) + } + NSApp.activate(ignoringOtherApps: true) + SpeechRecognitionPermission.request { [weak self] state in + guard let self else { return } + if priorPolicy == .accessory { + // Restore accessory only when the user has not enabled Dock icon. + if !self.config.trayToggles().showDockIcon { + NSApp.setActivationPolicy(.accessory) + } + } + appLogger.info( + "Speech Recognition at launch → \(String(describing: state), privacy: .public)") + } + } + + /// Bind the tray's app-level action closures (Help / About / Notes / + /// Diagnostics) to real behaviour. Navigation intents are wired separately via + /// `onIntent`; these are the non-navigation actions the tray view invokes. + private func wireTrayActions() { + model.tray.onAbout = { + NSApp.activate(ignoringOtherApps: true) + // Build provenance in the standard About panel (Pensieve-style): + // version/build come from Info.plist keys stamped by scripts/build-app.sh; + // commit + built-at land in the credits block below them. Privacy / + // Terms links open the public trust pages (MoR buyer requirement). + let info = Bundle.main.infoDictionary ?? [:] + let commit = info["CSBuildCommit"] as? String ?? "dev" + let builtAt = info["CSBuiltAt"] as? String ?? "unknown" + let mono: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .foregroundColor: NSColor.secondaryLabelColor, + ] + let credits = NSMutableAttributedString( + string: "Commit: \(commit)\nBuilt: \(builtAt)\n\n", + attributes: mono + ) + let privacy = NSAttributedString( + string: "Privacy Policy", + attributes: mono.merging([.link: Self.privacyURL]) { _, new in new } + ) + let terms = NSAttributedString( + string: "Terms of Use & EULA", + attributes: mono.merging([.link: Self.termsURL]) { _, new in new } + ) + credits.append(privacy) + credits.append(NSAttributedString(string: "\n", attributes: mono)) + credits.append(terms) + NSApp.orderFrontStandardAboutPanel(options: [.credits: credits]) + } + model.tray.onHelp = { + NSWorkspace.shared.open(Self.helpURL) + } + model.tray.onCheckForUpdates = { [weak self] in + self?.updater?.checkForUpdates() + } + // Re-open the setup wizard on demand. Unlike `presentIfNeeded()` (launch + // gate), `present()` always fronts the window — resume when onboarding is + // still due, or re-run from Welcome after completion. + model.tray.onOpenSetupWizard = { [onboarding] in + onboarding.present() + } + + // ── Notes ── + model.tray.onOpenNotesFolder = { [notes] in + NSWorkspace.shared.open(URL(fileURLWithPath: notes.notesDir())) + } + model.tray.onOpenTodayNote = { [notes] in + let path = notes.todayNotePath() + if FileManager.default.fileExists(atPath: path) { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } else { + // No note captured today yet — reveal the notes folder instead. + NSWorkspace.shared.open(URL(fileURLWithPath: notes.notesDir())) + } + } + // One-shot: append the most recent transcript to the daily note. No paste + // — Notes is a brain-dump destination. Result is surfaced in the popover + // (and the bridge's OS toast) so the action is never a silent no-op. + model.tray.onSaveLastTranscript = { [weak self, notes, threads, model] in + let text = Self.latestTranscriptText(threads) ?? "" + self?.saveToNote(tray: model.tray, emptyMessage: "No transcript to save") { + try notes.saveText(text: text) + } + } + // One-shot: capture the current selection into the daily note. The tray + // popover steals key focus and SwiftUI `Text.textSelection` doesn't expose + // `AXSelectedText`, so the system-wide AX read can't see a selection made + // in our own agent window — harvest it from that window's responder chain + // first, then fall back to the AX/clipboard path for other apps. + model.tray.onSaveSelection = { [weak self, notes, model] in + guard let self else { return } + self.saveToNote(tray: model.tray, emptyMessage: "No text selected") { + if let own = self.harvestAgentWindowSelection() { + notesLog.info( + "save selection: harvested \(own.count, privacy: .public) chars from agent window") + return try notes.saveText(text: own) + } + notesLog.info("save selection: no own-window selection; trying AX/clipboard path") + return try notes.saveSelection() + } + } + + // ── Diagnostics ── + model.tray.onOpenLogFolder = { [config] in + // stream.log + .env + notes/transcriptions all live under the data dir. + NSWorkspace.shared.open(URL(fileURLWithPath: config.configDir())) + } + model.tray.onCopyDebugInfo = { [config, notes, hotkeys] in + Task { @MainActor in + let recording = await hotkeys.isRecording() + let settings = config.loadSettings() + let info = Bundle.main.infoDictionary + let version = info?["CFBundleShortVersionString"] as? String ?? "?" + let build = info?["CFBundleVersion"] as? String ?? "?" + let stt = + settings.useLocalStt + ? "local (\(settings.localModel))" + : "cloud (\(settings.sttEndpoint ?? "default"))" + let text = [ + "codescribe debug info", + "app version: \(version) (\(build))", + "macOS: \(ProcessInfo.processInfo.operatingSystemVersionString)", + "recording: \(recording)", + "STT engine: \(stt)", + "config dir: \(config.configDir())", + "notes dir: \(notes.notesDir())", + ].joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + } + } + + /// Text of the most recent transcript artifact, mirroring the tray engine's + /// `latestTranscriptText` (newest history entry → its file contents). + private static func latestTranscriptText(_ threads: CodescribeThreads) -> String? { + // Skip failure / no-speech markers so "Save last transcript" never writes a + // "failed" placeholder into the daily note (see RealTrayEngine). + guard + let path = threads.recentHistory(limit: 32) + .first(where: { $0.kind.isCopyableTranscript })?.path + else { return nil } + return try? threads.readHistoryText(path: path) + } + + /// Run a Notes save and reflect the outcome in the still-open popover. The + /// bridge returns the saved payload (non-nil) on success, nil when there was + /// nothing to save, and throws on a write error — every branch gets a banner + /// so the action is fail-loud, never a silent no-op. + private func saveToNote( + tray: TrayViewModel, + emptyMessage: String, + _ perform: () throws -> String? + ) { + do { + let saved = try perform() + if let saved, !saved.isEmpty { + notesLog.info("note saved (\(saved.count, privacy: .public) chars)") + tray.showNoteStatus(.init(kind: .success, message: "Saved to daily note")) + } else { + notesLog.info("note save: nothing to save") + tray.showNoteStatus(.init(kind: .failure, message: emptyMessage)) + } + } catch { + notesLog.error("note save failed: \(error.localizedDescription, privacy: .public)") + tray.showNoteStatus(.init(kind: .failure, message: "Could not save note")) + } + } + + /// Best-effort harvest of the live text selection from our own agent window. + /// + /// The system-wide AX read used by the bridge can't see it: the tray popover + /// has stolen key focus and SwiftUI `Text.textSelection` doesn't expose + /// `AXSelectedText`. Instead we ask the agent window's responder chain to + /// `copy:`, snapshotting and restoring the real pasteboard so the user's + /// clipboard is left untouched. Returns nil when the window is absent/hidden + /// or holds no selection (a `copy:` on an empty selection leaves the + /// pasteboard `changeCount` unmoved). + private func harvestAgentWindowSelection() -> String? { + guard let window = agentWindow, window.isVisible else { + notesLog.info("harvest: no visible agent window") + return nil + } + let pasteboard = NSPasteboard.general + let changeCountBefore = pasteboard.changeCount + // Snapshot the ENTIRE pasteboard (every item, every type) so restoring it + // can't clobber images/files the user had copied — a string-only snapshot + // would drop them. Items read from the pasteboard are owned by it, so each + // is deep-copied into a fresh NSPasteboardItem before we overwrite them. + let savedItems: [NSPasteboardItem] = (pasteboard.pasteboardItems ?? []).map { item in + let copy = NSPasteboardItem() + for type in item.types { + if let data = item.data(forType: type) { + copy.setData(data, forType: type) + } + } + return copy + } + + let handled = + window.firstResponder? + .tryToPerform(#selector(NSText.copy(_:)), with: nil) ?? false + guard handled, pasteboard.changeCount != changeCountBefore else { + notesLog.info("harvest: responder copy produced no selection") + return nil + } + + let harvested = pasteboard.string(forType: .string)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + // Restore the user's full clipboard — Save selection must not clobber it. + pasteboard.clearContents() + if !savedItems.isEmpty { pasteboard.writeObjects(savedItems) } + + guard let harvested, !harvested.isEmpty else { return nil } + return harvested + } + + func applicationWillTerminate(_ notification: Notification) { + // Mirrors the launch guards: the test host never started hotkeys, and + // touching the lazy handle here would construct the bridge at teardown + // purely to stop something that was never running. + guard !Self.isRunningTests else { return } + hotkeys.stop() + sleepWakeObserver?.invalidate() + sleepWakeObserver = nil + if let textScaleMonitor { NSEvent.removeMonitor(textScaleMonitor) } + DistributedNotificationCenter.default().removeObserver(self) + } + + /// Bind the active recorder to the real host power lifecycle. + /// + /// The observer is object-scoped and coalesced. This closure runs on the + /// following main-queue tick and only launches the non-creating bridge + /// query; the provider transition happens in the Rust session loop. + private func installSystemSleepWakeObserver() { + let observer = SystemSleepWakeObserver { [weak self] in + Task { [weak self] in + guard let self else { return } + let reachedActiveRecorder = await self.hotkeys.noteSleepWake() + appLogger.info( + "Host sleep/wake boundary forwarded to active recorder: \(reachedActiveRecorder, privacy: .public)" ) - appActionListener = listener - hotkeys.setAppActionListener(listener: listener) - } - - private func startHotkeys() { - Task { [hotkeys] in - do { - try await hotkeys.start() - appLogger.info("Codescribe hotkeys active: \(hotkeys.isActive(), privacy: .public)") - } catch { - appLogger.error("Codescribe hotkeys unavailable: \(String(describing: error), privacy: .public)") - } - } - } - - private func prewarmRecordingController() { - Task { [hotkeys] in - do { - // Start warmup as early as possible after launch so the engine - // (model load + first-inference kernel compile) is ready before the - // user's first dictation. A brief settle keeps it off the very first - // UI frame; the heavy work runs on a background blocking thread. - try await Task.sleep(nanoseconds: 100_000_000) - try await hotkeys.prewarmRecording() - appLogger.info("Codescribe recording controller prewarmed") - } catch { - appLogger.error("Codescribe recording prewarm failed: \(String(describing: error), privacy: .public)") - } - } - } - - private static var isDuplicateInstance: Bool { - guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return false } - let currentPID = ProcessInfo.processInfo.processIdentifier - return NSRunningApplication - .runningApplications(withBundleIdentifier: bundleIdentifier) - .contains { app in - app.processIdentifier != currentPID && !app.isTerminated - } - } + } + } + sleepWakeObserver = observer + observer.start() + } + + // MARK: - Text scaling (⌘+ / ⌘- / ⌘0) + + /// Install one local key monitor that routes text-scale shortcuts to the SURFACE + /// under focus: the key window decides which scale you adjust. Handled events are + /// swallowed (return nil); anything else passes through untouched. + private func installTextScaleMonitor() { + textScaleMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + // Require ⌘ with no other command modifiers; Shift is allowed because + // "+" is Shift-"=" on most layouts. + guard flags.contains(.command), + !flags.contains(.option), !flags.contains(.control), + let controller = self.textScaleController(for: NSApp.keyWindow) + else { + return event + } + switch event.charactersIgnoringModifiers { + case "+", "=": + controller.increase() + return nil + case "-", "_": + controller.decrease() + return nil + case "0": + controller.reset() + return nil + default: return event + } + } + } + + /// The text-scale controller for a window, or nil when the key window is not a + /// scalable surface (Settings, tray popover, panels). The overlay is discriminated + /// by its `FloatingOverlayPanel` type; the chat by identity. + private func textScaleController(for window: NSWindow?) -> TextScaleController? { + guard let window else { return nil } + if window is FloatingOverlayPanel { return model.overlay.textScale } + if window == agentWindow { return model.chatTextScale } + return nil + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + private func installStatusItem() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = item.button { + button.imagePosition = .imageOnly + button.title = "" + button.action = #selector(toggleTray) + button.target = self + } + statusItem = item + trayStatus.onChange = { [weak self] status in + guard let self else { return } + self.model.overlay.handleIndicatorModeChange(status.indicatorMode) + self.applyStatusItemStatus() + } + applyStatusItemStatus() + } + + private func applyStatusItemStatus() { + guard let button = statusItem?.button else { return } + // The glyph never changes with status — it is always the brand mark. + // Mode is conveyed by the status dot composited into the glyph's + // bottom-right corner, 1:1 with the tray status feed: green ready / + // red recording / orange processing / purple assistive. + button.imagePosition = .imageOnly + button.title = "" + if let dot = trayStatus.menuBarDotColor, let base = statusItemImage() { + // The composite is a flattened, non-template image, so the + // unread-agent tint rides the glyph tint, not contentTintColor. + button.image = TrayStatusDotIcon.composite( + base: base, + dot: NSColor(dot), + glyphTint: hasUnreadAgentUpdate ? .systemYellow : nil + ) + button.contentTintColor = nil + } else { + button.image = statusItemImage() + button.contentTintColor = hasUnreadAgentUpdate ? NSColor.systemYellow : nil + } + button.toolTip = + hasUnreadAgentUpdate + ? "\(trayStatus.status.tooltip) - agent reply ready" + : trayStatus.status.tooltip + } + + private func statusItemImage() -> NSImage? { + // Brand mark from Assets.xcassets (template image → auto-tints for + // light/dark menu bars). Status is signaled by a colored dot composited + // into this icon's corner, never by swapping the glyph. A missing asset + // is a build bug to surface (empty item), not something to paper over. + let image = NSImage(named: "MenuBarIcon") + image?.isTemplate = true + return image + } + + private func ensureAgentWindow() -> NSWindow { + if let agentWindow { return agentWindow } + // Wrap in TextScaleRoot so ⌘+/-/0 on the chat window scale the message + // bodies + composer via `\.csTextScale`, independently of the overlay. + let root = TextScaleRoot(controller: model.chatTextScale) { + AgentChatView(store: model.chat) + .preferredColorScheme(.dark) + } + let hosting = NSHostingController(rootView: root) + let window = NSWindow(contentViewController: hosting) + window.title = "codescribe — Agent" + window.setContentSize(NSSize(width: 1120, height: 720)) + window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + // LSUIElement accessory: join the active Space so a passive + // `orderFrontRegardless` is actually visible during voice delivery. + window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] + window.center() + agentWindow = window + return window + } + + /// Show the agent chat window. + /// - Parameter activating: when true (tray/menu/summon), steal focus. When + /// false (voice TurnStarted / end-of-turn fallback), order front without + /// `NSApp.activate` so the user's frontmost app stays frontmost. + private func showAgent(activating: Bool = true) { + let window = ensureAgentWindow() + let revealIntent = AgentRevealPolicy.intent(activating: activating) + let shouldActivate = AgentRevealPolicy.shouldActivate(for: revealIntent) + // Keep Space-join behavior even if the window was created earlier on an + // older build of this method before collectionBehavior was set. + window.collectionBehavior.formUnion([.moveToActiveSpace, .fullScreenAuxiliary]) + if shouldActivate { + hasUnreadAgentUpdate = false + applyStatusItemStatus() + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + appLogger.info( + "w10a_agent_show activating=true isVisible=\(window.isVisible, privacy: .public)" + ) + } else if AgentRevealPolicy.shouldReorderEvenIfVisible(for: revealIntent) { + // Passive path: always re-order. Early-return on isVisible alone hid + // windows that were "visible" on another Space or occluded, so live + // voice turns never painted until the activating end-of-turn open. + hasUnreadAgentUpdate = true + applyStatusItemStatus() + window.orderFrontRegardless() + appLogger.info( + "w10a_agent_show activating=false isVisible=\(window.isVisible, privacy: .public) isKey=\(window.isKeyWindow, privacy: .public)" + ) + } + } + + /// Passive reveal for voice→agent delivery. Must never call NSApp.activate. + /// Structured log line is the runtime receipt anchor: reveal_ts < done_ts. + private func revealAgentForDelivery() { + appLogger.info("w10a_reveal_begin") + showAgent(activating: false) + appLogger.info("w10a_reveal_done") + } + + private func showTray() { + guard let button = statusItem.button else { return } + NSApp.activate(ignoringOtherApps: true) + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + + @objc private func toggleTray() { + if popover.isShown { + popover.performClose(nil) + } else { + showTray() + } + } + + @objc private func showAgentFromExternalLaunch() { + showAgent() + } + + /// Wire the voice-assistive agent reply stream into the chat window. The + /// hotkey / hands-off send path streams the reply from the core runtime; this + /// listener renders those events live (opening the chat window on turn start). + /// Registration is process-global on the bridge side, so it stands independent + /// of the `hotkeys.start()` Task above. + private func registerVoiceDelivery() { + let listener = VoiceDeliveryListener(store: model.chat) { [weak self] in + self?.revealAgentForDelivery() + } + voiceDeliveryListener = listener + hotkeys.setAgentDeliveryListener(listener: listener) + } + + private func registerAppActions() { + let action = agentSummonAction + let listener = AgentAppActionListener( + summonAgent: { [weak action] in + action?.perform() + appLogger.info("Agent summon command handled: window fronted and composer focus requested") + }, + captureAgent: { [weak action] command in + let mapped: ComposerCaptureCommand + switch command { + case .start: mapped = .startAssistive + case .stop: mapped = .stopAssistive + case .toggle: mapped = .toggleAssistive + } + action?.performCapture(mapped) + appLogger.info("Assistive hotkey handled by Agent composer microphone") + } + ) + appActionListener = listener + hotkeys.setAppActionListener(listener: listener) + } + + private func startHotkeys() { + Task { [hotkeys] in + do { + try await hotkeys.start() + appLogger.info("Codescribe hotkeys active: \(hotkeys.isActive(), privacy: .public)") + } catch { + appLogger.error( + "Codescribe hotkeys unavailable: \(String(describing: error), privacy: .public)") + } + } + } + + private func prewarmRecordingController() { + Task { [hotkeys] in + do { + // Start warmup as early as possible after launch so the engine + // (model load + first-inference kernel compile) is ready before the + // user's first dictation. A brief settle keeps it off the very first + // UI frame; the heavy work runs on a background blocking thread. + try await Task.sleep(nanoseconds: 100_000_000) + try await hotkeys.prewarmRecording() + appLogger.info("Codescribe recording controller prewarmed") + } catch { + appLogger.error( + "Codescribe recording prewarm failed: \(String(describing: error), privacy: .public)") + } + } + } + + private static var isDuplicateInstance: Bool { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return false } + let currentPID = ProcessInfo.processInfo.processIdentifier + return + NSRunningApplication + .runningApplications(withBundleIdentifier: bundleIdentifier) + .contains { app in + app.processIdentifier != currentPID && !app.isTerminated + } + } } diff --git a/macos/Codescribe/Bridge/codescribe_ffi.swift b/macos/Codescribe/Bridge/codescribe_ffi.swift index ac786d7c..9e3ab590 100644 --- a/macos/Codescribe/Bridge/codescribe_ffi.swift +++ b/macos/Codescribe/Bridge/codescribe_ffi.swift @@ -2631,6 +2631,15 @@ public protocol CodescribeHotkeysProtocol: AnyObject, Sendable { */ func isRecording() async -> Bool + /** + * Forward a macOS sleep/wake boundary to the active recorder, if any. + * + * Querying this surface never constructs the shared controller. The host + * notification callback can therefore remain a cheap no-op while idle and + * cannot surprise-load a model or start a provider. + */ + func noteSleepWake() async -> Bool + /** * Name of the app latched for the current overlay session, if known. * Read-only: the paste path keeps owning target activation and delivery. @@ -2698,6 +2707,15 @@ public protocol CodescribeHotkeysProtocol: AnyObject, Sendable { */ func setAppActionListener(listener: CsAppActionListener) + /** + * Publish the Agent UI's current thread selection as the voice-assistive + * routing target (operator contract 2026-08-13: dictation goes to the + * thread the user is looking at; a new thread only via an explicit + * "+ New thread"). `None` = the selection is a not-yet-persisted thread, + * so the next assistive turn mints a fresh one. + */ + func setAssistiveTargetThread(backendId: String?) + /** * Register the Swift overlay listener for the shared controller event stream. */ @@ -2981,6 +2999,31 @@ open func isRecording()async -> Bool { ) } + /** + * Forward a macOS sleep/wake boundary to the active recorder, if any. + * + * Querying this surface never constructs the shared controller. The host + * notification callback can therefore remain a cheap no-op while idle and + * cannot surprise-load a model or start a provider. + */ +open func noteSleepWake()async -> Bool { + return + try! await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_codescribe_ffi_fn_method_codescribehotkeys_note_sleep_wake( + self.uniffiCloneHandle() + + ) + }, + pollFunc: ffi_codescribe_ffi_rust_future_poll_i8, + completeFunc: ffi_codescribe_ffi_rust_future_complete_i8, + freeFunc: ffi_codescribe_ffi_rust_future_free_i8, + liftFunc: FfiConverterBool.lift, + errorHandler: nil + + ) +} + /** * Name of the app latched for the current overlay session, if known. * Read-only: the paste path keeps owning target activation and delivery. @@ -3137,6 +3180,21 @@ open func setAppActionListener(listener: CsAppActionListener) {try! rustCall() FfiConverterTypeCsAppActionListener_lower(listener),$0 ) } +} + + /** + * Publish the Agent UI's current thread selection as the voice-assistive + * routing target (operator contract 2026-08-13: dictation goes to the + * thread the user is looking at; a new thread only via an explicit + * "+ New thread"). `None` = the selection is a not-yet-persisted thread, + * so the next assistive turn mints a fresh one. + */ +open func setAssistiveTargetThread(backendId: String?) {try! rustCall() { + uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_assistive_target_thread( + self.uniffiCloneHandle(), + FfiConverterOptionString.lower(backendId),$0 + ) +} } /** @@ -8888,6 +8946,94 @@ public func FfiConverterTypeCsModelOption_lower(_ value: CsModelOption) -> RustB } +/** + * Span-based canvas highlight. Sample fields are the 3A PCM identity; + * char offsets are the Swift adapter onto already-committed utterance text. + */ +public struct CsOverlayHighlight: Equatable, Hashable { + public var kind: CsOverlayHighlightKind + public var utteranceId: UInt64 + public var charStart: UInt64 + public var charEnd: UInt64 + public var session: String + public var captureEpoch: UInt64 + public var sampleStart: UInt64 + public var sampleEnd: UInt64 + public var before: String + public var after: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(kind: CsOverlayHighlightKind, utteranceId: UInt64, charStart: UInt64, charEnd: UInt64, session: String, captureEpoch: UInt64, sampleStart: UInt64, sampleEnd: UInt64, before: String, after: String) { + self.kind = kind + self.utteranceId = utteranceId + self.charStart = charStart + self.charEnd = charEnd + self.session = session + self.captureEpoch = captureEpoch + self.sampleStart = sampleStart + self.sampleEnd = sampleEnd + self.before = before + self.after = after + } + + +} + +#if compiler(>=6) +extension CsOverlayHighlight: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCsOverlayHighlight: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CsOverlayHighlight { + return + try CsOverlayHighlight( + kind: FfiConverterTypeCsOverlayHighlightKind.read(from: &buf), + utteranceId: FfiConverterUInt64.read(from: &buf), + charStart: FfiConverterUInt64.read(from: &buf), + charEnd: FfiConverterUInt64.read(from: &buf), + session: FfiConverterString.read(from: &buf), + captureEpoch: FfiConverterUInt64.read(from: &buf), + sampleStart: FfiConverterUInt64.read(from: &buf), + sampleEnd: FfiConverterUInt64.read(from: &buf), + before: FfiConverterString.read(from: &buf), + after: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: CsOverlayHighlight, into buf: inout [UInt8]) { + FfiConverterTypeCsOverlayHighlightKind.write(value.kind, into: &buf) + FfiConverterUInt64.write(value.utteranceId, into: &buf) + FfiConverterUInt64.write(value.charStart, into: &buf) + FfiConverterUInt64.write(value.charEnd, into: &buf) + FfiConverterString.write(value.session, into: &buf) + FfiConverterUInt64.write(value.captureEpoch, into: &buf) + FfiConverterUInt64.write(value.sampleStart, into: &buf) + FfiConverterUInt64.write(value.sampleEnd, into: &buf) + FfiConverterString.write(value.before, into: &buf) + FfiConverterString.write(value.after, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsOverlayHighlight_lift(_ buf: RustBuffer) throws -> CsOverlayHighlight { + return try FfiConverterTypeCsOverlayHighlight.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsOverlayHighlight_lower(_ value: CsOverlayHighlight) -> RustBuffer { + return FfiConverterTypeCsOverlayHighlight.lower(value) +} + + /** * Full delivery truth for one overlay Insert, including the app names observed * at the exact delivery boundary and the Paste Here shortcut (or the reason it @@ -9499,6 +9645,12 @@ public struct CsSettings: Equatable, Hashable { public var holdStartDelayMs: UInt64 public var doubleTapIntervalMs: UInt64 public var toggleSilenceSec: Float + /** + * Deferred-insert chord (`DeferredInsertShortcut::wire_id()`), sourced + * from the canonical merged config snapshot. `"disabled"` is the + * product default when no persisted choice exists. + */ + public var deferredInsertShortcut: String public var whisperLanguage: CsLanguage public var aiFormattingEnabled: Bool /** @@ -9588,7 +9740,12 @@ public struct CsSettings: Equatable, Hashable { public init(holdExclusive: Bool, /** * Assistive-arm modifier on hold base: `"shift"` (default) or `"cmd"` (W10-B). - */holdArmModifier: String, holdStartDelayMs: UInt64, doubleTapIntervalMs: UInt64, toggleSilenceSec: Float, whisperLanguage: CsLanguage, aiFormattingEnabled: Bool, + */holdArmModifier: String, holdStartDelayMs: UInt64, doubleTapIntervalMs: UInt64, toggleSilenceSec: Float, + /** + * Deferred-insert chord (`DeferredInsertShortcut::wire_id()`), sourced + * from the canonical merged config snapshot. `"disabled"` is the + * product default when no persisted choice exists. + */deferredInsertShortcut: String, whisperLanguage: CsLanguage, aiFormattingEnabled: Bool, /** * `TranscriptSendMode::as_str()` — `"end_of_utterance"` / `"streaming"`. */transcriptSendMode: String, transcriptTaggingEnabled: Bool, transcriptTagTemplate: String, aiMaxTokens: Int32, aiAssistiveMaxTokens: Int32, showTrayGlyph: Bool, showDockIcon: Bool, transcriptionOverlayEnabled: Bool, holdIndicator: Bool, holdBadgeSize: UInt32, holdBadgeOffsetX: Int32, holdBadgeOffsetY: Int32, @@ -9627,6 +9784,7 @@ public struct CsSettings: Equatable, Hashable { self.holdStartDelayMs = holdStartDelayMs self.doubleTapIntervalMs = doubleTapIntervalMs self.toggleSilenceSec = toggleSilenceSec + self.deferredInsertShortcut = deferredInsertShortcut self.whisperLanguage = whisperLanguage self.aiFormattingEnabled = aiFormattingEnabled self.transcriptSendMode = transcriptSendMode @@ -9698,6 +9856,7 @@ public struct FfiConverterTypeCsSettings: FfiConverterRustBuffer { holdStartDelayMs: FfiConverterUInt64.read(from: &buf), doubleTapIntervalMs: FfiConverterUInt64.read(from: &buf), toggleSilenceSec: FfiConverterFloat.read(from: &buf), + deferredInsertShortcut: FfiConverterString.read(from: &buf), whisperLanguage: FfiConverterTypeCsLanguage.read(from: &buf), aiFormattingEnabled: FfiConverterBool.read(from: &buf), transcriptSendMode: FfiConverterString.read(from: &buf), @@ -9757,6 +9916,7 @@ public struct FfiConverterTypeCsSettings: FfiConverterRustBuffer { FfiConverterUInt64.write(value.holdStartDelayMs, into: &buf) FfiConverterUInt64.write(value.doubleTapIntervalMs, into: &buf) FfiConverterFloat.write(value.toggleSilenceSec, into: &buf) + FfiConverterString.write(value.deferredInsertShortcut, into: &buf) FfiConverterTypeCsLanguage.write(value.whisperLanguage, into: &buf) FfiConverterBool.write(value.aiFormattingEnabled, into: &buf) FfiConverterString.write(value.transcriptSendMode, into: &buf) @@ -11861,6 +12021,75 @@ public func FfiConverterTypeCsMcpRowTone_lower(_ value: CsMcpRowTone) -> RustBuf } +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +/** + * W13-6B highlight kind. Stringly so Swift can switch without another enum + * reshape if a third kind appears. + */ + +public enum CsOverlayHighlightKind: Equatable, Hashable { + + case lexiconCorrected + case speechGap + + + +} + +#if compiler(>=6) +extension CsOverlayHighlightKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCsOverlayHighlightKind: FfiConverterRustBuffer { + typealias SwiftType = CsOverlayHighlightKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CsOverlayHighlightKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .lexiconCorrected + + case 2: return .speechGap + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: CsOverlayHighlightKind, into buf: inout [UInt8]) { + switch value { + + + case .lexiconCorrected: + writeInt(&buf, Int32(1)) + + + case .speechGap: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsOverlayHighlightKind_lift(_ buf: RustBuffer) throws -> CsOverlayHighlightKind { + return try FfiConverterTypeCsOverlayHighlightKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsOverlayHighlightKind_lower(_ value: CsOverlayHighlightKind) -> RustBuffer { + return FfiConverterTypeCsOverlayHighlightKind.lower(value) +} + + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. /** @@ -13371,6 +13600,15 @@ public func micPermissionGranted() -> Bool { ) }) } +/** + * W13-6B lane flag. Default OFF. Read-only; no permission prompt. + */ +public func overlayHighlightsEnabled() -> Bool { + return try! FfiConverterBool.lift(try! rustCall() { + uniffi_codescribe_ffi_fn_func_overlay_highlights_enabled($0 + ) +}) +} /** * Finalize one correction: the revision always saves; word-level lexicon * pairs are derived and gated individually. `Err` means the SAVE failed. @@ -13404,6 +13642,19 @@ public func qualityTeachDictionaryFromStore()throws -> CsDictionaryTeachResult ) }) } +/** + * One-click Teach from a highlighted span. Reuses the existing quality + + * custom-lexicon writers — no new disk root, no new permission. + */ +public func qualityTeachSpan(variant: String, canonical: String, kind: String)throws -> CsQualityCommitResult { + return try FfiConverterTypeCsQualityCommitResult_lift(try rustCallWithError(FfiConverterTypeCsError_lift) { + uniffi_codescribe_ffi_fn_func_quality_teach_span( + FfiConverterString.lower(variant), + FfiConverterString.lower(canonical), + FfiConverterString.lower(kind),$0 + ) +}) +} /** * Request microphone permission (shows the system dialog when undetermined), * returning whether access is granted. @@ -13467,6 +13718,9 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_func_mic_permission_granted() != 26303) { return InitializationResult.apiChecksumMismatch } + if (uniffi_codescribe_ffi_checksum_func_overlay_highlights_enabled() != 21886) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_func_quality_finalize_correction() != 53355) { return InitializationResult.apiChecksumMismatch } @@ -13476,6 +13730,9 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_func_quality_teach_dictionary_from_store() != 46244) { return InitializationResult.apiChecksumMismatch } + if (uniffi_codescribe_ffi_checksum_func_quality_teach_span() != 20307) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_func_request_mic_permission() != 61967) { return InitializationResult.apiChecksumMismatch } @@ -13704,6 +13961,9 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_is_recording() != 25239) { return InitializationResult.apiChecksumMismatch } + if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_note_sleep_wake() != 35265) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_paste_target_app_name() != 18571) { return InitializationResult.apiChecksumMismatch } @@ -13731,6 +13991,9 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_app_action_listener() != 65409) { return InitializationResult.apiChecksumMismatch } + if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_assistive_target_thread() != 31256) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_listener() != 43285) { return InitializationResult.apiChecksumMismatch } diff --git a/macos/Codescribe/Bridge/codescribe_ffiFFI.h b/macos/Codescribe/Bridge/codescribe_ffiFFI.h index c924390b..bcc6d2c9 100644 --- a/macos/Codescribe/Bridge/codescribe_ffiFFI.h +++ b/macos/Codescribe/Bridge/codescribe_ffiFFI.h @@ -1045,6 +1045,11 @@ int8_t uniffi_codescribe_ffi_fn_method_codescribehotkeys_is_formatting_available uint64_t uniffi_codescribe_ffi_fn_method_codescribehotkeys_is_recording(uint64_t ptr ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_NOTE_SLEEP_WAKE +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_NOTE_SLEEP_WAKE +uint64_t uniffi_codescribe_ffi_fn_method_codescribehotkeys_note_sleep_wake(uint64_t ptr +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_PASTE_TARGET_APP_NAME #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_PASTE_TARGET_APP_NAME uint64_t uniffi_codescribe_ffi_fn_method_codescribehotkeys_paste_target_app_name(uint64_t ptr @@ -1090,6 +1095,11 @@ void uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_agent_delivery_listen void uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_app_action_listener(uint64_t ptr, uint64_t listener, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_ASSISTIVE_TARGET_THREAD +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_ASSISTIVE_TARGET_THREAD +void uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_assistive_target_thread(uint64_t ptr, RustBuffer backend_id, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_LISTENER #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBEHOTKEYS_SET_LISTENER void uniffi_codescribe_ffi_fn_method_codescribehotkeys_set_listener(uint64_t ptr, uint64_t listener, RustCallStatus *_Nonnull out_status @@ -1666,6 +1676,12 @@ RustBuffer uniffi_codescribe_ffi_fn_func_license_status(RustBuffer key, RustBuff #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_MIC_PERMISSION_GRANTED int8_t uniffi_codescribe_ffi_fn_func_mic_permission_granted(RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_OVERLAY_HIGHLIGHTS_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_OVERLAY_HIGHLIGHTS_ENABLED +int8_t uniffi_codescribe_ffi_fn_func_overlay_highlights_enabled(RustCallStatus *_Nonnull out_status + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_QUALITY_FINALIZE_CORRECTION @@ -1682,6 +1698,11 @@ RustBuffer uniffi_codescribe_ffi_fn_func_quality_recent_records(uint64_t limit, #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_QUALITY_TEACH_DICTIONARY_FROM_STORE RustBuffer uniffi_codescribe_ffi_fn_func_quality_teach_dictionary_from_store(RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_QUALITY_TEACH_SPAN +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_QUALITY_TEACH_SPAN +RustBuffer uniffi_codescribe_ffi_fn_func_quality_teach_span(RustBuffer variant, RustBuffer canonical, RustBuffer kind, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_REQUEST_MIC_PERMISSION @@ -2008,6 +2029,12 @@ uint16_t uniffi_codescribe_ffi_checksum_func_license_status(void #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_MIC_PERMISSION_GRANTED uint16_t uniffi_codescribe_ffi_checksum_func_mic_permission_granted(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_OVERLAY_HIGHLIGHTS_ENABLED +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_OVERLAY_HIGHLIGHTS_ENABLED +uint16_t uniffi_codescribe_ffi_checksum_func_overlay_highlights_enabled(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_QUALITY_FINALIZE_CORRECTION @@ -2026,6 +2053,12 @@ uint16_t uniffi_codescribe_ffi_checksum_func_quality_recent_records(void #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_QUALITY_TEACH_DICTIONARY_FROM_STORE uint16_t uniffi_codescribe_ffi_checksum_func_quality_teach_dictionary_from_store(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_QUALITY_TEACH_SPAN +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_QUALITY_TEACH_SPAN +uint16_t uniffi_codescribe_ffi_checksum_func_quality_teach_span(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_REQUEST_MIC_PERMISSION @@ -2482,6 +2515,12 @@ uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_is_formatting_a #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_IS_RECORDING uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_is_recording(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_NOTE_SLEEP_WAKE +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_NOTE_SLEEP_WAKE +uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_note_sleep_wake(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_PASTE_TARGET_APP_NAME @@ -2536,6 +2575,12 @@ uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_agent_deliv #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SET_APP_ACTION_LISTENER uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_app_action_listener(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SET_ASSISTIVE_TARGET_THREAD +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SET_ASSISTIVE_TARGET_THREAD +uint16_t uniffi_codescribe_ffi_checksum_method_codescribehotkeys_set_assistive_target_thread(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBEHOTKEYS_SET_LISTENER diff --git a/macos/Codescribe/Core/AgentRevealPolicy.swift b/macos/Codescribe/Core/AgentRevealPolicy.swift index b3b7a562..638d4cc5 100644 --- a/macos/Codescribe/Core/AgentRevealPolicy.swift +++ b/macos/Codescribe/Core/AgentRevealPolicy.swift @@ -8,33 +8,33 @@ import Foundation /// W10-A: end-of-turn used to call activating `.openChat`, which is why the /// window appeared only with the finished answer and stole focus. enum AgentRevealIntent: Equatable { - /// Tray menu, status-item double-click path, show-agent hotkey, external launch. - case explicitOpen - /// Voice TurnStarted passive reveal, or end-of-turn non-activating fallback. - case voiceDelivery + /// Tray menu, status-item double-click path, show-agent hotkey, external launch. + case explicitOpen + /// Voice TurnStarted passive reveal, or end-of-turn non-activating fallback. + case voiceDelivery } enum AgentRevealPolicy { - static func intent(activating: Bool) -> AgentRevealIntent { - activating ? .explicitOpen : .voiceDelivery - } + static func intent(activating: Bool) -> AgentRevealIntent { + activating ? .explicitOpen : .voiceDelivery + } - /// Whether `NSApp.activate(ignoringOtherApps:)` is allowed for this intent. - static func shouldActivate(for intent: AgentRevealIntent) -> Bool { - switch intent { - case .explicitOpen: - return true - case .voiceDelivery: - return false - } + /// Whether `NSApp.activate(ignoringOtherApps:)` is allowed for this intent. + static func shouldActivate(for intent: AgentRevealIntent) -> Bool { + switch intent { + case .explicitOpen: + return true + case .voiceDelivery: + return false } + } - /// Whether a passive reveal should re-order an already-visible window. - /// Always true: "visible" alone is not enough under LSUIElement + multi-Space. - static func shouldReorderEvenIfVisible(for intent: AgentRevealIntent) -> Bool { - switch intent { - case .explicitOpen, .voiceDelivery: - return true - } + /// Whether a passive reveal should re-order an already-visible window. + /// Always true: "visible" alone is not enough under LSUIElement + multi-Space. + static func shouldReorderEvenIfVisible(for intent: AgentRevealIntent) -> Bool { + switch intent { + case .explicitOpen, .voiceDelivery: + return true } + } } diff --git a/macos/Codescribe/Core/AppModel.swift b/macos/Codescribe/Core/AppModel.swift index 77b31dc8..d1db97b2 100644 --- a/macos/Codescribe/Core/AppModel.swift +++ b/macos/Codescribe/Core/AppModel.swift @@ -6,17 +6,17 @@ import SwiftUI /// thread index load, selected thread load, tool catalog load). Filter with: /// log show --predicate 'category == "agent-perf"' --info enum AgentPerf { - static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "agent-perf" - ) + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "agent-perf" + ) - static func log(_ label: String, since start: Date, detail: String = "") { - let ms = Int(Date().timeIntervalSince(start) * 1000) - logger.info( - "\(label, privacy: .public): \(ms, privacy: .public)ms \(detail, privacy: .public)" - ) - } + static func log(_ label: String, since start: Date, detail: String = "") { + let ms = Int(Date().timeIntervalSince(start) * 1000) + logger.info( + "\(label, privacy: .public): \(ms, privacy: .public)ms \(detail, privacy: .public)" + ) + } } /// Owns the app's long-lived view-models + engines so they can reference each @@ -24,40 +24,40 @@ enum AgentPerf { /// The menu-bar status item itself lives in the AppDelegate (proven reliable). @MainActor final class AppModel: ObservableObject { - static let shared = AppModel() + static let shared = AppModel() - let chat: AgentChatStore - let overlay: OverlayController - let tray: TrayViewModel - /// Independent text scale for the agent chat surface (⌘+/-/0 while the chat - /// window is key). The overlay's scale lives on `OverlayController`. - let chatTextScale = TextScaleController(key: "AgentChat.textScale.v1") + let chat: AgentChatStore + let overlay: OverlayController + let tray: TrayViewModel + /// Independent text scale for the agent chat surface (⌘+/-/0 while the chat + /// window is key). The overlay's scale lives on `OverlayController`. + let chatTextScale = TextScaleController(key: "AgentChat.textScale.v1") - init() { - let bootstrapStart = Date() - // Shell-first agent bootstrap: the store starts as a light event sink - // (voice delivery works immediately); the persisted thread index loads - // asynchronously OFF the main actor and merges in when ready. No disk - // I/O or thread-history parsing happens on this MainActor init path. - let chat = AgentChatStore( - engine: RealChatEngine(), - threadsProvider: RealThreadsEngine(), - licenseService: LicenseService.shared, - loadsThreadIndexEagerly: false - ) - chat.paletteSource = RealComposerPaletteSource( - settings: RealSettingsEngine(), - mcpAdmin: RealMCPAdminEngine() - ) - self.chat = chat - self.overlay = OverlayController(store: chat, engine: ControllerDictationEngine()) - self.tray = TrayViewModel(engine: RealTrayEngine()) - // AgentChatStore/composer is the sole Assistive route owner. Its existing - // dictation adapter is also used by the composer mic button; legacy - // Dictation/Formatting stay on RecordingController + overlay. - chat.dictation = RealComposerDictation(store: chat) - AgentPerf.log("app bootstrap (AppModel init)", since: bootstrapStart) - } + init() { + let bootstrapStart = Date() + // Shell-first agent bootstrap: the store starts as a light event sink + // (voice delivery works immediately); the persisted thread index loads + // asynchronously OFF the main actor and merges in when ready. No disk + // I/O or thread-history parsing happens on this MainActor init path. + let chat = AgentChatStore( + engine: RealChatEngine(), + threadsProvider: RealThreadsEngine(), + licenseService: LicenseService.shared, + loadsThreadIndexEagerly: false + ) + chat.paletteSource = RealComposerPaletteSource( + settings: RealSettingsEngine(), + mcpAdmin: RealMCPAdminEngine() + ) + self.chat = chat + self.overlay = OverlayController(store: chat, engine: ControllerDictationEngine()) + self.tray = TrayViewModel(engine: RealTrayEngine()) + // AgentChatStore/composer is the sole Assistive route owner. Its existing + // dictation adapter is also used by the composer mic button; legacy + // Dictation/Formatting stay on RecordingController + overlay. + chat.dictation = RealComposerDictation(store: chat) + AgentPerf.log("app bootstrap (AppModel init)", since: bootstrapStart) + } } /// Owns the floating dictation NSPanel + its OverlayState. @@ -65,217 +65,218 @@ final class AppModel: ObservableObject { /// only the SwiftUI surface for that single controller. @MainActor final class OverlayController: ObservableObject { - let state: OverlayState - private weak var store: AgentChatStore? - /// Independent text scale for the dictation overlay (⌘+/-/0 while the panel is - /// key). Separate from the chat scale so a distance-readable transcript and an - /// up-close chat can be tuned independently. - let textScale = TextScaleController(key: "DictationOverlayPanel.textScale.v1") - private var panel: NSPanel? - private let overlayEnabledProvider: () -> Bool - private let assistiveStatusProvider: () -> Bool - private let panelFactory: @MainActor (OverlayState, TextScaleController) -> NSPanel - private let orderPanelFront: @MainActor (NSPanel) -> Void - private let orderPanelOut: @MainActor (NSPanel) -> Void - /// Latched across the session (preparing → started → stopped) because the - /// Rust controller clears its assistive flag right after the stop pipeline — - /// a single read at finalize would race it. Mid-hold upgrades (Fn → Fn+Shift) - /// flip the tray status while recording, so every lifecycle hook re-polls. - private var sessionWasAssistive = false + let state: OverlayState + private weak var store: AgentChatStore? + /// Independent text scale for the dictation overlay (⌘+/-/0 while the panel is + /// key). Separate from the chat scale so a distance-readable transcript and an + /// up-close chat can be tuned independently. + let textScale = TextScaleController(key: "DictationOverlayPanel.textScale.v1") + private var panel: NSPanel? + private let overlayEnabledProvider: () -> Bool + private let assistiveStatusProvider: () -> Bool + private let panelFactory: @MainActor (OverlayState, TextScaleController) -> NSPanel + private let orderPanelFront: @MainActor (NSPanel) -> Void + private let orderPanelOut: @MainActor (NSPanel) -> Void + /// Latched across the session (preparing → started → stopped) because the + /// Rust controller clears its assistive flag right after the stop pipeline — + /// a single read at finalize would race it. Mid-hold upgrades (Fn → Fn+Shift) + /// flip the tray status while recording, so every lifecycle hook re-polls. + private var sessionWasAssistive = false - init( - store: AgentChatStore? = nil, - state: OverlayState? = nil, - engine: DictationEngine? = nil, - overlayEnabledProvider: @escaping () -> Bool = { - CodescribeConfig().trayToggles().transcriptionOverlayEnabled - }, - assistiveStatusProvider: @escaping () -> Bool = { - CodescribeTrayStatus().currentStatus().assistive - }, - panelFactory: (@MainActor (OverlayState, TextScaleController) -> NSPanel)? = nil, - orderPanelFront: (@MainActor (NSPanel) -> Void)? = nil, - orderPanelOut: (@MainActor (NSPanel) -> Void)? = nil - ) { - let state = state ?? OverlayState() - self.state = state - self.store = store - self.overlayEnabledProvider = overlayEnabledProvider - self.assistiveStatusProvider = assistiveStatusProvider - self.panelFactory = panelFactory ?? { - DictationOverlayWindow.make(state: $0, textScale: $1) - } - self.orderPanelFront = orderPanelFront ?? { $0.orderFrontRegardless() } - self.orderPanelOut = orderPanelOut ?? { $0.orderOut(nil) } - state.engine = engine - // Drive the tray status off the SAME authoritative recording lifecycle the - // overlay already receives. The tray view-model otherwise only polls on - // appear (and the popover is built once), so it stayed "Recording" after - // Finish. These hooks fire for every start/stop path (hotkey, tray, auto). - state.onRecordingPreparing = { [weak self] in - guard let self else { return } - self.sessionWasAssistive = false - self.refreshAssistiveLatch() - self.showForRecording() - AppModel.shared.tray.isStartingDictation = true - // Block the composer mic while the shared recorder owns the microphone. - AppModel.shared.chat.dictationBlocked = true - } - state.onRecordingStarted = { [weak self] in - guard let self else { return } - self.refreshAssistiveLatch() - self.showForRecording() - AppModel.shared.tray.isRecording = true - AppModel.shared.tray.isStartingDictation = false - AppModel.shared.chat.dictationBlocked = true - } - state.onRecordingStopped = { [weak self] in - guard let self else { return } - self.refreshAssistiveLatch() - self.markStopped() - AppModel.shared.tray.isRecording = false - AppModel.shared.tray.isStartingDictation = false - AppModel.shared.chat.dictationBlocked = false - } - state.onSuccessfulDictation = { - Task { @MainActor in - _ = await ActivationPing.shared.recordFirstSuccessfulDictation() - } - } - state.onClose = { [weak self] in self?.hide() } - state.onSendToAgent = { [weak self] text in - guard !text.isEmpty else { return } - // Rust already persisted and streamed the turn. TurnStarted opened - // the chat passively; do NOT activate here (focus-steal at Done was - // the wave10 operator bug). Fallback is also passive in case the - // delivery listener missed TurnStarted. - AppModel.shared.tray.onIntent(.revealChat) - self?.hide() - } - state.onPlacementChanged = { [weak self] in self?.applyPlacement(animated: true) } - state.attach() + init( + store: AgentChatStore? = nil, + state: OverlayState? = nil, + engine: DictationEngine? = nil, + overlayEnabledProvider: @escaping () -> Bool = { + CodescribeConfig().trayToggles().transcriptionOverlayEnabled + }, + assistiveStatusProvider: @escaping () -> Bool = { + CodescribeTrayStatus().currentStatus().assistive + }, + panelFactory: (@MainActor (OverlayState, TextScaleController) -> NSPanel)? = nil, + orderPanelFront: (@MainActor (NSPanel) -> Void)? = nil, + orderPanelOut: (@MainActor (NSPanel) -> Void)? = nil + ) { + let state = state ?? OverlayState() + self.state = state + self.store = store + self.overlayEnabledProvider = overlayEnabledProvider + self.assistiveStatusProvider = assistiveStatusProvider + self.panelFactory = + panelFactory ?? { + DictationOverlayWindow.make(state: $0, textScale: $1) + } + self.orderPanelFront = orderPanelFront ?? { $0.orderFrontRegardless() } + self.orderPanelOut = orderPanelOut ?? { $0.orderOut(nil) } + state.engine = engine + // Drive the tray status off the SAME authoritative recording lifecycle the + // overlay already receives. The tray view-model otherwise only polls on + // appear (and the popover is built once), so it stayed "Recording" after + // Finish. These hooks fire for every start/stop path (hotkey, tray, auto). + state.onRecordingPreparing = { [weak self] in + guard let self else { return } + self.sessionWasAssistive = false + self.refreshAssistiveLatch() + self.showForRecording() + AppModel.shared.tray.isStartingDictation = true + // Block the composer mic while the shared recorder owns the microphone. + AppModel.shared.chat.dictationBlocked = true } - - func prepareForRecordingStart() { - state.prepareForExternalStart() + state.onRecordingStarted = { [weak self] in + guard let self else { return } + self.refreshAssistiveLatch() + self.showForRecording() + AppModel.shared.tray.isRecording = true + AppModel.shared.tray.isStartingDictation = false + AppModel.shared.chat.dictationBlocked = true } - - /// Show the overlay for a dictation session, honouring the "Transcription - /// Overlay" toggle. When disabled, dictation runs headless — hold the hotkey, - /// dictate, and the text lands at the cursor (+ clipboard) with no window. - /// Delivery is engine-side (LocalFinalPass), independent of this window, so - /// hiding the overlay never suppresses the paste. - func showForRecording() { - refreshAssistiveLatch() - guard !agentCaptureOwnsMicrophone, !sessionWasAssistive else { - hide() - return - } - guard overlayEnabledProvider() else { - if panel != nil { hide() } - return - } - show() + state.onRecordingStopped = { [weak self] in + guard let self else { return } + self.refreshAssistiveLatch() + self.markStopped() + AppModel.shared.tray.isRecording = false + AppModel.shared.tray.isStartingDictation = false + AppModel.shared.chat.dictationBlocked = false } - - func show() { - guard !agentCaptureOwnsMicrophone else { - hide() - return - } - let panel = panel ?? panelFactory(state, textScale) - self.panel = panel - // A pending fade-out must not leave a freshly shown panel invisible. - panel.alphaValue = 1 - applyPlacement(animated: false) - orderPanelFront(panel) + state.onSuccessfulDictation = { + Task { @MainActor in + _ = await ActivationPing.shared.recordFirstSuccessfulDictation() + } } - - /// Derive and apply the panel's frame from the placement prefs: free motion - /// restores the last dragged origin, anchored derives from the anchor — - /// in ONE setFrame so there is no transient mismatched frame. Clamping the - /// size here covers programmatic sizing, which AppKit's minSize does not. - private func applyPlacement(animated: Bool) { - guard let panel else { return } - let screen = NSScreen.main - let size = DictationOverlayWindow.clamp(panel.frame.size, to: screen) - let origin: NSPoint? - if state.freeMotion { - origin = OverlayPlacement.restoredOrigin(size: size, on: screen) ?? panel.frame.origin - } else { - origin = OverlayPlacement.origin(for: state.placementAnchor, size: size, on: screen) - } - guard let origin else { - panel.setContentSize(size) - return - } - let frame = NSRect(origin: origin, size: size) - if animated, panel.isVisible { - panel.animator().setFrame(frame, display: true) - } else { - panel.setFrame(frame, display: false) - } + state.onClose = { [weak self] in self?.hide() } + state.onSendToAgent = { [weak self] text in + guard !text.isEmpty else { return } + // Rust already persisted and streamed the turn. TurnStarted opened + // the chat passively; do NOT activate here (focus-steal at Done was + // the wave10 operator bug). Fallback is also passive in case the + // delivery listener missed TurnStarted. + AppModel.shared.tray.onIntent(.revealChat) + self?.hide() } + state.onPlacementChanged = { [weak self] in self?.applyPlacement(animated: true) } + state.attach() + } - func markStopped() { - state.finishControllerRecording() - } + func prepareForRecordingStart() { + state.prepareForExternalStart() + } - /// Called by the live TrayStatusStore listener. Assistive is Agent-owned and - /// therefore forces the transcription overlay closed. - func handleIndicatorModeChange(_ mode: CsIndicatorMode) { - if mode == .assistive { - sessionWasAssistive = true - hide() - } - state.setAutoPasteControlAvailable(!sessionWasAssistive) - state.applyIndicatorMode(mode) + /// Show the overlay for a dictation session, honouring the "Transcription + /// Overlay" toggle. When disabled, dictation runs headless — hold the hotkey, + /// dictate, and the text lands at the cursor (+ clipboard) with no window. + /// Delivery is engine-side (LocalFinalPass), independent of this window, so + /// hiding the overlay never suppresses the paste. + func showForRecording() { + refreshAssistiveLatch() + guard !agentCaptureOwnsMicrophone, !sessionWasAssistive else { + hide() + return + } + guard overlayEnabledProvider() else { + if panel != nil { hide() } + return } + show() + } - func handleAssistiveStatusChange(_ assistive: Bool) { - handleIndicatorModeChange(assistive ? .assistive : .hold) + func show() { + guard !agentCaptureOwnsMicrophone else { + hide() + return } + let panel = panel ?? panelFactory(state, textScale) + self.panel = panel + // A pending fade-out must not leave a freshly shown panel invisible. + panel.alphaValue = 1 + applyPlacement(animated: false) + orderPanelFront(panel) + } - private func refreshAssistiveLatch() { - handleAssistiveStatusChange(assistiveStatusProvider()) + /// Derive and apply the panel's frame from the placement prefs: free motion + /// restores the last dragged origin, anchored derives from the anchor — + /// in ONE setFrame so there is no transient mismatched frame. Clamping the + /// size here covers programmatic sizing, which AppKit's minSize does not. + private func applyPlacement(animated: Bool) { + guard let panel else { return } + let screen = NSScreen.main + let size = DictationOverlayWindow.clamp(panel.frame.size, to: screen) + let origin: NSPoint? + if state.freeMotion { + origin = OverlayPlacement.restoredOrigin(size: size, on: screen) ?? panel.frame.origin + } else { + origin = OverlayPlacement.origin(for: state.placementAnchor, size: size, on: screen) + } + guard let origin else { + panel.setContentSize(size) + return } + let frame = NSRect(origin: origin, size: size) + if animated, panel.isVisible { + panel.animator().setFrame(frame, display: true) + } else { + panel.setFrame(frame, display: false) + } + } + + func markStopped() { + state.finishControllerRecording() + } - private var agentCaptureOwnsMicrophone: Bool { - guard let store else { return false } - return store.dictationPhase == .preparing || store.dictationPhase == .recording + /// Called by the live TrayStatusStore listener. Assistive is Agent-owned and + /// therefore forces the transcription overlay closed. + func handleIndicatorModeChange(_ mode: CsIndicatorMode) { + if mode == .assistive { + sessionWasAssistive = true + hide() } + state.setAutoPasteControlAvailable(!sessionWasAssistive) + state.applyIndicatorMode(mode) + } - func hide() { - // Persist the user's chosen size for next launch (replaces frame autosave, - // which used to write back the old feedback loop's runaway sizes) — and, - // in free motion, the dragged origin. - if let panel { - DictationOverlayWindow.persist(size: panel.frame.size) - if state.freeMotion { - OverlayPlacement.persistOrigin(panel.frame.origin) - } - } - if let panel { orderPanelOut(panel) } + func handleAssistiveStatusChange(_ assistive: Bool) { + handleIndicatorModeChange(assistive ? .assistive : .hold) + } + + private func refreshAssistiveLatch() { + handleAssistiveStatusChange(assistiveStatusProvider()) + } + + private var agentCaptureOwnsMicrophone: Bool { + guard let store else { return false } + return store.dictationPhase == .preparing || store.dictationPhase == .recording + } + + func hide() { + // Persist the user's chosen size for next launch (replaces frame autosave, + // which used to write back the old feedback loop's runaway sizes) — and, + // in free motion, the dragged origin. + if let panel { + DictationOverlayWindow.persist(size: panel.frame.size) + if state.freeMotion { + OverlayPlacement.persistOrigin(panel.frame.origin) + } } + if let panel { orderPanelOut(panel) } + } - /// The dictated transcript was handed to the agent (voice turn opened in the - /// chat window). The overlay's job is done — fade it out immediately instead - /// of lingering over the conversation it just fed. - func hideForAgentHandoff() { - guard let panel, panel.isVisible else { return } - DictationOverlayWindow.persist(size: panel.frame.size) - if state.freeMotion { - OverlayPlacement.persistOrigin(panel.frame.origin) - } - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - panel.animator().alphaValue = 0 - } completionHandler: { [weak self] in - Task { @MainActor in - guard let self, let panel = self.panel else { return } - self.orderPanelOut(panel) - panel.alphaValue = 1 - } - } + /// The dictated transcript was handed to the agent (voice turn opened in the + /// chat window). The overlay's job is done — fade it out immediately instead + /// of lingering over the conversation it just fed. + func hideForAgentHandoff() { + guard let panel, panel.isVisible else { return } + DictationOverlayWindow.persist(size: panel.frame.size) + if state.freeMotion { + OverlayPlacement.persistOrigin(panel.frame.origin) + } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + panel.animator().alphaValue = 0 + } completionHandler: { [weak self] in + Task { @MainActor in + guard let self, let panel = self.panel else { return } + self.orderPanelOut(panel) + panel.alphaValue = 1 + } } + } } diff --git a/macos/Codescribe/Core/ComposerDictation.swift b/macos/Codescribe/Core/ComposerDictation.swift index 200f3b8c..89c71e77 100644 --- a/macos/Codescribe/Core/ComposerDictation.swift +++ b/macos/Codescribe/Core/ComposerDictation.swift @@ -4,8 +4,8 @@ import OSLog /// Diagnostic breadcrumbs for the composer voice-note path. Filter with: /// log show --predicate 'subsystem == "com.vetcoders.codescribe"' --info private let dictationLog = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "composer-dictation" + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "composer-dictation" ) /// Real composer dictation adapter: a thin driver over the `CodescribeDictation` @@ -25,199 +25,209 @@ private let dictationLog = Logger( /// a longer live canvas, delivery keeps live and preserves the final alternative. @MainActor final class RealComposerDictation: ComposerDictating { - private let dictation = CodescribeDictation() - private let hotkeys = CodescribeHotkeys() - private weak var store: AgentChatStore? - /// Strong ref so the foreign listener outlives the Rust-side `Arc` handoff. - private var listener: ComposerDictationListener? - /// Whisper is idempotently loaded once, then reused for later notes. - private var modelReady = false - /// Guards against re-entrant toggles while an async start/stop is in flight. - private var transitioning = false - private var autoSendOnStop = false - private var pendingStopAfterStart = false + private let dictation = CodescribeDictation() + private let hotkeys = CodescribeHotkeys() + private weak var store: AgentChatStore? + /// Strong ref so the foreign listener outlives the Rust-side `Arc` handoff. + private var listener: ComposerDictationListener? + /// Whisper is idempotently loaded once, then reused for later notes. + private var modelReady = false + /// Guards against re-entrant toggles while an async start/stop is in flight. + private var transitioning = false + private var autoSendOnStop = false + private var pendingStopAfterStart = false - init(store: AgentChatStore) { - self.store = store - } + init(store: AgentChatStore) { + self.store = store + } - func toggle() { - guard let store, !transitioning else { return } - switch store.dictationPhase { - case .recording: - stop() - case .idle, .failed: - start(autoSend: false) - case .preparing: - break // mid-transition — ignore until it settles - } + func toggle() { + guard let store, !transitioning else { return } + switch store.dictationPhase { + case .recording: + stop() + case .idle, .failed: + start(autoSend: false) + case .preparing: + break // mid-transition — ignore until it settles } + } - func handle(_ command: ComposerCaptureCommand) { - guard let store else { return } - if transitioning { - switch command { - case .stopAssistive, .toggleAssistive: - pendingStopAfterStart = true - case .startAssistive: - break - } - return - } - switch command { - case .startAssistive: - if store.dictationPhase != .recording { start(autoSend: true) } - case .stopAssistive: - if store.dictationPhase == .recording { stop() } - case .toggleAssistive: - if store.dictationPhase == .recording { stop() } else { start(autoSend: true) } - } + func handle(_ command: ComposerCaptureCommand) { + guard let store else { return } + if transitioning { + switch command { + case .stopAssistive, .toggleAssistive: + pendingStopAfterStart = true + case .startAssistive: + break + } + return } + switch command { + case .startAssistive: + if store.dictationPhase != .recording { start(autoSend: true) } + case .stopAssistive: + if store.dictationPhase == .recording { stop() } + case .toggleAssistive: + if store.dictationPhase == .recording { stop() } else { start(autoSend: true) } + } + } - private func start(autoSend: Bool) { - guard let store else { return } - // Collision guard: a hotkey/tray/overlay dictation session owns the mic. - if store.dictationBlocked { - store.reportDictationFailure("Microphone is busy with a shortcut dictation.") - return + private func start(autoSend: Bool) { + guard let store else { return } + // Collision guard: a hotkey/tray/overlay dictation session owns the mic. + if store.dictationBlocked { + store.reportDictationFailure("Microphone is busy with a shortcut dictation.") + return + } + transitioning = true + autoSendOnStop = autoSend + store.beginDictationPreviewSession() + store.setDictationPhase(.preparing) + guard hotkeys.setAgentCaptureActive(active: true) else { + transitioning = false + store.reportDictationFailure("Transcription overlay already owns the microphone") + return + } + Task { @MainActor in + defer { + transitioning = false + if pendingStopAfterStart { + pendingStopAfterStart = false + if store.dictationPhase == .recording { + Task { @MainActor [weak self] in self?.stop() } + } } - transitioning = true - autoSendOnStop = autoSend - store.beginDictationPreviewSession() - store.setDictationPhase(.preparing) - guard hotkeys.setAgentCaptureActive(active: true) else { - transitioning = false - store.reportDictationFailure("Transcription overlay already owns the microphone") - return + } + guard await Self.ensureMicPermission() else { + _ = hotkeys.setAgentCaptureActive(active: false) + store.reportDictationFailure( + "Microphone access is off — enable it in System Settings › Privacy & Security.") + return + } + // Register a fresh listener (held strongly here) before starting; the + // bridge rejects `startRecording` without one. + let listener = ComposerDictationListener(store: store) { [weak self] message in + Task { @MainActor [weak self] in + self?.handleEngineError(message: message) } - Task { @MainActor in - defer { - transitioning = false - if pendingStopAfterStart { - pendingStopAfterStart = false - if store.dictationPhase == .recording { - Task { @MainActor [weak self] in self?.stop() } - } - } - } - guard await Self.ensureMicPermission() else { - _ = hotkeys.setAgentCaptureActive(active: false) - store.reportDictationFailure( - "Microphone access is off — enable it in System Settings › Privacy & Security.") - return - } - // Register a fresh listener (held strongly here) before starting; the - // bridge rejects `startRecording` without one. - let listener = ComposerDictationListener(store: store) { [weak self] message in - Task { @MainActor [weak self] in - self?.handleEngineError(message: message) - } - } - self.listener = listener - dictation.setListener(listener: listener) - do { - // Optional Whisper warm: Apple-live must start even when weights are - // missing (gap-fill degraded for the session). Bridge initModel is - // soft-fail for Apple; keep recording start unblocked either way. - if !modelReady { - do { - try await dictation.initModel() - modelReady = true - } catch { - dictationLog.warning( - "composer dictation: Whisper warm skipped (degraded gap-fill): \(error.localizedDescription, privacy: .public)" - ) - // Leave modelReady false so a later session can retry. - } - } - try await dictation.startRecording(language: nil) // auto-detect language - store.setDictationPhase(.recording) - dictationLog.info("composer dictation: recording started") - } catch { - _ = hotkeys.setAgentCaptureActive(active: false) - dictationLog.error("composer dictation start failed: \(error.localizedDescription, privacy: .public)") - store.clearDictationPreview() - store.reportDictationFailure("Couldn't start recording: \(error.localizedDescription)") - } + } + self.listener = listener + dictation.setListener(listener: listener) + do { + // Optional Whisper warm: Apple-live must start even when weights are + // missing (gap-fill degraded for the session). Bridge initModel is + // soft-fail for Apple; keep recording start unblocked either way. + if !modelReady { + do { + try await dictation.initModel() + modelReady = true + } catch { + dictationLog.warning( + "composer dictation: Whisper warm skipped (degraded gap-fill): \(error.localizedDescription, privacy: .public)" + ) + // Leave modelReady false so a later session can retry. + } } + try await dictation.startRecording(language: nil) // auto-detect language + store.setDictationPhase(.recording) + dictationLog.info("composer dictation: recording started") + } catch { + _ = hotkeys.setAgentCaptureActive(active: false) + dictationLog.error( + "composer dictation start failed: \(error.localizedDescription, privacy: .public)") + store.clearDictationPreview() + store.reportDictationFailure("Couldn't start recording: \(error.localizedDescription)") + } } + } - private func stop() { - guard let store else { return } - transitioning = true - store.setDictationPhase(.preparing) - Task { @MainActor in - defer { - transitioning = false - _ = hotkeys.setAgentCaptureActive(active: false) - } - do { - let transcript = try await dictation.stopRecording() - let resolution = store.resolveDictationDelivery( - final: transcript, - autoSend: autoSendOnStop - ) - let trimmed = resolution.text - if trimmed.isEmpty { - dictationLog.info("composer dictation: stopped with empty transcript") - store.clearDictationPreview() - store.reportDictationFailure("No speech detected.") - } else { - store.setDictationPhase(.idle) - var deliveredViaVoiceLane = false - if resolution.autoSend { - // Voice lane first: the controller attaches the - // trigger-time selection context and the context bucket - // (HOTKEYS_CONTRACT "captured in the trigger handler"), - // and the turn streams as a core-owned voice turn — the - // composer FIFO already skips threads with an active - // voice turn. A plain `store.send()` here delivered the - // spoken text alone (review P0-02). - deliveredViaVoiceLane = - (try? await hotkeys.sendAssistiveTranscript(text: trimmed)) ?? false - } - if deliveredViaVoiceLane { - store.clearDictationPreview() - dictationLog.info( - "composer dictation: assistive turn delivered via voice lane") - } else { - store.appendDictatedTranscript(trimmed) - if resolution.autoSend { - store.send() - } - dictationLog.info( - "composer dictation: inserted \(trimmed.count, privacy: .public) chars") - } - // Analytics must never delay transcript delivery or agent send. - Task { @MainActor in - _ = await ActivationPing.shared.recordFirstSuccessfulDictation() - } - } - } catch { - dictationLog.error("composer dictation stop failed: \(error.localizedDescription, privacy: .public)") - store.clearDictationPreview() - store.reportDictationFailure("Couldn't finish recording: \(error.localizedDescription)") + private func stop() { + guard let store else { return } + transitioning = true + store.setDictationPhase(.preparing) + Task { @MainActor in + defer { + transitioning = false + _ = hotkeys.setAgentCaptureActive(active: false) + } + do { + let transcript = try await dictation.stopRecording() + let resolution = store.resolveDictationDelivery( + final: transcript, + autoSend: autoSendOnStop + ) + let trimmed = resolution.text + if trimmed.isEmpty { + dictationLog.info("composer dictation: stopped with empty transcript") + store.clearDictationPreview() + store.reportDictationFailure("No speech detected.") + } else { + store.setDictationPhase(.idle) + var deliveredViaVoiceLane = false + if resolution.autoSend { + // Voice lane first: the controller attaches the + // trigger-time selection context and the context bucket + // (HOTKEYS_CONTRACT "captured in the trigger handler"), + // and the turn streams as a core-owned voice turn — the + // composer FIFO already skips threads with an active + // voice turn. A plain `store.send()` here delivered the + // spoken text alone (review P0-02). + deliveredViaVoiceLane = + (try? await hotkeys.sendAssistiveTranscript(text: trimmed)) ?? false + } + if deliveredViaVoiceLane { + store.clearDictationPreview() + dictationLog.info( + "composer dictation: assistive turn delivered via voice lane") + } else { + store.appendDictatedTranscript(trimmed) + if resolution.autoSend { + store.send() } + dictationLog.info( + "composer dictation: inserted \(trimmed.count, privacy: .public) chars") + } + // Analytics must never delay transcript delivery or agent send. + Task { @MainActor in + _ = await ActivationPing.shared.recordFirstSuccessfulDictation() + } } + } catch { + dictationLog.error( + "composer dictation stop failed: \(error.localizedDescription, privacy: .public)") + store.clearDictationPreview() + store.reportDictationFailure("Couldn't finish recording: \(error.localizedDescription)") + } } + } - private func handleEngineError(message: String) { - dictationLog.error("composer dictation engine error: \(message, privacy: .public)") - guard let store else { return } - guard transitioning || store.dictationPhase == .preparing || store.dictationPhase == .recording else { return } + private func handleEngineError(message: String) { + dictationLog.error("composer dictation engine error: \(message, privacy: .public)") + guard let store else { return } + guard transitioning || store.dictationPhase == .preparing || store.dictationPhase == .recording + else { return } - transitioning = false - listener = nil - _ = hotkeys.setAgentCaptureActive(active: false) - store.reportDictationFailure("Dictation stopped: \(message)") + transitioning = false + listener = nil + _ = hotkeys.setAgentCaptureActive(active: false) + // Never abandon a live recorder: dropping UI state without stopping the + // engine leaves the microphone captured behind an Idle tray, and the next + // toggle — reading the reset UI — starts a second, orphaned capture + // (2026-08-12 incident). Release the engine before reporting. + Task { @MainActor in + _ = try? await dictation.stopRecording() } + store.reportDictationFailure("Dictation stopped: \(message)") + } - /// Check (and, if undetermined, request) microphone access. The request wrapper - /// blocks on the system prompt, so it runs off the main actor. - private static func ensureMicPermission() async -> Bool { - if micPermissionGranted() { return true } - return await Task.detached { requestMicPermission() }.value - } + /// Check (and, if undetermined, request) microphone access. The request wrapper + /// blocks on the system prompt, so it runs off the main actor. + private static func ensureMicPermission() async -> Bool { + if micPermissionGranted() { return true } + return await Task.detached { requestMicPermission() }.value + } } /// Foreign dictation listener for the composer path. Preview callbacks carry the @@ -225,123 +235,127 @@ final class RealComposerDictation: ComposerDictating { /// listener's live display buffer. The composer still reads the authoritative /// final transcript from `stopRecording()` before mutating the draft. final class ComposerDictationListener: CsTranscriptionListener, @unchecked Sendable { - private weak var store: AgentChatStore? - private let onError: (String) -> Void - private let lock = NSLock() - private var committedSegments: [(utteranceId: UInt64, text: String)] = [] - private var activePreview = "" + private weak var store: AgentChatStore? + private let onError: (String) -> Void + private let lock = NSLock() + private var committedSegments: [(utteranceId: UInt64, text: String)] = [] + private var activePreview = "" - init(store: AgentChatStore, onError: @escaping (String) -> Void) { - self.store = store - self.onError = onError - } + init(store: AgentChatStore, onError: @escaping (String) -> Void) { + self.store = store + self.onError = onError + } - func onRecordingPreparing() {} - func onRecordingStarted() {} - func onRecordingStopped() {} - func onRecordingFinalising() {} - func onPreview(text: String) { - publishPreview { - activePreview = text - } - } - func onCorrection(text: String, previousText: String) {} - func onFinal( - utteranceId: UInt64, text: String, avgLogprob: Float?, speechPct: Float?, - confidenceFlags: [String] - ) { - publishPreview { - activePreview = "" - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if let index = committedSegments.firstIndex(where: { $0.utteranceId == utteranceId }) { - committedSegments[index].text = trimmed - } else { - committedSegments.append((utteranceId: utteranceId, text: trimmed)) - } - } - } - /// Layered bounded patch (Layer 1 tail-patch, Layer 2 lexicon/LLM). The - /// composer assembles its own live preview from `onFinal`, so a listener - /// that ignored these would keep showing text the engine has already - /// retracted — split-brain against the overlay, which does apply them. - /// `start`/`end` are Rust-canonical char offsets inside `utteranceId`; - /// unbound or overrunning windows are dropped whole rather than - /// half-applied, matching the overlay and the Rust committed buffer. - /// - /// `lastIndex` is not a style choice — it is the third copy of one algebra. - /// The same patch is resolved by `live_assembly.rs` (`rposition`) and by - /// `OverlayState.onReplaceRange` (`lastIndex`); this used to say - /// `firstIndex`, which picks the opposite slot the moment an utterance id is - /// ever sealed twice. Today it cannot be (`EngineEvent::UtteranceFinal` is - /// contracted "once per VAD-bounded segment"), so the divergence was latent - /// and invisible — see - /// `re_sealed_utterance_id_duplicates_here_but_not_in_the_swift_surfaces`, - /// which pins how the three surfaces disagree. Changing this one word costs - /// nothing today and removes one of the three ways they can drift apart. - func onReplaceRange(utteranceId: UInt64, start: UInt64, end: UInt64, text: String, source: CsLayerSource) { - publishPreview { - guard let index = committedSegments.lastIndex(where: { $0.utteranceId == utteranceId }), - let startOffset = Int(exactly: start), - let endOffset = Int(exactly: end), - startOffset <= endOffset - else { return } - var patched = committedSegments[index].text - guard endOffset <= patched.count else { return } - let lower = patched.index(patched.startIndex, offsetBy: startOffset) - let upper = patched.index(patched.startIndex, offsetBy: endOffset) - patched.replaceSubrange(lower.. Void) { - lock.lock() - update() - let snapshot = mergedPreviewLocked() - lock.unlock() - Task { @MainActor [weak store] in - store?.updateDictationPreview(snapshot) - } + private func publishPreview(_ update: () -> Void) { + lock.lock() + update() + let snapshot = mergedPreviewLocked() + lock.unlock() + Task { @MainActor [weak store] in + store?.updateDictationPreview(snapshot) } + } - private func publishFinalPreview(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - Task { @MainActor [weak store] in - store?.noteDictationFinalPreview(trimmed) - } + private func publishFinalPreview(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + Task { @MainActor [weak store] in + store?.noteDictationFinalPreview(trimmed) } + } - private func mergedPreviewLocked() -> String { - var parts = committedSegments.map(\.text) - let active = activePreview.trimmingCharacters(in: .whitespacesAndNewlines) - if !active.isEmpty { - parts.append(active) - } - return parts.joined(separator: " ") + private func mergedPreviewLocked() -> String { + var parts = committedSegments.map(\.text) + let active = activePreview.trimmingCharacters(in: .whitespacesAndNewlines) + if !active.isEmpty { + parts.append(active) } + return parts.joined(separator: " ") + } } diff --git a/macos/Codescribe/Core/ConfigChangeBus.swift b/macos/Codescribe/Core/ConfigChangeBus.swift index d77c6e4a..471fe075 100644 --- a/macos/Codescribe/Core/ConfigChangeBus.swift +++ b/macos/Codescribe/Core/ConfigChangeBus.swift @@ -11,9 +11,9 @@ import Foundation /// *next* badge show — this bus only syncs UI observers; it does not live-resize /// a visible caret badge. enum ConfigChangeBus { - static let holdBadgeDidChange = Notification.Name("codescribe.config.holdBadgeDidChange") + static let holdBadgeDidChange = Notification.Name("codescribe.config.holdBadgeDidChange") - static func postHoldBadgeChanged() { - NotificationCenter.default.post(name: holdBadgeDidChange, object: nil) - } + static func postHoldBadgeChanged() { + NotificationCenter.default.post(name: holdBadgeDidChange, object: nil) + } } diff --git a/macos/Codescribe/Core/QualityCaptureHost.swift b/macos/Codescribe/Core/QualityCaptureHost.swift index e6a69511..bf97c472 100644 --- a/macos/Codescribe/Core/QualityCaptureHost.swift +++ b/macos/Codescribe/Core/QualityCaptureHost.swift @@ -18,6 +18,6 @@ import Foundation /// Fails CLOSED for the user's data: when the marker is absent we assume /// production and write, which is the safe direction for a real dictation. enum QualityCaptureHost { - /// `XCTestCase` only exists in the process once the test bundle is loaded. - static let isRunningTests = NSClassFromString("XCTestCase") != nil + /// `XCTestCase` only exists in the process once the test bundle is loaded. + static let isRunningTests = NSClassFromString("XCTestCase") != nil } diff --git a/macos/Codescribe/Core/RealChatEngine.swift b/macos/Codescribe/Core/RealChatEngine.swift index 6b94c046..b8089f04 100644 --- a/macos/Codescribe/Core/RealChatEngine.swift +++ b/macos/Codescribe/Core/RealChatEngine.swift @@ -4,153 +4,163 @@ import OSLog /// Diagnostic breadcrumbs for the attachment staging path. Filter with: /// log show --predicate 'subsystem == "com.vetcoders.codescribe"' --info private let attachLog = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "attachments" + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "attachments" ) // Backs the Agent Chat with the REAL codescribe engine via the UniFFI bridge // (CodescribeAgent / CsAgentListener). Streaming token deltas are hopped onto the // main actor (FIFO) so SwiftUI @Published updates stay ordered and thread-safe. final class RealChatEngine: AgentChatEngine { - private let agent = CodescribeAgent() - private var onToolApprovalRequested: (@MainActor (PendingToolApproval) -> Void)? + private let agent = CodescribeAgent() + /// Same bridge surface the voice lane uses; carries the rail-selection + /// routing target down to the controller (operator contract 2026-08-13). + private let assistiveRouting = CodescribeHotkeys() + private var onToolApprovalRequested: (@MainActor (PendingToolApproval) -> Void)? - func isAvailable() -> Bool { agent.isAvailable() } + func isAvailable() -> Bool { agent.isAvailable() } - func availabilityDetail() -> String? { - let availability = agent.availability() - if availability.available { return nil } - // The bridge always fills `detail`; the fallback keeps the chat honest - // if an older dylib ever returns an empty reason. - return availability.detail.isEmpty - ? "The assistive model isn't reachable yet — open Settings → Engine to configure the assistive lane." - : availability.detail - } + func setAssistiveTargetThread(backendId: String?) { + assistiveRouting.setAssistiveTargetThread(backendId: backendId) + } - func generateThreadTitle(_ text: String) async throws -> String? { - try await agent.generateThreadTitle(text: text) - } + func availabilityDetail() -> String? { + let availability = agent.availability() + if availability.available { return nil } + // The bridge always fills `detail`; the fallback keeps the chat honest + // if an older dylib ever returns an empty reason. + return availability.detail.isEmpty + ? "The assistive model isn't reachable yet — open Settings → Engine to configure the assistive lane." + : availability.detail + } - func streamReply( - _ text: String, - threadId: String, - attachmentPaths: [String], - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (_ name: String, _ id: String) -> Void, - onToolResult: @escaping @MainActor (_ name: String, _ id: String, _ isError: Bool, _ reason: String) -> Void - ) async throws -> String { - let listener = StreamListener( - onDelta: onDelta, - onReasoning: onReasoning, - onToolExecuting: onToolExecuting, - onToolResult: onToolResult, - onToolApprovalRequested: onToolApprovalRequested - ) - // Text-only path stays byte-identical to before; only route through the - // vision method when the composer actually staged an image. - if attachmentPaths.isEmpty { - attachLog.info("RealChatEngine.streamReply: text-only path (streamReply, no attachments)") - return try await agent.streamReply(text: text, threadId: threadId, listener: listener) - } - attachLog.info( - "RealChatEngine.streamReply: vision path (streamReplyWithAttachments) with \(attachmentPaths.count, privacy: .public) attachment(s)" - ) - let attachments = attachmentPaths.map { CsAttachment(path: $0) } - return try await agent.streamReplyWithAttachments( - text: text, - threadId: threadId, - attachments: attachments, - listener: listener - ) - } + func generateThreadTitle(_ text: String) async throws -> String? { + try await agent.generateThreadTitle(text: text) + } - func cancelReply(threadId: String) -> Bool { - // Swift Task cancellation never reaches the Rust future through the - // generated UniFFI bindings (they poll to completion), so this explicit - // bridge call is what actually aborts the in-flight turn. - agent.cancelTurn(threadId: threadId) + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (_ name: String, _ id: String) -> Void, + onToolResult: + @escaping @MainActor (_ name: String, _ id: String, _ isError: Bool, _ reason: String) -> Void + ) async throws -> String { + let listener = StreamListener( + onDelta: onDelta, + onReasoning: onReasoning, + onToolExecuting: onToolExecuting, + onToolResult: onToolResult, + onToolApprovalRequested: onToolApprovalRequested + ) + // Text-only path stays byte-identical to before; only route through the + // vision method when the composer actually staged an image. + if attachmentPaths.isEmpty { + attachLog.info("RealChatEngine.streamReply: text-only path (streamReply, no attachments)") + return try await agent.streamReply(text: text, threadId: threadId, listener: listener) } + attachLog.info( + "RealChatEngine.streamReply: vision path (streamReplyWithAttachments) with \(attachmentPaths.count, privacy: .public) attachment(s)" + ) + let attachments = attachmentPaths.map { CsAttachment(path: $0) } + return try await agent.streamReplyWithAttachments( + text: text, + threadId: threadId, + attachments: attachments, + listener: listener + ) + } - func installToolApprovalHandler( - _ handler: @escaping @MainActor (PendingToolApproval) -> Void - ) { - onToolApprovalRequested = handler - } + func cancelReply(threadId: String) -> Bool { + // Swift Task cancellation never reaches the Rust future through the + // generated UniFFI bindings (they poll to completion), so this explicit + // bridge call is what actually aborts the in-flight turn. + agent.cancelTurn(threadId: threadId) + } - func resolveToolApproval( - _ request: PendingToolApproval, approved: Bool, remember: Bool - ) -> Bool { - agent.resolveToolApproval( - sessionId: request.sessionID, - threadId: request.threadID, - callId: request.callID, - approved: approved, - remember: remember - ) - } + func installToolApprovalHandler( + _ handler: @escaping @MainActor (PendingToolApproval) -> Void + ) { + onToolApprovalRequested = handler + } + + func resolveToolApproval( + _ request: PendingToolApproval, approved: Bool, remember: Bool + ) -> Bool { + agent.resolveToolApproval( + sessionId: request.sessionID, + threadId: request.threadID, + callId: request.callID, + approved: approved, + remember: remember + ) + } } /// Bridges Rust-side `CsAgentListener` callbacks (fired from a tokio thread) onto /// the main actor, preserving arrival order. final class StreamListener: CsAgentListener, @unchecked Sendable { - private let onDelta: @MainActor (String) -> Void - private let onReasoning: @MainActor (String) -> Void - private let onToolExecuting: @MainActor (String, String) -> Void - private let onToolResult: @MainActor (String, String, Bool, String) -> Void - private let onToolApprovalRequested: (@MainActor (PendingToolApproval) -> Void)? + private let onDelta: @MainActor (String) -> Void + private let onReasoning: @MainActor (String) -> Void + private let onToolExecuting: @MainActor (String, String) -> Void + private let onToolResult: @MainActor (String, String, Bool, String) -> Void + private let onToolApprovalRequested: (@MainActor (PendingToolApproval) -> Void)? - init( - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (String, String) -> Void, - onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void, - onToolApprovalRequested: (@MainActor (PendingToolApproval) -> Void)? - ) { - self.onDelta = onDelta - self.onReasoning = onReasoning - self.onToolExecuting = onToolExecuting - self.onToolResult = onToolResult - self.onToolApprovalRequested = onToolApprovalRequested - } + init( + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (String, String) -> Void, + onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void, + onToolApprovalRequested: (@MainActor (PendingToolApproval) -> Void)? + ) { + self.onDelta = onDelta + self.onReasoning = onReasoning + self.onToolExecuting = onToolExecuting + self.onToolResult = onToolResult + self.onToolApprovalRequested = onToolApprovalRequested + } - func onTextDelta(delta: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.onDelta(delta) } } - } - func onTextDone(text: String) {} - func onReasoningDelta(delta: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.onReasoning(delta) } } + func onTextDelta(delta: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.onDelta(delta) } } + } + func onTextDone(text: String) {} + func onReasoningDelta(delta: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.onReasoning(delta) } } + } + func onToolExecuting(name: String, id: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.onToolExecuting(name, id) } } + } + func onToolApprovalRequested(request ffiRequest: CsToolApprovalRequest) { + let request = PendingToolApproval( + callID: ffiRequest.callId, + sessionID: ffiRequest.sessionId, + threadID: ffiRequest.threadId, + tool: ffiRequest.tool, + server: ffiRequest.server, + risk: ffiRequest.risk, + summary: ffiRequest.summary, + command: ffiRequest.command, + cwd: ffiRequest.cwd, + paths: ffiRequest.paths + ) + DispatchQueue.main.async { + MainActor.assumeIsolated { self.onToolApprovalRequested?(request) } } - func onToolExecuting(name: String, id: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.onToolExecuting(name, id) } } - } - func onToolApprovalRequested(request ffiRequest: CsToolApprovalRequest) { - let request = PendingToolApproval( - callID: ffiRequest.callId, - sessionID: ffiRequest.sessionId, - threadID: ffiRequest.threadId, - tool: ffiRequest.tool, - server: ffiRequest.server, - risk: ffiRequest.risk, - summary: ffiRequest.summary, - command: ffiRequest.command, - cwd: ffiRequest.cwd, - paths: ffiRequest.paths - ) - DispatchQueue.main.async { - MainActor.assumeIsolated { self.onToolApprovalRequested?(request) } - } - } - func onToolResult(name: String, id: String, summary: String, isError: Bool) { - // `summary` already carries the tool's error reason on failure (see the - // Rust AgentUiEvent::ToolResult contract); forward it so the chat row can - // reveal the cause instead of a bare "failed". - DispatchQueue.main.async { MainActor.assumeIsolated { self.onToolResult(name, id, isError, summary) } } - } - func onDone() {} - func onError(message: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.onDelta("\n[error] " + message) } } + } + func onToolResult(name: String, id: String, summary: String, isError: Bool) { + // `summary` already carries the tool's error reason on failure (see the + // Rust AgentUiEvent::ToolResult contract); forward it so the chat row can + // reveal the cause instead of a bare "failed". + DispatchQueue.main.async { + MainActor.assumeIsolated { self.onToolResult(name, id, isError, summary) } } + } + func onDone() {} + func onError(message: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.onDelta("\n[error] " + message) } } + } } /// Bridges Rust-side `CsAgentDeliveryListener` callbacks (fired from a tokio @@ -166,91 +176,92 @@ final class StreamListener: CsAgentListener, @unchecked Sendable { /// /// `onTurnStarted` also asks AppDelegate for a passive reveal. AppDelegate owns /// focus policy: explicit opens activate, voice delivery never steals focus. -final class VoiceDeliveryListener: CsAgentDeliveryListener, VoiceTurnCancelling, @unchecked Sendable { - private let store: AgentChatStore - private let revealChat: @MainActor () -> Void - private let voiceTurns = CodescribeHotkeys() +final class VoiceDeliveryListener: CsAgentDeliveryListener, VoiceTurnCancelling, @unchecked Sendable +{ + private let store: AgentChatStore + private let revealChat: @MainActor () -> Void + private let voiceTurns = CodescribeHotkeys() - @MainActor - init(store: AgentChatStore, revealChat: @escaping @MainActor () -> Void) { - self.store = store - self.revealChat = revealChat - store.voiceTurnCanceller = self - } + @MainActor + init(store: AgentChatStore, revealChat: @escaping @MainActor () -> Void) { + self.store = store + self.revealChat = revealChat + store.voiceTurnCanceller = self + } - func cancelVoiceTurn(threadId: String) -> Bool { - voiceTurns.cancelVoiceTurn(threadId: threadId) - } + func cancelVoiceTurn(threadId: String) -> Bool { + voiceTurns.cancelVoiceTurn(threadId: threadId) + } - func onTurnStarted(threadId: String, userText: String) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - // The transcript is now the chat's You-bubble — the overlay's job - // is done, so it fades out instead of lingering over the reply. - // Order: hide overlay → passive reveal (no focus steal) → ingest - // so the You-bubble + streaming assistant render while the turn - // is still live. End-of-turn must not re-activate (W10-A). - AppModel.shared.overlay.hideForAgentHandoff() - self.revealChat() - self.store.ingestVoiceTurn(threadId: threadId, userText: userText) - } - } + func onTurnStarted(threadId: String, userText: String) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + // The transcript is now the chat's You-bubble — the overlay's job + // is done, so it fades out instead of lingering over the reply. + // Order: hide overlay → passive reveal (no focus steal) → ingest + // so the You-bubble + streaming assistant render while the turn + // is still live. End-of-turn must not re-activate (W10-A). + AppModel.shared.overlay.hideForAgentHandoff() + self.revealChat() + self.store.ingestVoiceTurn(threadId: threadId, userText: userText) + } } + } - func onTextDelta(delta: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.store.ingestVoiceDelta(delta) } } - } - func onTextDone(text: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.store.ingestVoiceTextDone(text) } } - } - func onReasoningDelta(delta: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.store.ingestVoiceReasoning(delta) } } - } - func onToolExecuting(name: String, id: String) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.store.ingestVoiceToolExecuting(name: name, id: id) - } - } + func onTextDelta(delta: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.store.ingestVoiceDelta(delta) } } + } + func onTextDone(text: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.store.ingestVoiceTextDone(text) } } + } + func onReasoningDelta(delta: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.store.ingestVoiceReasoning(delta) } } + } + func onToolExecuting(name: String, id: String) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.store.ingestVoiceToolExecuting(name: name, id: id) + } } - func onToolResult(name: String, id: String, summary: String, isError: Bool) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.store.ingestVoiceToolResult(name: name, id: id, isError: isError, reason: summary) - } - } + } + func onToolResult(name: String, id: String, summary: String, isError: Bool) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.store.ingestVoiceToolResult(name: name, id: id, isError: isError, reason: summary) + } } - func onDone() { - // Terminal only — never open/activate the agent window here (W10-A). - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.store.ingestVoiceDone() - // Turn-completed persistence edge: broadcast so every observer - // of the persisted thread set re-reads disk truth (rail live - // refresh, wave S cut C) — not just the store this listener - // happens to drive. - ThreadsChangeBus.postThreadsChanged() - } - } + } + func onDone() { + // Terminal only — never open/activate the agent window here (W10-A). + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.store.ingestVoiceDone() + // Turn-completed persistence edge: broadcast so every observer + // of the persisted thread set re-reads disk truth (rail live + // refresh, wave S cut C) — not just the store this listener + // happens to drive. + ThreadsChangeBus.postThreadsChanged() + } } - func onError(message: String) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.store.ingestVoiceError(message) - // Errored turns still persisted the user message (and any - // partial reply) — the rail must learn about the thread even - // without a clean onDone. - ThreadsChangeBus.postThreadsChanged() - } - } + } + func onError(message: String) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.store.ingestVoiceError(message) + // Errored turns still persisted the user message (and any + // partial reply) — the rail must learn about the thread even + // without a clean onDone. + ThreadsChangeBus.postThreadsChanged() + } } - func onCancelled(threadId: String) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.store.ingestVoiceCancelled(threadId: threadId) - // Cancelled turns persist their user half too — same rule. - ThreadsChangeBus.postThreadsChanged() - } - } + } + func onCancelled(threadId: String) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.store.ingestVoiceCancelled(threadId: threadId) + // Cancelled turns persist their user half too — same rule. + ThreadsChangeBus.postThreadsChanged() + } } + } } diff --git a/macos/Codescribe/Core/RealThreadsEngine.swift b/macos/Codescribe/Core/RealThreadsEngine.swift index 53257c2f..60532db2 100644 --- a/macos/Codescribe/Core/RealThreadsEngine.swift +++ b/macos/Codescribe/Core/RealThreadsEngine.swift @@ -5,177 +5,184 @@ import Foundation // summaries for the rail, loads messages on demand, and forwards lightweight // thread mutations that already exist in the core. final class RealThreadsEngine: ChatThreadsProviding { - private let threads = CodescribeThreads() - - func listThreads() -> [ChatThread] { - guard let list = try? threads.listThreads(filter: nil) else { return [] } - return list.map(Self.thread) - } - - func searchThreads(query: String) -> [ChatThread] { - guard let list = try? threads.searchThreads(query: query) else { return [] } - return list.map(Self.thread) - } - - func generateThreadId() -> String { - threads.generateThreadId() - } - - func loadMessages(backendId: String) -> [ChatMessage] { - guard let thread = try? threads.loadThread(id: backendId) else { return [] } - var toolNamesById: [String: String] = [:] - return thread.messages.compactMap { message -> ChatMessage? in - let content = StoredMessageContent(rawJson: message.rawJson) - content.toolUses.forEach { toolNamesById[$0.id] = $0.name } - - if !content.toolResults.isEmpty { - return Self.toolActivityMessage( - from: content.toolResults, - toolNamesById: toolNamesById, - timestampMs: message.timestampMs - ) - } - if content.hasToolUseOnly { - return nil - } - - let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - switch message.role { - case "user": - guard !text.isEmpty || content.hasDisplayableNonTextBlock else { return nil } - return ChatMessage(role: .you, timestamp: Self.timeString(timestampMs: message.timestampMs), text: text) - case "assistant": - guard !text.isEmpty else { return nil } - return ChatMessage(role: .assistant, timestamp: Self.timeString(timestampMs: message.timestampMs), text: text) - default: return nil // skip system/tool turns in the transcript view - } - } - } - - func deleteThread(backendId: String) -> Bool { - do { - try threads.deleteThread(id: backendId) - return true - } catch { - return false - } - } - - func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { - (try? threads.setThreadFavorite(id: backendId, isFavorite: isFavorite)) ?? false - } - - func renameThread(backendId: String, title: String) -> Bool { - (try? threads.renameThread(id: backendId, title: title)) ?? false - } - - func setGeneratedTitle(backendId: String, title: String) -> Bool { - (try? threads.setGeneratedTitle(id: backendId, title: title)) ?? false - } - - func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { - try? threads.exportThreadMarkdown(id: backendId, assistantOnly: assistantOnly) - } - - private static func thread(from summary: CsThreadSummary) -> ChatThread { - let updatedAt = Date(timeIntervalSince1970: Double(summary.updatedAtMs) / 1000.0) - var thread = ChatThread( - title: summary.title.isEmpty ? "Untitled" : summary.title, - meta: ThreadRailMeta.drawerSubtitle( - model: summary.model, - tokens: summary.totalTokens, - updatedAt: updatedAt - ), - isFavorite: summary.isFavorite + private let threads = CodescribeThreads() + + func listThreads() -> [ChatThread] { + guard let list = try? threads.listThreads(filter: nil) else { return [] } + return list.map(Self.thread) + } + + func searchThreads(query: String) -> [ChatThread] { + guard let list = try? threads.searchThreads(query: query) else { return [] } + return list.map(Self.thread) + } + + func generateThreadId() -> String { + threads.generateThreadId() + } + + func loadMessages(backendId: String) -> [ChatMessage] { + guard let thread = try? threads.loadThread(id: backendId) else { return [] } + var toolNamesById: [String: String] = [:] + return thread.messages.compactMap { message -> ChatMessage? in + let content = StoredMessageContent(rawJson: message.rawJson) + for toolUse in content.toolUses { toolNamesById[toolUse.id] = toolUse.name } + + if !content.toolResults.isEmpty { + return Self.toolActivityMessage( + from: content.toolResults, + toolNamesById: toolNamesById, + timestampMs: message.timestampMs + ) + } + if content.hasToolUseOnly { + return nil + } + + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + switch message.role { + case "user": + guard !text.isEmpty || content.hasDisplayableNonTextBlock else { return nil } + return ChatMessage( + role: .you, timestamp: Self.timeString(timestampMs: message.timestampMs), text: text) + case "assistant": + guard !text.isEmpty else { return nil } + return ChatMessage( + role: .assistant, timestamp: Self.timeString(timestampMs: message.timestampMs), text: text ) - thread.backendId = summary.id - thread.updatedAt = updatedAt - thread.model = summary.model - thread.totalTokens = summary.totalTokens - return thread + default: return nil // skip system/tool turns in the transcript view + } } - - private static func toolActivityMessage( - from results: [StoredToolResult], - toolNamesById: [String: String], - timestampMs: Int64 - ) -> ChatMessage { - let lines = results.map { result in - ToolLine( - verb: result.isError ? "failed" : "ran", - detail: toolNamesById[result.toolUseId] ?? "tool result" - ) - } - var message = ChatMessage(role: .tool, timestamp: timeString(timestampMs: timestampMs), text: "") - let n = lines.count - message.toolTitle = "What I checked · \(n) tool\(n == 1 ? "" : "s")" - message.toolLines = lines - return message + } + + func deleteThread(backendId: String) -> Bool { + do { + try threads.deleteThread(id: backendId) + return true + } catch { + return false } - - private static func timeString(timestampMs: Int64) -> String { - guard timestampMs > 0 else { return "" } - let date = Date(timeIntervalSince1970: Double(timestampMs) / 1000.0) - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm" - return formatter.string(from: date) + } + + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { + (try? threads.setThreadFavorite(id: backendId, isFavorite: isFavorite)) ?? false + } + + func renameThread(backendId: String, title: String) -> Bool { + (try? threads.renameThread(id: backendId, title: title)) ?? false + } + + func setGeneratedTitle(backendId: String, title: String) -> Bool { + (try? threads.setGeneratedTitle(id: backendId, title: title)) ?? false + } + + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { + try? threads.exportThreadMarkdown(id: backendId, assistantOnly: assistantOnly) + } + + private static func thread(from summary: CsThreadSummary) -> ChatThread { + let updatedAt = Date(timeIntervalSince1970: Double(summary.updatedAtMs) / 1000.0) + var thread = ChatThread( + title: summary.title.isEmpty ? "Untitled" : summary.title, + meta: ThreadRailMeta.drawerSubtitle( + model: summary.model, + tokens: summary.totalTokens, + updatedAt: updatedAt + ), + isFavorite: summary.isFavorite + ) + thread.backendId = summary.id + thread.updatedAt = updatedAt + thread.model = summary.model + thread.totalTokens = summary.totalTokens + return thread + } + + private static func toolActivityMessage( + from results: [StoredToolResult], + toolNamesById: [String: String], + timestampMs: Int64 + ) -> ChatMessage { + let lines = results.map { result in + ToolLine( + verb: result.isError ? "failed" : "ran", + detail: toolNamesById[result.toolUseId] ?? "tool result" + ) } + var message = ChatMessage( + role: .tool, timestamp: timeString(timestampMs: timestampMs), text: "") + let n = lines.count + message.toolTitle = "What I checked · \(n) tool\(n == 1 ? "" : "s")" + message.toolLines = lines + return message + } + + private static func timeString(timestampMs: Int64) -> String { + guard timestampMs > 0 else { return "" } + let date = Date(timeIntervalSince1970: Double(timestampMs) / 1000.0) + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } } private struct StoredMessageContent { - var toolUses: [StoredToolUse] = [] - var toolResults: [StoredToolResult] = [] - var hasDisplayableNonTextBlock = false - - var hasToolUseOnly: Bool { - !toolUses.isEmpty && toolResults.isEmpty + var toolUses: [StoredToolUse] = [] + var toolResults: [StoredToolResult] = [] + var hasDisplayableNonTextBlock = false + + var hasToolUseOnly: Bool { + !toolUses.isEmpty && toolResults.isEmpty + } + + init(rawJson: String) { + guard let data = rawJson.data(using: .utf8), + let blocks = try? JSONDecoder().decode([StoredContentBlock].self, from: data) + else { + return } - - init(rawJson: String) { - guard let data = rawJson.data(using: .utf8), - let blocks = try? JSONDecoder().decode([StoredContentBlock].self, from: data) else { - return - } - hasDisplayableNonTextBlock = blocks.contains { block in - guard let type = block.type else { return false } - return !["text", "input_text", "output_text", "tool_use", "tool_result"].contains(type) - } - toolUses = blocks.compactMap { block in - guard block.type == "tool_use", - let id = block.id, - let name = block.name else { return nil } - return StoredToolUse(id: id, name: name) - } - toolResults = blocks.compactMap { block in - guard block.type == "tool_result", - let toolUseId = block.toolUseId else { return nil } - return StoredToolResult(toolUseId: toolUseId, isError: block.isError ?? false) - } + hasDisplayableNonTextBlock = blocks.contains { block in + guard let type = block.type else { return false } + return !["text", "input_text", "output_text", "tool_use", "tool_result"].contains(type) } + toolUses = blocks.compactMap { block in + guard block.type == "tool_use", + let id = block.id, + let name = block.name + else { return nil } + return StoredToolUse(id: id, name: name) + } + toolResults = blocks.compactMap { block in + guard block.type == "tool_result", + let toolUseId = block.toolUseId + else { return nil } + return StoredToolResult(toolUseId: toolUseId, isError: block.isError ?? false) + } + } } private struct StoredContentBlock: Decodable { - let type: String? - let id: String? - let name: String? - let toolUseId: String? - let isError: Bool? - - enum CodingKeys: String, CodingKey { - case type - case id - case name - case toolUseId = "tool_use_id" - case isError = "is_error" - } + let type: String? + let id: String? + let name: String? + let toolUseId: String? + let isError: Bool? + + enum CodingKeys: String, CodingKey { + case type + case id + case name + case toolUseId = "tool_use_id" + case isError = "is_error" + } } private struct StoredToolUse { - let id: String - let name: String + let id: String + let name: String } private struct StoredToolResult { - let toolUseId: String - let isError: Bool + let toolUseId: String + let isError: Bool } diff --git a/macos/Codescribe/Core/SystemSleepWakeObserver.swift b/macos/Codescribe/Core/SystemSleepWakeObserver.swift new file mode 100644 index 00000000..12aa7277 --- /dev/null +++ b/macos/Codescribe/Core/SystemSleepWakeObserver.swift @@ -0,0 +1,64 @@ +import AppKit + +/// Bridges real NSWorkspace sleep/wake notifications into one coalesced, +/// payload-free recording lifecycle callback. +/// +/// AppKit can emit notifications from inside window/system operations. Both +/// observers are therefore bound to the one shared workspace object and their +/// callouts only schedule one next-main-queue callback. No bridge, disk, +/// formatting, layout, model, network, or blocking work occurs in the callout. +@MainActor +final class SystemSleepWakeObserver { + private let center: NotificationCenter + private weak var workspace: NSWorkspace? + private let onBoundary: () -> Void + private var tokens: [NSObjectProtocol] = [] + private var boundaryScheduled = false + + init( + center: NotificationCenter = NSWorkspace.shared.notificationCenter, + workspace: NSWorkspace = .shared, + onBoundary: @escaping () -> Void + ) { + self.center = center + self.workspace = workspace + self.onBoundary = onBoundary + } + + func start() { + guard tokens.isEmpty, let workspace else { return } + let handler: (Notification) -> Void = { [weak self] _ in + MainActor.assumeIsolated { self?.scheduleBoundary() } + } + tokens = [ + center.addObserver( + forName: NSWorkspace.willSleepNotification, + object: workspace, + queue: .main, + using: handler + ), + center.addObserver( + forName: NSWorkspace.didWakeNotification, + object: workspace, + queue: .main, + using: handler + ), + ] + } + + func invalidate() { + tokens.forEach(center.removeObserver) + tokens.removeAll() + boundaryScheduled = false + } + + private func scheduleBoundary() { + guard !boundaryScheduled else { return } + boundaryScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.boundaryScheduled = false + self.onBoundary() + } + } +} diff --git a/macos/Codescribe/Core/ThreadsChangeBus.swift b/macos/Codescribe/Core/ThreadsChangeBus.swift index 57598cfb..9d4a7c49 100644 --- a/macos/Codescribe/Core/ThreadsChangeBus.swift +++ b/macos/Codescribe/Core/ThreadsChangeBus.swift @@ -18,9 +18,9 @@ import Foundation /// wave. Until then, window activation (`NSWindow.didBecomeKeyNotification`, /// observed in `AgentChatStore`) covers discoverability on the next activation. enum ThreadsChangeBus { - static let threadsDidChange = Notification.Name("codescribe.threads.threadsDidChange") + static let threadsDidChange = Notification.Name("codescribe.threads.threadsDidChange") - static func postThreadsChanged() { - NotificationCenter.default.post(name: threadsDidChange, object: nil) - } + static func postThreadsChanged() { + NotificationCenter.default.post(name: threadsDidChange, object: nil) + } } diff --git a/macos/Codescribe/DesignSystem/CSIcon.swift b/macos/Codescribe/DesignSystem/CSIcon.swift index c327f32f..ff3c70e9 100644 --- a/macos/Codescribe/DesignSystem/CSIcon.swift +++ b/macos/Codescribe/DesignSystem/CSIcon.swift @@ -1,6 +1,6 @@ import AppKit -import SwiftUI import PhosphorSwift +import SwiftUI // CSIcon — the single icon language for codescribe's UI. // @@ -26,31 +26,31 @@ import PhosphorSwift /// (`Ph.IconWeight`). Regular is the UI default; fill marks an active/brand /// state; thin/light read as meta. enum CSIconWeight { - case thin, light, regular, medium, semibold, bold, fill + case thin, light, regular, medium, semibold, bold, fill - var phosphor: Ph.IconWeight { - switch self { - case .thin: return .thin - case .light: return .light - case .regular: return .regular - case .medium: return .regular // Phosphor has no medium; regular is the nearest step - case .semibold: return .bold // Phosphor has no semibold; bold is the nearest step - case .bold: return .bold - case .fill: return .fill - } + var phosphor: Ph.IconWeight { + switch self { + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .regular // Phosphor has no medium; regular is the nearest step + case .semibold: return .bold // Phosphor has no semibold; bold is the nearest step + case .bold: return .bold + case .fill: return .fill } + } - var font: Font.Weight { - switch self { - case .thin: return .thin - case .light: return .light - case .regular: return .regular - case .medium: return .medium - case .semibold: return .semibold - case .bold: return .bold - case .fill: return .semibold - } + var font: Font.Weight { + switch self { + case .thin: return .thin + case .light: return .light + case .regular: return .regular + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .fill: return .semibold } + } } /// Semantic icon set. Cases are derived from the app-wide emoji/dingbat @@ -58,178 +58,178 @@ enum CSIconWeight { /// and ready so later screens (Chat / Settings / Overlay / Onboarding) are a /// pure wiring job. enum CSIcon { - // Chrome / navigation - case settings - case setupWizard - case help - case info - case power + // Chrome / navigation + case settings + case setupWizard + case help + case info + case power - // Agent / capture - case agent - case mic - case record - case stop + // Agent / capture + case agent + case mic + case record + case stop - // Content actions - case copy - case edit - case notes - case notesMode - case history - case search - case send - case refresh - case attach - case shortcuts - case photo - case delete - case remove + // Content actions + case copy + case edit + case notes + case notesMode + case history + case search + case send + case refresh + case attach + case shortcuts + case photo + case delete + case remove - // Window modes - case dock - case overlay + // Window modes + case dock + case overlay - // Status / diagnostics - case success - case failure - case warning - case error - case tip - case caution - case diagnostics - case accountVerified + // Status / diagnostics + case success + case failure + case warning + case error + case tip + case caution + case diagnostics + case accountVerified - // Affordances / selection - case chevronRight - case chevronDown - case chevronUpDown - case close - case check - case more - case star - case starFill - case checkboxOn - case checkboxOff - case checkCircleFill - case circleEmpty + // Affordances / selection + case chevronRight + case chevronDown + case chevronUpDown + case close + case check + case more + case star + case starFill + case checkboxOn + case checkboxOff + case checkCircleFill + case circleEmpty - /// Which library draws this semantic icon. Owned by the design system, - /// invisible to call sites. `sf` is retained so a glyph can fall back to an - /// SF Symbol if Phosphor ever lacks one; today the whole set is Phosphor so - /// the UI reads as a single icon family (operator decision, 02-07-2026). - enum Backend { - case sf(String) - case phosphor(Ph) - case phosphorFill(Ph) // forces the fill weight — for genuinely filled-state glyphs - } + /// Which library draws this semantic icon. Owned by the design system, + /// invisible to call sites. `sf` is retained so a glyph can fall back to an + /// SF Symbol if Phosphor ever lacks one; today the whole set is Phosphor so + /// the UI reads as a single icon family (operator decision, 02-07-2026). + enum Backend { + case sf(String) + case phosphor(Ph) + case phosphorFill(Ph) // forces the fill weight — for genuinely filled-state glyphs + } - var backend: Backend { - switch self { - // Chrome / navigation - case .settings: return .phosphor(.gear) - case .setupWizard: return .phosphor(.sparkle) - case .help: return .phosphor(.question) - case .info: return .phosphor(.info) - case .power: return .phosphor(.power) + var backend: Backend { + switch self { + // Chrome / navigation + case .settings: return .phosphor(.gear) + case .setupWizard: return .phosphor(.sparkle) + case .help: return .phosphor(.question) + case .info: return .phosphor(.info) + case .power: return .phosphor(.power) - // Agent / capture - case .agent: return .phosphor(.chatCircle) // opens the agent conversation — a chat bubble - case .mic: return .phosphor(.microphone) - case .record: return .phosphor(.record) - case .stop: return .phosphor(.stop) + // Agent / capture + case .agent: return .phosphor(.chatCircle) // opens the agent conversation — a chat bubble + case .mic: return .phosphor(.microphone) + case .record: return .phosphor(.record) + case .stop: return .phosphor(.stop) - // Content actions - case .copy: return .phosphor(.copy) - case .edit: return .phosphor(.pencilSimple) - case .notes: return .phosphor(.notePencil) - case .notesMode: return .phosphor(.note) - case .history: return .phosphor(.clockCounterClockwise) - case .search: return .phosphor(.magnifyingGlass) - case .send: return .sf("arrow.up.circle.fill") - case .refresh: return .phosphor(.arrowClockwise) - case .attach: return .phosphor(.paperclip) - case .shortcuts: return .phosphor(.keyboard) - case .photo: return .phosphor(.image) - case .delete: return .phosphor(.trash) - case .remove: return .phosphor(.minusCircle) + // Content actions + case .copy: return .phosphor(.copy) + case .edit: return .phosphor(.pencilSimple) + case .notes: return .phosphor(.notePencil) + case .notesMode: return .phosphor(.note) + case .history: return .phosphor(.clockCounterClockwise) + case .search: return .phosphor(.magnifyingGlass) + case .send: return .sf("arrow.up.circle.fill") + case .refresh: return .phosphor(.arrowClockwise) + case .attach: return .phosphor(.paperclip) + case .shortcuts: return .phosphor(.keyboard) + case .photo: return .phosphor(.image) + case .delete: return .phosphor(.trash) + case .remove: return .phosphor(.minusCircle) - // Window modes - case .dock: return .phosphor(.appWindow) - case .overlay: return .phosphor(.pictureInPicture) + // Window modes + case .dock: return .phosphor(.appWindow) + case .overlay: return .phosphor(.pictureInPicture) - // Status / diagnostics - case .success: return .phosphor(.check) - case .failure: return .phosphor(.x) - case .warning: return .phosphor(.warning) - case .error: return .phosphor(.warningCircle) - case .tip: return .phosphor(.lightbulb) - case .caution: return .phosphor(.warningOctagon) - case .diagnostics: return .phosphor(.stethoscope) - case .accountVerified: return .phosphor(.userCircleCheck) + // Status / diagnostics + case .success: return .phosphor(.check) + case .failure: return .phosphor(.x) + case .warning: return .phosphor(.warning) + case .error: return .phosphor(.warningCircle) + case .tip: return .phosphor(.lightbulb) + case .caution: return .phosphor(.warningOctagon) + case .diagnostics: return .phosphor(.stethoscope) + case .accountVerified: return .phosphor(.userCircleCheck) - // Affordances / selection - case .chevronRight: return .phosphor(.caretRight) - case .chevronDown: return .phosphor(.caretDown) - case .chevronUpDown: return .phosphor(.caretUpDown) - case .close: return .phosphor(.x) - case .check: return .phosphor(.check) - case .more: return .phosphor(.dotsThree) - case .star: return .phosphor(.star) - case .starFill: return .phosphorFill(.star) - case .checkboxOn: return .phosphorFill(.checkSquare) - case .checkboxOff: return .phosphor(.square) - case .checkCircleFill: return .phosphorFill(.checkCircle) - case .circleEmpty: return .phosphor(.circle) - } + // Affordances / selection + case .chevronRight: return .phosphor(.caretRight) + case .chevronDown: return .phosphor(.caretDown) + case .chevronUpDown: return .phosphor(.caretUpDown) + case .close: return .phosphor(.x) + case .check: return .phosphor(.check) + case .more: return .phosphor(.dotsThree) + case .star: return .phosphor(.star) + case .starFill: return .phosphorFill(.star) + case .checkboxOn: return .phosphorFill(.checkSquare) + case .checkboxOff: return .phosphor(.square) + case .checkCircleFill: return .phosphorFill(.checkCircle) + case .circleEmpty: return .phosphor(.circle) } + } } /// Renders a `CSIcon` at a uniform size/weight/color across both backends. /// Call sites always go through this — never `Image(systemName:)` or `Ph.*` /// directly — so the icon language stays single-sourced. struct CSIconView: View { - let icon: CSIcon - var size: CGFloat = 13 - var weight: CSIconWeight = .regular - /// `nil` lets the glyph inherit the ambient `foregroundStyle` (matches call - /// sites that tint a whole row). Phosphor assets ship with a template - /// rendering intent, so — exactly like SF Symbols — they either inherit the - /// ambient tint or accept an explicit one through `foregroundStyle`. - var color: Color? = nil + let icon: CSIcon + var size: CGFloat = 13 + var weight: CSIconWeight = .regular + /// `nil` lets the glyph inherit the ambient `foregroundStyle` (matches call + /// sites that tint a whole row). Phosphor assets ship with a template + /// rendering intent, so — exactly like SF Symbols — they either inherit the + /// ambient tint or accept an explicit one through `foregroundStyle`. + var color: Color? = nil - var body: some View { - switch icon.backend { - case .sf(let name): - tinted(Image(systemName: name).font(.system(size: size, weight: weight.font))) - case .phosphor(let ph): - rasterized(ph.weight(weight.phosphor), key: "\(ph.rawValue).\(weight.phosphor.rawValue)") - case .phosphorFill(let ph): - rasterized(ph.fill, key: "\(ph.rawValue).fill") - } + var body: some View { + switch icon.backend { + case .sf(let name): + tinted(Image(systemName: name).font(.system(size: size, weight: weight.font))) + case .phosphor(let ph): + rasterized(ph.weight(weight.phosphor), key: "\(ph.rawValue).\(weight.phosphor.rawValue)") + case .phosphorFill(let ph): + rasterized(ph.fill, key: "\(ph.rawValue).fill") } + } - /// A Phosphor glyph is a *resizable* template image. When SwiftUI bridges a - /// `Menu` / `Picker` label to AppKit (NSPopUpButton / NSMenu) the label is - /// re-measured by the AppKit cell, which ignores SwiftUI `frame`/`fixedSize` - /// and paints the image at its native asset size (huge). Baking the glyph to - /// a fixed-point-size `NSImage` gives it a concrete intrinsic size the bridge - /// honours, so it stays `size × size` in every context. Result cached. - @ViewBuilder private func rasterized(_ base: Image, key: String) -> some View { - let nsImage = PhosphorRaster.templateImage(base, key: key, size: size) - tinted(Image(nsImage: nsImage).renderingMode(.template)) - } + /// A Phosphor glyph is a *resizable* template image. When SwiftUI bridges a + /// `Menu` / `Picker` label to AppKit (NSPopUpButton / NSMenu) the label is + /// re-measured by the AppKit cell, which ignores SwiftUI `frame`/`fixedSize` + /// and paints the image at its native asset size (huge). Baking the glyph to + /// a fixed-point-size `NSImage` gives it a concrete intrinsic size the bridge + /// honours, so it stays `size × size` in every context. Result cached. + @ViewBuilder private func rasterized(_ base: Image, key: String) -> some View { + let nsImage = PhosphorRaster.templateImage(base, key: key, size: size) + tinted(Image(nsImage: nsImage).renderingMode(.template)) + } - /// Apply an explicit tint, or leave the view to inherit the ambient one. - /// A template image (SF Symbol or the baked Phosphor `NSImage`) tints through - /// `foregroundStyle` and inherits the ambient one when `color` is `nil`. - @ViewBuilder private func tinted(_ view: some View) -> some View { - if let color { - view.foregroundStyle(color) - } else { - view - } + /// Apply an explicit tint, or leave the view to inherit the ambient one. + /// A template image (SF Symbol or the baked Phosphor `NSImage`) tints through + /// `foregroundStyle` and inherits the ambient one when `color` is `nil`. + @ViewBuilder private func tinted(_ view: some View) -> some View { + if let color { + view.foregroundStyle(color) + } else { + view } + } } /// Bakes Phosphor template glyphs into fixed-point-size template `NSImage`s. @@ -241,24 +241,25 @@ struct CSIconView: View { /// the source. Each glyph is rendered once at Retina density and cached. @MainActor private enum PhosphorRaster { - private static var cache: [String: NSImage] = [:] + private static var cache: [String: NSImage] = [:] - static func templateImage(_ base: Image, key: String, size: CGFloat) -> NSImage { - let cacheKey = "\(key)|\(size)" - if let hit = cache[cacheKey] { return hit } + static func templateImage(_ base: Image, key: String, size: CGFloat) -> NSImage { + let cacheKey = "\(key)|\(size)" + if let hit = cache[cacheKey] { return hit } - let renderer = ImageRenderer( - content: base - .resizable() - .frame(width: size, height: size) - .foregroundStyle(Color.black) // opaque fill → clean alpha mask for the template - ) - renderer.scale = 2 // Retina backing; crisp at 1x/2x, negligible cost + let renderer = ImageRenderer( + content: + base + .resizable() + .frame(width: size, height: size) + .foregroundStyle(Color.black) // opaque fill → clean alpha mask for the template + ) + renderer.scale = 2 // Retina backing; crisp at 1x/2x, negligible cost - let image = renderer.nsImage ?? NSImage(size: NSSize(width: size, height: size)) - image.size = NSSize(width: size, height: size) // pin the logical (point) size - image.isTemplate = true // tintable via foregroundStyle / menu text colour - cache[cacheKey] = image - return image - } + let image = renderer.nsImage ?? NSImage(size: NSSize(width: size, height: size)) + image.size = NSSize(width: size, height: size) // pin the logical (point) size + image.isTemplate = true // tintable via foregroundStyle / menu text colour + cache[cacheKey] = image + return image + } } diff --git a/macos/Codescribe/DesignSystem/Components.swift b/macos/Codescribe/DesignSystem/Components.swift index e9c9b5cc..28fc78d3 100644 --- a/macos/Codescribe/DesignSystem/Components.swift +++ b/macos/Codescribe/DesignSystem/Components.swift @@ -12,103 +12,104 @@ import SwiftUI /// hides the keyboard-visible focus cue that macOS users rely on. @MainActor enum CSFocusPolicy { - enum InputModality { - case keyboard - case pointer - } + enum InputModality { + case keyboard + case pointer + } - static func shouldReleaseFocus( - for modality: InputModality, - hitView: NSView? - ) -> Bool { - modality == .pointer && !isTextInput(hitView) - } + static func shouldReleaseFocus( + for modality: InputModality, + hitView: NSView? + ) -> Bool { + modality == .pointer && !isTextInput(hitView) + } - static func isTextInput(_ view: NSView?) -> Bool { - var candidate = view - while let current = candidate { - if current is NSTextField || current is NSTextView { - return true - } - // SwiftUI's TextEditor is hosted in an NSScrollView. Depending on - // which internal layer receives the click, hitTest can return the - // clip/document host instead of the NSTextView itself. Treat a - // scroll view backed by a text view as text input too, otherwise the - // pointer focus monitor clears first responder immediately and the - // editor looks read-only. - if let scrollView = current as? NSScrollView, - scrollView.documentView is NSTextView { - return true - } - candidate = current.superview - } - return false + static func isTextInput(_ view: NSView?) -> Bool { + var candidate = view + while let current = candidate { + if current is NSTextField || current is NSTextView { + return true + } + // SwiftUI's TextEditor is hosted in an NSScrollView. Depending on + // which internal layer receives the click, hitTest can return the + // clip/document host instead of the NSTextView itself. Treat a + // scroll view backed by a text view as text input too, otherwise the + // pointer focus monitor clears first responder immediately and the + // editor looks read-only. + if let scrollView = current as? NSScrollView, + scrollView.documentView is NSTextView + { + return true + } + candidate = current.superview } + return false + } } private struct CSFocusPolicyModifier: ViewModifier { - func body(content: Content) -> some View { - content.background { - CSFocusPolicyMonitor() - .frame(width: 0, height: 0) - .allowsHitTesting(false) - } + func body(content: Content) -> some View { + content.background { + CSFocusPolicyMonitor() + .frame(width: 0, height: 0) + .allowsHitTesting(false) } + } } private struct CSFocusPolicyMonitor: NSViewRepresentable { - func makeNSView(context: Context) -> CSFocusPolicyMonitorView { - CSFocusPolicyMonitorView() - } + func makeNSView(context: Context) -> CSFocusPolicyMonitorView { + CSFocusPolicyMonitorView() + } - func updateNSView(_ nsView: CSFocusPolicyMonitorView, context: Context) {} + func updateNSView(_ nsView: CSFocusPolicyMonitorView, context: Context) {} } @MainActor private final class CSFocusPolicyMonitorView: NSView { - private var mouseMonitor: Any? + private var mouseMonitor: Any? - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - removeMouseMonitor() - guard let window else { return } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + removeMouseMonitor() + guard let window else { return } - mouseMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] - ) { [weak window] event in - guard let window, event.window === window else { return event } - let hitView = window.contentView?.hitTest(event.locationInWindow) - guard CSFocusPolicy.shouldReleaseFocus(for: .pointer, hitView: hitView) else { - return event - } + mouseMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] + ) { [weak window] event in + guard let window, event.window === window else { return event } + let hitView = window.contentView?.hitTest(event.locationInWindow) + guard CSFocusPolicy.shouldReleaseFocus(for: .pointer, hitView: hitView) else { + return event + } - // Let SwiftUI deliver the click first, then release the responder it - // may have assigned to the button. Text inputs are excluded above. - DispatchQueue.main.async { [weak window] in - window?.makeFirstResponder(nil) - } - return event - } + // Let SwiftUI deliver the click first, then release the responder it + // may have assigned to the button. Text inputs are excluded above. + DispatchQueue.main.async { [weak window] in + window?.makeFirstResponder(nil) + } + return event } + } - deinit { - if let mouseMonitor { - NSEvent.removeMonitor(mouseMonitor) - } + deinit { + if let mouseMonitor { + NSEvent.removeMonitor(mouseMonitor) } + } - private func removeMouseMonitor() { - guard let mouseMonitor else { return } - NSEvent.removeMonitor(mouseMonitor) - self.mouseMonitor = nil - } + private func removeMouseMonitor() { + guard let mouseMonitor else { return } + NSEvent.removeMonitor(mouseMonitor) + self.mouseMonitor = nil + } } extension View { - /// Installs Codescribe's pointer-vs-keyboard focus policy for one window. - func csFocusPolicy() -> some View { - modifier(CSFocusPolicyModifier()) - } + /// Installs Codescribe's pointer-vs-keyboard focus policy for one window. + func csFocusPolicy() -> some View { + modifier(CSFocusPolicyModifier()) + } } /// Keyboard focus ring that follows the control's own rounded geometry. @@ -129,144 +130,144 @@ extension View { /// pointer clicks, so the ring appears exactly when a keyboard user is /// navigating — the accessibility cue stays, only its geometry is ours. struct CSFocusRingButtonStyle: ButtonStyle { - var cornerRadius: CGFloat - @Environment(\.isFocused) private var isFocused + var cornerRadius: CGFloat + @Environment(\.isFocused) private var isFocused - func makeBody(configuration: Configuration) -> some View { - configuration.label - .opacity(configuration.isPressed ? 0.82 : 1) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius + 2, style: .continuous) - .strokeBorder( - CSColor.chromeAccent.opacity(isFocused ? 0.9 : 0), - lineWidth: 1.5 - ) - .padding(-2) - ) - .animation(.easeOut(duration: 0.12), value: isFocused) - } + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.82 : 1) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius + 2, style: .continuous) + .strokeBorder( + CSColor.chromeAccent.opacity(isFocused ? 0.9 : 0), + lineWidth: 1.5 + ) + .padding(-2) + ) + .animation(.easeOut(duration: 0.12), value: isFocused) + } } extension ButtonStyle where Self == CSFocusRingButtonStyle { - /// Plain-look button carrying the Codescribe focus ring. Use instead of - /// `.plain` on custom-drawn chips, cards, and segments. - static func csFocusRing(cornerRadius: CGFloat) -> CSFocusRingButtonStyle { - CSFocusRingButtonStyle(cornerRadius: cornerRadius) - } + /// Plain-look button carrying the Codescribe focus ring. Use instead of + /// `.plain` on custom-drawn chips, cards, and segments. + static func csFocusRing(cornerRadius: CGFloat) -> CSFocusRingButtonStyle { + CSFocusRingButtonStyle(cornerRadius: cornerRadius) + } } extension View { - /// The one correct way to adopt the Codescribe focus ring on a Button. - /// - /// `focusEffectDisabled()` is an environment write and only flows DOWN the - /// tree — inside `makeBody` it reaches the label's descendants, never the - /// Button that actually draws AppKit's grey halo. So the kill switch must - /// ride on the Button itself, paired here with the style so the two can't - /// drift apart (adopting the style alone leaves the system ring stacked - /// on top of ours — operator screenshot 2026-08-09, the "stodoła"). - func csFocusRing(cornerRadius: CGFloat) -> some View { - buttonStyle(.csFocusRing(cornerRadius: cornerRadius)) - .focusEffectDisabled() - } + /// The one correct way to adopt the Codescribe focus ring on a Button. + /// + /// `focusEffectDisabled()` is an environment write and only flows DOWN the + /// tree — inside `makeBody` it reaches the label's descendants, never the + /// Button that actually draws AppKit's grey halo. So the kill switch must + /// ride on the Button itself, paired here with the style so the two can't + /// drift apart (adopting the style alone leaves the system ring stacked + /// on top of ours — operator screenshot 2026-08-09, the "stodoła"). + func csFocusRing(cornerRadius: CGFloat) -> some View { + buttonStyle(.csFocusRing(cornerRadius: cornerRadius)) + .focusEffectDisabled() + } } /// Dark glass container: ultraThinMaterial tinted + hairline border + deep shadow. struct GlassPanel: View { - var cornerRadius: CGFloat = CSRadius.window - var blurTint: Double = 0.84 - @ViewBuilder var content: Content + var cornerRadius: CGFloat = CSRadius.window + var blurTint: Double = 0.84 + @ViewBuilder var content: Content - var body: some View { - content - .background( - ZStack { - CSColor.glassUnder - Rectangle().fill(.ultraThinMaterial).environment(\.colorScheme, .dark) - CSColor.glassBase.opacity(blurTint - 0.6) // subtle warm tint over the material - } - ) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .strokeBorder(CSColor.hairline(0.09), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.6), radius: 50, x: 0, y: 40) - } + var body: some View { + content + .background( + ZStack { + CSColor.glassUnder + Rectangle().fill(.ultraThinMaterial).environment(\.colorScheme, .dark) + CSColor.glassBase.opacity(blurTint - 0.6) // subtle warm tint over the material + } + ) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(CSColor.hairline(0.09), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.6), radius: 50, x: 0, y: 40) + } } /// Small mode/brand dot. struct ModeDot: View { - var color: Color = CSColor.terracotta - var size: CGFloat = 9 - var body: some View { - Circle().fill(color).frame(width: size, height: size) - } + var color: Color = CSColor.terracotta + var size: CGFloat = 9 + var body: some View { + Circle().fill(color).frame(width: size, height: size) + } } /// Status pill with a softpulsing dot and an optional expanding ripple ring. struct StatusPill: View { - let text: String - var color: Color = CSColor.oliveLight - var rippling: Bool = false + let text: String + var color: Color = CSColor.oliveLight + var rippling: Bool = false - @State private var pulse = false - @State private var ripple = false + @State private var pulse = false + @State private var ripple = false - var body: some View { - HStack(spacing: 6) { - ZStack { - if rippling { - Circle().strokeBorder(color, lineWidth: 1) - .frame(width: 9, height: 9) - .scaleEffect(ripple ? 2.7 : 0.5) - .opacity(ripple ? 0 : 0.7) - // Animated pulse dot is rendered ONLY while rippling. Removing it - // from the view tree in Idle physically tears down the - // repeatForever animation — a Transaction(animation: nil) snap - // does NOT cancel an in-flight repeatForever, which left it - // ticking the render loop at ~30% CPU in Idle. - Circle().fill(color).frame(width: 6, height: 6) - .opacity(pulse ? 1 : 0.7) - } else { - Circle().fill(color).frame(width: 6, height: 6) - .opacity(0.7) - } - } + var body: some View { + HStack(spacing: 6) { + ZStack { + if rippling { + Circle().strokeBorder(color, lineWidth: 1) .frame(width: 9, height: 9) - Text(text) - .csMono(11, .medium) - .foregroundStyle(color) + .scaleEffect(ripple ? 2.7 : 0.5) + .opacity(ripple ? 0 : 0.7) + // Animated pulse dot is rendered ONLY while rippling. Removing it + // from the view tree in Idle physically tears down the + // repeatForever animation — a Transaction(animation: nil) snap + // does NOT cancel an in-flight repeatForever, which left it + // ticking the render loop at ~30% CPU in Idle. + Circle().fill(color).frame(width: 6, height: 6) + .opacity(pulse ? 1 : 0.7) + } else { + Circle().fill(color).frame(width: 6, height: 6) + .opacity(0.7) } - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background(color.opacity(0.12)) - .overlay(Capsule().strokeBorder(color.opacity(0.3), lineWidth: 1)) - .clipShape(Capsule()) - .onAppear { syncStatusAnimations() } - .onChange(of: rippling) { _, _ in syncStatusAnimations() } + } + .frame(width: 9, height: 9) + Text(text) + .csMono(11, .medium) + .foregroundStyle(color) } + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(color.opacity(0.12)) + .overlay(Capsule().strokeBorder(color.opacity(0.3), lineWidth: 1)) + .clipShape(Capsule()) + .onAppear { syncStatusAnimations() } + .onChange(of: rippling) { _, _ in syncStatusAnimations() } + } - /// `pulse` and `ripple` drive `.repeatForever` animations. They must run ONLY - /// while the pill represents a live/active state (`rippling`). Previously the - /// softpulse was started unconditionally in `onAppear` and never stopped — and - /// because this pill lives in the always-visible overlay header, that left a - /// repeatForever ticking the SwiftUI view graph every frame in Idle (100% CPU, - /// re-rasterizing the host panel's shadow + rounded-rect strokes each frame). - /// Gate it on `rippling` and, when inactive, snap the state back with animation - /// disabled so the in-flight repeatForever is torn down rather than left running. - private func syncStatusAnimations() { - if rippling { - withAnimation(CSMotion.softpulse) { pulse = true } - withAnimation(CSMotion.ripple) { ripple = true } - } else { - var transaction = Transaction(animation: nil) - transaction.disablesAnimations = true - withTransaction(transaction) { - pulse = false - ripple = false - } - } + /// `pulse` and `ripple` drive `.repeatForever` animations. They must run ONLY + /// while the pill represents a live/active state (`rippling`). Previously the + /// softpulse was started unconditionally in `onAppear` and never stopped — and + /// because this pill lives in the always-visible overlay header, that left a + /// repeatForever ticking the SwiftUI view graph every frame in Idle (100% CPU, + /// re-rasterizing the host panel's shadow + rounded-rect strokes each frame). + /// Gate it on `rippling` and, when inactive, snap the state back with animation + /// disabled so the in-flight repeatForever is torn down rather than left running. + private func syncStatusAnimations() { + if rippling { + withAnimation(CSMotion.softpulse) { pulse = true } + withAnimation(CSMotion.ripple) { ripple = true } + } else { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + pulse = false + ripple = false + } } + } } /// Non-animated status pill for Idle/final states. A SEPARATE view type (distinct @@ -276,35 +277,35 @@ struct StatusPill: View { /// the animated pill is removed from the tree (which actually tears the animation /// down) instead of relying on a fragile in-place cancel. struct StaticStatusPill: View { - let text: String - var color: Color = CSColor.oliveLight - var body: some View { - HStack(spacing: 6) { - Circle().fill(color).frame(width: 6, height: 6).opacity(0.7) - .frame(width: 9, height: 9) - Text(text) - .csMono(11, .medium) - .foregroundStyle(color) - } - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background(color.opacity(0.12)) - .overlay(Capsule().strokeBorder(color.opacity(0.3), lineWidth: 1)) - .clipShape(Capsule()) + let text: String + var color: Color = CSColor.oliveLight + var body: some View { + HStack(spacing: 6) { + Circle().fill(color).frame(width: 6, height: 6).opacity(0.7) + .frame(width: 9, height: 9) + Text(text) + .csMono(11, .medium) + .foregroundStyle(color) } + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(color.opacity(0.12)) + .overlay(Capsule().strokeBorder(color.opacity(0.3), lineWidth: 1)) + .clipShape(Capsule()) + } } /// Wordmark lockup: brand dot + lowercase "codescribe". struct Wordmark: View { - var size: CGFloat = 15 - var dotColor: Color = CSColor.terracotta - var body: some View { - HStack(spacing: 9) { - ModeDot(color: dotColor, size: size * 0.6) - Text("codescribe") - .font(CSFont.ui(size, .bold)) - .tracking(-0.3) - .foregroundStyle(CSColor.textHigh) - } + var size: CGFloat = 15 + var dotColor: Color = CSColor.terracotta + var body: some View { + HStack(spacing: 9) { + ModeDot(color: dotColor, size: size * 0.6) + Text("codescribe") + .font(CSFont.ui(size, .bold)) + .tracking(-0.3) + .foregroundStyle(CSColor.textHigh) } + } } diff --git a/macos/Codescribe/DesignSystem/DesignGallery.swift b/macos/Codescribe/DesignSystem/DesignGallery.swift index c67459e3..733f26e1 100644 --- a/macos/Codescribe/DesignSystem/DesignGallery.swift +++ b/macos/Codescribe/DesignSystem/DesignGallery.swift @@ -3,77 +3,78 @@ import SwiftUI // Visual verification surface for the design system. Open this preview to confirm // tokens render at the exact hex, bundled fonts load, and components look right. struct DesignGallery: View { - private let swatches: [(String, Color)] = [ - ("ink", CSColor.ink), - ("terracotta", CSColor.terracotta), - ("terracottaLight", CSColor.terracottaLight), - ("terracottaDeep", CSColor.terracottaDeep), - ("assistive", CSColor.assistive), - ("assistiveLight", CSColor.assistiveLight), - ("olive", CSColor.olive), - ("oliveLight", CSColor.oliveLight), - ("amber", CSColor.amber), - ("textHigh", CSColor.textHigh), - ("eyebrowOlive", CSColor.eyebrowOlive), - ] + private let swatches: [(String, Color)] = [ + ("ink", CSColor.ink), + ("terracotta", CSColor.terracotta), + ("terracottaLight", CSColor.terracottaLight), + ("terracottaDeep", CSColor.terracottaDeep), + ("assistive", CSColor.assistive), + ("assistiveLight", CSColor.assistiveLight), + ("olive", CSColor.olive), + ("oliveLight", CSColor.oliveLight), + ("amber", CSColor.amber), + ("textHigh", CSColor.textHigh), + ("eyebrowOlive", CSColor.eyebrowOlive), + ] - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - Wordmark(size: 22) + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Wordmark(size: 22) - EyebrowLabel(text: "Design System") - Text("Speak in. Code out.") - .font(CSFont.hero(40)) - .tracking(-1.6) - .foregroundStyle(CSColor.textHigh) + EyebrowLabel(text: "Design System") + Text("Speak in. Code out.") + .font(CSFont.hero(40)) + .tracking(-1.6) + .foregroundStyle(CSColor.textHigh) - // Type ramp - VStack(alignment: .leading, spacing: 6) { - Text("Space Grotesk — body 18").font(CSFont.bodyLg).foregroundStyle(CSColor.textBody) - Text("Space Grotesk — body 14").font(CSFont.body).foregroundStyle(CSColor.textBodyAlt) - Text("JetBrains Mono — meta 11").font(CSFont.metaMono).foregroundStyle(CSColor.textFaint) - } + // Type ramp + VStack(alignment: .leading, spacing: 6) { + Text("Space Grotesk — body 18").font(CSFont.bodyLg).foregroundStyle(CSColor.textBody) + Text("Space Grotesk — body 14").font(CSFont.body).foregroundStyle(CSColor.textBodyAlt) + Text("JetBrains Mono — meta 11").font(CSFont.metaMono).foregroundStyle(CSColor.textFaint) + } - // Swatches - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 10) { - ForEach(swatches, id: \.0) { name, color in - VStack(spacing: 6) { - RoundedRectangle(cornerRadius: 8).fill(color) - .frame(height: 44) - .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(CSColor.hairline(), lineWidth: 1)) - Text(name).font(CSFont.mono(9)).foregroundStyle(CSColor.textMuted) - } - } - } + // Swatches + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 10) { + ForEach(swatches, id: \.0) { name, color in + VStack(spacing: 6) { + RoundedRectangle(cornerRadius: 8).fill(color) + .frame(height: 44) + .overlay( + RoundedRectangle(cornerRadius: 8).strokeBorder(CSColor.hairline(), lineWidth: 1)) + Text(name).font(CSFont.mono(9)).foregroundStyle(CSColor.textMuted) + } + } + } - // Components - HStack(spacing: 12) { - StatusPill(text: "recording", color: CSColor.terracotta, rippling: true) - StaticStatusPill(text: "Idle", color: CSColor.oliveLight) - StaticStatusPill(text: "reasoned · 2.1s", color: CSColor.amber) - } + // Components + HStack(spacing: 12) { + StatusPill(text: "recording", color: CSColor.terracotta, rippling: true) + StaticStatusPill(text: "Idle", color: CSColor.oliveLight) + StaticStatusPill(text: "reasoned · 2.1s", color: CSColor.amber) + } - GlassPanel { - VStack(alignment: .leading, spacing: 8) { - Wordmark() - Text("GlassPanel — dark glass, hairline, deep shadow") - .font(CSFont.body).foregroundStyle(CSColor.textBody) - } - .padding(20) - } - .frame(maxWidth: .infinity) - } - .padding(28) + GlassPanel { + VStack(alignment: .leading, spacing: 8) { + Wordmark() + Text("GlassPanel — dark glass, hairline, deep shadow") + .font(CSFont.body).foregroundStyle(CSColor.textBody) + } + .padding(20) } - .frame(width: 560, height: 720) - .background(CSColor.ink) - .onAppear { FontLoader.register() } + .frame(maxWidth: .infinity) + } + .padding(28) } + .frame(width: 560, height: 720) + .background(CSColor.ink) + .onAppear { FontLoader.register() } + } } #if DEBUG -#Preview("Design Gallery") { + #Preview("Design Gallery") { DesignGallery() -} + } #endif diff --git a/macos/Codescribe/DesignSystem/FontLoader.swift b/macos/Codescribe/DesignSystem/FontLoader.swift index da12bae2..5aa56338 100644 --- a/macos/Codescribe/DesignSystem/FontLoader.swift +++ b/macos/Codescribe/DesignSystem/FontLoader.swift @@ -1,32 +1,33 @@ -import SwiftUI import CoreText +import SwiftUI // Registers the bundled OFL fonts at runtime so they work in the app AND in // SwiftUI Previews regardless of bundle layout. Idempotent; safe to call often. enum FontLoader { - static let spaceGrotesk = "Space Grotesk" - static let jetBrainsMono = "JetBrains Mono" + static let spaceGrotesk = "Space Grotesk" + static let jetBrainsMono = "JetBrains Mono" - private static var didRegister = false + private static var didRegister = false - static func register() { - guard !didRegister else { return } - didRegister = true - for name in ["SpaceGrotesk", "JetBrainsMono"] { - guard let url = Bundle.main.url(forResource: name, withExtension: "ttf") - ?? Bundle.main.url(forResource: name, withExtension: "ttf", subdirectory: "Fonts") - else { - #if DEBUG - print("[codescribe] font not found in bundle: \(name).ttf") - #endif - continue - } - var err: Unmanaged? - if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &err) { - #if DEBUG - print("[codescribe] font register skipped (likely already loaded): \(name)") - #endif - } - } + static func register() { + guard !didRegister else { return } + didRegister = true + for name in ["SpaceGrotesk", "JetBrainsMono"] { + guard + let url = Bundle.main.url(forResource: name, withExtension: "ttf") + ?? Bundle.main.url(forResource: name, withExtension: "ttf", subdirectory: "Fonts") + else { + #if DEBUG + print("[codescribe] font not found in bundle: \(name).ttf") + #endif + continue + } + var err: Unmanaged? + if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &err) { + #if DEBUG + print("[codescribe] font register skipped (likely already loaded): \(name)") + #endif + } } + } } diff --git a/macos/Codescribe/DesignSystem/Motion.swift b/macos/Codescribe/DesignSystem/Motion.swift index db5b09e2..439fbe79 100644 --- a/macos/Codescribe/DesignSystem/Motion.swift +++ b/macos/Codescribe/DesignSystem/Motion.swift @@ -2,18 +2,18 @@ import SwiftUI // Motion constants from the handoff keyframes. Short, breathing, never show-off. enum CSMotion { - // expanding mic ring - static let ripple = Animation.easeOut(duration: 2.5).repeatForever(autoreverses: false) - // opacity .6→1 breathing - static let softpulse = Animation.easeInOut(duration: 2.4).repeatForever(autoreverses: true) - // waveform bar scaleY .35→1 (per-bar duration varies; base here) - static func eq(_ duration: Double) -> Animation { - Animation.easeInOut(duration: duration).repeatForever(autoreverses: true) - } - // word/element rise + fade - static let floatIn = Animation.easeOut(duration: 0.35) - // cursor blink (1s steps) - static let blink = Animation.linear(duration: 1).repeatForever(autoreverses: true) - // hero glow breathe 6–7s - static let breathe = Animation.easeInOut(duration: 6.5).repeatForever(autoreverses: true) + // expanding mic ring + static let ripple = Animation.easeOut(duration: 2.5).repeatForever(autoreverses: false) + // opacity .6→1 breathing + static let softpulse = Animation.easeInOut(duration: 2.4).repeatForever(autoreverses: true) + // waveform bar scaleY .35→1 (per-bar duration varies; base here) + static func eq(_ duration: Double) -> Animation { + Animation.easeInOut(duration: duration).repeatForever(autoreverses: true) + } + // word/element rise + fade + static let floatIn = Animation.easeOut(duration: 0.35) + // cursor blink (1s steps) + static let blink = Animation.linear(duration: 1).repeatForever(autoreverses: true) + // hero glow breathe 6–7s + static let breathe = Animation.easeInOut(duration: 6.5).repeatForever(autoreverses: true) } diff --git a/macos/Codescribe/DesignSystem/TextScale.swift b/macos/Codescribe/DesignSystem/TextScale.swift index 85468232..63e89054 100644 --- a/macos/Codescribe/DesignSystem/TextScale.swift +++ b/macos/Codescribe/DesignSystem/TextScale.swift @@ -16,16 +16,16 @@ import SwiftUI // MARK: - Environment private struct CSTextScaleKey: EnvironmentKey { - static let defaultValue: CGFloat = 1.0 + static let defaultValue: CGFloat = 1.0 } extension EnvironmentValues { - /// Text-size multiplier for the enclosing surface (default 1.0). Set once at a - /// window's root by `TextScaleRoot`; read by the scaled-font helpers. - var csTextScale: CGFloat { - get { self[CSTextScaleKey.self] } - set { self[CSTextScaleKey.self] = newValue } - } + /// Text-size multiplier for the enclosing surface (default 1.0). Set once at a + /// window's root by `TextScaleRoot`; read by the scaled-font helpers. + var csTextScale: CGFloat { + get { self[CSTextScaleKey.self] } + set { self[CSTextScaleKey.self] = newValue } + } } // MARK: - Controller @@ -35,35 +35,35 @@ extension EnvironmentValues { /// drift off-grid or out of bounds. @MainActor final class TextScaleController: ObservableObject { - static let minScale: CGFloat = 0.8 - static let maxScale: CGFloat = 1.6 - static let step: CGFloat = 0.1 - - @Published private(set) var scale: CGFloat - private let defaultsKey: String - - init(key: String) { - defaultsKey = key - let stored = UserDefaults.standard.double(forKey: key) - scale = Self.clamp(stored > 0 ? CGFloat(stored) : 1.0) - } - - func increase() { apply(scale + Self.step) } - func decrease() { apply(scale - Self.step) } - func reset() { apply(1.0) } - - private func apply(_ value: CGFloat) { - let clamped = Self.clamp(value) - guard clamped != scale else { return } - scale = clamped - UserDefaults.standard.set(Double(clamped), forKey: defaultsKey) - } - - /// Snap to the nearest `step` and clamp into `[minScale, maxScale]`. - static func clamp(_ value: CGFloat) -> CGFloat { - let snapped = (value / step).rounded() * step - return min(max(snapped, minScale), maxScale) - } + static let minScale: CGFloat = 0.8 + static let maxScale: CGFloat = 1.6 + static let step: CGFloat = 0.1 + + @Published private(set) var scale: CGFloat + private let defaultsKey: String + + init(key: String) { + defaultsKey = key + let stored = UserDefaults.standard.double(forKey: key) + scale = Self.clamp(stored > 0 ? CGFloat(stored) : 1.0) + } + + func increase() { apply(scale + Self.step) } + func decrease() { apply(scale - Self.step) } + func reset() { apply(1.0) } + + private func apply(_ value: CGFloat) { + let clamped = Self.clamp(value) + guard clamped != scale else { return } + scale = clamped + UserDefaults.standard.set(Double(clamped), forKey: defaultsKey) + } + + /// Snap to the nearest `step` and clamp into `[minScale, maxScale]`. + static func clamp(_ value: CGFloat) -> CGFloat { + let snapped = (value / step).rounded() * step + return min(max(snapped, minScale), maxScale) + } } // MARK: - Root injector @@ -72,34 +72,34 @@ final class TextScaleController: ObservableObject { /// Observing the controller here (not deeper) keeps the re-render scoped: bumping /// the scale re-evaluates the surface's text, not the whole app. struct TextScaleRoot: View { - @ObservedObject var controller: TextScaleController - @ViewBuilder var content: Content - var body: some View { - content.environment(\.csTextScale, controller.scale) - } + @ObservedObject var controller: TextScaleController + @ViewBuilder var content: Content + var body: some View { + content.environment(\.csTextScale, controller.scale) + } } // MARK: - Scaled font helpers extension View { - /// Space Grotesk UI font at `size`, multiplied by the surrounding `\.csTextScale`. - func csFont(_ size: CGFloat, _ weight: Font.Weight = .regular) -> some View { - modifier(CSScaledFont(size: size, weight: weight, mono: false)) - } - - /// JetBrains Mono font at `size`, multiplied by the surrounding `\.csTextScale`. - func csMono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> some View { - modifier(CSScaledFont(size: size, weight: weight, mono: true)) - } + /// Space Grotesk UI font at `size`, multiplied by the surrounding `\.csTextScale`. + func csFont(_ size: CGFloat, _ weight: Font.Weight = .regular) -> some View { + modifier(CSScaledFont(size: size, weight: weight, mono: false)) + } + + /// JetBrains Mono font at `size`, multiplied by the surrounding `\.csTextScale`. + func csMono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> some View { + modifier(CSScaledFont(size: size, weight: weight, mono: true)) + } } private struct CSScaledFont: ViewModifier { - @Environment(\.csTextScale) private var scale - let size: CGFloat - let weight: Font.Weight - let mono: Bool - - func body(content: Content) -> some View { - content.font(mono ? CSFont.mono(size * scale, weight) : CSFont.ui(size * scale, weight)) - } + @Environment(\.csTextScale) private var scale + let size: CGFloat + let weight: Font.Weight + let mono: Bool + + func body(content: Content) -> some View { + content.font(mono ? CSFont.mono(size * scale, weight) : CSFont.ui(size * scale, weight)) + } } diff --git a/macos/Codescribe/DesignSystem/Tokens.swift b/macos/Codescribe/DesignSystem/Tokens.swift index 4df1d153..4af35842 100644 --- a/macos/Codescribe/DesignSystem/Tokens.swift +++ b/macos/Codescribe/DesignSystem/Tokens.swift @@ -10,68 +10,68 @@ import SwiftUI // Never hardcode a replacement for the system accent: the operator owns it. extension Color { - init(hex: UInt32, alpha: Double = 1.0) { - let r = Double((hex >> 16) & 0xFF) / 255.0 - let g = Double((hex >> 8) & 0xFF) / 255.0 - let b = Double(hex & 0xFF) / 255.0 - self.init(.sRGB, red: r, green: g, blue: b, opacity: alpha) - } + init(hex: UInt32, alpha: Double = 1.0) { + let r = Double((hex >> 16) & 0xFF) / 255.0 + let g = Double((hex >> 8) & 0xFF) / 255.0 + let b = Double(hex & 0xFF) / 255.0 + self.init(.sRGB, red: r, green: g, blue: b, opacity: alpha) + } } enum CSColor { - // Surfaces - static let ink = Color(hex: 0x090A0D) // page base (near-black) - static let glassBase = Color(hex: 0x12141A, alpha: 0.84) // app window material over #0b0c10 - static let glassUnder = Color(hex: 0x0B0C10) - static func surfaceRaised(_ a: Double = 0.03) -> Color { Color.white.opacity(a) } // .02–.04 - static func hairline(_ a: Double = 0.07) -> Color { Color.white.opacity(a) } // .06–.09 + // Surfaces + static let ink = Color(hex: 0x090A0D) // page base (near-black) + static let glassBase = Color(hex: 0x12141A, alpha: 0.84) // app window material over #0b0c10 + static let glassUnder = Color(hex: 0x0B0C10) + static func surfaceRaised(_ a: Double = 0.03) -> Color { Color.white.opacity(a) } // .02–.04 + static func hairline(_ a: Double = 0.07) -> Color { Color.white.opacity(a) } // .06–.09 - // App semantics — these colors carry information and do not follow macOS accent. - static let terracotta = Color(hex: 0xD97757) // dictation / processing / brand - static let terracottaLight = Color(hex: 0xE9B79F) // active labels (text on dark accent) - static let terracottaDeep = Color(hex: 0xC98A6E) // secondary voice accent - static let terracottaTintBars = Color(hex: 0xE6A98F) // every-5th waveform bar + // App semantics — these colors carry information and do not follow macOS accent. + static let terracotta = Color(hex: 0xD97757) // dictation / processing / brand + static let terracottaLight = Color(hex: 0xE9B79F) // active labels (text on dark accent) + static let terracottaDeep = Color(hex: 0xC98A6E) // secondary voice accent + static let terracottaTintBars = Color(hex: 0xE6A98F) // every-5th waveform bar - // Assistive accent — agent-routed voice - static let assistive = Color(hex: 0x9B72F2) - static let assistiveLight = Color(hex: 0xC9B7FF) + // Assistive accent — agent-routed voice + static let assistive = Color(hex: 0x9B72F2) + static let assistiveLight = Color(hex: 0xC9B7FF) - static let modeDictation = terracotta - static let modeAgent = assistive - static let modeRecording = modeDictation - static let modeProcessing = Color(hex: 0xF28C45) - static let modeReady = oliveLight - static let indicatorRecording = Color(hex: 0xFF3B30) + static let modeDictation = terracotta + static let modeAgent = assistive + static let modeRecording = modeDictation + static let modeProcessing = Color(hex: 0xF28C45) + static let modeReady = oliveLight + static let indicatorRecording = Color(hex: 0xFF3B30) - // UI chrome — selection, focus, and interactive controls follow macOS. - static var chromeAccent: Color { Color(nsColor: .controlAccentColor) } + // UI chrome — selection, focus, and interactive controls follow macOS. + static var chromeAccent: Color { Color(nsColor: .controlAccentColor) } - // Status — olive / green - static let olive = Color(hex: 0x5F6B3E) // healthy base - static let oliveLight = Color(hex: 0x9DB178) // idle / granted / success dot - static let eyebrowOlive = Color(hex: 0x7F8C5E) // section eyebrows + // Status — olive / green + static let olive = Color(hex: 0x5F6B3E) // healthy base + static let oliveLight = Color(hex: 0x9DB178) // idle / granted / success dot + static let eyebrowOlive = Color(hex: 0x7F8C5E) // section eyebrows - // Reasoning — amber - static let amber = Color(hex: 0xD6B24E) + // Reasoning — amber + static let amber = Color(hex: 0xD6B24E) - // Destructive actions — reserved for explicit danger-zone controls - static let danger = Color(hex: 0xD84A4A) - static let dangerLight = Color(hex: 0xFFAAA5) + // Destructive actions — reserved for explicit danger-zone controls + static let danger = Color(hex: 0xD84A4A) + static let dangerLight = Color(hex: 0xFFAAA5) - // Text - static let textHigh = Color(hex: 0xF4F2EC) // headlines - static let textBody = Color(hex: 0xE9E7E0) - static let textBodyAlt = Color(hex: 0xDFE2DB) - static let textMuted = Color(hex: 0x9A9D97) - static let textMutedAlt = Color(hex: 0x82857F) - static let textFaint = Color(hex: 0x6F7268) // mono meta - static let textFaintAlt = Color(hex: 0x5D6058) // timestamps + // Text + static let textHigh = Color(hex: 0xF4F2EC) // headlines + static let textBody = Color(hex: 0xE9E7E0) + static let textBodyAlt = Color(hex: 0xDFE2DB) + static let textMuted = Color(hex: 0x9A9D97) + static let textMutedAlt = Color(hex: 0x82857F) + static let textFaint = Color(hex: 0x6F7268) // mono meta + static let textFaintAlt = Color(hex: 0x5D6058) // timestamps } enum CSRadius { - static let input: CGFloat = 9 - static let card: CGFloat = 12 - static let window: CGFloat = 22 - static let tray: CGFloat = 14 - static let pill: CGFloat = 20 + static let input: CGFloat = 9 + static let card: CGFloat = 12 + static let window: CGFloat = 22 + static let tray: CGFloat = 14 + static let pill: CGFloat = 20 } diff --git a/macos/Codescribe/DesignSystem/Typography.swift b/macos/Codescribe/DesignSystem/Typography.swift index 75a74305..39fd0731 100644 --- a/macos/Codescribe/DesignSystem/Typography.swift +++ b/macos/Codescribe/DesignSystem/Typography.swift @@ -5,41 +5,42 @@ import SwiftUI // Mono is used ONLY for eyebrows, meta, code, logs — never as the page voice. enum CSFont { - // Display / UI — Space Grotesk - static func ui(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { - Font.custom(FontLoader.spaceGrotesk, size: size).weight(weight) - } - // Code / eyebrows / meta — JetBrains Mono - static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { - Font.custom(FontLoader.jetBrainsMono, size: size).weight(weight) - } - // AppKit twin of `mono` for NSTextView-backed surfaces (oversized bubble - // payloads); same face, graceful system-mono fallback. - static func nsMono(_ size: CGFloat) -> NSFont { - NSFont(name: FontLoader.jetBrainsMono, size: size) - ?? .monospacedSystemFont(ofSize: size, weight: .regular) - } + // Display / UI — Space Grotesk + static func ui(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { + Font.custom(FontLoader.spaceGrotesk, size: size).weight(weight) + } + // Code / eyebrows / meta — JetBrains Mono + static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { + Font.custom(FontLoader.jetBrainsMono, size: size).weight(weight) + } + // AppKit twin of `mono` for NSTextView-backed surfaces (oversized bubble + // payloads); same face, graceful system-mono fallback. + static func nsMono(_ size: CGFloat) -> NSFont { + NSFont(name: FontLoader.jetBrainsMono, size: size) + ?? .monospacedSystemFont(ofSize: size, weight: .regular) + } - // Named ramps from the handoff - static func hero(_ size: CGFloat = 64) -> Font { ui(size, .bold) } // -.03/-.04em tracking applied at call site - static let h2 = ui(26, .bold) - static let title = ui(15, .bold) - static let bodyLg = ui(18, .regular) - static let body = ui(14, .regular) - static let bodyStrong = ui(13, .semibold) - static let eyebrow = mono(11, .semibold) // tracking .18–.24em at call site - static let metaMono = mono(11, .medium) - static let tagMono = mono(10, .semibold) + // Named ramps from the handoff + // -.03/-.04em tracking applied at call site + static func hero(_ size: CGFloat = 64) -> Font { ui(size, .bold) } + static let h2 = ui(26, .bold) + static let title = ui(15, .bold) + static let bodyLg = ui(18, .regular) + static let body = ui(14, .regular) + static let bodyStrong = ui(13, .semibold) + static let eyebrow = mono(11, .semibold) // tracking .18–.24em at call site + static let metaMono = mono(11, .medium) + static let tagMono = mono(10, .semibold) } // Eyebrow label: mono, uppercase, wide tracking, olive — the section marker. struct EyebrowLabel: View { - let text: String - var color: Color = CSColor.eyebrowOlive - var body: some View { - Text(text.uppercased()) - .font(CSFont.eyebrow) - .tracking(2.2) - .foregroundStyle(color) - } + let text: String + var color: Color = CSColor.eyebrowOlive + var body: some View { + Text(text.uppercased()) + .font(CSFont.eyebrow) + .tracking(2.2) + .foregroundStyle(color) + } } diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift index a6116e49..7060f504 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift @@ -6,15 +6,15 @@ import SwiftUI /// Diagnostic breadcrumbs for the attachment staging path. Filter with: /// log show --predicate 'subsystem == "com.vetcoders.codescribe"' --info private let attachLog = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "attachments" + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "attachments" ) /// Turn-queue diagnostics: accept / promote / cancel with queue depth and /// thread binding. Never logs message text or attachment contents. private let queueLog = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "turn-queue" + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "turn-queue" ) // MARK: - Runtime contract (read before extending this screen) @@ -34,287 +34,293 @@ private let queueLog = Logger( /// (mapping `assistive` → `VistaAiMode.assistive`). Kept free of bridge types /// so the view-model + #Preview compile and render standalone. protocol AgentChatEngine: AnyObject { - /// True when the assistive provider can be built (keys present). - func isAvailable() -> Bool - /// Actionable reason the assistive lane cannot reach a model right now, - /// `nil` when a send can proceed. Names the missing lane/endpoint/key so - /// the chat renders honest guidance instead of a generic "add an API key". - func availabilityDetail() -> String? - /// Generate one isolated title from the raw first textual turn. This is a - /// sibling request to the assistive stream and carries no conversation state. - func generateThreadTitle(_ text: String) async throws -> String? - /// Streams a real assistant reply. Callbacks fire on the main actor as tokens - /// arrive; returns the final assembled text. - /// - /// `attachmentPaths` are absolute filesystem paths to images the composer - /// attached (empty for a text-only turn). Kept as plain paths — not bridge - /// types — so the view-model + #Preview stay standalone; the real adapter - /// maps them to the bridge `CsAttachment` at the edge. - func streamReply( - _ text: String, - threadId: String, - attachmentPaths: [String], - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (_ name: String, _ id: String) -> Void, - onToolResult: @escaping @MainActor (_ name: String, _ id: String, _ isError: Bool, _ reason: String) -> Void - ) async throws -> String - /// Abort the engine-side turn running for `threadId` (safe no-op when idle). - /// Cancelling the Swift `Task` that awaits `streamReply` is NOT enough: the - /// generated UniFFI bindings poll the Rust future to completion, so without - /// this call the agent keeps executing tools (typing/clipboard/fs) after a - /// "cancelled" turn. - @discardableResult - func cancelReply(threadId: String) -> Bool - func installToolApprovalHandler( - _ handler: @escaping @MainActor (PendingToolApproval) -> Void - ) - @discardableResult - func resolveToolApproval( - _ request: PendingToolApproval, approved: Bool, remember: Bool - ) -> Bool + /// True when the assistive provider can be built (keys present). + func isAvailable() -> Bool + /// Actionable reason the assistive lane cannot reach a model right now, + /// `nil` when a send can proceed. Names the missing lane/endpoint/key so + /// the chat renders honest guidance instead of a generic "add an API key". + func availabilityDetail() -> String? + /// Generate one isolated title from the raw first textual turn. This is a + /// sibling request to the assistive stream and carries no conversation state. + func generateThreadTitle(_ text: String) async throws -> String? + /// Streams a real assistant reply. Callbacks fire on the main actor as tokens + /// arrive; returns the final assembled text. + /// + /// `attachmentPaths` are absolute filesystem paths to images the composer + /// attached (empty for a text-only turn). Kept as plain paths — not bridge + /// types — so the view-model + #Preview stay standalone; the real adapter + /// maps them to the bridge `CsAttachment` at the edge. + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (_ name: String, _ id: String) -> Void, + onToolResult: + @escaping @MainActor (_ name: String, _ id: String, _ isError: Bool, _ reason: String) -> Void + ) async throws -> String + /// Abort the engine-side turn running for `threadId` (safe no-op when idle). + /// Cancelling the Swift `Task` that awaits `streamReply` is NOT enough: the + /// generated UniFFI bindings poll the Rust future to completion, so without + /// this call the agent keeps executing tools (typing/clipboard/fs) after a + /// "cancelled" turn. + @discardableResult + func cancelReply(threadId: String) -> Bool + func installToolApprovalHandler( + _ handler: @escaping @MainActor (PendingToolApproval) -> Void + ) + @discardableResult + func resolveToolApproval( + _ request: PendingToolApproval, approved: Bool, remember: Bool + ) -> Bool } extension AgentChatEngine { - func installToolApprovalHandler( - _ handler: @escaping @MainActor (PendingToolApproval) -> Void - ) {} - func resolveToolApproval( - _ request: PendingToolApproval, approved: Bool, remember: Bool - ) -> Bool { false } + func installToolApprovalHandler( + _ handler: @escaping @MainActor (PendingToolApproval) -> Void + ) {} + func resolveToolApproval( + _ request: PendingToolApproval, approved: Bool, remember: Bool + ) -> Bool { false } + /// Publish the rail's current selection as the voice-assistive routing + /// target (operator contract 2026-08-13: dictation goes to the thread the + /// user is looking at; a new thread only via an explicit "+ New thread"). + /// Default no-op keeps preview/mock stores standalone. + func setAssistiveTargetThread(backendId: String?) {} } /// Source-specific adapter for hotkey/voice turns owned by the shared controller /// runtime. Kept separate from `AgentChatEngine`, whose registry owns composer /// sends, so the single Stop action cannot cancel through the wrong backend. protocol VoiceTurnCancelling: AnyObject { - @discardableResult - func cancelVoiceTurn(threadId: String) -> Bool + @discardableResult + func cancelVoiceTurn(threadId: String) -> Bool } // MARK: - Models enum ComposerTurnPhase: Equatable { - case thinking - case streaming - case cancelling + case thinking + case streaming + case cancelling } /// The single composer-originated turn owned by the Swift UI. The local thread /// id targets the bubble/task; the backend id is the exact Rust cancellation /// key. `id` prevents a draining cancelled task from clearing a newer send. struct ActiveComposerTurn: Equatable { - let id: UUID - let threadID: UUID - let backendThreadID: String - let assistantMessageID: UUID - var phase: ComposerTurnPhase + let id: UUID + let threadID: UUID + let backendThreadID: String + let assistantMessageID: UUID + var phase: ComposerTurnPhase } struct PendingToolApproval: Identifiable, Equatable { - var id: String { "\(sessionID):\(threadID):\(callID)" } - let callID: String - let sessionID: String - let threadID: String - let tool: String - let server: String - let risk: String - let summary: String - let command: String? - let cwd: String? - let paths: [String] + var id: String { "\(sessionID):\(threadID):\(callID)" } + let callID: String + let sessionID: String + let threadID: String + let tool: String + let server: String + let risk: String + let summary: String + let command: String? + let cwd: String? + let paths: [String] } enum ChatRole { - case you - case tool - case assistant + case you + case tool + case assistant } /// How an assistant bubble renders its body. `raw` (mono plain — exactly what /// streamed) is the DEFAULT per the operator's C2b decision: stream and settled /// turn look identical, rich markdown/highlight is per-bubble opt-in. enum MessageRenderMode: Equatable { - case raw - case rich + case raw + case rich - /// Pure toggle used by the meta-row raw↔rich button (XCTest-covered). - static func nextRenderMode(after mode: MessageRenderMode) -> MessageRenderMode { - mode == .raw ? .rich : .raw - } + /// Pure toggle used by the meta-row raw↔rich button (XCTest-covered). + static func nextRenderMode(after mode: MessageRenderMode) -> MessageRenderMode { + mode == .raw ? .rich : .raw + } } enum ToolLineState: Hashable { - case running - case succeeded - case failed - case cancelled - case unknown + case running + case succeeded + case failed + case cancelled + case unknown } struct ToolLine: Identifiable, Hashable { - let id: UUID - var callID: String? - var verb: String // "grep", "read" — rendered olive; "failed" — terracotta - let detail: String // tool name or "events/bus.ts · ui/store.ts" - var state: ToolLineState - /// Result summary for a settled line (success summary or failure reason). - /// `nil` for running lines and for reloaded/persisted turns that do not - /// carry payload. Drives the expandable inspect panel. - var reason: String? - /// Wall-clock start of the live tool call (UI-only; not persisted). - var startedAt: Date? - /// Elapsed milliseconds once the call settles (UI-only; not persisted). - var durationMs: Int? - - init( - id: UUID = UUID(), - callID: String? = nil, - verb: String, - detail: String, - state: ToolLineState = .succeeded, - reason: String? = nil, - startedAt: Date? = nil, - durationMs: Int? = nil - ) { - self.id = id - self.callID = callID - self.verb = verb - self.detail = detail - self.state = state - self.reason = reason - self.startedAt = startedAt - self.durationMs = durationMs - } - - /// True when the row can open an inspect disclosure (summary, call id, or timing). - var hasInspectPayload: Bool { - ToolInspectPresentation.hasInspectPayload( - reason: reason, - callID: callID, - durationMs: durationMs - ) - } - - /// Plain-text technical dump for copy (name, status, duration, call id, summary). - var technicalCopyText: String { - ToolInspectPresentation.technicalCopy( - verb: verb, - detail: detail, - state: state, - reason: reason, - callID: callID, - durationMs: durationMs - ) - } + let id: UUID + var callID: String? + var verb: String // "grep", "read" — rendered olive; "failed" — terracotta + let detail: String // tool name or "events/bus.ts · ui/store.ts" + var state: ToolLineState + /// Result summary for a settled line (success summary or failure reason). + /// `nil` for running lines and for reloaded/persisted turns that do not + /// carry payload. Drives the expandable inspect panel. + var reason: String? + /// Wall-clock start of the live tool call (UI-only; not persisted). + var startedAt: Date? + /// Elapsed milliseconds once the call settles (UI-only; not persisted). + var durationMs: Int? + + init( + id: UUID = UUID(), + callID: String? = nil, + verb: String, + detail: String, + state: ToolLineState = .succeeded, + reason: String? = nil, + startedAt: Date? = nil, + durationMs: Int? = nil + ) { + self.id = id + self.callID = callID + self.verb = verb + self.detail = detail + self.state = state + self.reason = reason + self.startedAt = startedAt + self.durationMs = durationMs + } + + /// True when the row can open an inspect disclosure (summary, call id, or timing). + var hasInspectPayload: Bool { + ToolInspectPresentation.hasInspectPayload( + reason: reason, + callID: callID, + durationMs: durationMs + ) + } + + /// Plain-text technical dump for copy (name, status, duration, call id, summary). + var technicalCopyText: String { + ToolInspectPresentation.technicalCopy( + verb: verb, + detail: detail, + state: state, + reason: reason, + callID: callID, + durationMs: durationMs + ) + } } /// Pure presentation helpers for tool-activity inspect (testable without SwiftUI). enum ToolInspectPresentation { - static func hasInspectPayload(reason: String?, callID: String?, durationMs: Int?) -> Bool { - if let reason, !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } - if let callID, !callID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } - if let durationMs, durationMs >= 0 { return true } - return false - } - - static func statusLabel(for state: ToolLineState) -> String { - switch state { - case .running: return "running" - case .succeeded: return "succeeded" - case .failed: return "failed" - case .cancelled: return "cancelled" - case .unknown: return "ended" - } - } - - static func durationLabel(ms: Int?) -> String? { - guard let ms, ms >= 0 else { return nil } - if ms < 1000 { return "\(ms) ms" } - let seconds = Double(ms) / 1000.0 - if seconds < 10 { - return String(format: "%.1f s", seconds) - } - return "\(Int(seconds.rounded())) s" - } - - static func technicalCopy( - verb: String, - detail: String, - state: ToolLineState, - reason: String?, - callID: String?, - durationMs: Int? - ) -> String { - var lines: [String] = [ - "tool: \(detail)", - "verb: \(verb)", - "status: \(statusLabel(for: state))", - ] - if let durationLabel = durationLabel(ms: durationMs) { - lines.append("duration: \(durationLabel)") - } - if let callID, !callID.isEmpty { - lines.append("call_id: \(callID)") - } - if let reason, !reason.isEmpty { - lines.append("summary: \(reason)") - } - return lines.joined(separator: "\n") - } + static func hasInspectPayload(reason: String?, callID: String?, durationMs: Int?) -> Bool { + if let reason, !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + if let callID, !callID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + if let durationMs, durationMs >= 0 { return true } + return false + } + + static func statusLabel(for state: ToolLineState) -> String { + switch state { + case .running: return "running" + case .succeeded: return "succeeded" + case .failed: return "failed" + case .cancelled: return "cancelled" + case .unknown: return "ended" + } + } + + static func durationLabel(ms: Int?) -> String? { + guard let ms, ms >= 0 else { return nil } + if ms < 1000 { return "\(ms) ms" } + let seconds = Double(ms) / 1000.0 + if seconds < 10 { + return String(format: "%.1f s", seconds) + } + return "\(Int(seconds.rounded())) s" + } + + static func technicalCopy( + verb: String, + detail: String, + state: ToolLineState, + reason: String?, + callID: String?, + durationMs: Int? + ) -> String { + var lines: [String] = [ + "tool: \(detail)", + "verb: \(verb)", + "status: \(statusLabel(for: state))", + ] + if let durationLabel = durationLabel(ms: durationMs) { + lines.append("duration: \(durationLabel)") + } + if let callID, !callID.isEmpty { + lines.append("call_id: \(callID)") + } + if let reason, !reason.isEmpty { + lines.append("summary: \(reason)") + } + return lines.joined(separator: "\n") + } } struct ChatMessage: Identifiable { - let id = UUID() - var role: ChatRole - var timestamp: String - /// Body text. May contain `backtick` code spans for assistant/you turns. - var text: String - - /// Files attached to a sent user turn (empty otherwise). Rendered as chips in - /// the You bubble. Restored attachment names/types are recovered from the - /// Swift-side metadata sidecar because the bridge's persisted message JSON - /// carries image blocks but not original file names. - var attachments: [MessageAttachment] = [] - - // Assistive wire split (U17). For a voice-assistive user turn the engine - // sends a fixed prompt skeleton to the LLM; the bubble must show the spoken - // instruction, not the skeleton. `text` holds the display text; the fields - // below carry the rest of the wire truth (nil for composer/plain turns). - /// Full prompt as sent to the model ("Copy full prompt" / debug). Non-nil - /// only when `text` was rewritten from an assistive skeleton. - var wireText: String? = nil - /// ZAZNACZONY_TEKST captured with the turn, shown behind the context chip. - var contextSelection: String? = nil - /// Frontmost app from the KONTEKST section, shown behind the context chip. - var contextApp: String? = nil - - // Tool-activity turn - var toolTitle: String = "" // "What I checked · 2 tools" - var toolLines: [ToolLine] = [] - - // Assistant turn - var reasonedSeconds: Double? = nil - var isThinking: Bool = false // pre-reply "thinking…" state - var isStreaming: Bool = false // word-reveal in progress (shows caret) - var wasStopped: Bool = false // cancelled terminal; partial text remains intact - var reasoning: String = "" // streamed model reasoning, rendered separately - var renderMode: MessageRenderMode = .rich + let id = UUID() + var role: ChatRole + var timestamp: String + /// Body text. May contain `backtick` code spans for assistant/you turns. + var text: String + + /// Files attached to a sent user turn (empty otherwise). Rendered as chips in + /// the You bubble. Restored attachment names/types are recovered from the + /// Swift-side metadata sidecar because the bridge's persisted message JSON + /// carries image blocks but not original file names. + var attachments: [MessageAttachment] = [] + + // Assistive wire split (U17). For a voice-assistive user turn the engine + // sends a fixed prompt skeleton to the LLM; the bubble must show the spoken + // instruction, not the skeleton. `text` holds the display text; the fields + // below carry the rest of the wire truth (nil for composer/plain turns). + /// Full prompt as sent to the model ("Copy full prompt" / debug). Non-nil + /// only when `text` was rewritten from an assistive skeleton. + var wireText: String? = nil + /// ZAZNACZONY_TEKST captured with the turn, shown behind the context chip. + var contextSelection: String? = nil + /// Frontmost app from the KONTEKST section, shown behind the context chip. + var contextApp: String? = nil + + // Tool-activity turn + var toolTitle: String = "" // "What I checked · 2 tools" + var toolLines: [ToolLine] = [] + + // Assistant turn + var reasonedSeconds: Double? = nil + var isThinking: Bool = false // pre-reply "thinking…" state + var isStreaming: Bool = false // word-reveal in progress (shows caret) + var wasStopped: Bool = false // cancelled terminal; partial text remains intact + var reasoning: String = "" // streamed model reasoning, rendered separately + var renderMode: MessageRenderMode = .rich } /// An image the user staged in the composer but has not sent yet. Referenced by /// file URL (NSOpenPanel / clipboard-saved temp file); the send path forwards the /// path to the bridge, which loads + validates the bytes. struct PendingAttachment: Identifiable, Hashable { - let id = UUID() - let url: URL - var name: String { url.lastPathComponent } - var type: String { MessageAttachment.inferredType(name: name, url: url) } - var previewAttachment: MessageAttachment { MessageAttachment(name: name, url: url, type: type) } + let id = UUID() + let url: URL + var name: String { url.lastPathComponent } + var type: String { MessageAttachment.inferredType(name: name, url: url) } + var previewAttachment: MessageAttachment { MessageAttachment(name: name, url: url, type: type) } } /// An attachment carried by a *sent* chat message, surfaced as a chip in the You @@ -322,43 +328,45 @@ struct PendingAttachment: Identifiable, Hashable { /// is nil for restored turns (the persisted thread has no source path), in which /// case the chip shows the filename only. struct MessageAttachment: Identifiable, Hashable { - let id = UUID() - let name: String - let url: URL? - let type: String - - init(name: String, url: URL?, type: String? = nil) { - self.name = name - self.url = url - self.type = type ?? Self.inferredType(name: name, url: url) - } - - static func inferredType(name: String, url: URL?) -> String { - let ext = (url?.pathExtension.isEmpty == false ? url?.pathExtension : nil) ?? (name as NSString).pathExtension - switch ext.lowercased() { - case "png": return "image/png" - case "jpg", "jpeg": return "image/jpeg" - case "gif": return "image/gif" - case "webp": return "image/webp" - case "bmp": return "image/bmp" - case "tif", "tiff": return "image/tiff" - default: return ext.isEmpty ? "file" : "file/\(ext.lowercased())" - } - } + let id = UUID() + let name: String + let url: URL? + let type: String + + init(name: String, url: URL?, type: String? = nil) { + self.name = name + self.url = url + self.type = type ?? Self.inferredType(name: name, url: url) + } + + static func inferredType(name: String, url: URL?) -> String { + let ext = + (url?.pathExtension.isEmpty == false ? url?.pathExtension : nil) + ?? (name as NSString).pathExtension + switch ext.lowercased() { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "bmp": return "image/bmp" + case "tif", "tiff": return "image/tiff" + default: return ext.isEmpty ? "file" : "file/\(ext.lowercased())" + } + } } struct ChatThread: Identifiable { - let id = UUID() - var title: String - var meta: String // mono subtitle, e.g. "active · restored" / "today 18:40 · gpt-5 · 1.2k tok" - var isRestored: Bool = false - var isFavorite: Bool = false - var backendId: String? = nil // codescribe ThreadStore id (nil = local-only, not yet persisted) - var messagesLoaded: Bool = false // lazy-load guard for persisted threads - var messages: [ChatMessage] = [] - var updatedAt: Date? = nil // nil (local-only draft) groups under Today - var model: String? = nil - var totalTokens: UInt64? = nil + let id = UUID() + var title: String + var meta: String // mono subtitle, e.g. "active · restored" / "today 18:40 · gpt-5 · 1.2k tok" + var isRestored: Bool = false + var isFavorite: Bool = false + var backendId: String? = nil // codescribe ThreadStore id (nil = local-only, not yet persisted) + var messagesLoaded: Bool = false // lazy-load guard for persisted threads + var messages: [ChatMessage] = [] + var updatedAt: Date? = nil // nil (local-only draft) groups under Today + var model: String? = nil + var totalTokens: UInt64? = nil } /// Shared Swift-side title guard for coordinator results, manual renames, and @@ -366,98 +374,102 @@ struct ChatThread: Identifiable { /// provider failure or stale legacy row from flashing transport punctuation in /// the live model before disk truth refreshes. enum ThreadTitlePolicy { - static func normalized(_ value: String?, limit: Int = 72) -> String? { - guard let value else { return nil } - let collapsed = strippingContextMarkers(from: value) - .split(whereSeparator: \Character.isWhitespace) - .joined(separator: " ") - guard !collapsed.hasPrefix("<<<"), - collapsed.contains(where: { $0.isLetter || $0.isNumber }) else { return nil } - return String(collapsed.prefix(limit)) - } - - static func firstUserExcerpt(in messages: [ChatMessage], limit: Int = 72) -> String? { - guard let message = messages.first(where: { $0.role == .you }) else { return nil } - let presented = AssistivePromptParser.presented(message) - return normalized(presented.text, limit: limit) - } - - /// Vowel inventory used to recognise word fragments left behind by a - /// mid-word context-marker capture. Mirrors `TITLE_FRAGMENT_VOWELS` in - /// `core/agent/thread_store.rs` (the durable owner of title derivation). - private static let fragmentVowels = Set("aeiouyąęóàáâäãåèéêëìíîïòôöõùúûü") - - /// Remove `{selection_N}` / `{image_N}` context-bucket markers from a - /// title candidate. Mirror of the Rust `strip_context_markers`: the - /// overlay space-pads a marker even when the capture lands mid-word - /// ("mnie" -> "mn {selection_1} ie"), so after removal a letter run of - /// two or more characters without any vowel is treated as a split-word - /// fragment and glued back without a space; otherwise a single space - /// stays. Titles only — message bodies keep their markers untouched. - static func strippingContextMarkers(from text: String) -> String { - guard text.contains("{selection_") || text.contains("{image_") else { return text } - var chars = Array(text) - while let marker = contextMarkerRange(in: chars) { - var leftEnd = marker.lowerBound - while leftEnd > 0, chars[leftEnd - 1].isWhitespace { leftEnd -= 1 } - var rightStart = marker.upperBound - while rightStart < chars.count, chars[rightStart].isWhitespace { rightStart += 1 } - // Unpadded marker (no whitespace on either side) is the overlay's - // lossless mid-word form ("mn{selection_1}ie") — glue without the - // vowel heuristic; that heuristic only serves legacy padded texts. - let noGap = leftEnd == marker.lowerBound && rightStart == marker.upperBound - let keepSpace = leftEnd > 0 - && rightStart < chars.count - && !noGap - && !gluesSplitWord(chars: chars, leftEnd: leftEnd, rightStart: rightStart) - chars.replaceSubrange(leftEnd.. Range? { - var open = 0 - while open < chars.count { - defer { open += 1 } - guard chars[open] == "{" else { continue } - for label in ["selection_", "image_"] { - let labelChars = Array(label) - let digitsStart = open + 1 + labelChars.count - guard digitsStart <= chars.count, - Array(chars[(open + 1).. digitsStart, close < chars.count, chars[close] == "}" { - return open..<(close + 1) - } - } - } - return nil - } - - private static func gluesSplitWord(chars: [Character], leftEnd: Int, rightStart: Int) -> Bool { - var left: [Character] = [] - var index = leftEnd - 1 - while index >= 0, chars[index].isLetter { - left.append(chars[index]) - index -= 1 - } - var right: [Character] = [] - index = rightStart - while index < chars.count, chars[index].isLetter { - right.append(chars[index]) - index += 1 - } - return fragmentLacksVowel(left) || fragmentLacksVowel(right) - } - - private static func fragmentLacksVowel(_ fragment: [Character]) -> Bool { - fragment.count >= 2 && !fragment.contains { ch in - ch.lowercased().contains { fragmentVowels.contains($0) } - } - } + static func normalized(_ value: String?, limit: Int = 72) -> String? { + guard let value else { return nil } + let collapsed = strippingContextMarkers(from: value) + .split(whereSeparator: \Character.isWhitespace) + .joined(separator: " ") + guard !collapsed.hasPrefix("<<<"), + collapsed.contains(where: { $0.isLetter || $0.isNumber }) + else { return nil } + return String(collapsed.prefix(limit)) + } + + static func firstUserExcerpt(in messages: [ChatMessage], limit: Int = 72) -> String? { + guard let message = messages.first(where: { $0.role == .you }) else { return nil } + let presented = AssistivePromptParser.presented(message) + return normalized(presented.text, limit: limit) + } + + /// Vowel inventory used to recognise word fragments left behind by a + /// mid-word context-marker capture. Mirrors `TITLE_FRAGMENT_VOWELS` in + /// `core/agent/thread_store.rs` (the durable owner of title derivation). + private static let fragmentVowels = Set("aeiouyąęóàáâäãåèéêëìíîïòôöõùúûü") + + /// Remove `{selection_N}` / `{image_N}` context-bucket markers from a + /// title candidate. Mirror of the Rust `strip_context_markers`: the + /// overlay space-pads a marker even when the capture lands mid-word + /// ("mnie" -> "mn {selection_1} ie"), so after removal a letter run of + /// two or more characters without any vowel is treated as a split-word + /// fragment and glued back without a space; otherwise a single space + /// stays. Titles only — message bodies keep their markers untouched. + static func strippingContextMarkers(from text: String) -> String { + guard text.contains("{selection_") || text.contains("{image_") else { return text } + var chars = Array(text) + while let marker = contextMarkerRange(in: chars) { + var leftEnd = marker.lowerBound + while leftEnd > 0, chars[leftEnd - 1].isWhitespace { leftEnd -= 1 } + var rightStart = marker.upperBound + while rightStart < chars.count, chars[rightStart].isWhitespace { rightStart += 1 } + // Unpadded marker (no whitespace on either side) is the overlay's + // lossless mid-word form ("mn{selection_1}ie") — glue without the + // vowel heuristic; that heuristic only serves legacy padded texts. + let noGap = leftEnd == marker.lowerBound && rightStart == marker.upperBound + let keepSpace = + leftEnd > 0 + && rightStart < chars.count + && !noGap + && !gluesSplitWord(chars: chars, leftEnd: leftEnd, rightStart: rightStart) + chars.replaceSubrange(leftEnd.. Range? { + var open = 0 + while open < chars.count { + defer { open += 1 } + guard chars[open] == "{" else { continue } + for label in ["selection_", "image_"] { + let labelChars = Array(label) + let digitsStart = open + 1 + labelChars.count + guard digitsStart <= chars.count, + Array(chars[(open + 1).. digitsStart, close < chars.count, chars[close] == "}" { + return open..<(close + 1) + } + } + } + return nil + } + + private static func gluesSplitWord(chars: [Character], leftEnd: Int, rightStart: Int) -> Bool { + var left: [Character] = [] + var index = leftEnd - 1 + while index >= 0, chars[index].isLetter { + left.append(chars[index]) + index -= 1 + } + var right: [Character] = [] + index = rightStart + while index < chars.count, chars[index].isLetter { + right.append(chars[index]) + index += 1 + } + return fragmentLacksVowel(left) || fragmentLacksVowel(right) + } + + private static func fragmentLacksVowel(_ fragment: [Character]) -> Bool { + fragment.count >= 2 + && !fragment.contains { ch in + ch.lowercased().contains { fragmentVowels.contains($0) } + } + } } // MARK: - Threads provider (read-only access to persisted codescribe threads) @@ -466,24 +478,24 @@ enum ThreadTitlePolicy { /// codescribe ThreadStore (via `CodescribeThreads`). Kept separate from /// `AgentChatEngine` so the #Preview mock stays standalone. protocol ChatThreadsProviding: AnyObject { - func listThreads() -> [ChatThread] - func searchThreads(query: String) -> [ChatThread] - func loadMessages(backendId: String) -> [ChatMessage] - func deleteThread(backendId: String) -> Bool - func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool - /// Rename a persisted thread; the core marks the title user-custom so - /// auto-titling won't overwrite it. Returns `false` on failure / no such thread. - func renameThread(backendId: String, title: String) -> Bool - /// Persist a generated title without overriding a user-custom title. - /// Returns `false` while the first turn has not created the thread on disk, - /// when the user already owns the title, or on persistence failure. - func setGeneratedTitle(backendId: String, title: String) -> Bool - /// Export a persisted thread to a Markdown file under - /// `~/.codescribe/transcriptions/YYYY-MM-DD/`. Returns the absolute path of the - /// written file, or `nil` on failure. `assistantOnly` keeps only assistant turns. - func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? - /// Mint a fresh ThreadStore id for a new conversation (so it persists). - func generateThreadId() -> String + func listThreads() -> [ChatThread] + func searchThreads(query: String) -> [ChatThread] + func loadMessages(backendId: String) -> [ChatMessage] + func deleteThread(backendId: String) -> Bool + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool + /// Rename a persisted thread; the core marks the title user-custom so + /// auto-titling won't overwrite it. Returns `false` on failure / no such thread. + func renameThread(backendId: String, title: String) -> Bool + /// Persist a generated title without overriding a user-custom title. + /// Returns `false` while the first turn has not created the thread on disk, + /// when the user already owns the title, or on persistence failure. + func setGeneratedTitle(backendId: String, title: String) -> Bool + /// Export a persisted thread to a Markdown file under + /// `~/.codescribe/transcriptions/YYYY-MM-DD/`. Returns the absolute path of the + /// written file, or `nil` on failure. `assistantOnly` keeps only assistant turns. + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? + /// Mint a fresh ThreadStore id for a new conversation (so it persists). + func generateThreadId() -> String } // MARK: - Composer dictation seam (voice message → transcript into the draft) @@ -491,30 +503,30 @@ protocol ChatThreadsProviding: AnyObject { /// Lifecycle of the composer's own voice-note dictation. Independent from the /// hotkey / overlay dictation session — this drives only the composer mic. enum ComposerDictationPhase: Equatable { - case idle - case preparing // permission / model load / start-stop transition in flight - case recording - case failed(String) + case idle + case preparing // permission / model load / start-stop transition in flight + case recording + case failed(String) } enum ComposerCaptureCommand { - case startAssistive - case stopAssistive - case toggleAssistive + case startAssistive + case stopAssistive + case toggleAssistive } enum DictationDeliverySource: Equatable { - case live - case final - case edited - - var label: String { - switch self { - case .live: return "live chosen" - case .final: return "final chosen" - case .edited: return "edited text chosen" - } + case live + case final + case edited + + var label: String { + switch self { + case .live: return "live chosen" + case .final: return "final chosen" + case .edited: return "edited text chosen" } + } } /// UI-only seam over the composer dictation controller. The real adapter @@ -523,1958 +535,2057 @@ enum DictationDeliverySource: Equatable { /// is a no-op, e.g. in previews). @MainActor protocol ComposerDictating: AnyObject { - /// Start recording when idle, stop-and-insert when recording. - func toggle() - func handle(_ command: ComposerCaptureCommand) + /// Start recording when idle, stop-and-insert when recording. + func toggle() + func handle(_ command: ComposerCaptureCommand) } // MARK: - Store @MainActor final class AgentChatStore: ObservableObject { - @Published var threads: [ChatThread] - @Published var selectedThreadID: UUID? - @Published var draft: String = "" - /// Monotonic UI command consumed by the composer. It carries no text and - /// deliberately does not mutate the selected thread or staged attachments. - @Published private(set) var composerFocusRequest: UInt64 = 0 - @Published private(set) var dictationPreview: String = "" - @Published private(set) var dictationLivePreview: String = "" - @Published private(set) var dictationFinalPreview: String? - @Published private(set) var dictationFinalChangedText = false - @Published private(set) var dictationVadActive = false - @Published private(set) var dictationPreviewUserEdited = false - @Published private(set) var dictationDeliverySource: DictationDeliverySource = .live - - /// Images staged in the composer for the next message. Cleared when the - /// message is dispatched. - @Published var pendingAttachments: [PendingAttachment] = [] - @Published private(set) var pendingToolApprovals: [PendingToolApproval] = [] - - // MARK: Composer dictation - - /// Current phase of the composer's voice-note dictation. Drives the mic - /// affordance (ripple while `.recording`) and the inline error feedback. - @Published private(set) var dictationPhase: ComposerDictationPhase = .idle - - /// True while a hotkey / tray / overlay dictation session owns the microphone. - /// Set from the authoritative recording lifecycle hooks (see OverlayController) - /// so the composer mic can't open a second, colliding recorder. - @Published var dictationBlocked: Bool = false - - /// Injected real adapter (Core). `nil` in previews / mock → mic is inert. - var dictation: ComposerDictating? - - /// Guards the auto-clear of a `.failed` phase against a stale timer overwriting - /// a newer state. - private var dictationFailureToken = UUID() - - /// Toggle the composer voice note (start ↔ stop-and-insert). - func toggleDictation() { dictation?.toggle() } - - func handleAssistiveCapture(_ command: ComposerCaptureCommand) { - dictation?.handle(command) - } - - func requestComposerFocus() { - composerFocusRequest &+= 1 - } - - /// Set by the real adapter as the dictation session transitions. No-op-safe - /// when no adapter is wired. - func setDictationPhase(_ phase: ComposerDictationPhase) { dictationPhase = phase } - - /// Latest live voice-note preview. This is a snapshot buffer from the STT - /// listener, not a delta stream, and stays separate from `draft` until stop. - func beginDictationPreviewSession() { - dictationPreview = "" - dictationLivePreview = "" - dictationFinalPreview = nil - dictationFinalChangedText = false - dictationVadActive = false - dictationPreviewUserEdited = false - dictationDeliverySource = .live - } - - /// Idempotent on purpose. Apple live polls partials every ~40 ms, so during a - /// pause the SAME text arrives ~25×/s. Publishing an unchanged value still - /// fires `objectWillChange`, rebuilding the whole Agent window body — and the - /// preview's `TextEditor` is NSTextView-backed, so each rebuild mutates the - /// AppKit subtree, invalidates the window's structural regions and re-runs the - /// deep `cursorUpdate:` walk (measured 2026-08-05: ~30% of main-thread samples - /// in `setCursorForMouseLocation:` → `NSCursor _reallySet`, visible as the - /// pointer flickering between I-beam and arrow, plus a PDF cursor-image reload - /// per frame). Writing only on real change removes the whole storm at the - /// source; see `plans/gtm-closure-260804/evidence/2026-08-05_cursor-storm-sample.txt`. - func updateDictationPreview(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if dictationLivePreview != trimmed { dictationLivePreview = trimmed } - guard !dictationPreviewUserEdited else { return } - if dictationPreview != trimmed { dictationPreview = trimmed } - } - - func editDictationPreview(_ text: String) { - dictationPreview = text - dictationPreviewUserEdited = true - dictationDeliverySource = .edited - } - - func noteDictationFinalPreview(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - let final = trimmed.isEmpty ? nil : trimmed - let changed = !trimmed.isEmpty && trimmed != dictationLivePreview - if dictationFinalPreview != final { dictationFinalPreview = final } - if dictationFinalChangedText != changed { dictationFinalChangedText = changed } - } - - /// Same idempotence contract as `updateDictationPreview`: the VAD callback - /// fires per audio chunk, and republishing an unchanged flag rebuilds the - /// window body for nothing. - func setDictationVadActive(_ active: Bool) { - guard dictationVadActive != active else { return } - dictationVadActive = active - } - - /// Preserve both hypotheses and explicitly choose the delivery text. A - /// materially shorter final pass may fill gaps but must never erase a better - /// live canvas. User edits always win and cancel Assistive auto-send. - func resolveDictationDelivery(final text: String, autoSend: Bool) -> (text: String, autoSend: Bool) { - let final = text.trimmingCharacters(in: .whitespacesAndNewlines) - dictationFinalPreview = final.isEmpty ? nil : final - dictationFinalChangedText = !final.isEmpty && final != dictationLivePreview - - if dictationPreviewUserEdited { - dictationDeliverySource = .edited - return (dictationPreview.trimmingCharacters(in: .whitespacesAndNewlines), false) - } - - let live = dictationLivePreview.trimmingCharacters(in: .whitespacesAndNewlines) - let liveWords = live.split(whereSeparator: \Character.isWhitespace).count - let finalWords = final.split(whereSeparator: \Character.isWhitespace).count - let finalRegressed = !live.isEmpty && (final.isEmpty || finalWords * 100 < liveWords * 85) - let chosen = finalRegressed ? live : final - dictationDeliverySource = finalRegressed ? .live : .final - dictationPreview = chosen - return (chosen, autoSend) - } - - func clearDictationPreview() { - dictationPreview = "" - dictationLivePreview = "" - dictationFinalPreview = nil - dictationFinalChangedText = false - dictationVadActive = false - dictationPreviewUserEdited = false - dictationDeliverySource = .live - } - - /// Surface a recoverable dictation failure with a self-clearing inline message - /// (auto-returns to `.idle` after a few seconds so the composer doesn't keep a - /// stale error banner). - func reportDictationFailure(_ message: String) { - clearDictationPreview() - dictationPhase = .failed(message) - let token = UUID() - dictationFailureToken = token - Task { @MainActor in - try? await Task.sleep(nanoseconds: 4_000_000_000) - guard dictationFailureToken == token, case .failed = dictationPhase else { return } - dictationPhase = .idle - } - } - - /// Append the explicitly resolved voice transcript to the editable draft. - /// Preview provenance remains visible until the next capture starts. - func appendDictatedTranscript(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if draft.isEmpty { - draft = trimmed - } else { - let needsSeparator = !(draft.last?.isWhitespace ?? false) - draft += (needsSeparator ? " " : "") + trimmed - } - } - - /// Injected by W2-01. `nil` until then; `send` degrades gracefully. - var engine: AgentChatEngine? - - /// Injected provider for persisted threads. `nil` → falls back to mock seed. - var threadsProvider: ChatThreadsProviding? - - /// Backs the composer slash-command palette. `nil` (previews, unit tests - /// without a runtime) ⇒ every command lists nothing rather than lying about - /// what is configured. - var paletteSource: ComposerPaletteSourcing? - - /// Entries for one palette command, resolved on demand so a freshly saved - /// model or a just-granted tool shows up without reopening the window. - func paletteEntries(for command: ComposerPaletteCommand) -> [ComposerPaletteEntry] { - let start = Date() - defer { AgentPerf.log("tool catalog load", since: start, detail: command.rawValue) } - return paletteSource?.entries(for: command) ?? [] - } - - /// Apply a palette pick. Failures surface as a system line in the thread — - /// silently ignoring a click would leave the operator believing the model - /// changed when it did not. - func applyPaletteEntry(_ entry: ComposerPaletteEntry, for command: ComposerPaletteCommand) { - guard let paletteSource else { return } - do { - try paletteSource.apply(entry, for: command) - } catch { - guard let threadID = currentThread?.id else { return } - append( - ChatMessage( - role: .tool, - timestamp: "now", - text: "Nie udało się zastosować „\(entry.title)”: " - + error.localizedDescription - ), - to: threadID - ) - } - } - - private var revealTask: Task? - private var didStartDemo = false - - /// Exactly one composer send may own the composer-side cancellation path. - @Published private(set) var activeComposerTurn: ActiveComposerTurn? - - /// Active voice-assistive turn being streamed from the core runtime (hotkey / - /// hands-off), NOT the composer. `nil` when no voice reply is in flight. The - /// core owns the provider call + disk persistence for this turn; the store - /// only renders the incoming delivery events — it must never call `send()` for - /// a voice turn, which would fire a second, composer-side provider call. - private var voiceTurnThreadID: UUID? - private var voiceAssistantID: UUID? - private var voiceTurnStartedAt: Date? - @Published private(set) var voiceTurnPhase: ComposerTurnPhase? - weak var voiceTurnCanceller: VoiceTurnCancelling? - - /// In-flight `send()` streaming tasks keyed by thread. Tracked so deleting a - /// thread can cancel its running reply — otherwise the task's post-stream - /// `refreshThreads` (plus the agent's best-effort re-persist) would resurrect - /// the just-deleted thread. - private struct InFlightSend { - let id: UUID - let task: Task - } - - private var inFlightSends: [UUID: InFlightSend] = [:] - - /// Bookkeeping for the one title request allowed on a first textual turn, - /// regardless of source: the composer `send()` and the voice ingest path - /// (`ingestVoiceTurn` → `ingestVoiceDone`/`Error`/`Cancelled`) share this - /// coordinator. MainActor serialization makes the turn/title completion race - /// explicit: whichever result lands first updates this state, and the - /// turn-side settlement flushes at most one queued write before refreshing - /// the rail. - private struct FirstTurnTitleState { - let backendThreadID: String - let generationID: UUID - let originalTitle: String - var streamCompleted = false - var generationFinished = false - var pendingGeneratedTitle: String? - var pendingCustomTitle: String? - } - - private var firstTurnTitleStates: [UUID: FirstTurnTitleState] = [:] - private var titleGenerationTasks: [UUID: Task] = [:] - /// Local authority marker used to reject a late generated result even when - /// the first disk persist and a manual rename interleave. - private var customTitleThreadIDs: Set = [] - - /// NotificationCenter tokens for the event-driven rail refresh (wave S, - /// cut C): window activation + cross-surface `threadsDidChange`. Removed - /// on deinit; empty when no threads provider is wired (preview/mock). - private var externalThreadsObservers: [NSObjectProtocol] = [] - private let licenseService: LicenseService? - private var licenseChangeSink: AnyCancellable? - - /// `loadsThreadIndexEagerly: false` (production `AppModel` path) turns init - /// into a light shell: no disk I/O on the MainActor bootstrap — the real - /// thread index loads asynchronously off the main actor and merges in. - /// The default `true` preserves the synchronous contract tests and previews - /// rely on (threads visible immediately after init). - init(engine: AgentChatEngine? = nil, - threadsProvider: ChatThreadsProviding? = nil, - threads: [ChatThread]? = nil, - voiceTurnCanceller: VoiceTurnCancelling? = nil, - licenseService: LicenseService? = nil, - loadsThreadIndexEagerly: Bool = true) { - self.engine = engine - self.threadsProvider = threadsProvider - self.voiceTurnCanceller = voiceTurnCanceller - self.licenseService = licenseService - - let seeded: [ChatThread] - var deferredIndexLoad = false - if let threads { - seeded = threads // explicit (preview/mock) - } else if threadsProvider != nil, !loadsThreadIndexEagerly { - seeded = [ChatThread(title: "New thread", meta: "now")] // shell; index merges async - deferredIndexLoad = true - } else if let real = threadsProvider?.listThreads(), !real.isEmpty { - seeded = real // real persisted threads - } else if threadsProvider != nil { - seeded = [ChatThread(title: "New thread", meta: "now")] // real provider, empty history - } else { - seeded = Self.seedThreads() // no provider → mock seed - } - self.threads = seeded - self.selectedThreadID = seeded.first?.id - engine?.installToolApprovalHandler { [weak self] request in - guard let self else { return } - self.pendingToolApprovals.removeAll { $0.id == request.id } - self.pendingToolApprovals.append(request) - } - if !deferredIndexLoad, let first = seeded.first { loadMessagesIfNeeded(first.id) } - beginObservingExternalThreadChanges() - if deferredIndexLoad { - scheduleInitialThreadIndexLoad() - } else if threadsProvider != nil { - restoreAcceptedTurnsFromDisk() - } - licenseChangeSink = licenseService?.objectWillChange.sink { [weak self] _ in - self?.objectWillChange.send() - } - } - - /// Load the persisted thread index OFF the main actor, then merge it in on - /// the MainActor via the same `replaceThreads` path every other refresh - /// uses. A turn accepted before the index landed owns the rail (a freshly - /// minted thread is not on disk until its first stream completes and would - /// be dropped by a mid-turn replace), so the merge waits for idle. - private func scheduleInitialThreadIndexLoad() { - Task { @MainActor [weak self] in - guard let self, let provider = self.threadsProvider else { return } - let start = Date() - let loaded = await Task.detached(priority: .userInitiated) { - provider.listThreads() - }.value - AgentPerf.log("thread index load", since: start, detail: "\(loaded.count) threads") - var idleWaits = 0 - while self.activeComposerTurn != nil || self.voiceTurnPhase != nil, idleWaits < 240 { - idleWaits += 1 - try? await Task.sleep(for: .milliseconds(500)) - } - if !loaded.isEmpty { - let mergeStart = Date() - self.replaceThreads( - with: loaded, - selectingBackendId: self.currentThread?.backendId, - keepLocalDrafts: true - ) - AgentPerf.log("thread index merge + selected thread load", since: mergeStart) - } - // Replay messages accepted before the last app death only after the - // index is in, so they re-bind to their persisted threads. - self.restoreAcceptedTurnsFromDisk() - } - } - - deinit { - for observer in externalThreadsObservers { - NotificationCenter.default.removeObserver(observer) - } - } - - var currentThread: ChatThread? { - threads.first { $0.id == selectedThreadID } - } - - var usesRealThreadSearch: Bool { threadsProvider != nil } - - /// True while the current thread's latest assistant turn is in its pre-reply - /// "thinking…" state. Drives the header status pill (Idle → Thinking). - var isThinking: Bool { - currentThread?.messages.last { $0.role == .assistant }?.isThinking ?? false - } - - /// True while the current thread's latest assistant turn is revealing tokens. - /// Drives the header status pill (Thinking → Streaming). - var isStreaming: Bool { - currentThread?.messages.last { $0.role == .assistant }?.isStreaming ?? false - } - - /// Active phase for the selected thread only. The composer keeps consuming - /// this established projection, while source-specific cancellation stays - /// behind the composer engine or voice adapter. - var selectedComposerTurnPhase: ComposerTurnPhase? { - if let turn = activeComposerTurn, turn.threadID == selectedThreadID { - return turn.phase - } - if voiceTurnThreadID == selectedThreadID { - return voiceTurnPhase - } - return nil - } - - var isCancelling: Bool { selectedComposerTurnPhase == .cancelling } - - var currentToolApprovals: [PendingToolApproval] { - guard let backendID = currentThread?.backendId else { return [] } - return pendingToolApprovals.filter { $0.threadID == backendID } - } - - func resolveToolApproval( - _ request: PendingToolApproval, approved: Bool, remember: Bool = false - ) { - _ = engine?.resolveToolApproval(request, approved: approved, remember: remember) - pendingToolApprovals.removeAll { $0.id == request.id } - } - - // MARK: Thread ops - - func newThread() { - let t = ChatThread(title: "New thread", meta: "now", messages: []) - threads.insert(t, at: 0) - selectedThreadID = t.id - draft = "" - } - - func refreshThreads() { - guard let threadsProvider else { return } - replaceThreads( - with: threadsProvider.listThreads(), - selectingBackendId: currentThread?.backendId, - keepLocalDrafts: true - ) - } - - // MARK: External refresh (rail live refresh — wave S, cut C) - - /// Wire the event-driven rail refresh. Two triggers, zero polling: - /// 1. `ThreadsChangeBus.threadsDidChange` — some surface finished a turn - /// whose persistence this store did not perform itself. - /// 2. `NSWindow.didBecomeKeyNotification` — window activation. A thread - /// saved by an overlay/assistive turn while the Agent window was - /// inactive becomes discoverable on the next activation, no app restart - /// (incident 2026-07-21: the reply persisted but the open window kept - /// rendering the launch-time list). - /// Provider-gated: a preview/mock store has no disk truth to re-read. - private func beginObservingExternalThreadChanges() { - guard threadsProvider != nil else { return } - let handler: (Notification) -> Void = { [weak self] _ in - MainActor.assumeIsolated { self?.scheduleExternalThreadsRefresh() } - } - externalThreadsObservers = [ - NotificationCenter.default.addObserver( - forName: ThreadsChangeBus.threadsDidChange, - object: nil, - queue: .main, - using: handler - ), - NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: nil, - queue: .main, - using: handler - ), - ] - } - - /// One pending refresh per main-queue tick. Every window in the app posts - /// `didBecomeKey` (observer has object: nil), and AppKit fires it from - /// INSIDE window-ordering operations (popover close → orderOut → - /// becomeKeyWindow) — sample 2026-08-07 10:43 caught the main thread - /// pinned 93/93 re-listing threads from within _NSPopoverCloseAndAnimate. - /// Coalescing onto the next tick collapses the storm AND moves the disk - /// re-read out of the notification callout. - private var externalRefreshScheduled = false - - private func scheduleExternalThreadsRefresh() { - guard !externalRefreshScheduled else { return } - externalRefreshScheduled = true - DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { - guard let self else { return } - self.externalRefreshScheduled = false - self.refreshThreadsFromExternalChange() - } - } - } - - /// Re-read persisted threads after an external change signal. Deliberately - /// a no-op while a composer or voice turn is in flight: the turn's own - /// terminal already refreshes with the right selection, and a mid-stream - /// replace could drop a freshly minted thread that does not exist on disk - /// until its first stream completes. - func refreshThreadsFromExternalChange() { - guard threadsProvider != nil else { return } - guard activeComposerTurn == nil, voiceTurnPhase == nil else { return } - refreshThreads() - } - - func searchThreads(_ query: String) { - guard let threadsProvider else { return } - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - refreshThreads() - } else { - replaceThreads( - with: threadsProvider.searchThreads(query: trimmed), - selectingBackendId: currentThread?.backendId, - keepLocalDrafts: false, - allowEmpty: true - ) - } - } - - func select(_ id: UUID) { - selectedThreadID = id - loadMessagesIfNeeded(id) - } - - func toggleFavorite(_ thread: ChatThread) { - let next = !thread.isFavorite - guard let ti = threads.firstIndex(where: { $0.id == thread.id }) else { return } - if let backendId = thread.backendId { - guard threadsProvider?.setThreadFavorite(backendId: backendId, isFavorite: next) == true else { return } - } - threads[ti].isFavorite = next - } - - /// Rename a thread from the rail. Persists through the threads provider when - /// the thread is backed on disk; a not-yet-persisted local thread is renamed - /// in memory only. No-ops on an empty or unchanged title. The chat header - /// reads `currentThread.title`, so it updates reactively too. - func rename(_ thread: ChatThread, to newTitle: String) { - guard let trimmed = ThreadTitlePolicy.normalized(newTitle), trimmed != thread.title, - let ti = threads.firstIndex(where: { $0.id == thread.id }) else { return } - if let backendId = thread.backendId { - if threadsProvider?.renameThread(backendId: backendId, title: trimmed) != true { - guard queueCustomTitle(trimmed, for: thread.id, backendThreadID: backendId) else { return } - } - } - customTitleThreadIDs.insert(thread.id) - if var state = firstTurnTitleStates[thread.id] { - state.pendingGeneratedTitle = nil - firstTurnTitleStates[thread.id] = state - } - threads[ti].title = trimmed - } - - /// Flip one bubble between raw mono and rich markdown (meta-row toggle). - /// Per-message, in-memory only; deliberately does NOT touch the fields the - /// scroll signature reads, so a toggle never auto-scrolls the list. - func toggleRenderMode(messageID: UUID, in threadID: UUID) { - update(messageID, in: threadID) { - $0.renderMode = MessageRenderMode.nextRenderMode(after: $0.renderMode) - } - } - - /// Export a thread to a Markdown transcript on disk, returning the file path - /// so the caller can reveal it in Finder. Only persisted threads (with a - /// backend id) can be exported; a not-yet-saved local thread returns `nil`. - func exportMarkdown(_ thread: ChatThread, assistantOnly: Bool) -> String? { - guard let backendId = thread.backendId else { return nil } - return threadsProvider?.exportThreadMarkdown(backendId: backendId, assistantOnly: assistantOnly) - } - - func delete(_ thread: ChatThread) { - if let backendId = thread.backendId { - let deleted = threadsProvider?.deleteThread(backendId: backendId) == true - // A freshly minted backend id does not exist on disk until the - // first stream returns. In that one known race, local delete still - // wins and the existing engine cancellation prevents persistence. - guard deleted || firstTurnTitleStates[thread.id] != nil else { return } - // The attachment sidecar is written before the first stream starts, - // so the missing-file race still has local metadata to remove. - removePersistedAttachmentMetadata(for: backendId) - } - titleGenerationTasks[thread.id]?.cancel() - titleGenerationTasks[thread.id] = nil - firstTurnTitleStates[thread.id] = nil - customTitleThreadIDs.remove(thread.id) - // Deleting a thread deletes its queue — in memory and on disk. - queuedTurns.removeAll { $0.threadID == thread.id } - if let backendId = thread.backendId { - removeDurableAcceptedTurns(backendThreadID: backendId) - } - // Cancel any in-flight reply for this thread so its post-stream refresh - // can't re-list (and the caret/finalize can't mutate) a deleted thread. - // Swift-task cancel first (so the awaiting send sees isCancelled and - // stays silent), then the engine-side cancel, which actually aborts the - // Rust turn — stopping tool side effects, not just the UI updates. - inFlightSends[thread.id]?.task.cancel() - inFlightSends[thread.id] = nil - if let backendId = thread.backendId { - _ = engine?.cancelReply(threadId: backendId) - pendingToolApprovals.removeAll { $0.threadID == backendId } - } - if activeComposerTurn?.threadID == thread.id { - activeComposerTurn = nil - } - threads.removeAll { $0.id == thread.id } - if selectedThreadID == thread.id { - selectedThreadID = threads.first?.id - if let selectedThreadID { loadMessagesIfNeeded(selectedThreadID) } - } - if threads.isEmpty { - newThread() - } - } - - /// Lazily pull a persisted thread's messages the first time it is selected. - private func loadMessagesIfNeeded(_ id: UUID) { - guard let provider = threadsProvider, - let ti = threads.firstIndex(where: { $0.id == id }), - let backendId = threads[ti].backendId, - !threads[ti].messagesLoaded else { return } - let start = Date() - defer { AgentPerf.log("selected thread load", since: start, detail: backendId) } - // Persisted user turns carry the wire skeleton (disk keeps the LLM - // truth); rewrite them for display so restored threads render the - // spoken instruction, exactly like a live turn. - threads[ti].messages = applyingPersistedAttachmentMetadata( - to: provider.loadMessages(backendId: backendId), - backendId: backendId - ).map(AssistivePromptParser.presented) - threads[ti].messagesLoaded = true - } - - /// Resolve (and lazily mint) the ThreadStore id for a thread so the agent - /// persists the conversation under a stable id across turns + restarts. - private func ensureBackendId(_ threadID: UUID) -> String { - guard let ti = threads.firstIndex(where: { $0.id == threadID }) else { - return "t_\(UUID().uuidString)" - } - if let existing = threads[ti].backendId { return existing } - let id = threadsProvider?.generateThreadId() ?? "t_\(UUID().uuidString)" - threads[ti].backendId = id - threads[ti].messagesLoaded = true // freshly-minted thread starts in sync - return id - } - - // MARK: Attachments (composer staging) - - /// Stage image files chosen in the composer, de-duplicating by URL. - func addAttachments(_ urls: [URL]) { - let before = pendingAttachments.count - for url in urls where !pendingAttachments.contains(where: { $0.url == url }) { - pendingAttachments.append(PendingAttachment(url: url)) - } - attachLog.info( - "addAttachments: incoming=\(urls.count, privacy: .public) staged=\(self.pendingAttachments.count - before, privacy: .public) (post-dedupe) pendingAttachments.count=\(self.pendingAttachments.count, privacy: .public)" - ) - } - - /// Remove a staged attachment before it is sent. - func removeAttachment(_ id: UUID) { - pendingAttachments.removeAll { $0.id == id } - } - - /// True when there is something to send: text, at least one staged image, or - /// both. Drives the send button's enabled state. A turn already in flight no - /// longer blocks acceptance — `send()` queues instead of dropping. - var canSend: Bool { - !isAgenticLocked - && (!draft.trimmingCharacters(in: .whitespaces).isEmpty || !pendingAttachments.isEmpty) - } - - var isAgenticLocked: Bool { licenseService?.canUseAgentic == false } - var agenticBlockMessage: String? { - isAgenticLocked ? licenseService?.agenticBlockMessage : nil - } - - // MARK: Turn queue (messages accepted while a turn is in flight) - - /// A message the UI accepted. Durable from the moment of acceptance: it is - /// written to the sidecar in `accept` and removed only when its turn reaches - /// a terminal (success, provider error, or stop), so an app death in between - /// replays it on the next launch instead of losing it. - struct QueuedTurn: Identifiable, Equatable { - let id: UUID - let threadID: UUID - let backendThreadID: String - var text: String - let attachments: [PendingAttachment] - let enqueuedAt: Date - } - - /// FIFO of accepted-but-not-yet-running messages, ordered by acceptance - /// across all threads; per-thread order is what the contract guarantees. - @Published private(set) var queuedTurns: [QueuedTurn] = [] - - func queuedTurns(in threadID: UUID) -> [QueuedTurn] { - queuedTurns.filter { $0.threadID == threadID } - } - - /// Terminal-style composer history, oldest → newest. It includes already - /// dispatched user turns and accepted queued turns, so Up can recover the - /// exact message the operator just queued without cancelling it first. - func composerHistory(in threadID: UUID) -> [String] { - let sent = threads.first(where: { $0.id == threadID })?.messages - .filter { $0.role == .you } - .map(\.text) ?? [] - let queued = queuedTurns(in: threadID).map(\.text) - return (sent + queued).reduce(into: [String]()) { result, text in - let text = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, result.last != text else { return } - result.append(text) - } - } - - /// Cancel one still-queued (never dispatched) message. - func cancelQueuedTurn(_ id: UUID) { - guard queuedTurns.contains(where: { $0.id == id }) else { return } - queuedTurns.removeAll { $0.id == id } - removeDurableAcceptedTurn(id: id) - } - - /// Edit an accepted turn while it is still queued. The same durable sidecar - /// is replaced immediately, so a crash/relaunch cannot resurrect the old - /// wording. Attachments and FIFO position stay unchanged. - @discardableResult - func editQueuedTurn(_ id: UUID, text: String) -> Bool { - guard let index = queuedTurns.firstIndex(where: { $0.id == id }) else { return false } - let text = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty || !queuedTurns[index].attachments.isEmpty else { return false } - queuedTurns[index].text = text - persistDurableAcceptedTurn(queuedTurns[index]) - return true - } - - // MARK: Send (accept → queue → serialized dispatch) - - func send() { - guard !isAgenticLocked else { return } - let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - let staged = pendingAttachments - attachLog.info( - "send: building request attachmentPaths.count=\(staged.count, privacy: .public) text.isEmpty=\(text.isEmpty, privacy: .public)" - ) - guard (!text.isEmpty || !staged.isEmpty), let threadID = selectedThreadID else { return } - draft = "" - pendingAttachments = [] - accept(text: text, staged: staged, threadID: threadID) - } - - /// Accept a message: persist it durably, enqueue it FIFO on its thread, and - /// let the single dispatch owner start it if the composer slot is idle. - private func accept(text: String, staged: [PendingAttachment], threadID: UUID) { - let backendId = ensureBackendId(threadID) - let turn = QueuedTurn( - id: UUID(), - threadID: threadID, - backendThreadID: backendId, - text: text, - attachments: staged, - enqueuedAt: Date() - ) - persistDurableAcceptedTurn(turn) - queuedTurns.append(turn) - queueLog.info( - "accepted turn \(turn.id, privacy: .public) thread=\(backendId, privacy: .public) queued=\(self.queuedTurns.count, privacy: .public)" - ) - dispatchNextQueuedTurnIfIdle() - } - - /// The ONLY `queued → running` transition. MainActor-serialized, guarded by - /// the single composer slot, and `startTurn` claims that slot synchronously — - /// so exactly one owner can ever promote a queued message. Threads with an - /// active voice turn are skipped (the core owns that thread's stream); their - /// queued messages wait for the voice terminal. - private func dispatchNextQueuedTurnIfIdle() { - guard activeComposerTurn == nil else { return } - guard let index = queuedTurns.firstIndex(where: { turn in - !(voiceTurnThreadID == turn.threadID && voiceTurnPhase != nil) - }) else { return } - var turn = queuedTurns.remove(at: index) - // The local UUID can go stale while queued (a rail refresh re-mints - // rows); the DURABLE binding is the backend thread id. Re-bind instead - // of dropping — an explicitly deleted thread already purged its queue - // in `delete`, so anything still here must run. - if !threads.contains(where: { $0.id == turn.threadID }) { - let resolvedID: UUID - if let match = threads.first(where: { $0.backendId == turn.backendThreadID }) { - resolvedID = match.id - } else { - var thread = ChatThread(title: "Restored draft", meta: "now") - thread.backendId = turn.backendThreadID - thread.messagesLoaded = true - threads.insert(thread, at: 0) - resolvedID = thread.id - } - turn = QueuedTurn( - id: turn.id, - threadID: resolvedID, - backendThreadID: turn.backendThreadID, - text: turn.text, - attachments: turn.attachments, - enqueuedAt: turn.enqueuedAt - ) - } - queueLog.info( - "promoting turn \(turn.id, privacy: .public) thread=\(turn.backendThreadID, privacy: .public) queued=\(self.queuedTurns.count, privacy: .public)" - ) - startTurn(turn) - } - - /// Run one accepted message as the active composer turn. The acceptance id - /// IS the turn id, so one element is traceable end-to-end (accept → queue → - /// running → terminal → durable-record removal). - private func startTurn(_ queued: QueuedTurn) { - let threadID = queued.threadID - let backendId = queued.backendThreadID - let text = queued.text - let staged = queued.attachments - let attachmentPaths = staged.map { $0.url.path } - let userTurnIndex = currentUserTurnCount(in: threadID) - - // Carry the staged attachments onto the You bubble so the sender sees a - // chip (name + optional thumbnail) for what they attached. - let sent = staged.map { MessageAttachment(name: $0.name, url: $0.url, type: $0.type) } - persistAttachmentMetadata(sent, for: backendId, userTurnIndex: userTurnIndex) - append(ChatMessage(role: .you, timestamp: now(), text: text, attachments: sent), to: threadID) - let assistant = ChatMessage(role: .assistant, timestamp: "now", text: "", isThinking: true) - let assistantID = assistant.id - append(assistant, to: threadID) - let turnID = queued.id - activeComposerTurn = ActiveComposerTurn( - id: turnID, - threadID: threadID, - backendThreadID: backendId, - assistantMessageID: assistantID, - phase: .thinking + @Published var threads: [ChatThread] + @Published var selectedThreadID: UUID? { + // Every selection change re-routes the voice-assistive lane to the thread + // the user is looking at (operator contract 2026-08-13). Observers do not + // fire during init — the seeding path publishes once explicitly. + didSet { publishAssistiveTarget() } + } + @Published var draft: String = "" + /// Monotonic UI command consumed by the composer. It carries no text and + /// deliberately does not mutate the selected thread or staged attachments. + @Published private(set) var composerFocusRequest: UInt64 = 0 + @Published private(set) var dictationPreview: String = "" + @Published private(set) var dictationLivePreview: String = "" + @Published private(set) var dictationFinalPreview: String? + @Published private(set) var dictationFinalChangedText = false + @Published private(set) var dictationVadActive = false + @Published private(set) var dictationPreviewUserEdited = false + @Published private(set) var dictationDeliverySource: DictationDeliverySource = .live + + /// Images staged in the composer for the next message. Cleared when the + /// message is dispatched. + @Published var pendingAttachments: [PendingAttachment] = [] + @Published private(set) var pendingToolApprovals: [PendingToolApproval] = [] + + // MARK: Composer dictation + + /// Current phase of the composer's voice-note dictation. Drives the mic + /// affordance (ripple while `.recording`) and the inline error feedback. + @Published private(set) var dictationPhase: ComposerDictationPhase = .idle + + /// True while a hotkey / tray / overlay dictation session owns the microphone. + /// Set from the authoritative recording lifecycle hooks (see OverlayController) + /// so the composer mic can't open a second, colliding recorder. + @Published var dictationBlocked: Bool = false + + /// Injected real adapter (Core). `nil` in previews / mock → mic is inert. + var dictation: ComposerDictating? + + /// Guards the auto-clear of a `.failed` phase against a stale timer overwriting + /// a newer state. + private var dictationFailureToken = UUID() + + /// Toggle the composer voice note (start ↔ stop-and-insert). + func toggleDictation() { dictation?.toggle() } + + func handleAssistiveCapture(_ command: ComposerCaptureCommand) { + dictation?.handle(command) + } + + func requestComposerFocus() { + composerFocusRequest &+= 1 + } + + /// Set by the real adapter as the dictation session transitions. No-op-safe + /// when no adapter is wired. + func setDictationPhase(_ phase: ComposerDictationPhase) { dictationPhase = phase } + + /// Latest live voice-note preview. This is a snapshot buffer from the STT + /// listener, not a delta stream, and stays separate from `draft` until stop. + func beginDictationPreviewSession() { + dictationPreview = "" + dictationLivePreview = "" + dictationFinalPreview = nil + dictationFinalChangedText = false + dictationVadActive = false + dictationPreviewUserEdited = false + dictationDeliverySource = .live + } + + /// Idempotent on purpose. Apple live polls partials every ~40 ms, so during a + /// pause the SAME text arrives ~25×/s. Publishing an unchanged value still + /// fires `objectWillChange`, rebuilding the whole Agent window body — and the + /// preview's `TextEditor` is NSTextView-backed, so each rebuild mutates the + /// AppKit subtree, invalidates the window's structural regions and re-runs the + /// deep `cursorUpdate:` walk (measured 2026-08-05: ~30% of main-thread samples + /// in `setCursorForMouseLocation:` → `NSCursor _reallySet`, visible as the + /// pointer flickering between I-beam and arrow, plus a PDF cursor-image reload + /// per frame). Writing only on real change removes the whole storm at the + /// source; see `plans/gtm-closure-260804/evidence/2026-08-05_cursor-storm-sample.txt`. + func updateDictationPreview(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if dictationLivePreview != trimmed { dictationLivePreview = trimmed } + guard !dictationPreviewUserEdited else { return } + if dictationPreview != trimmed { dictationPreview = trimmed } + } + + func editDictationPreview(_ text: String) { + dictationPreview = text + dictationPreviewUserEdited = true + dictationDeliverySource = .edited + } + + func noteDictationFinalPreview(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let final = trimmed.isEmpty ? nil : trimmed + let changed = !trimmed.isEmpty && trimmed != dictationLivePreview + if dictationFinalPreview != final { dictationFinalPreview = final } + if dictationFinalChangedText != changed { dictationFinalChangedText = changed } + } + + /// Same idempotence contract as `updateDictationPreview`: the VAD callback + /// fires per audio chunk, and republishing an unchanged flag rebuilds the + /// window body for nothing. + func setDictationVadActive(_ active: Bool) { + guard dictationVadActive != active else { return } + dictationVadActive = active + } + + /// Preserve both hypotheses and explicitly choose the delivery text. A + /// materially shorter final pass may fill gaps but must never erase a better + /// live canvas. User edits always win and cancel Assistive auto-send. + func resolveDictationDelivery(final text: String, autoSend: Bool) -> ( + text: String, autoSend: Bool + ) { + let final = text.trimmingCharacters(in: .whitespacesAndNewlines) + dictationFinalPreview = final.isEmpty ? nil : final + dictationFinalChangedText = !final.isEmpty && final != dictationLivePreview + + if dictationPreviewUserEdited { + dictationDeliverySource = .edited + return (dictationPreview.trimmingCharacters(in: .whitespacesAndNewlines), false) + } + + let live = dictationLivePreview.trimmingCharacters(in: .whitespacesAndNewlines) + let liveWords = live.split(whereSeparator: \Character.isWhitespace).count + let finalWords = final.split(whereSeparator: \Character.isWhitespace).count + let finalRegressed = !live.isEmpty && (final.isEmpty || finalWords * 100 < liveWords * 85) + let chosen = finalRegressed ? live : final + dictationDeliverySource = finalRegressed ? .live : .final + dictationPreview = chosen + return (chosen, autoSend) + } + + func clearDictationPreview() { + dictationPreview = "" + dictationLivePreview = "" + dictationFinalPreview = nil + dictationFinalChangedText = false + dictationVadActive = false + dictationPreviewUserEdited = false + dictationDeliverySource = .live + } + + /// Surface a recoverable dictation failure with a self-clearing inline message + /// (auto-returns to `.idle` after a few seconds so the composer doesn't keep a + /// stale error banner). + func reportDictationFailure(_ message: String) { + clearDictationPreview() + dictationPhase = .failed(message) + let token = UUID() + dictationFailureToken = token + Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + guard dictationFailureToken == token, case .failed = dictationPhase else { return } + dictationPhase = .idle + } + } + + /// Append the explicitly resolved voice transcript to the editable draft. + /// Preview provenance remains visible until the next capture starts. + func appendDictatedTranscript(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if draft.isEmpty { + draft = trimmed + } else { + let needsSeparator = !(draft.last?.isWhitespace ?? false) + draft += (needsSeparator ? " " : "") + trimmed + } + } + + /// Injected by W2-01. `nil` until then; `send` degrades gracefully. + var engine: AgentChatEngine? + + /// Injected provider for persisted threads. `nil` → falls back to mock seed. + var threadsProvider: ChatThreadsProviding? + + /// Backs the composer slash-command palette. `nil` (previews, unit tests + /// without a runtime) ⇒ every command lists nothing rather than lying about + /// what is configured. + var paletteSource: ComposerPaletteSourcing? + + /// Entries for one palette command, resolved on demand so a freshly saved + /// model or a just-granted tool shows up without reopening the window. + func paletteEntries(for command: ComposerPaletteCommand) -> [ComposerPaletteEntry] { + let start = Date() + defer { AgentPerf.log("tool catalog load", since: start, detail: command.rawValue) } + return paletteSource?.entries(for: command) ?? [] + } + + /// Apply a palette pick. Failures surface as a system line in the thread — + /// silently ignoring a click would leave the operator believing the model + /// changed when it did not. + func applyPaletteEntry(_ entry: ComposerPaletteEntry, for command: ComposerPaletteCommand) { + guard let paletteSource else { return } + do { + try paletteSource.apply(entry, for: command) + } catch { + guard let threadID = currentThread?.id else { return } + append( + ChatMessage( + role: .tool, + timestamp: "now", + text: "Nie udało się zastosować „\(entry.title)”: " + + error.localizedDescription + ), + to: threadID + ) + } + } + + private var revealTask: Task? + private var didStartDemo = false + + /// Exactly one composer send may own the composer-side cancellation path. + @Published private(set) var activeComposerTurn: ActiveComposerTurn? + + /// Active voice-assistive turn being streamed from the core runtime (hotkey / + /// hands-off), NOT the composer. `nil` when no voice reply is in flight. The + /// core owns the provider call + disk persistence for this turn; the store + /// only renders the incoming delivery events — it must never call `send()` for + /// a voice turn, which would fire a second, composer-side provider call. + private var voiceTurnThreadID: UUID? + private var voiceAssistantID: UUID? + private var voiceTurnStartedAt: Date? + @Published private(set) var voiceTurnPhase: ComposerTurnPhase? + weak var voiceTurnCanceller: VoiceTurnCancelling? + + /// In-flight `send()` streaming tasks keyed by thread. Tracked so deleting a + /// thread can cancel its running reply — otherwise the task's post-stream + /// `refreshThreads` (plus the agent's best-effort re-persist) would resurrect + /// the just-deleted thread. + private struct InFlightSend { + let id: UUID + let task: Task + } + + private var inFlightSends: [UUID: InFlightSend] = [:] + + /// Bookkeeping for the one title request allowed on a first textual turn, + /// regardless of source: the composer `send()` and the voice ingest path + /// (`ingestVoiceTurn` → `ingestVoiceDone`/`Error`/`Cancelled`) share this + /// coordinator. MainActor serialization makes the turn/title completion race + /// explicit: whichever result lands first updates this state, and the + /// turn-side settlement flushes at most one queued write before refreshing + /// the rail. + private struct FirstTurnTitleState { + let backendThreadID: String + let generationID: UUID + let originalTitle: String + var streamCompleted = false + var generationFinished = false + var pendingGeneratedTitle: String? + var pendingCustomTitle: String? + } + + private var firstTurnTitleStates: [UUID: FirstTurnTitleState] = [:] + private var titleGenerationTasks: [UUID: Task] = [:] + /// Local authority marker used to reject a late generated result even when + /// the first disk persist and a manual rename interleave. + private var customTitleThreadIDs: Set = [] + + /// NotificationCenter tokens for the event-driven rail refresh (wave S, + /// cut C): window activation + cross-surface `threadsDidChange`. Removed + /// on deinit; empty when no threads provider is wired (preview/mock). + private var externalThreadsObservers: [NSObjectProtocol] = [] + private let licenseService: LicenseService? + private var licenseChangeSink: AnyCancellable? + + /// `loadsThreadIndexEagerly: false` (production `AppModel` path) turns init + /// into a light shell: no disk I/O on the MainActor bootstrap — the real + /// thread index loads asynchronously off the main actor and merges in. + /// The default `true` preserves the synchronous contract tests and previews + /// rely on (threads visible immediately after init). + init( + engine: AgentChatEngine? = nil, + threadsProvider: ChatThreadsProviding? = nil, + threads: [ChatThread]? = nil, + voiceTurnCanceller: VoiceTurnCancelling? = nil, + licenseService: LicenseService? = nil, + loadsThreadIndexEagerly: Bool = true + ) { + self.engine = engine + self.threadsProvider = threadsProvider + self.voiceTurnCanceller = voiceTurnCanceller + self.licenseService = licenseService + + let seeded: [ChatThread] + var deferredIndexLoad = false + if let threads { + seeded = threads // explicit (preview/mock) + } else if threadsProvider != nil, !loadsThreadIndexEagerly { + seeded = [ChatThread(title: "New thread", meta: "now")] // shell; index merges async + deferredIndexLoad = true + } else if let real = threadsProvider?.listThreads(), !real.isEmpty { + seeded = real // real persisted threads + } else if threadsProvider != nil { + seeded = [ChatThread(title: "New thread", meta: "now")] // real provider, empty history + } else { + seeded = Self.seedThreads() // no provider → mock seed + } + self.threads = seeded + self.selectedThreadID = seeded.first?.id + // didSet does not fire inside init — publish the seed selection once so + // the assistive lane routes to what the rail shows from the first frame. + publishAssistiveTarget() + engine?.installToolApprovalHandler { [weak self] request in + guard let self else { return } + self.pendingToolApprovals.removeAll { $0.id == request.id } + self.pendingToolApprovals.append(request) + } + if !deferredIndexLoad, let first = seeded.first { loadMessagesIfNeeded(first.id) } + beginObservingExternalThreadChanges() + if deferredIndexLoad { + scheduleInitialThreadIndexLoad() + } else if threadsProvider != nil { + restoreAcceptedTurnsFromDisk() + } + licenseChangeSink = licenseService?.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + } + + /// Load the persisted thread index OFF the main actor, then merge it in on + /// the MainActor via the same `replaceThreads` path every other refresh + /// uses. A turn accepted before the index landed owns the rail (a freshly + /// minted thread is not on disk until its first stream completes and would + /// be dropped by a mid-turn replace), so the merge waits for idle. + private func scheduleInitialThreadIndexLoad() { + Task { @MainActor [weak self] in + guard let self, let provider = self.threadsProvider else { return } + let start = Date() + let loaded = await Task.detached(priority: .userInitiated) { + provider.listThreads() + }.value + AgentPerf.log("thread index load", since: start, detail: "\(loaded.count) threads") + var idleWaits = 0 + while self.activeComposerTurn != nil || self.voiceTurnPhase != nil, idleWaits < 240 { + idleWaits += 1 + try? await Task.sleep(for: .milliseconds(500)) + } + if !loaded.isEmpty { + let mergeStart = Date() + self.replaceThreads( + with: loaded, + selectingBackendId: self.currentThread?.backendId, + keepLocalDrafts: true ) - if userTurnIndex == 0, !text.isEmpty, engine != nil { - prepareFirstTurnTitle(for: threadID, backendThreadID: backendId) - } - let sendTask = Task { @MainActor in - var titleStreamSettled = false - defer { - if !titleStreamSettled { - settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) - } - // The turn reached a terminal (success, provider error, or a - // cancelled Swift task) — its outcome is visible in the UI, so - // the durable acceptance record is consumed. Only an app death - // BEFORE this point leaves the record behind for restart replay. - removeDurableAcceptedTurn(id: turnID) - releaseComposerTurn(turnID, in: threadID) - } - guard let engine else { - finish(assistantID, in: threadID, - text: "Engine not wired yet.") - return - } - // Graceful unavailable path — the engine reports WHAT is missing - // (lane, endpoint or key) so the reply is actionable, not generic. - if let unavailableDetail = engine.availabilityDetail() { - finishTitleGenerationWithoutRequest(for: threadID) - finish(assistantID, in: threadID, text: unavailableDetail) - return - } - if userTurnIndex == 0, !text.isEmpty { - launchFirstTurnTitle( - text, - for: threadID, - backendThreadID: backendId, - engine: engine - ) - } - let start = Date() - do { - // REAL streaming: tokens land live as the agent emits them. - let finalText = try await engine.streamReply( - text, - threadId: backendId, - attachmentPaths: attachmentPaths, - onDelta: { [weak self] delta in - guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true else { - return - } - self?.setComposerPhase(.streaming, for: turnID) - self?.update(assistantID, in: threadID) { - $0.isThinking = false - $0.isStreaming = true - if $0.reasonedSeconds == nil { - $0.reasonedSeconds = Date().timeIntervalSince(start) - } - $0.text += delta - } - }, - onReasoning: { [weak self] delta in - guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true else { - return - } - self?.appendReasoning(delta, to: assistantID, in: threadID) - }, - onToolExecuting: { [weak self] name, id in - guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true else { - return - } - self?.recordToolStarted(name: name, callID: id, before: assistantID, in: threadID) - }, - onToolResult: { [weak self] name, id, isError, reason in - self?.pendingToolApprovals.removeAll { - $0.threadID == backendId && $0.callID == id - } - guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true else { - return - } - self?.recordToolResult(name: name, callID: id, isError: isError, reason: reason, - before: assistantID, in: threadID) - } - ) - settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) - titleStreamSettled = true - // The thread may have been deleted mid-stream; drop the late - // finalize + refresh so a cancelled send can't bring it back. - if Task.isCancelled { return } - finishPendingTools(before: assistantID, in: threadID) - update(assistantID, in: threadID) { - $0.isThinking = false - $0.isStreaming = false - // A provider that emits only a final TextDone (no token deltas) - // leaves the bubble empty; fall back to the assembled return so - // the reply is never a blank bubble. - if $0.text.isEmpty { $0.text = finalText } - $0.timestamp = self.now() - } - refreshThreads(selectingBackendId: backendId) - } catch { - if Task.isCancelled { return } - finish(assistantID, in: threadID, - text: "Something went wrong: \(error.localizedDescription)") - } - } - inFlightSends[threadID] = InFlightSend(id: turnID, task: sendTask) - } - - /// Launch the title lane as an independent, unstructured MainActor task. - /// Awaiting the engine releases the actor, so this request runs concurrently - /// with the conversational turn (composer `streamReply` or the core-owned - /// voice stream) without escaping non-Sendable engine/provider seams. It is - /// a stateless sibling request — it never re-enters `send()`/`streamReply`. - private func launchFirstTurnTitle( - _ text: String, - for threadID: UUID, - backendThreadID: String, - engine: AgentChatEngine - ) { - guard let state = firstTurnTitleStates[threadID], - state.backendThreadID == backendThreadID else { return } - let generationID = state.generationID - let task = Task { @MainActor [weak self] in - do { - let title = try await engine.generateThreadTitle(text) - guard !Task.isCancelled else { return } - self?.receiveGeneratedTitle(title, for: threadID, generationID: generationID) - } catch { - guard !Task.isCancelled else { return } - self?.finishTitleGeneration(for: threadID, generationID: generationID) - } - } - titleGenerationTasks[threadID] = task - } - - /// Establish the race authority synchronously inside `send()`. A rail action - /// performed immediately after `send()` returns can therefore queue a custom - /// write or discard title work even before the unstructured task is scheduled. - private func prepareFirstTurnTitle(for threadID: UUID, backendThreadID: String) { - guard threadsProvider != nil, - !customTitleThreadIDs.contains(threadID), - firstTurnTitleStates[threadID] == nil, - let originalTitle = threads.first(where: { $0.id == threadID })?.title else { return } - firstTurnTitleStates[threadID] = FirstTurnTitleState( - backendThreadID: backendThreadID, - generationID: UUID(), - originalTitle: originalTitle + AgentPerf.log("thread index merge + selected thread load", since: mergeStart) + } + // Replay messages accepted before the last app death only after the + // index is in, so they re-bind to their persisted threads. + self.restoreAcceptedTurnsFromDisk() + } + } + + deinit { + for observer in externalThreadsObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + var currentThread: ChatThread? { + threads.first { $0.id == selectedThreadID } + } + + /// Push the rail's current selection down as the voice-assistive routing + /// target. A selection without a backend id (freshly minted "+ New thread") + /// publishes `nil`, which the controller reads as "mint a fresh thread on + /// the next assistive turn". + private func publishAssistiveTarget() { + engine?.setAssistiveTargetThread(backendId: currentThread?.backendId) + } + + var usesRealThreadSearch: Bool { threadsProvider != nil } + + /// True while the current thread's latest assistant turn is in its pre-reply + /// "thinking…" state. Drives the header status pill (Idle → Thinking). + var isThinking: Bool { + currentThread?.messages.last { $0.role == .assistant }?.isThinking ?? false + } + + /// True while the current thread's latest assistant turn is revealing tokens. + /// Drives the header status pill (Thinking → Streaming). + var isStreaming: Bool { + currentThread?.messages.last { $0.role == .assistant }?.isStreaming ?? false + } + + /// Active phase for the selected thread only. The composer keeps consuming + /// this established projection, while source-specific cancellation stays + /// behind the composer engine or voice adapter. + var selectedComposerTurnPhase: ComposerTurnPhase? { + if let turn = activeComposerTurn, turn.threadID == selectedThreadID { + return turn.phase + } + if voiceTurnThreadID == selectedThreadID { + return voiceTurnPhase + } + return nil + } + + var isCancelling: Bool { selectedComposerTurnPhase == .cancelling } + + var currentToolApprovals: [PendingToolApproval] { + guard let backendID = currentThread?.backendId else { return [] } + return pendingToolApprovals.filter { $0.threadID == backendID } + } + + func resolveToolApproval( + _ request: PendingToolApproval, approved: Bool, remember: Bool = false + ) { + _ = engine?.resolveToolApproval(request, approved: approved, remember: remember) + pendingToolApprovals.removeAll { $0.id == request.id } + } + + // MARK: Thread ops + + func newThread() { + let t = ChatThread(title: "New thread", meta: "now", messages: []) + threads.insert(t, at: 0) + selectedThreadID = t.id + draft = "" + } + + func refreshThreads() { + guard let threadsProvider else { return } + replaceThreads( + with: threadsProvider.listThreads(), + selectingBackendId: currentThread?.backendId, + keepLocalDrafts: true + ) + } + + // MARK: External refresh (rail live refresh — wave S, cut C) + + /// Wire the event-driven rail refresh. Two triggers, zero polling: + /// 1. `ThreadsChangeBus.threadsDidChange` — some surface finished a turn + /// whose persistence this store did not perform itself. + /// 2. `NSWindow.didBecomeKeyNotification` — window activation. A thread + /// saved by an overlay/assistive turn while the Agent window was + /// inactive becomes discoverable on the next activation, no app restart + /// (incident 2026-07-21: the reply persisted but the open window kept + /// rendering the launch-time list). + /// Provider-gated: a preview/mock store has no disk truth to re-read. + private func beginObservingExternalThreadChanges() { + guard threadsProvider != nil else { return } + let handler: (Notification) -> Void = { [weak self] _ in + MainActor.assumeIsolated { self?.scheduleExternalThreadsRefresh() } + } + externalThreadsObservers = [ + NotificationCenter.default.addObserver( + forName: ThreadsChangeBus.threadsDidChange, + object: nil, + queue: .main, + using: handler + ), + NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main, + using: handler + ), + ] + } + + /// One pending refresh per main-queue tick. Every window in the app posts + /// `didBecomeKey` (observer has object: nil), and AppKit fires it from + /// INSIDE window-ordering operations (popover close → orderOut → + /// becomeKeyWindow) — sample 2026-08-07 10:43 caught the main thread + /// pinned 93/93 re-listing threads from within _NSPopoverCloseAndAnimate. + /// Coalescing onto the next tick collapses the storm AND moves the disk + /// re-read out of the notification callout. + private var externalRefreshScheduled = false + + private func scheduleExternalThreadsRefresh() { + guard !externalRefreshScheduled else { return } + externalRefreshScheduled = true + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.externalRefreshScheduled = false + self.refreshThreadsFromExternalChange() + } + } + } + + /// Re-read persisted threads after an external change signal. Deliberately + /// a no-op while a composer or voice turn is in flight: the turn's own + /// terminal already refreshes with the right selection, and a mid-stream + /// replace could drop a freshly minted thread that does not exist on disk + /// until its first stream completes. + func refreshThreadsFromExternalChange() { + guard threadsProvider != nil else { return } + guard activeComposerTurn == nil, voiceTurnPhase == nil else { return } + refreshThreads() + } + + func searchThreads(_ query: String) { + guard let threadsProvider else { return } + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + refreshThreads() + } else { + replaceThreads( + with: threadsProvider.searchThreads(query: trimmed), + selectingBackendId: currentThread?.backendId, + keepLocalDrafts: false, + allowEmpty: true + ) + } + } + + func select(_ id: UUID) { + selectedThreadID = id + loadMessagesIfNeeded(id) + } + + func toggleFavorite(_ thread: ChatThread) { + let next = !thread.isFavorite + guard let ti = threads.firstIndex(where: { $0.id == thread.id }) else { return } + if let backendId = thread.backendId { + guard threadsProvider?.setThreadFavorite(backendId: backendId, isFavorite: next) == true + else { return } + } + threads[ti].isFavorite = next + } + + /// Rename a thread from the rail. Persists through the threads provider when + /// the thread is backed on disk; a not-yet-persisted local thread is renamed + /// in memory only. No-ops on an empty or unchanged title. The chat header + /// reads `currentThread.title`, so it updates reactively too. + func rename(_ thread: ChatThread, to newTitle: String) { + guard let trimmed = ThreadTitlePolicy.normalized(newTitle), trimmed != thread.title, + let ti = threads.firstIndex(where: { $0.id == thread.id }) + else { return } + if let backendId = thread.backendId { + if threadsProvider?.renameThread(backendId: backendId, title: trimmed) != true { + guard queueCustomTitle(trimmed, for: thread.id, backendThreadID: backendId) else { return } + } + } + customTitleThreadIDs.insert(thread.id) + if var state = firstTurnTitleStates[thread.id] { + state.pendingGeneratedTitle = nil + firstTurnTitleStates[thread.id] = state + } + threads[ti].title = trimmed + } + + /// Flip one bubble between raw mono and rich markdown (meta-row toggle). + /// Per-message, in-memory only; deliberately does NOT touch the fields the + /// scroll signature reads, so a toggle never auto-scrolls the list. + func toggleRenderMode(messageID: UUID, in threadID: UUID) { + update(messageID, in: threadID) { + $0.renderMode = MessageRenderMode.nextRenderMode(after: $0.renderMode) + } + } + + /// Export a thread to a Markdown transcript on disk, returning the file path + /// so the caller can reveal it in Finder. Only persisted threads (with a + /// backend id) can be exported; a not-yet-saved local thread returns `nil`. + func exportMarkdown(_ thread: ChatThread, assistantOnly: Bool) -> String? { + guard let backendId = thread.backendId else { return nil } + return threadsProvider?.exportThreadMarkdown(backendId: backendId, assistantOnly: assistantOnly) + } + + func delete(_ thread: ChatThread) { + if let backendId = thread.backendId { + let deleted = threadsProvider?.deleteThread(backendId: backendId) == true + // A freshly minted backend id does not exist on disk until the + // first stream returns. In that one known race, local delete still + // wins and the existing engine cancellation prevents persistence. + guard deleted || firstTurnTitleStates[thread.id] != nil else { return } + // The attachment sidecar is written before the first stream starts, + // so the missing-file race still has local metadata to remove. + removePersistedAttachmentMetadata(for: backendId) + } + titleGenerationTasks[thread.id]?.cancel() + titleGenerationTasks[thread.id] = nil + firstTurnTitleStates[thread.id] = nil + customTitleThreadIDs.remove(thread.id) + // Deleting a thread deletes its queue — in memory and on disk. + queuedTurns.removeAll { $0.threadID == thread.id } + if let backendId = thread.backendId { + removeDurableAcceptedTurns(backendThreadID: backendId) + } + // Cancel any in-flight reply for this thread so its post-stream refresh + // can't re-list (and the caret/finalize can't mutate) a deleted thread. + // Swift-task cancel first (so the awaiting send sees isCancelled and + // stays silent), then the engine-side cancel, which actually aborts the + // Rust turn — stopping tool side effects, not just the UI updates. + inFlightSends[thread.id]?.task.cancel() + inFlightSends[thread.id] = nil + if let backendId = thread.backendId { + _ = engine?.cancelReply(threadId: backendId) + pendingToolApprovals.removeAll { $0.threadID == backendId } + } + if activeComposerTurn?.threadID == thread.id { + activeComposerTurn = nil + } + threads.removeAll { $0.id == thread.id } + if selectedThreadID == thread.id { + selectedThreadID = threads.first?.id + if let selectedThreadID { loadMessagesIfNeeded(selectedThreadID) } + } + if threads.isEmpty { + newThread() + } + } + + /// Lazily pull a persisted thread's messages the first time it is selected. + private func loadMessagesIfNeeded(_ id: UUID) { + guard let provider = threadsProvider, + let ti = threads.firstIndex(where: { $0.id == id }), + let backendId = threads[ti].backendId, + !threads[ti].messagesLoaded + else { return } + let start = Date() + defer { AgentPerf.log("selected thread load", since: start, detail: backendId) } + // Persisted user turns carry the wire skeleton (disk keeps the LLM + // truth); rewrite them for display so restored threads render the + // spoken instruction, exactly like a live turn. + threads[ti].messages = applyingPersistedAttachmentMetadata( + to: provider.loadMessages(backendId: backendId), + backendId: backendId + ).map(AssistivePromptParser.presented) + threads[ti].messagesLoaded = true + } + + /// Resolve (and lazily mint) the ThreadStore id for a thread so the agent + /// persists the conversation under a stable id across turns + restarts. + private func ensureBackendId(_ threadID: UUID) -> String { + guard let ti = threads.firstIndex(where: { $0.id == threadID }) else { + return "t_\(UUID().uuidString)" + } + if let existing = threads[ti].backendId { return existing } + let id = threadsProvider?.generateThreadId() ?? "t_\(UUID().uuidString)" + threads[ti].backendId = id + threads[ti].messagesLoaded = true // freshly-minted thread starts in sync + return id + } + + // MARK: Attachments (composer staging) + + /// Stage image files chosen in the composer, de-duplicating by URL. + func addAttachments(_ urls: [URL]) { + let before = pendingAttachments.count + for url in urls where !pendingAttachments.contains(where: { $0.url == url }) { + pendingAttachments.append(PendingAttachment(url: url)) + } + attachLog.info( + "addAttachments: incoming=\(urls.count, privacy: .public) staged=\(self.pendingAttachments.count - before, privacy: .public) (post-dedupe) pendingAttachments.count=\(self.pendingAttachments.count, privacy: .public)" + ) + } + + /// Remove a staged attachment before it is sent. + func removeAttachment(_ id: UUID) { + pendingAttachments.removeAll { $0.id == id } + } + + /// True when there is something to send: text, at least one staged image, or + /// both. Drives the send button's enabled state. A turn already in flight no + /// longer blocks acceptance — `send()` queues instead of dropping. + var canSend: Bool { + !isAgenticLocked + && (!draft.trimmingCharacters(in: .whitespaces).isEmpty || !pendingAttachments.isEmpty) + } + + var isAgenticLocked: Bool { licenseService?.canUseAgentic == false } + var agenticBlockMessage: String? { + isAgenticLocked ? licenseService?.agenticBlockMessage : nil + } + + // MARK: Turn queue (messages accepted while a turn is in flight) + + /// A message the UI accepted. Durable from the moment of acceptance: it is + /// written to the sidecar in `accept` and removed only when its turn reaches + /// a terminal (success, provider error, or stop), so an app death in between + /// replays it on the next launch instead of losing it. + struct QueuedTurn: Identifiable, Equatable { + let id: UUID + let threadID: UUID + let backendThreadID: String + var text: String + let attachments: [PendingAttachment] + let enqueuedAt: Date + } + + /// FIFO of accepted-but-not-yet-running messages, ordered by acceptance + /// across all threads; per-thread order is what the contract guarantees. + @Published private(set) var queuedTurns: [QueuedTurn] = [] + + func queuedTurns(in threadID: UUID) -> [QueuedTurn] { + queuedTurns.filter { $0.threadID == threadID } + } + + /// Terminal-style composer history, oldest → newest. It includes already + /// dispatched user turns and accepted queued turns, so Up can recover the + /// exact message the operator just queued without cancelling it first. + func composerHistory(in threadID: UUID) -> [String] { + let sent = + threads.first(where: { $0.id == threadID })?.messages + .filter { $0.role == .you } + .map(\.text) ?? [] + let queued = queuedTurns(in: threadID).map(\.text) + return (sent + queued).reduce(into: [String]()) { result, text in + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, result.last != text else { return } + result.append(text) + } + } + + /// Cancel one still-queued (never dispatched) message. + func cancelQueuedTurn(_ id: UUID) { + guard queuedTurns.contains(where: { $0.id == id }) else { return } + queuedTurns.removeAll { $0.id == id } + removeDurableAcceptedTurn(id: id) + } + + /// Edit an accepted turn while it is still queued. The same durable sidecar + /// is replaced immediately, so a crash/relaunch cannot resurrect the old + /// wording. Attachments and FIFO position stay unchanged. + @discardableResult + func editQueuedTurn(_ id: UUID, text: String) -> Bool { + guard let index = queuedTurns.firstIndex(where: { $0.id == id }) else { return false } + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty || !queuedTurns[index].attachments.isEmpty else { return false } + queuedTurns[index].text = text + persistDurableAcceptedTurn(queuedTurns[index]) + return true + } + + // MARK: Send (accept → queue → serialized dispatch) + + func send() { + guard !isAgenticLocked else { return } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + let staged = pendingAttachments + attachLog.info( + "send: building request attachmentPaths.count=\(staged.count, privacy: .public) text.isEmpty=\(text.isEmpty, privacy: .public)" + ) + guard !text.isEmpty || !staged.isEmpty, let threadID = selectedThreadID else { return } + draft = "" + pendingAttachments = [] + accept(text: text, staged: staged, threadID: threadID) + } + + /// Accept a message: persist it durably, enqueue it FIFO on its thread, and + /// let the single dispatch owner start it if the composer slot is idle. + private func accept(text: String, staged: [PendingAttachment], threadID: UUID) { + let backendId = ensureBackendId(threadID) + let turn = QueuedTurn( + id: UUID(), + threadID: threadID, + backendThreadID: backendId, + text: text, + attachments: staged, + enqueuedAt: Date() + ) + persistDurableAcceptedTurn(turn) + queuedTurns.append(turn) + queueLog.info( + "accepted turn \(turn.id, privacy: .public) thread=\(backendId, privacy: .public) queued=\(self.queuedTurns.count, privacy: .public)" + ) + dispatchNextQueuedTurnIfIdle() + } + + /// The ONLY `queued → running` transition. MainActor-serialized, guarded by + /// the single composer slot, and `startTurn` claims that slot synchronously — + /// so exactly one owner can ever promote a queued message. Threads with an + /// active voice turn are skipped (the core owns that thread's stream); their + /// queued messages wait for the voice terminal. + private func dispatchNextQueuedTurnIfIdle() { + guard activeComposerTurn == nil else { return } + guard + let index = queuedTurns.firstIndex(where: { turn in + !(voiceTurnThreadID == turn.threadID && voiceTurnPhase != nil) + }) + else { return } + var turn = queuedTurns.remove(at: index) + // The local UUID can go stale while queued (a rail refresh re-mints + // rows); the DURABLE binding is the backend thread id. Re-bind instead + // of dropping — an explicitly deleted thread already purged its queue + // in `delete`, so anything still here must run. + if !threads.contains(where: { $0.id == turn.threadID }) { + let resolvedID: UUID + if let match = threads.first(where: { $0.backendId == turn.backendThreadID }) { + resolvedID = match.id + } else { + var thread = ChatThread(title: "Restored draft", meta: "now") + thread.backendId = turn.backendThreadID + thread.messagesLoaded = true + threads.insert(thread, at: 0) + resolvedID = thread.id + } + turn = QueuedTurn( + id: turn.id, + threadID: resolvedID, + backendThreadID: turn.backendThreadID, + text: turn.text, + attachments: turn.attachments, + enqueuedAt: turn.enqueuedAt + ) + } + queueLog.info( + "promoting turn \(turn.id, privacy: .public) thread=\(turn.backendThreadID, privacy: .public) queued=\(self.queuedTurns.count, privacy: .public)" + ) + startTurn(turn) + } + + /// Run one accepted message as the active composer turn. The acceptance id + /// IS the turn id, so one element is traceable end-to-end (accept → queue → + /// running → terminal → durable-record removal). + private func startTurn(_ queued: QueuedTurn) { + let threadID = queued.threadID + let backendId = queued.backendThreadID + let text = queued.text + let staged = queued.attachments + let attachmentPaths = staged.map { $0.url.path } + let userTurnIndex = currentUserTurnCount(in: threadID) + + // Carry the staged attachments onto the You bubble so the sender sees a + // chip (name + optional thumbnail) for what they attached. + let sent = staged.map { MessageAttachment(name: $0.name, url: $0.url, type: $0.type) } + persistAttachmentMetadata(sent, for: backendId, userTurnIndex: userTurnIndex) + append(ChatMessage(role: .you, timestamp: now(), text: text, attachments: sent), to: threadID) + let assistant = ChatMessage(role: .assistant, timestamp: "now", text: "", isThinking: true) + let assistantID = assistant.id + append(assistant, to: threadID) + let turnID = queued.id + activeComposerTurn = ActiveComposerTurn( + id: turnID, + threadID: threadID, + backendThreadID: backendId, + assistantMessageID: assistantID, + phase: .thinking + ) + if userTurnIndex == 0, !text.isEmpty, engine != nil { + prepareFirstTurnTitle(for: threadID, backendThreadID: backendId) + } + let sendTask = Task { @MainActor in + var titleStreamSettled = false + defer { + if !titleStreamSettled { + settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) + } + // The turn reached a terminal (success, provider error, or a + // cancelled Swift task) — its outcome is visible in the UI, so + // the durable acceptance record is consumed. Only an app death + // BEFORE this point leaves the record behind for restart replay. + removeDurableAcceptedTurn(id: turnID) + releaseComposerTurn(turnID, in: threadID) + } + guard let engine else { + finish( + assistantID, in: threadID, + text: "Engine not wired yet.") + return + } + // Graceful unavailable path — the engine reports WHAT is missing + // (lane, endpoint or key) so the reply is actionable, not generic. + if let unavailableDetail = engine.availabilityDetail() { + finishTitleGenerationWithoutRequest(for: threadID) + finish(assistantID, in: threadID, text: unavailableDetail) + return + } + if userTurnIndex == 0, !text.isEmpty { + launchFirstTurnTitle( + text, + for: threadID, + backendThreadID: backendId, + engine: engine ) - } - - /// Voice entry to the SAME first-turn coordinator `send()` uses. The core - /// runtime owns the conversational provider call and its persistence; this - /// launches only the stateless title sibling — never `send()`/`streamReply` - /// — so the exchange is not dispatched twice. `ingestVoiceDone` is the - /// stream-completed settle point (core persistence has finished by then). - private func launchVoiceFirstTurnTitle( - _ presentedText: String, - for threadID: UUID, - backendThreadID: String - ) { - guard !presentedText.isEmpty, let engine else { return } - prepareFirstTurnTitle(for: threadID, backendThreadID: backendThreadID) - guard firstTurnTitleStates[threadID] != nil else { return } - guard engine.availabilityDetail() == nil else { - finishTitleGenerationWithoutRequest(for: threadID) - return - } - launchFirstTurnTitle(presentedText, for: threadID, backendThreadID: backendThreadID, engine: engine) - } - - private func finishTitleGenerationWithoutRequest(for threadID: UUID) { - guard var state = firstTurnTitleStates[threadID] else { return } - state.generationFinished = true - firstTurnTitleStates[threadID] = state - cleanUpFirstTurnTitleStateIfFinished(for: threadID) - } - - private func receiveGeneratedTitle(_ title: String?, for threadID: UUID, generationID: UUID) { - guard var state = firstTurnTitleStates[threadID], state.generationID == generationID else { return } - state.generationFinished = true - guard let trimmed = ThreadTitlePolicy.normalized(title), - !customTitleThreadIDs.contains(threadID), - state.pendingCustomTitle == nil, - let ti = threads.firstIndex(where: { $0.id == threadID }) else { - firstTurnTitleStates[threadID] = state - cleanUpFirstTurnTitleStateIfFinished(for: threadID) - return - } - - threads[ti].title = trimmed - let persisted = threadsProvider?.setGeneratedTitle( - backendId: state.backendThreadID, - title: trimmed - ) == true - if !persisted { - if state.streamCompleted { - if threads[ti].title == trimmed { threads[ti].title = state.originalTitle } - } else { - state.pendingGeneratedTitle = trimmed + } + let start = Date() + do { + // REAL streaming: tokens land live as the agent emits them. + let finalText = try await engine.streamReply( + text, + threadId: backendId, + attachmentPaths: attachmentPaths, + onDelta: { [weak self] delta in + guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true + else { + return } - } - firstTurnTitleStates[threadID] = state - cleanUpFirstTurnTitleStateIfFinished(for: threadID) - } - - private func finishTitleGeneration(for threadID: UUID, generationID: UUID) { - guard var state = firstTurnTitleStates[threadID], state.generationID == generationID else { return } - state.generationFinished = true - firstTurnTitleStates[threadID] = state - cleanUpFirstTurnTitleStateIfFinished(for: threadID) - } - - /// Mark the Rust stream (and therefore its best-effort first persistence) - /// complete, flush a queued custom rename first, otherwise retry one queued - /// generated title exactly once. This runs before `refreshThreads`. - private func settleFirstTurnTitleAfterStream(for threadID: UUID, backendThreadID: String) { - guard var state = firstTurnTitleStates[threadID], state.backendThreadID == backendThreadID else { return } - guard !state.streamCompleted else { return } - state.streamCompleted = true - - if let customTitle = state.pendingCustomTitle { - _ = threadsProvider?.renameThread(backendId: backendThreadID, title: customTitle) - state.pendingCustomTitle = nil - state.pendingGeneratedTitle = nil - } else if let generatedTitle = state.pendingGeneratedTitle { - let persisted = threadsProvider?.setGeneratedTitle( - backendId: backendThreadID, - title: generatedTitle - ) == true - state.pendingGeneratedTitle = nil - if !persisted, - !customTitleThreadIDs.contains(threadID), - let ti = threads.firstIndex(where: { $0.id == threadID }), - threads[ti].title == generatedTitle { - threads[ti].title = state.originalTitle + self?.setComposerPhase(.streaming, for: turnID) + self?.update(assistantID, in: threadID) { + $0.isThinking = false + $0.isStreaming = true + if $0.reasonedSeconds == nil { + $0.reasonedSeconds = Date().timeIntervalSince(start) + } + $0.text += delta } - } - - firstTurnTitleStates[threadID] = state - cleanUpFirstTurnTitleStateIfFinished(for: threadID) - } - - /// Queue a rename only for the active first-turn missing-file window. - /// One dictionary slot means repeated UI commits collapse to the latest - /// custom title, while generated persistence is discarded immediately. - private func queueCustomTitle(_ title: String, for threadID: UUID, backendThreadID: String) -> Bool { - guard var state = firstTurnTitleStates[threadID], - state.backendThreadID == backendThreadID, - !state.streamCompleted else { return false } - state.pendingCustomTitle = title - state.pendingGeneratedTitle = nil - firstTurnTitleStates[threadID] = state - return true - } - - private func cleanUpFirstTurnTitleStateIfFinished(for threadID: UUID) { - guard let state = firstTurnTitleStates[threadID], - state.streamCompleted, - state.generationFinished, - state.pendingGeneratedTitle == nil, - state.pendingCustomTitle == nil else { return } - firstTurnTitleStates[threadID] = nil - titleGenerationTasks[threadID] = nil - } - - /// Stop the selected Agent turn through its owning adapter. Voice is checked - /// first because it has no Swift waiter and must never touch the composer - /// registry. Composer ordering remains deliberate: waiter first, Rust second. - func stopActiveTurn() { - if let threadID = voiceTurnThreadID, - threadID == selectedThreadID, - let phase = voiceTurnPhase, - phase != .cancelling, - let backendId = threads.first(where: { $0.id == threadID })?.backendId { - voiceTurnPhase = .cancelling - if voiceTurnCanceller?.cancelVoiceTurn(threadId: backendId) != true { - // The runtime may have crossed its successful terminal just before - // the click. Keep accepting that terminal instead of stranding the - // local bubble in a false Cancelling state. - voiceTurnPhase = phase + }, + onReasoning: { [weak self] delta in + guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true + else { + return } - return - } - - guard var turn = activeComposerTurn, - turn.threadID == selectedThreadID, - turn.phase != .cancelling else { return } - - turn.phase = .cancelling - activeComposerTurn = turn - inFlightSends[turn.threadID]?.task.cancel() - let firstAcknowledgement = engine?.cancelReply(threadId: turn.backendThreadID) ?? false - pendingToolApprovals.removeAll { $0.threadID == turn.backendThreadID } - - // A very fast Stop can beat Rust's registry setup while the provider and - // persisted history are still loading. Retry only that unacknowledged - // race; the UI click remains idempotent and every probe uses the same - // exact backend id. Settle after acknowledgement (or a bounded idle race). - Task { @MainActor [weak self] in - var acknowledged = firstAcknowledgement - var attempts = 0 - while !acknowledged, attempts < 80 { - guard let self, - self.activeComposerTurn?.id == turn.id, - self.activeComposerTurn?.phase == .cancelling, - let engine = self.engine else { break } - attempts += 1 - try? await Task.sleep(for: .milliseconds(25)) - acknowledged = engine.cancelReply(threadId: turn.backendThreadID) + self?.appendReasoning(delta, to: assistantID, in: threadID) + }, + onToolExecuting: { [weak self] name, id in + guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true + else { + return } - await Task.yield() - self?.settleStoppedComposerTurn(turn) - } - } - - // MARK: Voice-assistive delivery (core runtime → live render, no re-send) - // - // These ingest the reply the CORE runtime is already streaming for a hotkey / - // voice turn (via the bridge `CsAgentDeliveryListener`). They ONLY render: - // insert bubbles and mutate them from deltas. They deliberately do not call - // `send()` / `streamReply` — the core already made the provider call and - // persists the thread to disk. Doing otherwise would double-dispatch the - // turn. The single engine call allowed on this path is the stateless - // first-turn title sibling (`generateThreadTitle`), which carries no - // conversation state and never touches the thread's response chain. - - /// Open a voice turn: bind (or create) a thread for the core `backendId` - /// and insert the You-bubble + an assistant placeholder. Delivery events - /// never change the rail selection: a matching active thread renders live, - /// while a turn for another thread updates in the background. Only explicit - /// user actions (`select`, `newThread`, delete fallback) move selection. - func ingestVoiceTurn(threadId backendId: String, userText: String) { - // Defensive: a new voice turn can open before the previous one closed - // (rapid double-press / a fresh session). Finalize the stale assistant - // bubble in the UI before we overwrite the turn references below — - // otherwise it sticks in isThinking/isStreaming forever. - if let staleThreadID = voiceTurnThreadID, let staleID = voiceAssistantID { - finishPendingTools(before: staleID, in: staleThreadID) - update(staleID, in: staleThreadID) { - $0.isThinking = false - $0.isStreaming = false - $0.timestamp = self.now() + self?.recordToolStarted(name: name, callID: id, before: assistantID, in: threadID) + }, + onToolResult: { [weak self] name, id, isError, reason in + self?.pendingToolApprovals.removeAll { + $0.threadID == backendId && $0.callID == id } - // The stale turn ended without its own terminal event, so settle its - // title coordinator here — a queued generated title must not outlive - // the turn that owned it. - if let staleBackendID = threads.first(where: { $0.id == staleThreadID })?.backendId { - settleFirstTurnTitleAfterStream(for: staleThreadID, backendThreadID: staleBackendID) + guard self?.acceptsComposerEvent(turnID, assistantID: assistantID, in: threadID) == true + else { + return } - } - - // The core sends the WIRE prompt (assistive skeleton); the bubble shows - // the spoken instruction. The wire + selection/app context ride along on - // the message for the context chip and "Copy full prompt". - let userTurn = AssistivePromptParser.presented( - ChatMessage(role: .you, timestamp: now(), text: userText) + self?.recordToolResult( + name: name, callID: id, isError: isError, reason: reason, + before: assistantID, in: threadID) + } ) - - let threadID: UUID - var isFirstExchange = false - if let existing = threads.first(where: { $0.backendId == backendId }) { - threadID = existing.id - loadMessagesIfNeeded(threadID) // surface prior history before appending - } else if let draftIndex = threads.firstIndex(where: { - $0.id == selectedThreadID && $0.backendId == nil && $0.messages.isEmpty - }) { - // The user is sitting in an empty local-only draft ("+ New thread") - // watching their dictation stream: the voice turn must land THERE. - // Binding the draft to the core's thread id keeps the user where they - // are; minting a parallel thread here yanked the conversation into a - // surprise rail entry mid-sentence (UI_DIVERGENCE_AUDIT / operator - // report 2026-08-08). - threads[draftIndex].backendId = backendId - threads[draftIndex].messagesLoaded = true // freshly bound → in sync - threads[draftIndex].title = - ThreadTitlePolicy.normalized(userTurn.text, limit: 48) ?? "Voice chat" - threads[draftIndex].meta = "now" - threadID = threads[draftIndex].id - isFirstExchange = true - } else { - let title = ThreadTitlePolicy.normalized(userTurn.text, limit: 48) ?? "Voice chat" - var thread = ChatThread(title: title, meta: "now") - thread.backendId = backendId - thread.messagesLoaded = true // freshly bound to a core id → in sync - threads.insert(thread, at: 0) - threadID = thread.id - isFirstExchange = true - } - // A skeleton turn can carry context with an empty instruction (e.g. a - // clipped dictation) — the bubble still renders for the chip. - if !userTurn.text.isEmpty || userTurn.wireText != nil { - append(userTurn, to: threadID) - } - let assistant = ChatMessage(role: .assistant, timestamp: "now", text: "", isThinking: true) - voiceTurnThreadID = threadID - voiceAssistantID = assistant.id - voiceTurnStartedAt = Date() - voiceTurnPhase = .thinking - append(assistant, to: threadID) - if isFirstExchange { - launchVoiceFirstTurnTitle(userTurn.text, for: threadID, backendThreadID: backendId) - } - } - - /// Append a streamed token to the active voice assistant bubble. - func ingestVoiceDelta(_ delta: String) { - guard voiceTurnPhase != .cancelling, - let threadID = voiceTurnThreadID, let id = voiceAssistantID else { return } - voiceTurnPhase = .streaming - update(id, in: threadID) { - $0.isThinking = false - $0.isStreaming = true - if $0.reasonedSeconds == nil, let started = self.voiceTurnStartedAt { - $0.reasonedSeconds = Date().timeIntervalSince(started) - } - $0.text += delta - } - } - - /// Append streamed model reasoning to the active voice assistant bubble. - func ingestVoiceReasoning(_ delta: String) { - guard voiceTurnPhase != .cancelling, - let threadID = voiceTurnThreadID, let id = voiceAssistantID else { return } - voiceTurnPhase = .streaming - appendReasoning(delta, to: id, in: threadID) - } - - /// Final assembled text for the turn. Only used as a fallback when the reply - /// arrived without token deltas (otherwise the bubble already holds the text). - func ingestVoiceTextDone(_ text: String) { - guard voiceTurnPhase != .cancelling, - let threadID = voiceTurnThreadID, let id = voiceAssistantID else { return } - voiceTurnPhase = .streaming - update(id, in: threadID) { if $0.text.isEmpty { $0.text = text } } - } - - /// Surface a pending tool call for the active voice turn. The bridge's `id` - /// is kept end-to-end so the matching result can update this row in place. - func ingestVoiceToolExecuting(name: String, id callID: String) { - guard voiceTurnPhase != .cancelling, - let threadID = voiceTurnThreadID, let assistantID = voiceAssistantID else { return } - voiceTurnPhase = .streaming - recordToolStarted(name: name, callID: callID, before: assistantID, in: threadID) - } - - /// Surface a completed tool call for the active voice turn (same rendering as - /// the composer path's tool-activity row). - func ingestVoiceToolResult(name: String, id callID: String, isError: Bool, reason: String) { - guard voiceTurnPhase != .cancelling, - let threadID = voiceTurnThreadID, let assistantID = voiceAssistantID else { return } - voiceTurnPhase = .streaming - recordToolResult(name: name, callID: callID, isError: isError, reason: reason, before: assistantID, in: threadID) - } - - /// Finalize the active voice turn and pull disk truth (the core persisted the - /// thread). No re-persist here — the store only mirrors what the core wrote. - /// This is also the title coordinator's stream-completed settle point: core - /// persistence finished before this terminal, so a queued generated title - /// flushes exactly once here, before the rail refresh. - func ingestVoiceDone() { - guard voiceTurnPhase != .cancelling, - let threadID = voiceTurnThreadID, let id = voiceAssistantID else { return } - finishPendingTools(before: id, in: threadID) - update(id, in: threadID) { - $0.isThinking = false - $0.isStreaming = false - $0.timestamp = self.now() - } - let backendId = threads.first(where: { $0.id == threadID })?.backendId - if let backendId { - settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) - } - clearVoiceTurnState() - if let backendId { refreshThreads(selectingBackendId: backendId) } - } - - /// Surface a runtime error on the active voice turn and close it. The core - /// error path may not emit a separate `Done`, so clear the turn state here; a - /// late `Done` then no-ops against the cleared state. - func ingestVoiceError(_ message: String) { - guard voiceTurnPhase != .cancelling, - let threadID = voiceTurnThreadID, let id = voiceAssistantID else { return } - finishPendingTools(before: id, in: threadID) - update(id, in: threadID) { - $0.isThinking = false - $0.isStreaming = false - $0.text += ($0.text.isEmpty ? "" : "\n") + "[error] " + message - $0.timestamp = self.now() - } - // A failed turn persisted nothing; settling lets the coordinator try a - // queued title once, fail against the missing thread, and restore the - // fallback title instead of leaving the queue open forever. - if let backendId = threads.first(where: { $0.id == threadID })?.backendId { - settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) - } - clearVoiceTurnState() - } - - /// Settle the single keyed cancellation terminal. Partial text remains, an - /// empty response becomes a quiet Stopped marker, and running tools become - /// stopped without refreshing disk truth (the core intentionally did not - /// persist this turn as successful). - func ingestVoiceCancelled(threadId backendId: String) { - guard voiceTurnPhase == .cancelling, - let threadID = voiceTurnThreadID, - let id = voiceAssistantID, - threads.first(where: { $0.id == threadID })?.backendId == backendId else { return } - cancelPendingTools(before: id, in: threadID) - update(id, in: threadID) { - $0.isThinking = false - $0.isStreaming = false - $0.wasStopped = true - if $0.text.isEmpty { $0.text = "Stopped" } - $0.timestamp = self.now() - } - // Mirror the composer's cancel path (its defer settles too): the core - // did not persist this turn, so a late generated title fails to persist - // and the fallback title survives. settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) - clearVoiceTurnState() - } - - // MARK: Demo stream (reproduces the mock's mid-stream last turn) - - /// Kicks off the mock's animated final turn exactly once, so the first - /// render matches the prototype's streaming + blink-caret state. - func startDemoStreamIfNeeded() { - guard !didStartDemo, let threadID = threads.first(where: { $0.isRestored })?.id else { return } - didStartDemo = true - let demo = ChatMessage(role: .assistant, timestamp: "now", text: "", isThinking: true) - let id = demo.id - append(demo, to: threadID) - Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_600_000_000) - startStream( - id, in: threadID, - fullText: "On it — patching events/bus.ts to emit once per settled retry, de-duping the store subscription on remount, and adding a regression test for the double-fire case.", - reasoned: 2.1 - ) - } - } - - // MARK: Simulated reveal - - private func startStream(_ id: UUID, in threadID: UUID, fullText: String, reasoned: Double) { - revealTask?.cancel() - update(id, in: threadID) { - $0.isThinking = false - $0.isStreaming = true - $0.reasonedSeconds = reasoned - $0.text = "" - } - revealTask = Task { @MainActor in - let words = fullText.split(separator: " ", omittingEmptySubsequences: false) - var shown = "" - for (i, w) in words.enumerated() { - if Task.isCancelled { return } - shown += (i == 0 ? "" : " ") + w - update(id, in: threadID) { $0.text = shown } - try? await Task.sleep(nanoseconds: 95_000_000) - } - update(id, in: threadID) { - $0.isStreaming = false - $0.timestamp = self.now() - } - } - } - - private func finish(_ id: UUID, in threadID: UUID, text: String) { - finishPendingTools(before: id, in: threadID) - update(id, in: threadID) { - $0.isThinking = false - $0.isStreaming = false - $0.text = text - $0.timestamp = self.now() - } - } - - private func acceptsComposerEvent(_ turnID: UUID, assistantID: UUID, in threadID: UUID) -> Bool { - guard let turn = activeComposerTurn else { return false } - return turn.id == turnID - && turn.threadID == threadID - && turn.assistantMessageID == assistantID - && turn.phase != .cancelling - } - - private func setComposerPhase(_ phase: ComposerTurnPhase, for turnID: UUID) { - guard var turn = activeComposerTurn, turn.id == turnID, turn.phase != .cancelling else { return } - turn.phase = phase - activeComposerTurn = turn - } - - private func releaseComposerTurn(_ turnID: UUID, in threadID: UUID) { - if inFlightSends[threadID]?.id == turnID { - inFlightSends[threadID] = nil - } - if activeComposerTurn?.id == turnID, - activeComposerTurn?.phase != .cancelling { - activeComposerTurn = nil - } - // Terminal of any kind frees the composer slot; the queue continues. - // A provider error or cancel must never silently strand queued items. - dispatchNextQueuedTurnIfIdle() - } - - private func settleStoppedComposerTurn(_ turn: ActiveComposerTurn) { - guard activeComposerTurn?.id == turn.id, - activeComposerTurn?.phase == .cancelling else { return } - cancelPendingTools(before: turn.assistantMessageID, in: turn.threadID) - update(turn.assistantMessageID, in: turn.threadID) { - $0.isThinking = false - $0.isStreaming = false - $0.wasStopped = true - if $0.text.isEmpty { $0.text = "Stopped" } - $0.timestamp = self.now() - } - if inFlightSends[turn.threadID]?.id == turn.id { - inFlightSends[turn.threadID] = nil - } - activeComposerTurn = nil - // A user Stop consumed its own turn but not the queue: FIFO continues. - dispatchNextQueuedTurnIfIdle() - } - - private func clearVoiceTurnState() { - voiceTurnThreadID = nil - voiceAssistantID = nil - voiceTurnStartedAt = nil - voiceTurnPhase = nil - // The voice terminal frees its thread for queued composer messages. - dispatchNextQueuedTurnIfIdle() - } - - // MARK: Mutation helpers - - private func append(_ message: ChatMessage, to threadID: UUID) { - guard let ti = threads.firstIndex(where: { $0.id == threadID }) else { return } - threads[ti].messages.append(message) - } - - private func update(_ id: UUID, in threadID: UUID, _ body: (inout ChatMessage) -> Void) { - guard let ti = threads.firstIndex(where: { $0.id == threadID }), - let mi = threads[ti].messages.firstIndex(where: { $0.id == id }) else { return } - body(&threads[ti].messages[mi]) - } - - private func currentUserTurnCount(in threadID: UUID) -> Int { - threads.first(where: { $0.id == threadID })?.messages.filter { $0.role == .you }.count ?? 0 - } - - private struct PersistedAttachmentMetadata: Codable, Hashable { - let name: String - let type: String - let path: String? - } - - private struct PersistedAttachmentTurn: Codable, Hashable { - let userTurnIndex: Int - let attachments: [PersistedAttachmentMetadata] - } - - private static let attachmentMetadataDefaultsKey = "AgentChatStore.attachmentMetadata.v1" - - // MARK: Durable accepted-turn sidecar (queue persistence) - - /// Wire form of an accepted message. Keyed by the durable backend thread id - /// (the local UUID does not survive a restart); FIFO order restores from - /// `enqueuedAtEpoch`. - private struct DurableAcceptedTurn: Codable, Hashable { - let id: UUID - let backendThreadID: String - let text: String - let attachmentPaths: [String] - let enqueuedAtEpoch: TimeInterval - } - - static let acceptedTurnsDefaultsKey = "AgentChatStore.acceptedTurns.v1" - - private func persistDurableAcceptedTurn(_ turn: QueuedTurn) { - var stored = readDurableAcceptedTurns() - stored.removeAll { $0.id == turn.id } - stored.append(DurableAcceptedTurn( - id: turn.id, - backendThreadID: turn.backendThreadID, - text: turn.text, - attachmentPaths: turn.attachments.map { $0.url.path }, - enqueuedAtEpoch: turn.enqueuedAt.timeIntervalSince1970 - )) - writeDurableAcceptedTurns(stored) - } + titleStreamSettled = true + // The thread may have been deleted mid-stream; drop the late + // finalize + refresh so a cancelled send can't bring it back. + if Task.isCancelled { return } + finishPendingTools(before: assistantID, in: threadID) + update(assistantID, in: threadID) { + $0.isThinking = false + $0.isStreaming = false + // A provider that emits only a final TextDone (no token deltas) + // leaves the bubble empty; fall back to the assembled return so + // the reply is never a blank bubble. + if $0.text.isEmpty { $0.text = finalText } + $0.timestamp = self.now() + } + refreshThreads(selectingBackendId: backendId) + } catch { + if Task.isCancelled { return } + finish( + assistantID, in: threadID, + text: "Something went wrong: \(error.localizedDescription)") + } + } + inFlightSends[threadID] = InFlightSend(id: turnID, task: sendTask) + } + + /// Launch the title lane as an independent, unstructured MainActor task. + /// Awaiting the engine releases the actor, so this request runs concurrently + /// with the conversational turn (composer `streamReply` or the core-owned + /// voice stream) without escaping non-Sendable engine/provider seams. It is + /// a stateless sibling request — it never re-enters `send()`/`streamReply`. + private func launchFirstTurnTitle( + _ text: String, + for threadID: UUID, + backendThreadID: String, + engine: AgentChatEngine + ) { + guard let state = firstTurnTitleStates[threadID], + state.backendThreadID == backendThreadID + else { return } + let generationID = state.generationID + let task = Task { @MainActor [weak self] in + do { + let title = try await engine.generateThreadTitle(text) + guard !Task.isCancelled else { return } + self?.receiveGeneratedTitle(title, for: threadID, generationID: generationID) + } catch { + guard !Task.isCancelled else { return } + self?.finishTitleGeneration(for: threadID, generationID: generationID) + } + } + titleGenerationTasks[threadID] = task + } + + /// Establish the race authority synchronously inside `send()`. A rail action + /// performed immediately after `send()` returns can therefore queue a custom + /// write or discard title work even before the unstructured task is scheduled. + private func prepareFirstTurnTitle(for threadID: UUID, backendThreadID: String) { + guard threadsProvider != nil, + !customTitleThreadIDs.contains(threadID), + firstTurnTitleStates[threadID] == nil, + let originalTitle = threads.first(where: { $0.id == threadID })?.title + else { return } + firstTurnTitleStates[threadID] = FirstTurnTitleState( + backendThreadID: backendThreadID, + generationID: UUID(), + originalTitle: originalTitle + ) + } + + /// Voice entry to the SAME first-turn coordinator `send()` uses. The core + /// runtime owns the conversational provider call and its persistence; this + /// launches only the stateless title sibling — never `send()`/`streamReply` + /// — so the exchange is not dispatched twice. `ingestVoiceDone` is the + /// stream-completed settle point (core persistence has finished by then). + private func launchVoiceFirstTurnTitle( + _ presentedText: String, + for threadID: UUID, + backendThreadID: String + ) { + guard !presentedText.isEmpty, let engine else { return } + prepareFirstTurnTitle(for: threadID, backendThreadID: backendThreadID) + guard firstTurnTitleStates[threadID] != nil else { return } + guard engine.availabilityDetail() == nil else { + finishTitleGenerationWithoutRequest(for: threadID) + return + } + launchFirstTurnTitle( + presentedText, for: threadID, backendThreadID: backendThreadID, engine: engine) + } + + private func finishTitleGenerationWithoutRequest(for threadID: UUID) { + guard var state = firstTurnTitleStates[threadID] else { return } + state.generationFinished = true + firstTurnTitleStates[threadID] = state + cleanUpFirstTurnTitleStateIfFinished(for: threadID) + } + + private func receiveGeneratedTitle(_ title: String?, for threadID: UUID, generationID: UUID) { + guard var state = firstTurnTitleStates[threadID], state.generationID == generationID else { + return + } + state.generationFinished = true + guard let trimmed = ThreadTitlePolicy.normalized(title), + !customTitleThreadIDs.contains(threadID), + state.pendingCustomTitle == nil, + let ti = threads.firstIndex(where: { $0.id == threadID }) + else { + firstTurnTitleStates[threadID] = state + cleanUpFirstTurnTitleStateIfFinished(for: threadID) + return + } + + threads[ti].title = trimmed + let persisted = + threadsProvider?.setGeneratedTitle( + backendId: state.backendThreadID, + title: trimmed + ) == true + if !persisted { + if state.streamCompleted { + if threads[ti].title == trimmed { threads[ti].title = state.originalTitle } + } else { + state.pendingGeneratedTitle = trimmed + } + } + firstTurnTitleStates[threadID] = state + cleanUpFirstTurnTitleStateIfFinished(for: threadID) + } + + private func finishTitleGeneration(for threadID: UUID, generationID: UUID) { + guard var state = firstTurnTitleStates[threadID], state.generationID == generationID else { + return + } + state.generationFinished = true + firstTurnTitleStates[threadID] = state + cleanUpFirstTurnTitleStateIfFinished(for: threadID) + } + + /// Mark the Rust stream (and therefore its best-effort first persistence) + /// complete, flush a queued custom rename first, otherwise retry one queued + /// generated title exactly once. This runs before `refreshThreads`. + private func settleFirstTurnTitleAfterStream(for threadID: UUID, backendThreadID: String) { + guard var state = firstTurnTitleStates[threadID], state.backendThreadID == backendThreadID + else { return } + guard !state.streamCompleted else { return } + state.streamCompleted = true + + if let customTitle = state.pendingCustomTitle { + _ = threadsProvider?.renameThread(backendId: backendThreadID, title: customTitle) + state.pendingCustomTitle = nil + state.pendingGeneratedTitle = nil + } else if let generatedTitle = state.pendingGeneratedTitle { + let persisted = + threadsProvider?.setGeneratedTitle( + backendId: backendThreadID, + title: generatedTitle + ) == true + state.pendingGeneratedTitle = nil + if !persisted, + !customTitleThreadIDs.contains(threadID), + let ti = threads.firstIndex(where: { $0.id == threadID }), + threads[ti].title == generatedTitle + { + threads[ti].title = state.originalTitle + } + } + + firstTurnTitleStates[threadID] = state + cleanUpFirstTurnTitleStateIfFinished(for: threadID) + } + + /// Queue a rename only for the active first-turn missing-file window. + /// One dictionary slot means repeated UI commits collapse to the latest + /// custom title, while generated persistence is discarded immediately. + private func queueCustomTitle(_ title: String, for threadID: UUID, backendThreadID: String) + -> Bool + { + guard var state = firstTurnTitleStates[threadID], + state.backendThreadID == backendThreadID, + !state.streamCompleted + else { return false } + state.pendingCustomTitle = title + state.pendingGeneratedTitle = nil + firstTurnTitleStates[threadID] = state + return true + } + + private func cleanUpFirstTurnTitleStateIfFinished(for threadID: UUID) { + guard let state = firstTurnTitleStates[threadID], + state.streamCompleted, + state.generationFinished, + state.pendingGeneratedTitle == nil, + state.pendingCustomTitle == nil + else { return } + firstTurnTitleStates[threadID] = nil + titleGenerationTasks[threadID] = nil + } + + /// Stop the selected Agent turn through its owning adapter. Voice is checked + /// first because it has no Swift waiter and must never touch the composer + /// registry. Composer ordering remains deliberate: waiter first, Rust second. + func stopActiveTurn() { + if let threadID = voiceTurnThreadID, + threadID == selectedThreadID, + let phase = voiceTurnPhase, + phase != .cancelling, + let backendId = threads.first(where: { $0.id == threadID })?.backendId + { + voiceTurnPhase = .cancelling + if voiceTurnCanceller?.cancelVoiceTurn(threadId: backendId) != true { + // The runtime may have crossed its successful terminal just before + // the click. Keep accepting that terminal instead of stranding the + // local bubble in a false Cancelling state. + voiceTurnPhase = phase + } + return + } + + guard var turn = activeComposerTurn, + turn.threadID == selectedThreadID, + turn.phase != .cancelling + else { return } + + turn.phase = .cancelling + activeComposerTurn = turn + inFlightSends[turn.threadID]?.task.cancel() + let firstAcknowledgement = engine?.cancelReply(threadId: turn.backendThreadID) ?? false + pendingToolApprovals.removeAll { $0.threadID == turn.backendThreadID } + + // A very fast Stop can beat Rust's registry setup while the provider and + // persisted history are still loading. Retry only that unacknowledged + // race; the UI click remains idempotent and every probe uses the same + // exact backend id. Settle after acknowledgement (or a bounded idle race). + Task { @MainActor [weak self] in + var acknowledged = firstAcknowledgement + var attempts = 0 + while !acknowledged, attempts < 80 { + guard let self, + self.activeComposerTurn?.id == turn.id, + self.activeComposerTurn?.phase == .cancelling, + let engine = self.engine + else { break } + attempts += 1 + try? await Task.sleep(for: .milliseconds(25)) + acknowledged = engine.cancelReply(threadId: turn.backendThreadID) + } + await Task.yield() + self?.settleStoppedComposerTurn(turn) + } + } + + // MARK: Voice-assistive delivery (core runtime → live render, no re-send) + // + // These ingest the reply the CORE runtime is already streaming for a hotkey / + // voice turn (via the bridge `CsAgentDeliveryListener`). They ONLY render: + // insert bubbles and mutate them from deltas. They deliberately do not call + // `send()` / `streamReply` — the core already made the provider call and + // persists the thread to disk. Doing otherwise would double-dispatch the + // turn. The single engine call allowed on this path is the stateless + // first-turn title sibling (`generateThreadTitle`), which carries no + // conversation state and never touches the thread's response chain. + + /// Open a voice turn: bind (or create) a thread for the core `backendId` + /// and insert the You-bubble + an assistant placeholder. Delivery events + /// never change the rail selection: a matching active thread renders live, + /// while a turn for another thread updates in the background. Only explicit + /// user actions (`select`, `newThread`, delete fallback) move selection. + func ingestVoiceTurn(threadId backendId: String, userText: String) { + // Defensive: a new voice turn can open before the previous one closed + // (rapid double-press / a fresh session). Finalize the stale assistant + // bubble in the UI before we overwrite the turn references below — + // otherwise it sticks in isThinking/isStreaming forever. + if let staleThreadID = voiceTurnThreadID, let staleID = voiceAssistantID { + finishPendingTools(before: staleID, in: staleThreadID) + update(staleID, in: staleThreadID) { + $0.isThinking = false + $0.isStreaming = false + $0.timestamp = self.now() + } + // The stale turn ended without its own terminal event, so settle its + // title coordinator here — a queued generated title must not outlive + // the turn that owned it. + if let staleBackendID = threads.first(where: { $0.id == staleThreadID })?.backendId { + settleFirstTurnTitleAfterStream(for: staleThreadID, backendThreadID: staleBackendID) + } + } + + // The core sends the WIRE prompt (assistive skeleton); the bubble shows + // the spoken instruction. The wire + selection/app context ride along on + // the message for the context chip and "Copy full prompt". + let userTurn = AssistivePromptParser.presented( + ChatMessage(role: .you, timestamp: now(), text: userText) + ) - private func removeDurableAcceptedTurn(id: UUID) { - var stored = readDurableAcceptedTurns() - let before = stored.count - stored.removeAll { $0.id == id } - guard stored.count != before else { return } - writeDurableAcceptedTurns(stored) - } + let threadID: UUID + var isFirstExchange = false + if let existing = threads.first(where: { $0.backendId == backendId }) { + threadID = existing.id + loadMessagesIfNeeded(threadID) // surface prior history before appending + } else if let draftIndex = threads.firstIndex(where: { + $0.id == selectedThreadID && $0.backendId == nil && $0.messages.isEmpty + }) { + // The user is sitting in an empty local-only draft ("+ New thread") + // watching their dictation stream: the voice turn must land THERE. + // Binding the draft to the core's thread id keeps the user where they + // are; minting a parallel thread here yanked the conversation into a + // surprise rail entry mid-sentence (UI_DIVERGENCE_AUDIT / operator + // report 2026-08-08). + threads[draftIndex].backendId = backendId + threads[draftIndex].messagesLoaded = true // freshly bound → in sync + threads[draftIndex].title = + ThreadTitlePolicy.normalized(userTurn.text, limit: 48) ?? "Voice chat" + threads[draftIndex].meta = "now" + threadID = threads[draftIndex].id + isFirstExchange = true + } else { + let title = ThreadTitlePolicy.normalized(userTurn.text, limit: 48) ?? "Voice chat" + var thread = ChatThread(title: title, meta: "now") + thread.backendId = backendId + thread.messagesLoaded = true // freshly bound to a core id → in sync + threads.insert(thread, at: 0) + threadID = thread.id + isFirstExchange = true + } + // A skeleton turn can carry context with an empty instruction (e.g. a + // clipped dictation) — the bubble still renders for the chip. + if !userTurn.text.isEmpty || userTurn.wireText != nil { + append(userTurn, to: threadID) + } + let assistant = ChatMessage(role: .assistant, timestamp: "now", text: "", isThinking: true) + voiceTurnThreadID = threadID + voiceAssistantID = assistant.id + voiceTurnStartedAt = Date() + voiceTurnPhase = .thinking + append(assistant, to: threadID) + if isFirstExchange { + launchVoiceFirstTurnTitle(userTurn.text, for: threadID, backendThreadID: backendId) + } + } + + /// Append a streamed token to the active voice assistant bubble. + func ingestVoiceDelta(_ delta: String) { + guard voiceTurnPhase != .cancelling, + let threadID = voiceTurnThreadID, let id = voiceAssistantID + else { return } + voiceTurnPhase = .streaming + update(id, in: threadID) { + $0.isThinking = false + $0.isStreaming = true + if $0.reasonedSeconds == nil, let started = self.voiceTurnStartedAt { + $0.reasonedSeconds = Date().timeIntervalSince(started) + } + $0.text += delta + } + } + + /// Append streamed model reasoning to the active voice assistant bubble. + func ingestVoiceReasoning(_ delta: String) { + guard voiceTurnPhase != .cancelling, + let threadID = voiceTurnThreadID, let id = voiceAssistantID + else { return } + voiceTurnPhase = .streaming + appendReasoning(delta, to: id, in: threadID) + } + + /// Final assembled text for the turn. Only used as a fallback when the reply + /// arrived without token deltas (otherwise the bubble already holds the text). + func ingestVoiceTextDone(_ text: String) { + guard voiceTurnPhase != .cancelling, + let threadID = voiceTurnThreadID, let id = voiceAssistantID + else { return } + voiceTurnPhase = .streaming + update(id, in: threadID) { if $0.text.isEmpty { $0.text = text } } + } + + /// Surface a pending tool call for the active voice turn. The bridge's `id` + /// is kept end-to-end so the matching result can update this row in place. + func ingestVoiceToolExecuting(name: String, id callID: String) { + guard voiceTurnPhase != .cancelling, + let threadID = voiceTurnThreadID, let assistantID = voiceAssistantID + else { return } + voiceTurnPhase = .streaming + recordToolStarted(name: name, callID: callID, before: assistantID, in: threadID) + } + + /// Surface a completed tool call for the active voice turn (same rendering as + /// the composer path's tool-activity row). + func ingestVoiceToolResult(name: String, id callID: String, isError: Bool, reason: String) { + guard voiceTurnPhase != .cancelling, + let threadID = voiceTurnThreadID, let assistantID = voiceAssistantID + else { return } + voiceTurnPhase = .streaming + recordToolResult( + name: name, callID: callID, isError: isError, reason: reason, before: assistantID, + in: threadID) + } + + /// Finalize the active voice turn and pull disk truth (the core persisted the + /// thread). No re-persist here — the store only mirrors what the core wrote. + /// This is also the title coordinator's stream-completed settle point: core + /// persistence finished before this terminal, so a queued generated title + /// flushes exactly once here, before the rail refresh. + func ingestVoiceDone() { + guard voiceTurnPhase != .cancelling, + let threadID = voiceTurnThreadID, let id = voiceAssistantID + else { return } + finishPendingTools(before: id, in: threadID) + update(id, in: threadID) { + $0.isThinking = false + $0.isStreaming = false + $0.timestamp = self.now() + } + let backendId = threads.first(where: { $0.id == threadID })?.backendId + if let backendId { + settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) + } + clearVoiceTurnState() + if let backendId { refreshThreads(selectingBackendId: backendId) } + } + + /// Surface a runtime error on the active voice turn and close it. The core + /// error path may not emit a separate `Done`, so clear the turn state here; a + /// late `Done` then no-ops against the cleared state. + func ingestVoiceError(_ message: String) { + guard voiceTurnPhase != .cancelling, + let threadID = voiceTurnThreadID, let id = voiceAssistantID + else { return } + finishPendingTools(before: id, in: threadID) + update(id, in: threadID) { + $0.isThinking = false + $0.isStreaming = false + $0.text += ($0.text.isEmpty ? "" : "\n") + "[error] " + message + $0.timestamp = self.now() + } + // A failed turn persisted nothing; settling lets the coordinator try a + // queued title once, fail against the missing thread, and restore the + // fallback title instead of leaving the queue open forever. + if let backendId = threads.first(where: { $0.id == threadID })?.backendId { + settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) + } + clearVoiceTurnState() + } + + /// Settle the single keyed cancellation terminal. Partial text remains, an + /// empty response becomes a quiet Stopped marker, and running tools become + /// stopped without refreshing disk truth (the core intentionally did not + /// persist this turn as successful). + func ingestVoiceCancelled(threadId backendId: String) { + guard voiceTurnPhase == .cancelling, + let threadID = voiceTurnThreadID, + let id = voiceAssistantID, + threads.first(where: { $0.id == threadID })?.backendId == backendId + else { return } + cancelPendingTools(before: id, in: threadID) + update(id, in: threadID) { + $0.isThinking = false + $0.isStreaming = false + $0.wasStopped = true + if $0.text.isEmpty { $0.text = "Stopped" } + $0.timestamp = self.now() + } + // Mirror the composer's cancel path (its defer settles too): the core + // did not persist this turn, so a late generated title fails to persist + // and the fallback title survives. + settleFirstTurnTitleAfterStream(for: threadID, backendThreadID: backendId) + clearVoiceTurnState() + } + + // MARK: Demo stream (reproduces the mock's mid-stream last turn) + + /// Kicks off the mock's animated final turn exactly once, so the first + /// render matches the prototype's streaming + blink-caret state. + func startDemoStreamIfNeeded() { + guard !didStartDemo, let threadID = threads.first(where: { $0.isRestored })?.id else { return } + didStartDemo = true + let demo = ChatMessage(role: .assistant, timestamp: "now", text: "", isThinking: true) + let id = demo.id + append(demo, to: threadID) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_600_000_000) + startStream( + id, in: threadID, + fullText: + "On it — patching events/bus.ts to emit once per settled retry, de-duping the store subscription on remount, and adding a regression test for the double-fire case.", + reasoned: 2.1 + ) + } + } + + // MARK: Simulated reveal + + private func startStream(_ id: UUID, in threadID: UUID, fullText: String, reasoned: Double) { + revealTask?.cancel() + update(id, in: threadID) { + $0.isThinking = false + $0.isStreaming = true + $0.reasonedSeconds = reasoned + $0.text = "" + } + revealTask = Task { @MainActor in + let words = fullText.split(separator: " ", omittingEmptySubsequences: false) + var shown = "" + for (i, w) in words.enumerated() { + if Task.isCancelled { return } + shown += (i == 0 ? "" : " ") + w + update(id, in: threadID) { $0.text = shown } + try? await Task.sleep(nanoseconds: 95_000_000) + } + update(id, in: threadID) { + $0.isStreaming = false + $0.timestamp = self.now() + } + } + } + + private func finish(_ id: UUID, in threadID: UUID, text: String) { + finishPendingTools(before: id, in: threadID) + update(id, in: threadID) { + $0.isThinking = false + $0.isStreaming = false + $0.text = text + $0.timestamp = self.now() + } + } + + private func acceptsComposerEvent(_ turnID: UUID, assistantID: UUID, in threadID: UUID) -> Bool { + guard let turn = activeComposerTurn else { return false } + return turn.id == turnID + && turn.threadID == threadID + && turn.assistantMessageID == assistantID + && turn.phase != .cancelling + } + + private func setComposerPhase(_ phase: ComposerTurnPhase, for turnID: UUID) { + guard var turn = activeComposerTurn, turn.id == turnID, turn.phase != .cancelling else { + return + } + turn.phase = phase + activeComposerTurn = turn + } + + private func releaseComposerTurn(_ turnID: UUID, in threadID: UUID) { + if inFlightSends[threadID]?.id == turnID { + inFlightSends[threadID] = nil + } + if activeComposerTurn?.id == turnID, + activeComposerTurn?.phase != .cancelling + { + activeComposerTurn = nil + } + // Terminal of any kind frees the composer slot; the queue continues. + // A provider error or cancel must never silently strand queued items. + dispatchNextQueuedTurnIfIdle() + } + + private func settleStoppedComposerTurn(_ turn: ActiveComposerTurn) { + guard activeComposerTurn?.id == turn.id, + activeComposerTurn?.phase == .cancelling + else { return } + cancelPendingTools(before: turn.assistantMessageID, in: turn.threadID) + update(turn.assistantMessageID, in: turn.threadID) { + $0.isThinking = false + $0.isStreaming = false + $0.wasStopped = true + if $0.text.isEmpty { $0.text = "Stopped" } + $0.timestamp = self.now() + } + if inFlightSends[turn.threadID]?.id == turn.id { + inFlightSends[turn.threadID] = nil + } + activeComposerTurn = nil + // A user Stop consumed its own turn but not the queue: FIFO continues. + dispatchNextQueuedTurnIfIdle() + } + + private func clearVoiceTurnState() { + voiceTurnThreadID = nil + voiceAssistantID = nil + voiceTurnStartedAt = nil + voiceTurnPhase = nil + // The voice terminal frees its thread for queued composer messages. + dispatchNextQueuedTurnIfIdle() + } + + // MARK: Mutation helpers + + private func append(_ message: ChatMessage, to threadID: UUID) { + guard let ti = threads.firstIndex(where: { $0.id == threadID }) else { return } + threads[ti].messages.append(message) + } + + private func update(_ id: UUID, in threadID: UUID, _ body: (inout ChatMessage) -> Void) { + guard let ti = threads.firstIndex(where: { $0.id == threadID }), + let mi = threads[ti].messages.firstIndex(where: { $0.id == id }) + else { return } + body(&threads[ti].messages[mi]) + } + + private func currentUserTurnCount(in threadID: UUID) -> Int { + threads.first(where: { $0.id == threadID })?.messages.filter { $0.role == .you }.count ?? 0 + } + + private struct PersistedAttachmentMetadata: Codable, Hashable { + let name: String + let type: String + let path: String? + } - private func removeDurableAcceptedTurns(backendThreadID: String) { - var stored = readDurableAcceptedTurns() - let before = stored.count - stored.removeAll { $0.backendThreadID == backendThreadID } - guard stored.count != before else { return } - writeDurableAcceptedTurns(stored) - } + private struct PersistedAttachmentTurn: Codable, Hashable { + let userTurnIndex: Int + let attachments: [PersistedAttachmentMetadata] + } - private func readDurableAcceptedTurns() -> [DurableAcceptedTurn] { - guard let data = UserDefaults.standard.data(forKey: Self.acceptedTurnsDefaultsKey) else { - return [] - } - do { - return try JSONDecoder().decode([DurableAcceptedTurn].self, from: data) - } catch { - // Corrupt payload is not "empty queue" — quarantine so the next - // accept can rewrite a healthy sidecar, and leave an OSLog trail. - queueLog.error( - "durable accepted-turns decode failed; quarantining payload: \(error.localizedDescription, privacy: .public)" - ) - UserDefaults.standard.removeObject(forKey: Self.acceptedTurnsDefaultsKey) - return [] - } - } + private static let attachmentMetadataDefaultsKey = "AgentChatStore.attachmentMetadata.v1" - private func writeDurableAcceptedTurns(_ turns: [DurableAcceptedTurn]) { - do { - let data = try JSONEncoder().encode(turns) - UserDefaults.standard.set(data, forKey: Self.acceptedTurnsDefaultsKey) - } catch { - // Flagship durable queue must never fail silently (PR-68 review). - queueLog.error( - "durable accepted-turns encode failed (count=\(turns.count, privacy: .public)): \(error.localizedDescription, privacy: .public)" - ) - } - } + // MARK: Durable accepted-turn sidecar (queue persistence) - /// Re-enqueue messages that were accepted before the last app death but - /// never reached a terminal. Runs once the thread index is present (init on - /// the eager path, after the async index merge on the deferred path). - private func restoreAcceptedTurnsFromDisk() { - // Stored order IS acceptance order (append-only sidecar) — no sort, so - // two messages accepted in the same millisecond can never swap. - let persisted = readDurableAcceptedTurns() - guard !persisted.isEmpty else { return } - for item in persisted { - guard !queuedTurns.contains(where: { $0.id == item.id }) else { continue } - let threadID: UUID - if let existing = threads.first(where: { $0.backendId == item.backendThreadID }) { - threadID = existing.id - } else { - // Accepted for a thread that never reached disk (the app died - // before its first stream persisted) — re-mint a bound shell. - var thread = ChatThread(title: "Restored draft", meta: "now") - thread.backendId = item.backendThreadID - thread.messagesLoaded = true - threads.insert(thread, at: 0) - threadID = thread.id - } - queuedTurns.append(QueuedTurn( - id: item.id, - threadID: threadID, - backendThreadID: item.backendThreadID, - text: item.text, - attachments: item.attachmentPaths.map { PendingAttachment(url: URL(fileURLWithPath: $0)) }, - enqueuedAt: Date(timeIntervalSince1970: item.enqueuedAtEpoch) - )) - } - dispatchNextQueuedTurnIfIdle() - } - - private func persistAttachmentMetadata( - _ attachments: [MessageAttachment], - for backendId: String, - userTurnIndex: Int - ) { - guard !attachments.isEmpty else { return } - var sidecar = readAttachmentMetadataSidecar() - var turns = sidecar[backendId, default: []] - turns.removeAll { $0.userTurnIndex == userTurnIndex } - turns.append(PersistedAttachmentTurn( - userTurnIndex: userTurnIndex, - attachments: attachments.map { - PersistedAttachmentMetadata(name: $0.name, type: $0.type, path: $0.url?.path) - } + /// Wire form of an accepted message. Keyed by the durable backend thread id + /// (the local UUID does not survive a restart); FIFO order restores from + /// `enqueuedAtEpoch`. + private struct DurableAcceptedTurn: Codable, Hashable { + let id: UUID + let backendThreadID: String + let text: String + let attachmentPaths: [String] + let enqueuedAtEpoch: TimeInterval + } + + static let acceptedTurnsDefaultsKey = "AgentChatStore.acceptedTurns.v1" + + private func persistDurableAcceptedTurn(_ turn: QueuedTurn) { + var stored = readDurableAcceptedTurns() + stored.removeAll { $0.id == turn.id } + stored.append( + DurableAcceptedTurn( + id: turn.id, + backendThreadID: turn.backendThreadID, + text: turn.text, + attachmentPaths: turn.attachments.map { $0.url.path }, + enqueuedAtEpoch: turn.enqueuedAt.timeIntervalSince1970 + )) + writeDurableAcceptedTurns(stored) + } + + private func removeDurableAcceptedTurn(id: UUID) { + var stored = readDurableAcceptedTurns() + let before = stored.count + stored.removeAll { $0.id == id } + guard stored.count != before else { return } + writeDurableAcceptedTurns(stored) + } + + private func removeDurableAcceptedTurns(backendThreadID: String) { + var stored = readDurableAcceptedTurns() + let before = stored.count + stored.removeAll { $0.backendThreadID == backendThreadID } + guard stored.count != before else { return } + writeDurableAcceptedTurns(stored) + } + + private func readDurableAcceptedTurns() -> [DurableAcceptedTurn] { + guard let data = UserDefaults.standard.data(forKey: Self.acceptedTurnsDefaultsKey) else { + return [] + } + do { + return try JSONDecoder().decode([DurableAcceptedTurn].self, from: data) + } catch { + // Corrupt payload is not "empty queue" — quarantine so the next + // accept can rewrite a healthy sidecar, and leave an OSLog trail. + queueLog.error( + "durable accepted-turns decode failed; quarantining payload: \(error.localizedDescription, privacy: .public)" + ) + UserDefaults.standard.removeObject(forKey: Self.acceptedTurnsDefaultsKey) + return [] + } + } + + private func writeDurableAcceptedTurns(_ turns: [DurableAcceptedTurn]) { + do { + let data = try JSONEncoder().encode(turns) + UserDefaults.standard.set(data, forKey: Self.acceptedTurnsDefaultsKey) + } catch { + // Flagship durable queue must never fail silently (PR-68 review). + queueLog.error( + "durable accepted-turns encode failed (count=\(turns.count, privacy: .public)): \(error.localizedDescription, privacy: .public)" + ) + } + } + + /// Re-enqueue messages that were accepted before the last app death but + /// never reached a terminal. Runs once the thread index is present (init on + /// the eager path, after the async index merge on the deferred path). + private func restoreAcceptedTurnsFromDisk() { + // Stored order IS acceptance order (append-only sidecar) — no sort, so + // two messages accepted in the same millisecond can never swap. + let persisted = readDurableAcceptedTurns() + guard !persisted.isEmpty else { return } + for item in persisted { + guard !queuedTurns.contains(where: { $0.id == item.id }) else { continue } + let threadID: UUID + if let existing = threads.first(where: { $0.backendId == item.backendThreadID }) { + threadID = existing.id + } else { + // Accepted for a thread that never reached disk (the app died + // before its first stream persisted) — re-mint a bound shell. + var thread = ChatThread(title: "Restored draft", meta: "now") + thread.backendId = item.backendThreadID + thread.messagesLoaded = true + threads.insert(thread, at: 0) + threadID = thread.id + } + queuedTurns.append( + QueuedTurn( + id: item.id, + threadID: threadID, + backendThreadID: item.backendThreadID, + text: item.text, + attachments: item.attachmentPaths.map { + PendingAttachment(url: URL(fileURLWithPath: $0)) + }, + enqueuedAt: Date(timeIntervalSince1970: item.enqueuedAtEpoch) )) - sidecar[backendId] = turns.sorted { $0.userTurnIndex < $1.userTurnIndex } - writeAttachmentMetadataSidecar(sidecar) - } - - private func applyingPersistedAttachmentMetadata( - to messages: [ChatMessage], - backendId: String - ) -> [ChatMessage] { - let sidecar = readAttachmentMetadataSidecar() - let turns = sidecar[backendId] ?? [] - guard !turns.isEmpty else { return messages } - var byUserTurn: [Int: [PersistedAttachmentMetadata]] = [:] - for turn in turns { - byUserTurn[turn.userTurnIndex] = turn.attachments - } - var userTurnIndex = 0 - var restored = messages - for index in restored.indices where restored[index].role == .you { - if let metadata = byUserTurn[userTurnIndex], !metadata.isEmpty { - restored[index].attachments = metadata.map { - let url = $0.path.map(URL.init(fileURLWithPath:)) - return MessageAttachment(name: $0.name, url: url, type: $0.type) - } - } - userTurnIndex += 1 - } - return restored - } - - private func removePersistedAttachmentMetadata(for backendId: String) { - var sidecar = readAttachmentMetadataSidecar() - guard sidecar.removeValue(forKey: backendId) != nil else { return } - writeAttachmentMetadataSidecar(sidecar) - } - - private func readAttachmentMetadataSidecar() -> [String: [PersistedAttachmentTurn]] { - guard let data = UserDefaults.standard.data(forKey: Self.attachmentMetadataDefaultsKey) else { - return [:] - } - do { - return try JSONDecoder().decode([String: [PersistedAttachmentTurn]].self, from: data) - } catch { - attachLog.error( - "attachment metadata decode failed; quarantining payload: \(error.localizedDescription, privacy: .public)" - ) - UserDefaults.standard.removeObject(forKey: Self.attachmentMetadataDefaultsKey) - return [:] - } - } - - private func writeAttachmentMetadataSidecar(_ sidecar: [String: [PersistedAttachmentTurn]]) { - do { - let data = try JSONEncoder().encode(sidecar) - UserDefaults.standard.set(data, forKey: Self.attachmentMetadataDefaultsKey) - } catch { - attachLog.error( - "attachment metadata encode failed (threads=\(sidecar.count, privacy: .public)): \(error.localizedDescription, privacy: .public)" - ) - } - } - - /// Surface a completed tool call as a `.tool` activity turn placed immediately - /// before the streaming assistant bubble (matches the mock's "What I checked"). - private func recordToolActivity(name: String, isError: Bool, reason: String, before assistantID: UUID, in threadID: UUID) { - recordToolResult(name: name, callID: nil, isError: isError, reason: reason, before: assistantID, in: threadID) - } - - private func recordToolStarted(name: String, callID rawCallID: String, before assistantID: UUID, in threadID: UUID) { - let callID = rawCallID.isEmpty ? nil : rawCallID - guard let ti = threads.firstIndex(where: { $0.id == threadID }), - let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }) else { return } - let line = ToolLine( - callID: callID, - verb: "tool", - detail: name, - state: .running, - startedAt: Date() - ) - if let row = toolRowIndex(before: ai, inThreadAt: ti) { - if let callID, - let existing = threads[ti].messages[row].toolLines.firstIndex(where: { $0.callID == callID }) { - threads[ti].messages[row].toolLines[existing] = line - } else { - threads[ti].messages[row].toolLines.append(line) - } - updateToolTitle(threadIndex: ti, messageIndex: row) - } else { - var tool = ChatMessage(role: .tool, timestamp: now(), text: "") - tool.toolLines = [line] - tool.toolTitle = Self.toolTitle(for: tool.toolLines) - threads[ti].messages.insert(tool, at: ai) - } - } - - private func recordToolResult( - name: String, - callID rawCallID: String?, - isError: Bool, - reason: String, - before assistantID: UUID, - in threadID: UUID - ) { - let callID = rawCallID.flatMap { $0.isEmpty ? nil : $0 } - guard let ti = threads.firstIndex(where: { $0.id == threadID }), - let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }) else { return } - var startedAt: Date? - var durationMs: Int? - if let row = toolRowIndex(before: ai, inThreadAt: ti), - let callID, - let existing = threads[ti].messages[row].toolLines.firstIndex(where: { $0.callID == callID }) { - startedAt = threads[ti].messages[row].toolLines[existing].startedAt - if let startedAt { - durationMs = max(0, Int(Date().timeIntervalSince(startedAt) * 1000)) - } - } - let line = ToolLine( - callID: callID, - verb: isError ? "failed" : "ran", - detail: name, - state: isError ? .failed : .succeeded, - reason: reason.isEmpty ? nil : reason, - startedAt: startedAt, - durationMs: durationMs - ) - if let row = toolRowIndex(before: ai, inThreadAt: ti) { - if let callID, - let existing = threads[ti].messages[row].toolLines.firstIndex(where: { $0.callID == callID }) { - threads[ti].messages[row].toolLines[existing] = line - } else { - threads[ti].messages[row].toolLines.append(line) - } - updateToolTitle(threadIndex: ti, messageIndex: row) - } else { - var tool = ChatMessage(role: .tool, timestamp: now(), text: "") - tool.toolLines = [line] - tool.toolTitle = Self.toolTitle(for: tool.toolLines) - threads[ti].messages.insert(tool, at: ai) - } - } - - private func finishPendingTools(before assistantID: UUID, in threadID: UUID) { - guard let ti = threads.firstIndex(where: { $0.id == threadID }), - let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }), - let row = toolRowIndex(before: ai, inThreadAt: ti) else { return } - var changed = false - for index in threads[ti].messages[row].toolLines.indices - where threads[ti].messages[row].toolLines[index].state == .running { - threads[ti].messages[row].toolLines[index].state = .unknown - threads[ti].messages[row].toolLines[index].verb = "ended" - changed = true - } - if changed { updateToolTitle(threadIndex: ti, messageIndex: row) } - } - - private func cancelPendingTools(before assistantID: UUID, in threadID: UUID) { - guard let ti = threads.firstIndex(where: { $0.id == threadID }), - let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }), - let row = toolRowIndex(before: ai, inThreadAt: ti) else { return } - var changed = false - for index in threads[ti].messages[row].toolLines.indices - where threads[ti].messages[row].toolLines[index].state == .running { - threads[ti].messages[row].toolLines[index].state = .cancelled - threads[ti].messages[row].toolLines[index].verb = "stopped" - changed = true - } - if changed { updateToolTitle(threadIndex: ti, messageIndex: row) } - } - - private func appendReasoning(_ delta: String, to assistantID: UUID, in threadID: UUID) { - guard !delta.isEmpty else { return } - update(assistantID, in: threadID) { - $0.reasoning += delta - } - } - - private func toolRowIndex(before assistantIndex: Int, inThreadAt threadIndex: Int) -> Int? { - guard assistantIndex > 0, threads[threadIndex].messages[assistantIndex - 1].role == .tool else { return nil } - return assistantIndex - 1 } - - private func updateToolTitle(threadIndex: Int, messageIndex: Int) { - threads[threadIndex].messages[messageIndex].toolTitle = Self.toolTitle( - for: threads[threadIndex].messages[messageIndex].toolLines - ) - } - - private static func toolTitle(for lines: [ToolLine]) -> String { - let count = lines.count - let running = lines.filter { $0.state == .running }.count - let cancelled = lines.filter { $0.state == .cancelled }.count - let noun = count == 1 ? "tool" : "tools" - if running > 0 { - return "What I checked · \(running) running · \(count) \(noun)" - } - if cancelled > 0 { - return "What I checked · \(cancelled) stopped · \(count) \(noun)" - } - return "What I checked · \(count) \(noun)" - } - - private func now() -> String { Self.timeFmt.string(from: Date()) } - private static let timeFmt: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "HH:mm" - return f - }() - - private func refreshThreads(selectingBackendId backendId: String) { - guard let threadsProvider else { return } - replaceThreads( - with: threadsProvider.listThreads(), - selectingBackendId: backendId, - keepLocalDrafts: true + dispatchNextQueuedTurnIfIdle() + } + + private func persistAttachmentMetadata( + _ attachments: [MessageAttachment], + for backendId: String, + userTurnIndex: Int + ) { + guard !attachments.isEmpty else { return } + var sidecar = readAttachmentMetadataSidecar() + var turns = sidecar[backendId, default: []] + turns.removeAll { $0.userTurnIndex == userTurnIndex } + turns.append( + PersistedAttachmentTurn( + userTurnIndex: userTurnIndex, + attachments: attachments.map { + PersistedAttachmentMetadata(name: $0.name, type: $0.type, path: $0.url?.path) + } + )) + sidecar[backendId] = turns.sorted { $0.userTurnIndex < $1.userTurnIndex } + writeAttachmentMetadataSidecar(sidecar) + } + + private func applyingPersistedAttachmentMetadata( + to messages: [ChatMessage], + backendId: String + ) -> [ChatMessage] { + let sidecar = readAttachmentMetadataSidecar() + let turns = sidecar[backendId] ?? [] + guard !turns.isEmpty else { return messages } + var byUserTurn: [Int: [PersistedAttachmentMetadata]] = [:] + for turn in turns { + byUserTurn[turn.userTurnIndex] = turn.attachments + } + var userTurnIndex = 0 + var restored = messages + for index in restored.indices where restored[index].role == .you { + if let metadata = byUserTurn[userTurnIndex], !metadata.isEmpty { + restored[index].attachments = metadata.map { + let url = $0.path.map(URL.init(fileURLWithPath:)) + return MessageAttachment(name: $0.name, url: url, type: $0.type) + } + } + userTurnIndex += 1 + } + return restored + } + + private func removePersistedAttachmentMetadata(for backendId: String) { + var sidecar = readAttachmentMetadataSidecar() + guard sidecar.removeValue(forKey: backendId) != nil else { return } + writeAttachmentMetadataSidecar(sidecar) + } + + private func readAttachmentMetadataSidecar() -> [String: [PersistedAttachmentTurn]] { + guard let data = UserDefaults.standard.data(forKey: Self.attachmentMetadataDefaultsKey) else { + return [:] + } + do { + return try JSONDecoder().decode([String: [PersistedAttachmentTurn]].self, from: data) + } catch { + attachLog.error( + "attachment metadata decode failed; quarantining payload: \(error.localizedDescription, privacy: .public)" + ) + UserDefaults.standard.removeObject(forKey: Self.attachmentMetadataDefaultsKey) + return [:] + } + } + + private func writeAttachmentMetadataSidecar(_ sidecar: [String: [PersistedAttachmentTurn]]) { + do { + let data = try JSONEncoder().encode(sidecar) + UserDefaults.standard.set(data, forKey: Self.attachmentMetadataDefaultsKey) + } catch { + attachLog.error( + "attachment metadata encode failed (threads=\(sidecar.count, privacy: .public)): \(error.localizedDescription, privacy: .public)" + ) + } + } + + /// Surface a completed tool call as a `.tool` activity turn placed immediately + /// before the streaming assistant bubble (matches the mock's "What I checked"). + private func recordToolActivity( + name: String, isError: Bool, reason: String, before assistantID: UUID, in threadID: UUID + ) { + recordToolResult( + name: name, callID: nil, isError: isError, reason: reason, before: assistantID, in: threadID) + } + + private func recordToolStarted( + name: String, callID rawCallID: String, before assistantID: UUID, in threadID: UUID + ) { + let callID = rawCallID.isEmpty ? nil : rawCallID + guard let ti = threads.firstIndex(where: { $0.id == threadID }), + let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }) + else { return } + let line = ToolLine( + callID: callID, + verb: "tool", + detail: name, + state: .running, + startedAt: Date() + ) + if let row = toolRowIndex(before: ai, inThreadAt: ti) { + if let callID, + let existing = threads[ti].messages[row].toolLines.firstIndex(where: { $0.callID == callID } ) - } - - /// Row-level equality on everything the rail renders. Matched incoming - /// rows reuse the existing `ChatThread` instances (same `id`s), so equal - /// rows ⇒ identical identity set ⇒ selection resolution is a no-op too. - private static func railRowsEqual(_ lhs: [ChatThread], _ rhs: [ChatThread]) -> Bool { - guard lhs.count == rhs.count else { return false } - return zip(lhs, rhs).allSatisfy { l, r in - l.id == r.id && l.backendId == r.backendId && l.title == r.title - && l.meta == r.meta && l.isFavorite == r.isFavorite - && l.isRestored == r.isRestored - } - } - - private func replaceThreads( - with incoming: [ChatThread], - selectingBackendId backendId: String?, - keepLocalDrafts: Bool, - allowEmpty: Bool = false - ) { - let previousSelectedID = selectedThreadID - let existingByBackend = Dictionary( - uniqueKeysWithValues: threads.compactMap { thread -> (String, ChatThread)? in - guard let backendId = thread.backendId else { return nil } - return (backendId, thread) - } + { + threads[ti].messages[row].toolLines[existing] = line + } else { + threads[ti].messages[row].toolLines.append(line) + } + updateToolTitle(threadIndex: ti, messageIndex: row) + } else { + var tool = ChatMessage(role: .tool, timestamp: now(), text: "") + tool.toolLines = [line] + tool.toolTitle = Self.toolTitle(for: tool.toolLines) + threads[ti].messages.insert(tool, at: ai) + } + } + + private func recordToolResult( + name: String, + callID rawCallID: String?, + isError: Bool, + reason: String, + before assistantID: UUID, + in threadID: UUID + ) { + let callID = rawCallID.flatMap { $0.isEmpty ? nil : $0 } + guard let ti = threads.firstIndex(where: { $0.id == threadID }), + let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }) + else { return } + var startedAt: Date? + var durationMs: Int? + if let row = toolRowIndex(before: ai, inThreadAt: ti), + let callID, + let existing = threads[ti].messages[row].toolLines.firstIndex(where: { $0.callID == callID }) + { + startedAt = threads[ti].messages[row].toolLines[existing].startedAt + if let startedAt { + durationMs = max(0, Int(Date().timeIntervalSince(startedAt) * 1000)) + } + } + let line = ToolLine( + callID: callID, + verb: isError ? "failed" : "ran", + detail: name, + state: isError ? .failed : .succeeded, + reason: reason.isEmpty ? nil : reason, + startedAt: startedAt, + durationMs: durationMs + ) + if let row = toolRowIndex(before: ai, inThreadAt: ti) { + if let callID, + let existing = threads[ti].messages[row].toolLines.firstIndex(where: { $0.callID == callID } ) + { + threads[ti].messages[row].toolLines[existing] = line + } else { + threads[ti].messages[row].toolLines.append(line) + } + updateToolTitle(threadIndex: ti, messageIndex: row) + } else { + var tool = ChatMessage(role: .tool, timestamp: now(), text: "") + tool.toolLines = [line] + tool.toolTitle = Self.toolTitle(for: tool.toolLines) + threads[ti].messages.insert(tool, at: ai) + } + } + + private func finishPendingTools(before assistantID: UUID, in threadID: UUID) { + guard let ti = threads.firstIndex(where: { $0.id == threadID }), + let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }), + let row = toolRowIndex(before: ai, inThreadAt: ti) + else { return } + var changed = false + for index in threads[ti].messages[row].toolLines.indices + where threads[ti].messages[row].toolLines[index].state == .running { + threads[ti].messages[row].toolLines[index].state = .unknown + threads[ti].messages[row].toolLines[index].verb = "ended" + changed = true + } + if changed { updateToolTitle(threadIndex: ti, messageIndex: row) } + } + + private func cancelPendingTools(before assistantID: UUID, in threadID: UUID) { + guard let ti = threads.firstIndex(where: { $0.id == threadID }), + let ai = threads[ti].messages.firstIndex(where: { $0.id == assistantID }), + let row = toolRowIndex(before: ai, inThreadAt: ti) + else { return } + var changed = false + for index in threads[ti].messages[row].toolLines.indices + where threads[ti].messages[row].toolLines[index].state == .running { + threads[ti].messages[row].toolLines[index].state = .cancelled + threads[ti].messages[row].toolLines[index].verb = "stopped" + changed = true + } + if changed { updateToolTitle(threadIndex: ti, messageIndex: row) } + } + + private func appendReasoning(_ delta: String, to assistantID: UUID, in threadID: UUID) { + guard !delta.isEmpty else { return } + update(assistantID, in: threadID) { + $0.reasoning += delta + } + } + + private func toolRowIndex(before assistantIndex: Int, inThreadAt threadIndex: Int) -> Int? { + guard assistantIndex > 0, threads[threadIndex].messages[assistantIndex - 1].role == .tool else { + return nil + } + return assistantIndex - 1 + } + + private func updateToolTitle(threadIndex: Int, messageIndex: Int) { + threads[threadIndex].messages[messageIndex].toolTitle = Self.toolTitle( + for: threads[threadIndex].messages[messageIndex].toolLines + ) + } + + private static func toolTitle(for lines: [ToolLine]) -> String { + let count = lines.count + let running = lines.filter { $0.state == .running }.count + let cancelled = lines.filter { $0.state == .cancelled }.count + let noun = count == 1 ? "tool" : "tools" + if running > 0 { + return "What I checked · \(running) running · \(count) \(noun)" + } + if cancelled > 0 { + return "What I checked · \(cancelled) stopped · \(count) \(noun)" + } + return "What I checked · \(count) \(noun)" + } + + private func now() -> String { Self.timeFmt.string(from: Date()) } + private static let timeFmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() + + private func refreshThreads(selectingBackendId backendId: String) { + guard let threadsProvider else { return } + replaceThreads( + with: threadsProvider.listThreads(), + selectingBackendId: backendId, + keepLocalDrafts: true + ) + } + + /// Row-level equality on everything the rail renders. Matched incoming + /// rows reuse the existing `ChatThread` instances (same `id`s), so equal + /// rows ⇒ identical identity set ⇒ selection resolution is a no-op too. + private static func railRowsEqual(_ lhs: [ChatThread], _ rhs: [ChatThread]) -> Bool { + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { l, r in + l.id == r.id && l.backendId == r.backendId && l.title == r.title + && l.meta == r.meta && l.isFavorite == r.isFavorite + && l.isRestored == r.isRestored + } + } + + private func replaceThreads( + with incoming: [ChatThread], + selectingBackendId backendId: String?, + keepLocalDrafts: Bool, + allowEmpty: Bool = false + ) { + let previousSelectedID = selectedThreadID + let existingByBackend = Dictionary( + uniqueKeysWithValues: threads.compactMap { thread -> (String, ChatThread)? in + guard let backendId = thread.backendId else { return nil } + return (backendId, thread) + } + ) - var next = incoming.map { remote -> ChatThread in - guard let backendId = remote.backendId, var existing = existingByBackend[backendId] else { - return remote - } - existing.title = remote.title - existing.meta = remote.meta - existing.isRestored = remote.isRestored - existing.isFavorite = remote.isFavorite - return existing - } - - if keepLocalDrafts { - let locals = threads.filter { thread in - thread.backendId == nil && (thread.id == previousSelectedID || !thread.messages.isEmpty) - } - next.append(contentsOf: locals) - } - - let resolved = - next.isEmpty && !allowEmpty - ? [ChatThread(title: "New thread", meta: "now", messages: [])] : next - // Identical rail rows must not publish: an unchanged `threads =` - // still tears down and rebuilds the whole window body (the 937189fd - // lesson). Matched rows reuse existing instances (same ids), so - // row-equality here also guarantees the selection below would not - // move — skipping the whole tail is safe. - if !next.isEmpty || allowEmpty, Self.railRowsEqual(resolved, threads) { - return - } - threads = resolved - // Selection is user-owned. A completion refresh may reorder or replace - // rail rows, but it must preserve the thread the user is reading. The - // completed backend is only a fallback when that selection disappeared. - if let previousSelectedID, threads.contains(where: { $0.id == previousSelectedID }) { - selectedThreadID = previousSelectedID - } else if let backendId, let match = threads.first(where: { $0.backendId == backendId }) { - selectedThreadID = match.id - } else { - selectedThreadID = threads.first?.id - } - if let selectedThreadID { loadMessagesIfNeeded(selectedThreadID) } - } - - // MARK: Seed (mock data — keeps #Preview standalone) - - static func seedThreads() -> [ChatThread] { - var active = ChatThread(title: "auth-refactor", meta: "active · restored", isRestored: true) - active.messages = [ - ChatMessage(role: .you, timestamp: "18:39", text: "where do we double-dispatch events?"), - ChatMessage( - role: .tool, timestamp: "18:39", text: "", - toolTitle: "What I checked · 2 tools", - toolLines: [ - ToolLine(verb: "grep", detail: "events/bus.ts · ui/store.ts"), - ToolLine(verb: "read", detail: "2 files · 318 lines"), - ] - ), - ChatMessage( - role: .assistant, timestamp: "18:40", - text: "Two spots. `events/bus.ts` re-emits on retry, and `ui/store.ts` subscribes twice on remount. Want a minimal patch plus a regression test?", - reasonedSeconds: 2.1 - ), - ChatMessage(role: .you, timestamp: "18:41", text: "yes, and add the test"), - ] - // updatedAt offsets keep the preview's recency sections honest with the - // hardcoded meta labels. - let day: TimeInterval = 86_400 - return [ - active, - ChatThread(title: "rate-limiter spec", meta: "today · 18:40", updatedAt: Date()), - ChatThread(title: "release notes → PL", meta: "yesterday", updatedAt: Date(timeIntervalSinceNow: -day)), - ChatThread(title: "whisper warm-start idea", meta: "yesterday", updatedAt: Date(timeIntervalSinceNow: -day)), - ChatThread(title: "standup notes", meta: "Thu", updatedAt: Date(timeIntervalSinceNow: -5 * day)), + var next = incoming.map { remote -> ChatThread in + guard let backendId = remote.backendId, var existing = existingByBackend[backendId] else { + return remote + } + existing.title = remote.title + existing.meta = remote.meta + existing.isRestored = remote.isRestored + existing.isFavorite = remote.isFavorite + return existing + } + + if keepLocalDrafts { + let locals = threads.filter { thread in + thread.backendId == nil && (thread.id == previousSelectedID || !thread.messages.isEmpty) + } + next.append(contentsOf: locals) + } + + let resolved = + next.isEmpty && !allowEmpty + ? [ChatThread(title: "New thread", meta: "now", messages: [])] : next + // Identical rail rows must not publish: an unchanged `threads =` + // still tears down and rebuilds the whole window body (the 937189fd + // lesson). Matched rows reuse existing instances (same ids), so + // row-equality here also guarantees the selection below would not + // move — skipping the whole tail is safe. + if !next.isEmpty || allowEmpty, Self.railRowsEqual(resolved, threads) { + return + } + threads = resolved + // Selection is user-owned. A completion refresh may reorder or replace + // rail rows, but it must preserve the thread the user is reading. The + // completed backend is only a fallback when that selection disappeared. + if let previousSelectedID, threads.contains(where: { $0.id == previousSelectedID }) { + selectedThreadID = previousSelectedID + } else if let backendId, let match = threads.first(where: { $0.backendId == backendId }) { + selectedThreadID = match.id + } else { + selectedThreadID = threads.first?.id + } + if let selectedThreadID { loadMessagesIfNeeded(selectedThreadID) } + } + + // MARK: Seed (mock data — keeps #Preview standalone) + + static func seedThreads() -> [ChatThread] { + var active = ChatThread(title: "auth-refactor", meta: "active · restored", isRestored: true) + active.messages = [ + ChatMessage(role: .you, timestamp: "18:39", text: "where do we double-dispatch events?"), + ChatMessage( + role: .tool, timestamp: "18:39", text: "", + toolTitle: "What I checked · 2 tools", + toolLines: [ + ToolLine(verb: "grep", detail: "events/bus.ts · ui/store.ts"), + ToolLine(verb: "read", detail: "2 files · 318 lines"), ] - } + ), + ChatMessage( + role: .assistant, timestamp: "18:40", + text: + "Two spots. `events/bus.ts` re-emits on retry, and `ui/store.ts` subscribes twice on remount. Want a minimal patch plus a regression test?", + reasonedSeconds: 2.1 + ), + ChatMessage(role: .you, timestamp: "18:41", text: "yes, and add the test"), + ] + // updatedAt offsets keep the preview's recency sections honest with the + // hardcoded meta labels. + let day: TimeInterval = 86_400 + return [ + active, + ChatThread(title: "rate-limiter spec", meta: "today · 18:40", updatedAt: Date()), + ChatThread( + title: "release notes → PL", meta: "yesterday", updatedAt: Date(timeIntervalSinceNow: -day)), + ChatThread( + title: "whisper warm-start idea", meta: "yesterday", + updatedAt: Date(timeIntervalSinceNow: -day)), + ChatThread( + title: "standup notes", meta: "Thu", updatedAt: Date(timeIntervalSinceNow: -5 * day)), + ] + } } // MARK: - Preview engine (canned single-shot reply) #if DEBUG -final class MockChatEngine: AgentChatEngine { + final class MockChatEngine: AgentChatEngine { func isAvailable() -> Bool { true } func availabilityDetail() -> String? { nil } func generateThreadTitle(_ text: String) async throws -> String? { nil } func streamReply( - _ text: String, - threadId: String, - attachmentPaths: [String], - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (_ name: String, _ id: String) -> Void, - onToolResult: @escaping @MainActor (_ name: String, _ id: String, _ isError: Bool, _ reason: String) -> Void + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (_ name: String, _ id: String) -> Void, + onToolResult: + @escaping @MainActor (_ name: String, _ id: String, _ isError: Bool, _ reason: String) -> + Void ) async throws -> String { - let seen = attachmentPaths.isEmpty ? "" : " (saw \(attachmentPaths.count) image\(attachmentPaths.count == 1 ? "" : "s"))" - let reply = "On it — \(text.lowercased())\(seen). I'd start with a minimal patch and a regression test." - var assembled = "" - await onReasoning("Reading the turn and checking the smallest useful next step.") - let mockToolID = "mock-preview-tool" - await onToolExecuting("preview-context", mockToolID) - for word in reply.split(separator: " ", omittingEmptySubsequences: false) { - try? await Task.sleep(nanoseconds: 60_000_000) - let chunk = (assembled.isEmpty ? "" : " ") + word - assembled += chunk - await onDelta(chunk) - } - await onToolResult("preview-context", mockToolID, false, "mock context ready") - return assembled + let seen = + attachmentPaths.isEmpty + ? "" : " (saw \(attachmentPaths.count) image\(attachmentPaths.count == 1 ? "" : "s"))" + let reply = + "On it — \(text.lowercased())\(seen). I'd start with a minimal patch and a regression test." + var assembled = "" + await onReasoning("Reading the turn and checking the smallest useful next step.") + let mockToolID = "mock-preview-tool" + await onToolExecuting("preview-context", mockToolID) + for word in reply.split(separator: " ", omittingEmptySubsequences: false) { + try? await Task.sleep(nanoseconds: 60_000_000) + let chunk = (assembled.isEmpty ? "" : " ") + word + assembled += chunk + await onDelta(chunk) + } + await onToolResult("preview-context", mockToolID, false, "mock context ready") + return assembled } func cancelReply(threadId: String) -> Bool { false } -} + } #endif diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatView.swift b/macos/Codescribe/Screens/AgentChat/AgentChatView.swift index 88fd420b..22907476 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatView.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatView.swift @@ -8,467 +8,470 @@ import SwiftUI /// for the full FFI-gap note (no streaming / threads / tools backend yet — /// real streaming chat is a tracked core-change follow-up). struct AgentChatView: View { - @StateObject var store: AgentChatStore - /// Rail state survives window close/reopen and app relaunch. Collapse is - /// the NATIVE split-view collapse (`columnVisibility = .detailOnly`) — the - /// same mechanism the Settings window uses, so both windows speak one - /// design language. The previous shape faked collapse by clamping the - /// column to a 56pt icon strip, but `navigationSplitViewColumnWidth` is - /// read only when the column is built and the split view's width autosave - /// outlives an `.id()` content rebuild, so "Collapse sidebar" left the - /// monogram strip floating in a 200-500pt band (operator screenshots, - /// 2026-08-09). Recovery stays guaranteed: the toggle lives in the DETAIL - /// header, which never collapses. - @AppStorage("AgentChat.sidebarExpanded.v1") private var sidebarExpanded = true - @AppStorage("AgentChat.alwaysOnTop.v1") private var isPinned = false - @State private var columnVisibility: NavigationSplitViewVisibility = .all - - init(store: AgentChatStore) { - _store = StateObject(wrappedValue: store) + @StateObject var store: AgentChatStore + /// Rail state survives window close/reopen and app relaunch. Collapse is + /// the NATIVE split-view collapse (`columnVisibility = .detailOnly`) — the + /// same mechanism the Settings window uses, so both windows speak one + /// design language. The previous shape faked collapse by clamping the + /// column to a 56pt icon strip, but `navigationSplitViewColumnWidth` is + /// read only when the column is built and the split view's width autosave + /// outlives an `.id()` content rebuild, so "Collapse sidebar" left the + /// monogram strip floating in a 200-500pt band (operator screenshots, + /// 2026-08-09). Recovery stays guaranteed: the toggle lives in the DETAIL + /// header, which never collapses. + @AppStorage("AgentChat.sidebarExpanded.v1") private var sidebarExpanded = true + @AppStorage("AgentChat.alwaysOnTop.v1") private var isPinned = false + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + init(store: AgentChatStore) { + _store = StateObject(wrappedValue: store) + } + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + ThreadRail(store: store, mode: .expanded) + // Drag-resizable within the expanded bounds. The rail view + // itself carries no fixed width — a hardcoded 236 inside a + // resizable column left a dead band between rail and detail. + .navigationSplitViewColumnWidth( + min: AgentSidebarMode.expanded.minimumWidth, + ideal: AgentSidebarMode.expanded.idealWidth, + max: AgentSidebarMode.expanded.maximumWidth + ) + .toolbar(removing: .sidebarToggle) + } detail: { + ThreadDetail( + store: store, + isSidebarExpanded: sidebarExpanded, + isPinned: $isPinned, + toggleSidebar: toggleSidebar + ) } - - var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - ThreadRail(store: store, mode: .expanded) - // Drag-resizable within the expanded bounds. The rail view - // itself carries no fixed width — a hardcoded 236 inside a - // resizable column left a dead band between rail and detail. - .navigationSplitViewColumnWidth( - min: AgentSidebarMode.expanded.minimumWidth, - ideal: AgentSidebarMode.expanded.idealWidth, - max: AgentSidebarMode.expanded.maximumWidth - ) - .toolbar(removing: .sidebarToggle) - } detail: { - ThreadDetail( - store: store, - isSidebarExpanded: sidebarExpanded, - isPinned: $isPinned, - toggleSidebar: toggleSidebar - ) - } - .navigationSplitViewStyle(.balanced) - .csFocusPolicy() - .background(CSColor.glassBase) - .background(AgentWindowCapabilities(isPinned: isPinned)) - .frame(minWidth: 760, idealWidth: 960, minHeight: 560, idealHeight: 600) - .task { - // Point-in-time marker: correlate with the adjacent "thread index - // load" / "selected thread load" durations in the same log stream. - AgentPerf.logger.info("agent window shell rendered") - store.startDemoStreamIfNeeded() - } - // Restore the persisted rail state through the native mechanism. - .onAppear { columnVisibility = sidebarExpanded ? .all : .detailOnly } + .navigationSplitViewStyle(.balanced) + .csFocusPolicy() + .background(CSColor.glassBase) + .background(AgentWindowCapabilities(isPinned: isPinned)) + .frame(minWidth: 760, idealWidth: 960, minHeight: 560, idealHeight: 600) + .task { + // Point-in-time marker: correlate with the adjacent "thread index + // load" / "selected thread load" durations in the same log stream. + AgentPerf.logger.info("agent window shell rendered") + store.startDemoStreamIfNeeded() } - - private func toggleSidebar() { - withAnimation { - sidebarExpanded.toggle() - columnVisibility = sidebarExpanded ? .all : .detailOnly - } + // Restore the persisted rail state through the native mechanism. + .onAppear { columnVisibility = sidebarExpanded ? .all : .detailOnly } + } + + private func toggleSidebar() { + withAnimation { + sidebarExpanded.toggle() + columnVisibility = sidebarExpanded ? .all : .detailOnly } + } } /// The rail's two presentation states and the column geometry each owns. Pure, /// so the widths are unit-testable without rendering a split view. enum AgentSidebarMode: Equatable { - case expanded - case compact + case expanded + case compact - /// Icon strip width: one hit target plus symmetric padding. - static let compactWidth: CGFloat = 56 + /// Icon strip width: one hit target plus symmetric padding. + static let compactWidth: CGFloat = 56 - var isExpanded: Bool { self == .expanded } - var minimumWidth: CGFloat { isExpanded ? 200 : Self.compactWidth } - var idealWidth: CGFloat { isExpanded ? 236 : Self.compactWidth } - var maximumWidth: CGFloat { isExpanded ? 360 : Self.compactWidth } + var isExpanded: Bool { self == .expanded } + var minimumWidth: CGFloat { isExpanded ? 200 : Self.compactWidth } + var idealWidth: CGFloat { isExpanded ? 236 : Self.compactWidth } + var maximumWidth: CGFloat { isExpanded ? 360 : Self.compactWidth } - static func toggled(_ mode: AgentSidebarMode) -> AgentSidebarMode { - mode == .expanded ? .compact : .expanded - } + static func toggled(_ mode: AgentSidebarMode) -> AgentSidebarMode { + mode == .expanded ? .compact : .expanded + } } /// Applies the persisted pin to the one AppDelegate-owned Agent NSWindow. /// Updating level never orders or activates the window. enum AgentWindowLevelPolicy { - static func level(isPinned: Bool) -> NSWindow.Level { - isPinned ? .floating : .normal - } + static func level(isPinned: Bool) -> NSWindow.Level { + isPinned ? .floating : .normal + } } private struct AgentWindowCapabilities: NSViewRepresentable { - let isPinned: Bool + let isPinned: Bool - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - DispatchQueue.main.async { configure(view.window) } - return view - } + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + DispatchQueue.main.async { configure(view.window) } + return view + } - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { configure(nsView.window) } - } + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { configure(nsView.window) } + } - private func configure(_ window: NSWindow?) { - window?.level = AgentWindowLevelPolicy.level(isPinned: isPinned) - } + private func configure(_ window: NSWindow?) { + window?.level = AgentWindowLevelPolicy.level(isPinned: isPinned) + } } // MARK: - Detail (header · title bar · messages · composer) private struct ThreadDetail: View { - @ObservedObject var store: AgentChatStore - /// Sidebar controls live in the DETAIL header so the toggle stays reachable - /// while the rail is in its compact icon state. - let isSidebarExpanded: Bool - @Binding var isPinned: Bool - let toggleSidebar: () -> Void - @Environment(\.openSettings) private var openSettings - @State private var isRenaming = false - @State private var renameText = "" - /// Shared with `MessageList` via `ChatLayoutPolicy.defaultsKey`. - @AppStorage(ChatLayoutPolicy.defaultsKey) private var widthModeRaw = ChatLayoutPolicy.defaultMode.rawValue - - private var widthMode: ChatWidthMode { ChatWidthMode.resolve(widthModeRaw) } - - var body: some View { - VStack(spacing: 0) { - header - titleBar - if let thread = store.currentThread { - MessageList(threadID: thread.id, messages: thread.messages) { messageID in - store.toggleRenderMode(messageID: messageID, in: thread.id) - } - } else { - Spacer() - } - if let thread = store.currentThread { - ForEach(store.queuedTurns(in: thread.id)) { queued in - QueuedTurnRow( - turn: queued, - save: { store.editQueuedTurn(queued.id, text: $0) }, - cancel: { store.cancelQueuedTurn(queued.id) } - ) - .padding(.horizontal, 20) - .padding(.bottom, 6) - } - } - ForEach(store.currentToolApprovals) { request in - ToolApprovalCard( - request: request, - reject: { store.resolveToolApproval(request, approved: false) }, - allowOnce: { store.resolveToolApproval(request, approved: true) }, - allowAlways: { - store.resolveToolApproval(request, approved: true, remember: true) - } - ) - .padding(.horizontal, 20) - .padding(.bottom, 10) - } - Composer(store: store) + @ObservedObject var store: AgentChatStore + /// Sidebar controls live in the DETAIL header so the toggle stays reachable + /// while the rail is in its compact icon state. + let isSidebarExpanded: Bool + @Binding var isPinned: Bool + let toggleSidebar: () -> Void + @Environment(\.openSettings) private var openSettings + @State private var isRenaming = false + @State private var renameText = "" + /// Shared with `MessageList` via `ChatLayoutPolicy.defaultsKey`. + @AppStorage(ChatLayoutPolicy.defaultsKey) private var widthModeRaw = ChatLayoutPolicy.defaultMode + .rawValue + + private var widthMode: ChatWidthMode { ChatWidthMode.resolve(widthModeRaw) } + + var body: some View { + VStack(spacing: 0) { + header + titleBar + if let thread = store.currentThread { + MessageList(threadID: thread.id, messages: thread.messages) { messageID in + store.toggleRenderMode(messageID: messageID, in: thread.id) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(CSColor.glassBase) - .alert("Rename thread", isPresented: $isRenaming) { - TextField("Thread title", text: $renameText) - Button("Rename") { - if let thread = store.currentThread { store.rename(thread, to: renameText) } - } - Button("Cancel", role: .cancel) {} + } else { + Spacer() + } + if let thread = store.currentThread { + ForEach(store.queuedTurns(in: thread.id)) { queued in + QueuedTurnRow( + turn: queued, + save: { store.editQueuedTurn(queued.id, text: $0) }, + cancel: { store.cancelQueuedTurn(queued.id) } + ) + .padding(.horizontal, 20) + .padding(.bottom, 6) } + } + ForEach(store.currentToolApprovals) { request in + ToolApprovalCard( + request: request, + reject: { store.resolveToolApproval(request, approved: false) }, + allowOnce: { store.resolveToolApproval(request, approved: true) }, + allowAlways: { + store.resolveToolApproval(request, approved: true, remember: true) + } + ) + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + Composer(store: store) } - - // Header: sidebar toggle · live status pill · width density · Settings · thread menu - private var header: some View { - HStack(spacing: 12) { - Button(action: toggleSidebar) { - Image(systemName: "sidebar.leading") - .font(.system(size: 14, weight: .medium)) - } - .csFocusRing(cornerRadius: 8) - .foregroundStyle(isSidebarExpanded ? CSColor.textBody : CSColor.textFaint) - .keyboardShortcut("s", modifiers: [.command, .control]) - .help(isSidebarExpanded ? "Collapse sidebar (⌃⌘S)" : "Expand sidebar (⌃⌘S)") - .accessibilityLabel("Toggle Sidebar") - .accessibilityValue(isSidebarExpanded ? "Expanded" : "Compact") - - StaticStatusPill(text: status.label, color: status.color) - Spacer() - HStack(spacing: 14) { - widthModeMenu - - Button { - isPinned.toggle() - } label: { - Image(systemName: isPinned ? "pin.fill" : "pin") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(isPinned ? CSColor.chromeAccent : CSColor.textFaint) - } - .csFocusRing(cornerRadius: 8) - .help(isPinned ? "Disable Always on Top" : "Enable Always on Top") - .accessibilityLabel(isPinned ? "Agent pinned, disable Always on Top" : "Agent unpinned, enable Always on Top") - .accessibilityValue(isPinned ? "Pinned" : "Unpinned") - - Button(action: { openSettings() }) { - CSIconView(icon: .settings, size: 16) - } - .csFocusRing(cornerRadius: 8) - .help("Settings") - - threadMenu - } - .foregroundStyle(CSColor.textFaint) - } - .padding(.horizontal, 18) - .padding(.vertical, 14) - .overlay(alignment: .bottom) { - Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) - } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(CSColor.glassBase) + .alert("Rename thread", isPresented: $isRenaming) { + TextField("Thread title", text: $renameText) + Button("Rename") { + if let thread = store.currentThread { store.rename(thread, to: renameText) } + } + Button("Cancel", role: .cancel) {} } - - /// Comfortable / Wide / Full — persists via `ChatLayoutPolicy.defaultsKey`. - private var widthModeMenu: some View { - Menu { - ForEach(ChatWidthMode.allCases) { mode in - Button { - widthModeRaw = mode.rawValue - } label: { - if mode == widthMode { - Label(mode.label, systemImage: "checkmark") - } else { - Text(mode.label) - } - } - } + } + + // Header: sidebar toggle · live status pill · width density · Settings · thread menu + private var header: some View { + HStack(spacing: 12) { + Button(action: toggleSidebar) { + Image(systemName: "sidebar.leading") + .font(.system(size: 14, weight: .medium)) + } + .csFocusRing(cornerRadius: 8) + .foregroundStyle(isSidebarExpanded ? CSColor.textBody : CSColor.textFaint) + .keyboardShortcut("s", modifiers: [.command, .control]) + .help(isSidebarExpanded ? "Collapse sidebar (⌃⌘S)" : "Expand sidebar (⌃⌘S)") + .accessibilityLabel("Toggle Sidebar") + .accessibilityValue(isSidebarExpanded ? "Expanded" : "Compact") + + StaticStatusPill(text: status.label, color: status.color) + Spacer() + HStack(spacing: 14) { + widthModeMenu + + Button { + isPinned.toggle() } label: { - HStack(spacing: 4) { - CSIconView(icon: .setupWizard, size: 12) - Text(widthMode.label) - .font(CSFont.mono(10, .medium)) - } + Image(systemName: isPinned ? "pin.fill" : "pin") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(isPinned ? CSColor.chromeAccent : CSColor.textFaint) } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .help("Chat column width: Comfortable, Wide, or Full") - } + .csFocusRing(cornerRadius: 8) + .help(isPinned ? "Disable Always on Top" : "Enable Always on Top") + .accessibilityLabel( + isPinned ? "Agent pinned, disable Always on Top" : "Agent unpinned, enable Always on Top" + ) + .accessibilityValue(isPinned ? "Pinned" : "Unpinned") - // Current-thread actions. Export entries appear only for persisted threads - // (a not-yet-saved local thread has no backend id to export from). - private var threadMenu: some View { - Menu { - if let thread = store.currentThread { - Button("Rename") { beginRename(thread) } - Button(thread.isFavorite ? "Unfavorite" : "Favorite") { - store.toggleFavorite(thread) - } - if thread.backendId != nil { - Button("Export to Markdown") { export(thread, assistantOnly: false) } - Button("Export assistant replies only") { export(thread, assistantOnly: true) } - } - Divider() - Button("Delete Thread", role: .destructive) { store.delete(thread) } - } - } label: { - CSIconView(icon: .more, size: 16, weight: .bold) + Button(action: { openSettings() }) { + CSIconView(icon: .settings, size: 16) } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .help("Thread actions") - } + .csFocusRing(cornerRadius: 8) + .help("Settings") - private func beginRename(_ thread: ChatThread) { - renameText = thread.title - isRenaming = true + threadMenu + } + .foregroundStyle(CSColor.textFaint) } - - /// Export the thread and reveal the written file in Finder (no permission - /// prompt — the path lives under the app's own `~/.codescribe` data dir). - private func export(_ thread: ChatThread, assistantOnly: Bool) { - guard let path = store.exportMarkdown(thread, assistantOnly: assistantOnly) else { return } - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .overlay(alignment: .bottom) { + Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) } - - // Live status: Idle → Thinking → Streaming → Stopping. - private var status: (label: String, color: Color) { - if store.isCancelling { return ("Stopping", CSColor.textFaintAlt) } - if store.isStreaming { return ("Streaming", CSColor.terracottaLight) } - if store.isThinking { return ("Thinking", CSColor.amber) } - return ("Idle", CSColor.oliveLight) + } + + /// Comfortable / Wide / Full — persists via `ChatLayoutPolicy.defaultsKey`. + private var widthModeMenu: some View { + Menu { + ForEach(ChatWidthMode.allCases) { mode in + Button { + widthModeRaw = mode.rawValue + } label: { + if mode == widthMode { + Label(mode.label, systemImage: "checkmark") + } else { + Text(mode.label) + } + } + } + } label: { + HStack(spacing: 4) { + CSIconView(icon: .setupWizard, size: 12) + Text(widthMode.label) + .font(CSFont.mono(10, .medium)) + } } - - // Title bar: thread title · turn count - private var titleBar: some View { - HStack(spacing: 10) { - Text(store.currentThread?.title ?? "—") - .font(CSFont.ui(14, .semibold)) - .foregroundStyle(ChatPalette.nameActive) - Text("· \(turnCount) turns") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - Spacer() + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Chat column width: Comfortable, Wide, or Full") + } + + // Current-thread actions. Export entries appear only for persisted threads + // (a not-yet-saved local thread has no backend id to export from). + private var threadMenu: some View { + Menu { + if let thread = store.currentThread { + Button("Rename") { beginRename(thread) } + Button(thread.isFavorite ? "Unfavorite" : "Favorite") { + store.toggleFavorite(thread) } - .padding(.horizontal, 20) - .padding(.vertical, 12) - .overlay(alignment: .bottom) { - Rectangle().fill(CSColor.hairline(0.04)).frame(height: 1) + if thread.backendId != nil { + Button("Export to Markdown") { export(thread, assistantOnly: false) } + Button("Export assistant replies only") { export(thread, assistantOnly: true) } } + Divider() + Button("Delete Thread", role: .destructive) { store.delete(thread) } + } + } label: { + CSIconView(icon: .more, size: 16, weight: .bold) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Thread actions") + } + + private func beginRename(_ thread: ChatThread) { + renameText = thread.title + isRenaming = true + } + + /// Export the thread and reveal the written file in Finder (no permission + /// prompt — the path lives under the app's own `~/.codescribe` data dir). + private func export(_ thread: ChatThread, assistantOnly: Bool) { + guard let path = store.exportMarkdown(thread, assistantOnly: assistantOnly) else { return } + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + + // Live status: Idle → Thinking → Streaming → Stopping. + private var status: (label: String, color: Color) { + if store.isCancelling { return ("Stopping", CSColor.textFaintAlt) } + if store.isStreaming { return ("Streaming", CSColor.terracottaLight) } + if store.isThinking { return ("Thinking", CSColor.amber) } + return ("Idle", CSColor.oliveLight) + } + + // Title bar: thread title · turn count + private var titleBar: some View { + HStack(spacing: 10) { + Text(store.currentThread?.title ?? "—") + .font(CSFont.ui(14, .semibold)) + .foregroundStyle(ChatPalette.nameActive) + Text("· \(turnCount) turns") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + Spacer() } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .overlay(alignment: .bottom) { + Rectangle().fill(CSColor.hairline(0.04)).frame(height: 1) + } + } - private var turnCount: Int { store.currentThread?.messages.count ?? 0 } + private var turnCount: Int { store.currentThread?.messages.count ?? 0 } } /// One accepted-but-not-yet-running message. Visible until the queue's single /// dispatch owner promotes it to the active turn; the ✕ cancels it before it /// is ever sent. private struct QueuedTurnRow: View { - let turn: AgentChatStore.QueuedTurn - let save: (String) -> Bool - let cancel: () -> Void - @State private var isEditing = false - @State private var editText = "" - - var body: some View { - HStack(spacing: 10) { - Text("Queued") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.amber) - if isEditing { - TextField("Queued message", text: $editText, axis: .vertical) - .textFieldStyle(.plain) - .font(CSFont.ui(12, .regular)) - .foregroundStyle(CSColor.textHigh) - .lineLimit(1 ... 4) - .onSubmit { commitEdit() } - .onExitCommand { isEditing = false } - } else { - Text(turn.text.isEmpty ? "\(turn.attachments.count) attachment(s)" : turn.text) - .font(CSFont.ui(12, .regular)) - .foregroundStyle(CSColor.textBody) - .lineLimit(2) - .truncationMode(.tail) - .textSelection(.enabled) - .onTapGesture(count: 2) { beginEdit() } - } - Spacer() - if isEditing { - Button("Save") { commitEdit() } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.oliveLight) - Button("Cancel") { isEditing = false } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } else { - Button(action: beginEdit) { - Image(systemName: "pencil.circle.fill") - .font(.system(size: 13)) - .foregroundStyle(CSColor.textFaintAlt) - } - .csFocusRing(cornerRadius: 8) - .help("Edit queued message") - .accessibilityLabel("Edit queued message") - } - Button(action: cancel) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 13)) - .foregroundStyle(CSColor.textFaintAlt) - } - .csFocusRing(cornerRadius: 8) - .help("Cancel queued message") - .accessibilityLabel("Cancel queued message") + let turn: AgentChatStore.QueuedTurn + let save: (String) -> Bool + let cancel: () -> Void + @State private var isEditing = false + @State private var editText = "" + + var body: some View { + HStack(spacing: 10) { + Text("Queued") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.amber) + if isEditing { + TextField("Queued message", text: $editText, axis: .vertical) + .textFieldStyle(.plain) + .font(CSFont.ui(12, .regular)) + .foregroundStyle(CSColor.textHigh) + .lineLimit(1...4) + .onSubmit { commitEdit() } + .onExitCommand { isEditing = false } + } else { + Text(turn.text.isEmpty ? "\(turn.attachments.count) attachment(s)" : turn.text) + .font(CSFont.ui(12, .regular)) + .foregroundStyle(CSColor.textBody) + .lineLimit(2) + .truncationMode(.tail) + .textSelection(.enabled) + .onTapGesture(count: 2) { beginEdit() } + } + Spacer() + if isEditing { + Button("Save") { commitEdit() } + .csFocusRing(cornerRadius: 8) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.oliveLight) + Button("Cancel") { isEditing = false } + .csFocusRing(cornerRadius: 8) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } else { + Button(action: beginEdit) { + Image(systemName: "pencil.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(CSColor.textFaintAlt) } - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(CSColor.surfaceRaised(0.04)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.09), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) - } - - private func beginEdit() { - editText = turn.text - isEditing = true - } - - private func commitEdit() { - if save(editText) { isEditing = false } + .csFocusRing(cornerRadius: 8) + .help("Edit queued message") + .accessibilityLabel("Edit queued message") + } + Button(action: cancel) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(CSColor.textFaintAlt) + } + .csFocusRing(cornerRadius: 8) + .help("Cancel queued message") + .accessibilityLabel("Cancel queued message") } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(CSColor.surfaceRaised(0.04)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.09), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + } + + private func beginEdit() { + editText = turn.text + isEditing = true + } + + private func commitEdit() { + if save(editText) { isEditing = false } + } } private struct ToolApprovalCard: View { - let request: PendingToolApproval - let reject: () -> Void - let allowOnce: () -> Void - let allowAlways: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 9) { - HStack { - Text("Permission required") - .font(CSFont.ui(13, .semibold)) - .foregroundStyle(CSColor.amber) - Spacer() - Text(request.risk.replacingOccurrences(of: "_", with: " ")) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - Text("\(request.server) · \(request.tool)") - .font(CSFont.mono(11.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - .textSelection(.enabled) - if !request.summary.isEmpty { - Text(request.summary) - .font(CSFont.ui(12, .regular)) - .foregroundStyle(CSColor.textBody) - } - if let command = request.command { - Text("$ \(command)") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.terracottaLight) - .textSelection(.enabled) - } - if let cwd = request.cwd { - Text("cwd: \(cwd)") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - .textSelection(.enabled) - } - ForEach(request.paths, id: \.self) { path in - Text(path) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - .textSelection(.enabled) - } - HStack { - Spacer() - Button("Deny", role: .cancel, action: reject) - Button("Always allow", action: allowAlways) - Button("Allow once", action: allowOnce) - .buttonStyle(.borderedProminent) - } - } - .padding(14) - .background(CSColor.surfaceRaised(0.04)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.amber.opacity(0.35), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + let request: PendingToolApproval + let reject: () -> Void + let allowOnce: () -> Void + let allowAlways: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text("Permission required") + .font(CSFont.ui(13, .semibold)) + .foregroundStyle(CSColor.amber) + Spacer() + Text(request.risk.replacingOccurrences(of: "_", with: " ")) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + Text("\(request.server) · \(request.tool)") + .font(CSFont.mono(11.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + .textSelection(.enabled) + if !request.summary.isEmpty { + Text(request.summary) + .font(CSFont.ui(12, .regular)) + .foregroundStyle(CSColor.textBody) + } + if let command = request.command { + Text("$ \(command)") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.terracottaLight) + .textSelection(.enabled) + } + if let cwd = request.cwd { + Text("cwd: \(cwd)") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + .textSelection(.enabled) + } + ForEach(request.paths, id: \.self) { path in + Text(path) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + .textSelection(.enabled) + } + HStack { + Spacer() + Button("Deny", role: .cancel, action: reject) + Button("Always allow", action: allowAlways) + Button("Allow once", action: allowOnce) + .buttonStyle(.borderedProminent) + } } + .padding(14) + .background(CSColor.surfaceRaised(0.04)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.amber.opacity(0.35), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + } } // MARK: - Preview (standalone — mock engine + seeded threads) #if DEBUG -#Preview("Agent Chat") { + #Preview("Agent Chat") { AgentChatView(store: AgentChatStore(engine: MockChatEngine())) - .frame(width: 960, height: 600) - .preferredColorScheme(.dark) -} + .frame(width: 960, height: 600) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/AgentChat/AssistivePromptPresentation.swift b/macos/Codescribe/Screens/AgentChat/AssistivePromptPresentation.swift index a7e60b04..a3484115 100644 --- a/macos/Codescribe/Screens/AgentChat/AssistivePromptPresentation.swift +++ b/macos/Codescribe/Screens/AgentChat/AssistivePromptPresentation.swift @@ -32,315 +32,320 @@ import Foundation // text that does not match the skeleton byte-for-byte is returned untouched — // a composer message that merely mentions "INSTRUKCJA" must never be rewritten. enum AssistivePromptParser { - /// The wire prompt split into what the user said and what was attached. - struct Parts: Equatable { - /// The spoken instruction — the only thing the You-bubble shows by default. - let instruction: String - /// Captured selection, if the skeleton carried one (`nil` for the - /// "brak dostępnego zaznaczenia" variant). - let selectedText: String? - /// Frontmost app from the KONTEKST section, if present. - let frontmostApp: String? - } + /// The wire prompt split into what the user said and what was attached. + struct Parts: Equatable { + /// The spoken instruction — the only thing the You-bubble shows by default. + let instruction: String + /// Captured selection, if the skeleton carried one (`nil` for the + /// "brak dostępnego zaznaczenia" variant). + let selectedText: String? + /// Frontmost app from the KONTEKST section, if present. + let frontmostApp: String? + } - // Exact skeleton markers from `build_assistive_input` — one source of truth - // on the Rust side, mirrored (not reinterpreted) here. Two dialects: the - // canonical English labels (current wires) and the legacy Polish labels - // (threads persisted before the rename) — both must keep parsing. - private struct Markers { - let header: String - let selectionHeredoc: String - /// Single-line variants that close the instruction heredoc without a - /// selection heredoc ("no selection" / "carried in codescribe_context"). - let selectionAbsent: [String] - /// Prefix-matched carried-line variant: the suffix carries a live - /// selection count ("… (3 selections).") so exact matching would rot - /// with every count. The matched range extends through the line's - /// trailing newline. Covers the pre-counter wires too (same prefix). - let selectionCarriedPrefix: String? - let contextPrefix: String - } + // Exact skeleton markers from `build_assistive_input` — one source of truth + // on the Rust side, mirrored (not reinterpreted) here. Two dialects: the + // canonical English labels (current wires) and the legacy Polish labels + // (threads persisted before the rename) — both must keep parsing. + private struct Markers { + let header: String + let selectionHeredoc: String + /// Single-line variants that close the instruction heredoc without a + /// selection heredoc ("no selection" / "carried in codescribe_context"). + let selectionAbsent: [String] + /// Prefix-matched carried-line variant: the suffix carries a live + /// selection count ("… (3 selections).") so exact matching would rot + /// with every count. The matched range extends through the line's + /// trailing newline. Covers the pre-counter wires too (same prefix). + let selectionCarriedPrefix: String? + let contextPrefix: String + } - private static let english = Markers( - header: "USER_INSTRUCTION:\n<<<\n", - selectionHeredoc: "\n>\n\nSELECTED_TEXT:\n<<<\n", - selectionAbsent: [ - "\n>\n\nSELECTED_TEXT: no selection available.\n" - ], - selectionCarriedPrefix: "\n>\n\nSELECTED_TEXT: carried in ", - contextPrefix: "\nCONTEXT:\n- frontmost_app: " - ) + private static let english = Markers( + header: "USER_INSTRUCTION:\n<<<\n", + selectionHeredoc: "\n>\n\nSELECTED_TEXT:\n<<<\n", + selectionAbsent: [ + "\n>\n\nSELECTED_TEXT: no selection available.\n" + ], + selectionCarriedPrefix: "\n>\n\nSELECTED_TEXT: carried in ", + contextPrefix: "\nCONTEXT:\n- frontmost_app: " + ) - private static let legacyPolish = Markers( - header: "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n", - selectionHeredoc: "\n>\n\nZAZNACZONY_TEKST:\n<<<\n", - selectionAbsent: ["\n>\n\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n"], - selectionCarriedPrefix: nil, - contextPrefix: "\nKONTEKST:\n- frontmost_app: " - ) + private static let legacyPolish = Markers( + header: "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n", + selectionHeredoc: "\n>\n\nZAZNACZONY_TEKST:\n<<<\n", + selectionAbsent: ["\n>\n\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n"], + selectionCarriedPrefix: nil, + contextPrefix: "\nKONTEKST:\n- frontmost_app: " + ) - /// Alternate selection-section openers seen in older gpt-5.5 / June-era - /// wires where the instruction heredoc close (`>`) was dropped, doubled, - /// or written with a single newline. Exact canonical markers stay first; - /// these are a second pass so salvage never rewrites a plain composer - /// message that merely mentions "SELECTED_TEXT". - private static let englishHeredocAlts = [ - "\n>\nSELECTED_TEXT:\n<<<\n", - "\n\nSELECTED_TEXT:\n<<<\n", - "\nSELECTED_TEXT:\n<<<\n", - ] - private static let legacyHeredocAlts = [ - "\n>\nZAZNACZONY_TEKST:\n<<<\n", - "\n\nZAZNACZONY_TEKST:\n<<<\n", - "\nZAZNACZONY_TEKST:\n<<<\n", - ] - private static let englishAbsentAlts = [ - "\n>\nSELECTED_TEXT: no selection available.\n", - "\n\nSELECTED_TEXT: no selection available.\n", - "\nSELECTED_TEXT: no selection available.\n", - ] - private static let legacyAbsentAlts = [ - "\n>\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n", - "\n\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n", - "\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n", - ] + /// Alternate selection-section openers seen in older gpt-5.5 / June-era + /// wires where the instruction heredoc close (`>`) was dropped, doubled, + /// or written with a single newline. Exact canonical markers stay first; + /// these are a second pass so salvage never rewrites a plain composer + /// message that merely mentions "SELECTED_TEXT". + private static let englishHeredocAlts = [ + "\n>\nSELECTED_TEXT:\n<<<\n", + "\n\nSELECTED_TEXT:\n<<<\n", + "\nSELECTED_TEXT:\n<<<\n", + ] + private static let legacyHeredocAlts = [ + "\n>\nZAZNACZONY_TEKST:\n<<<\n", + "\n\nZAZNACZONY_TEKST:\n<<<\n", + "\nZAZNACZONY_TEKST:\n<<<\n", + ] + private static let englishAbsentAlts = [ + "\n>\nSELECTED_TEXT: no selection available.\n", + "\n\nSELECTED_TEXT: no selection available.\n", + "\nSELECTED_TEXT: no selection available.\n", + ] + private static let legacyAbsentAlts = [ + "\n>\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n", + "\n\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n", + "\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n", + ] - private static let heredocClose = "\n>\n" + private static let heredocClose = "\n>\n" - /// Parse a wire prompt into its parts. Returns `nil` when `wire` is not an - /// assistive skeleton (the caller then renders the text as-is). - static func parse(_ wire: String) -> Parts? { - for markers in [english, legacyPolish] where wire.hasPrefix(markers.header) { - if let parts = parse(wire: wire, markers: markers) { - return parts - } - // Canonical markers failed — try the tolerant second pass used by - // June-era / gpt-5.5 wires that still open with the same header. - if let parts = parseTolerant(wire: wire, markers: markers) { - return parts - } - } - return nil + /// Parse a wire prompt into its parts. Returns `nil` when `wire` is not an + /// assistive skeleton (the caller then renders the text as-is). + static func parse(_ wire: String) -> Parts? { + for markers in [english, legacyPolish] where wire.hasPrefix(markers.header) { + if let parts = parse(wire: wire, markers: markers) { + return parts + } + // Canonical markers failed — try the tolerant second pass used by + // June-era / gpt-5.5 wires that still open with the same header. + if let parts = parseTolerant(wire: wire, markers: markers) { + return parts + } } + return nil + } - private static func parse(wire: String, markers: Markers) -> Parts? { - let body = String(wire.dropFirst(markers.header.count)) + private static func parse(wire: String, markers: Markers) -> Parts? { + let body = String(wire.dropFirst(markers.header.count)) - // The instruction ends at the FIRST selection marker. The instruction is - // spoken text, so it realistically never contains the heredoc skeleton; - // taking the first occurrence keeps a pathological selection that embeds - // the marker from stealing part of itself into the instruction. - let heredocRange = body.range(of: markers.selectionHeredoc) - let missingRange = (markers.selectionAbsent.compactMap { body.range(of: $0) } - + [carriedRange(in: body, markers: markers)].compactMap { $0 }) - .min { $0.lowerBound < $1.lowerBound } + // The instruction ends at the FIRST selection marker. The instruction is + // spoken text, so it realistically never contains the heredoc skeleton; + // taking the first occurrence keeps a pathological selection that embeds + // the marker from stealing part of itself into the instruction. + let heredocRange = body.range(of: markers.selectionHeredoc) + let missingRange = + (markers.selectionAbsent.compactMap { body.range(of: $0) } + + [carriedRange(in: body, markers: markers)].compactMap { $0 }) + .min { $0.lowerBound < $1.lowerBound } - switch (heredocRange, missingRange) { - case let (.some(heredoc), .some(missing)): - return heredoc.lowerBound < missing.lowerBound - ? parseWithSelection(body: body, marker: heredoc, markers: markers) - : parseWithoutSelection(body: body, marker: missing, markers: markers) - case let (.some(heredoc), nil): - return parseWithSelection(body: body, marker: heredoc, markers: markers) - case let (nil, .some(missing)): - return parseWithoutSelection(body: body, marker: missing, markers: markers) - case (nil, nil): - return nil - } + switch (heredocRange, missingRange) { + case (.some(let heredoc), .some(let missing)): + return heredoc.lowerBound < missing.lowerBound + ? parseWithSelection(body: body, marker: heredoc, markers: markers) + : parseWithoutSelection(body: body, marker: missing, markers: markers) + case (.some(let heredoc), nil): + return parseWithSelection(body: body, marker: heredoc, markers: markers) + case (nil, .some(let missing)): + return parseWithoutSelection(body: body, marker: missing, markers: markers) + case (nil, nil): + return nil } + } - /// Second-pass parse for wires that open with a known header but use a - /// non-canonical seam between the instruction and the selection section. - private static func parseTolerant(wire: String, markers: Markers) -> Parts? { - let body = String(wire.dropFirst(markers.header.count)) - let isEnglish = markers.header.hasPrefix("USER_INSTRUCTION") - let heredocAlts = isEnglish ? englishHeredocAlts : legacyHeredocAlts - let absentAlts = isEnglish ? englishAbsentAlts : legacyAbsentAlts + /// Second-pass parse for wires that open with a known header but use a + /// non-canonical seam between the instruction and the selection section. + private static func parseTolerant(wire: String, markers: Markers) -> Parts? { + let body = String(wire.dropFirst(markers.header.count)) + let isEnglish = markers.header.hasPrefix("USER_INSTRUCTION") + let heredocAlts = isEnglish ? englishHeredocAlts : legacyHeredocAlts + let absentAlts = isEnglish ? englishAbsentAlts : legacyAbsentAlts - let heredocRange = heredocAlts.compactMap { body.range(of: $0) } - .min { $0.lowerBound < $1.lowerBound } - let missingRange = (absentAlts.compactMap { body.range(of: $0) } - + [carriedRange(in: body, markers: markers)].compactMap { $0 }) - .min { $0.lowerBound < $1.lowerBound } + let heredocRange = heredocAlts.compactMap { body.range(of: $0) } + .min { $0.lowerBound < $1.lowerBound } + let missingRange = + (absentAlts.compactMap { body.range(of: $0) } + + [carriedRange(in: body, markers: markers)].compactMap { $0 }) + .min { $0.lowerBound < $1.lowerBound } - switch (heredocRange, missingRange) { - case let (.some(heredoc), .some(missing)): - return heredoc.lowerBound < missing.lowerBound - ? parseWithSelection(body: body, marker: heredoc, markers: markers) - : parseWithoutSelection(body: body, marker: missing, markers: markers) - case let (.some(heredoc), nil): - return parseWithSelection(body: body, marker: heredoc, markers: markers) - case let (nil, .some(missing)): - return parseWithoutSelection(body: body, marker: missing, markers: markers) - case (nil, nil): - // Header matched but no selection section — still salvage the - // spoken instruction so the bubble never renders the raw skeleton. - return salvageInstructionOnly(body: body, markers: markers) - } + switch (heredocRange, missingRange) { + case (.some(let heredoc), .some(let missing)): + return heredoc.lowerBound < missing.lowerBound + ? parseWithSelection(body: body, marker: heredoc, markers: markers) + : parseWithoutSelection(body: body, marker: missing, markers: markers) + case (.some(let heredoc), nil): + return parseWithSelection(body: body, marker: heredoc, markers: markers) + case (nil, .some(let missing)): + return parseWithoutSelection(body: body, marker: missing, markers: markers) + case (nil, nil): + // Header matched but no selection section — still salvage the + // spoken instruction so the bubble never renders the raw skeleton. + return salvageInstructionOnly(body: body, markers: markers) } + } - /// Last-resort salvage: header is known, but the body is truncated or uses - /// an unknown selection dialect. Prefer a short spoken instruction over a - /// full-wire dump that collapses the Agent window layout. - private static func salvageInstructionOnly(body: String, markers: Markers) -> Parts? { - var instruction = body - // Strip a trailing unclosed heredoc close if present. - if instruction.hasSuffix(heredocClose) { - instruction = String(instruction.dropLast(heredocClose.count)) - } else if instruction.hasSuffix("\n>") { - instruction = String(instruction.dropLast(2)) - } - instruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) - guard !instruction.isEmpty else { return nil } - // Refuse salvage when the body still looks like pure composer text that - // never opened a heredoc — callers only reach here after a known header. - return Parts(instruction: instruction, selectedText: nil, frontmostApp: nil) + /// Last-resort salvage: header is known, but the body is truncated or uses + /// an unknown selection dialect. Prefer a short spoken instruction over a + /// full-wire dump that collapses the Agent window layout. + private static func salvageInstructionOnly(body: String, markers: Markers) -> Parts? { + var instruction = body + // Strip a trailing unclosed heredoc close if present. + if instruction.hasSuffix(heredocClose) { + instruction = String(instruction.dropLast(heredocClose.count)) + } else if instruction.hasSuffix("\n>") { + instruction = String(instruction.dropLast(2)) } + instruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) + guard !instruction.isEmpty else { return nil } + // Refuse salvage when the body still looks like pure composer text that + // never opened a heredoc — callers only reach here after a known header. + return Parts(instruction: instruction, selectedText: nil, frontmostApp: nil) + } - /// Rewrite a wire-skeleton user message for display: `text` becomes the - /// spoken instruction, the full skeleton moves to `wireText`, and the - /// selection/app land in the context fields. Non-skeleton messages (and - /// non-user roles) pass through untouched, so this is safe to run on every - /// restored message. - static func presented(_ message: ChatMessage) -> ChatMessage { - guard message.role == .you, - message.wireText == nil, - let parts = parse(message.text) else { return message } - var presented = message - presented.wireText = message.text - presented.text = parts.instruction - presented.contextSelection = parts.selectedText - presented.contextApp = parts.frontmostApp - return presented - } + /// Rewrite a wire-skeleton user message for display: `text` becomes the + /// spoken instruction, the full skeleton moves to `wireText`, and the + /// selection/app land in the context fields. Non-skeleton messages (and + /// non-user roles) pass through untouched, so this is safe to run on every + /// restored message. + static func presented(_ message: ChatMessage) -> ChatMessage { + guard message.role == .you, + message.wireText == nil, + let parts = parse(message.text) + else { return message } + var presented = message + presented.wireText = message.text + presented.text = parts.instruction + presented.contextSelection = parts.selectedText + presented.contextApp = parts.frontmostApp + return presented + } - // MARK: - Variants + // MARK: - Variants - /// `SELECTED_TEXT:\n<<<\n{selection}\n>\n[\nCONTEXT…]` (or legacy PL labels). - private static func parseWithSelection( - body: String, marker: Range, markers: Markers - ) -> Parts? { - // Instruction may still carry a trailing heredoc close when the - // selection marker was matched via a tolerant alt that did not - // consume the `>` line — strip it so the bubble shows speech only. - let cleanedInstruction = stripTrailingHeredocClose(String(body[..\n[\nCONTEXT…]` (or legacy PL labels). + private static func parseWithSelection( + body: String, marker: Range, markers: Markers + ) -> Parts? { + // Instruction may still carry a trailing heredoc close when the + // selection marker was matched via a tolerant alt that did not + // consume the `>` line — strip it so the bubble shows speech only. + let cleanedInstruction = stripTrailingHeredocClose(String(body[..\n` stays intact. - let contextSeams = [ - heredocClose + markers.contextPrefix, - "\n>" + markers.contextPrefix, - markers.contextPrefix, - ] - for seamToken in contextSeams { - if let seam = rest.range(of: seamToken, options: .backwards) { - let selected = String(rest[..") { - let selected = String(rest.dropLast(2)) - .trimmingCharacters(in: .whitespacesAndNewlines) - return Parts( - instruction: cleanedInstruction, - selectedText: selected.isEmpty ? nil : selected, - frontmostApp: nil - ) - } - // Selection present but never closed — still show the spoken instruction - // and keep the open selection payload behind the context chip rather than - // dumping the whole wire into the bubble (R1 layout collapse). - let selected = rest.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleanedInstruction.isEmpty || !selected.isEmpty else { return nil } + // The selection heredoc closes right before the (optional) CONTEXT + // section or the end of the prompt. Search from the back so a selection + // that itself contains `\n>\n` stays intact. + let contextSeams = [ + heredocClose + markers.contextPrefix, + "\n>" + markers.contextPrefix, + markers.contextPrefix, + ] + for seamToken in contextSeams { + if let seam = rest.range(of: seamToken, options: .backwards) { + let selected = String(rest[.. String { - var result = text - if result.hasSuffix(heredocClose) { - result = String(result.dropLast(heredocClose.count)) - } else if result.hasSuffix("\n>") { - result = String(result.dropLast(2)) - } else if result.hasSuffix(">") { - result = String(result.dropLast()) - } - return result.trimmingCharacters(in: .whitespacesAndNewlines) + if rest.hasSuffix(heredocClose) { + let selected = String(rest.dropLast(heredocClose.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return Parts( + instruction: cleanedInstruction, + selectedText: selected.isEmpty ? nil : selected, + frontmostApp: nil + ) } + if rest.hasSuffix("\n>") { + let selected = String(rest.dropLast(2)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return Parts( + instruction: cleanedInstruction, + selectedText: selected.isEmpty ? nil : selected, + frontmostApp: nil + ) + } + // Selection present but never closed — still show the spoken instruction + // and keep the open selection payload behind the context chip rather than + // dumping the whole wire into the bubble (R1 layout collapse). + let selected = rest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanedInstruction.isEmpty || !selected.isEmpty else { return nil } + return Parts( + instruction: cleanedInstruction, + selectedText: selected.isEmpty ? nil : selected, + frontmostApp: nil + ) + } - /// `SELECTED_TEXT: no selection available.\n[\nCONTEXT…]` (or the - /// carried-in-codescribe_context variant, or legacy PL labels). - private static func parseWithoutSelection( - body: String, marker: Range, markers: Markers - ) -> Parts? { - let instruction = stripTrailingHeredocClose(String(body[.. String { + var result = text + if result.hasSuffix(heredocClose) { + result = String(result.dropLast(heredocClose.count)) + } else if result.hasSuffix("\n>") { + result = String(result.dropLast(2)) + } else if result.hasSuffix(">") { + result = String(result.dropLast()) } + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } - /// Range of the prefix-matched carried-line variant, extended through the - /// line's trailing newline so callers can split exactly like an exact match. - private static func carriedRange( - in body: String, markers: Markers - ) -> Range? { - guard let prefix = markers.selectionCarriedPrefix, - let start = body.range(of: prefix) else { return nil } - guard let lineBreak = body[start.upperBound...].firstIndex(of: "\n") else { - return start.lowerBound.., markers: Markers + ) -> Parts? { + let instruction = stripTrailingHeredocClose(String(body[.. String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + /// Range of the prefix-matched carried-line variant, extended through the + /// line's trailing newline so callers can split exactly like an exact match. + private static func carriedRange( + in body: String, markers: Markers + ) -> Range? { + guard let prefix = markers.selectionCarriedPrefix, + let start = body.range(of: prefix) + else { return nil } + guard let lineBreak = body[start.upperBound...].firstIndex(of: "\n") else { + return start.lowerBound.. String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } diff --git a/macos/Codescribe/Screens/AgentChat/ChatComponents.swift b/macos/Codescribe/Screens/AgentChat/ChatComponents.swift index 73601f64..7d871858 100644 --- a/macos/Codescribe/Screens/AgentChat/ChatComponents.swift +++ b/macos/Codescribe/Screens/AgentChat/ChatComponents.swift @@ -1,92 +1,92 @@ -import SwiftUI import AppKit +import SwiftUI // Screen-local helpers for Agent Chat. Off-token shades from the mock that the // shared CSColor palette does not carry verbatim live here (and ONLY here). enum ChatPalette { - static let nameInactive = Color(hex: 0xC7CABF) // inactive thread name / segmented body - static let nameActive = Color(hex: 0xF0EEE7) // active thread name / titles / you-bubble text - static var activeThreadSub: Color { CSColor.chromeAccent.opacity(0.78) } - static let toolBody = Color(hex: 0x9AA093) // tool-activity detail text - static let thinking = Color(hex: 0x8A8D87) // "thinking…" label - static let sendGlyph = Color(hex: 0x0A0A0A) + static let nameInactive = Color(hex: 0xC7CABF) // inactive thread name / segmented body + static let nameActive = Color(hex: 0xF0EEE7) // active thread name / titles / you-bubble text + static var activeThreadSub: Color { CSColor.chromeAccent.opacity(0.78) } + static let toolBody = Color(hex: 0x9AA093) // tool-activity detail text + static let thinking = Color(hex: 0x8A8D87) // "thinking…" label + static let sendGlyph = Color(hex: 0x0A0A0A) } enum ComposerMicVisualState: CaseIterable, Equatable { - case idle - case preparing - case recording - case blocked - - var accessibilityLabel: String { - switch self { - case .idle: return "Start voice input" - case .preparing: return "Preparing voice input" - case .recording: return "Stop voice input" - case .blocked: return "Microphone busy with shortcut dictation" - } + case idle + case preparing + case recording + case blocked + + var accessibilityLabel: String { + switch self { + case .idle: return "Start voice input" + case .preparing: return "Preparing voice input" + case .recording: return "Stop voice input" + case .blocked: return "Microphone busy with shortcut dictation" } + } - var isEnabled: Bool { self == .idle || self == .recording } - var icon: CSIcon { .mic } + var isEnabled: Bool { self == .idle || self == .recording } + var icon: CSIcon { .mic } } /// Recognizable microphone glyph with an expanding recording ring. Motion is /// mounted only while active; purpose remains legible in every static state. struct RippleMic: View { - let state: ComposerMicVisualState - private var isActive: Bool { state == .recording } - - var body: some View { - ZStack { - if isActive { - ExpandingRing() - } - Circle() - .fill(isActive ? CSColor.terracotta.opacity(0.18) : Color.clear) - .frame( - width: ComposerControlMetrics.hitTargetSize, - height: ComposerControlMetrics.hitTargetSize - ) - CSIconView( - icon: state.icon, - size: ComposerControlMetrics.glyphSize, - weight: isActive ? .semibold : .regular, - color: isActive ? CSColor.terracottaLight : CSColor.textFaint - ) - } + let state: ComposerMicVisualState + private var isActive: Bool { state == .recording } + + var body: some View { + ZStack { + if isActive { + ExpandingRing() + } + Circle() + .fill(isActive ? CSColor.terracotta.opacity(0.18) : Color.clear) .frame( - width: ComposerControlMetrics.hitTargetSize, - height: ComposerControlMetrics.hitTargetSize + width: ComposerControlMetrics.hitTargetSize, + height: ComposerControlMetrics.hitTargetSize ) + CSIconView( + icon: state.icon, + size: ComposerControlMetrics.glyphSize, + weight: isActive ? .semibold : .regular, + color: isActive ? CSColor.terracottaLight : CSColor.textFaint + ) } + .frame( + width: ComposerControlMetrics.hitTargetSize, + height: ComposerControlMetrics.hitTargetSize + ) + } } /// The pulsing ring, split out so its `repeatForever` animation exists only while /// mounted (i.e. while the mic is active) — unmounting stops the render loop. private struct ExpandingRing: View { - @State private var animate = false - var body: some View { - Circle() - .strokeBorder(CSColor.terracotta, lineWidth: 1) - .frame(width: 18, height: 18) - .scaleEffect(animate ? 1.65 : 0.7) - .opacity(animate ? 0 : 0.7) - .onAppear { withAnimation(CSMotion.ripple) { animate = true } } - } + @State private var animate = false + var body: some View { + Circle() + .strokeBorder(CSColor.terracotta, lineWidth: 1) + .frame(width: 18, height: 18) + .scaleEffect(animate ? 1.65 : 0.7) + .opacity(animate ? 0 : 0.7) + .onAppear { withAnimation(CSMotion.ripple) { animate = true } } + } } /// Blinking terracotta caret shown while a turn streams. struct BlinkCaret: View { - @State private var on = true - var body: some View { - Rectangle() - .fill(CSColor.terracotta) - .frame(width: 7, height: 15) - .opacity(on ? 1 : 0) - .onAppear { withAnimation(CSMotion.blink) { on = false } } - } + @State private var on = true + var body: some View { + Rectangle() + .fill(CSColor.terracotta) + .frame(width: 7, height: 15) + .opacity(on ? 1 : 0) + .onAppear { withAnimation(CSMotion.blink) { on = false } } + } } /// Block-level markdown body for a chat turn: paragraphs, `#`–`###` headings, @@ -101,564 +101,594 @@ struct BlinkCaret: View { /// `body` (and re-parses) ONLY when the text changes. During a stream that is /// the single growing turn, never the whole history. struct MarkdownText: View { - let raw: String - var size: CGFloat = 14 - var bodyColor: Color = CSColor.textBodyAlt - var showsCaret: Bool = false - - /// Per-surface text scale (chat window ⌘+/-/0). A single multiplier over the - /// block's base `size` drives EVERY element (headings, lists, code, tables, - /// inline runs), which are all derived from `s` — so the whole markdown body - /// scales together instead of per-style hand-tuning. - @Environment(\.csTextScale) private var textScale - private var s: CGFloat { size * textScale } - - var body: some View { - let blocks = MDBlock.parse(raw) - VStack(alignment: .leading, spacing: 7) { - ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in - blockView(block, isLast: index == blocks.count - 1) - } - } - // Rich rendering must not trade away the platform's basic copy - // semantics. Apply at the markdown root so paragraphs, headings, lists, - // quotes, tables and code inherit selection by default. - .textSelection(.enabled) - .environment(\.openURL, OpenURLAction { url in - NSWorkspace.shared.open(url) - return .handled - }) - } - - @ViewBuilder - private func blockView(_ block: MDBlock, isLast: Bool) -> some View { - switch block { - case let .paragraph(text): - inlineText(text, baseFont: CSFont.ui(s), baseColor: bodyColor, - fontSize: s, isLast: isLast) - case let .heading(level, text): - let hSize = headingSize(level) - inlineText(text, baseFont: CSFont.ui(hSize, .bold), baseColor: CSColor.textHigh, - fontSize: hSize, isLast: isLast) - .padding(.top, level <= 2 ? 3 : 1) - case let .bullet(indent, text): - listRow(marker: bulletMarker(indent), indent: indent, text: text, - isLast: isLast, deep: indent >= 2) - case let .ordered(indent, number, text): - listRow(marker: "\(number).", indent: indent, text: text, - isLast: isLast, deep: false) - case let .task(indent, done, text): - taskRow(indent: indent, done: done, text: text, isLast: isLast) - case let .blockquote(text): - blockquoteView(text) - case let .table(header, rows): - tableView(header: header, rows: rows) - case let .code(language, content): - codeBlock(language, content, isLast: isLast) - case .thematicBreak: - Rectangle() - .fill(CSColor.hairline(0.12)) - .frame(height: 1) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - } + let raw: String + var size: CGFloat = 14 + var bodyColor: Color = CSColor.textBodyAlt + var showsCaret: Bool = false + + /// Per-surface text scale (chat window ⌘+/-/0). A single multiplier over the + /// block's base `size` drives EVERY element (headings, lists, code, tables, + /// inline runs), which are all derived from `s` — so the whole markdown body + /// scales together instead of per-style hand-tuning. + @Environment(\.csTextScale) private var textScale + private var s: CGFloat { size * textScale } + + var body: some View { + let blocks = MDBlock.parse(raw) + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in + blockView(block, isLast: index == blocks.count - 1) + } } - - /// Bullet glyph by depth: a filled dot at the top two levels, then a hollow - /// ring for level 3+ so deep nesting reads lighter. - private func bulletMarker(_ indent: Int) -> String { - indent >= 2 ? "◦" : "•" + // Rich rendering must not trade away the platform's basic copy + // semantics. Apply at the markdown root so paragraphs, headings, lists, + // quotes, tables and code inherit selection by default. + .textSelection(.enabled) + .environment( + \.openURL, + OpenURLAction { url in + NSWorkspace.shared.open(url) + return .handled + }) + } + + @ViewBuilder + private func blockView(_ block: MDBlock, isLast: Bool) -> some View { + switch block { + case .paragraph(let text): + inlineText( + text, baseFont: CSFont.ui(s), baseColor: bodyColor, + fontSize: s, isLast: isLast) + case .heading(let level, let text): + let hSize = headingSize(level) + inlineText( + text, baseFont: CSFont.ui(hSize, .bold), baseColor: CSColor.textHigh, + fontSize: hSize, isLast: isLast + ) + .padding(.top, level <= 2 ? 3 : 1) + case .bullet(let indent, let text): + listRow( + marker: bulletMarker(indent), indent: indent, text: text, + isLast: isLast, deep: indent >= 2) + case .ordered(let indent, let number, let text): + listRow( + marker: "\(number).", indent: indent, text: text, + isLast: isLast, deep: false) + case .task(let indent, let done, let text): + taskRow(indent: indent, done: done, text: text, isLast: isLast) + case .blockquote(let text): + blockquoteView(text) + case .table(let header, let rows): + tableView(header: header, rows: rows) + case .code(let language, let content): + codeBlock(language, content, isLast: isLast) + case .thematicBreak: + Rectangle() + .fill(CSColor.hairline(0.12)) + .frame(height: 1) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) } - - /// Renders a list of already-parsed blocks (used for blockquote / callout - /// bodies). No streaming caret — nested content is never the live turn tail. - // `AnyView` breaks the otherwise-recursive opaque return type: blockView -> - // blockquoteView/calloutView -> blocksView -> blockView would define `some - // View` in terms of itself, which the compiler rejects. - private func blocksView(_ blocks: [MDBlock]) -> some View { - VStack(alignment: .leading, spacing: 7) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in - AnyView(blockView(block, isLast: false)) - } - } + } + + /// Bullet glyph by depth: a filled dot at the top two levels, then a hollow + /// ring for level 3+ so deep nesting reads lighter. + private func bulletMarker(_ indent: Int) -> String { + indent >= 2 ? "◦" : "•" + } + + /// Renders a list of already-parsed blocks (used for blockquote / callout + /// bodies). No streaming caret — nested content is never the live turn tail. + // `AnyView` breaks the otherwise-recursive opaque return type: blockView -> + // blockquoteView/calloutView -> blocksView -> blockView would define `some + // View` in terms of itself, which the compiler rejects. + private func blocksView(_ blocks: [MDBlock]) -> some View { + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + AnyView(blockView(block, isLast: false)) + } } - - private func headingSize(_ level: Int) -> CGFloat { - switch level { - case 1: return s + 7 - case 2: return s + 4 - case 3: return s + 2 - default: return s + 1 - } + } + + private func headingSize(_ level: Int) -> CGFloat { + switch level { + case 1: return s + 7 + case 2: return s + 4 + case 3: return s + 2 + default: return s + 1 } - - @ViewBuilder - private func inlineText(_ text: String, baseFont: Font, baseColor: Color, - fontSize: CGFloat, isLast: Bool) -> some View { - let attr = Self.inlineAttributed(text, fontSize: fontSize, - baseFont: baseFont, baseColor: baseColor) - let content = Text(attr).lineSpacing(5) - if isLast, showsCaret { - HStack(alignment: .bottom, spacing: 2) { - content.fixedSize(horizontal: false, vertical: true) - BlinkCaret() - } - } else { - content - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } + } + + @ViewBuilder + private func inlineText( + _ text: String, baseFont: Font, baseColor: Color, + fontSize: CGFloat, isLast: Bool + ) -> some View { + let attr = Self.inlineAttributed( + text, fontSize: fontSize, + baseFont: baseFont, baseColor: baseColor) + let content = Text(attr).lineSpacing(5) + if isLast, showsCaret { + HStack(alignment: .bottom, spacing: 2) { + content.fixedSize(horizontal: false, vertical: true) + BlinkCaret() + } + } else { + content + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } - - @ViewBuilder - private func listRow(marker: String, indent: Int, text: String, isLast: Bool, - deep: Bool) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 7) { - Text(marker) - .font(CSFont.mono(deep ? s - 4 : s - 2)) - .foregroundStyle(deep ? CSColor.textFaint : CSColor.textMutedAlt) - .frame(minWidth: 14, alignment: .trailing) - inlineText(text, baseFont: CSFont.ui(s), baseColor: bodyColor, - fontSize: s, isLast: isLast) - } - .padding(.leading, CGFloat(min(indent, 4)) * 16) - } - - /// A `- [x]` / `- [ ]` checklist row: a filled/empty SF Symbol checkbox in - /// place of the literal brackets, olive when done, faint when open. Done - /// items read slightly dimmer so an open task stands out. - @ViewBuilder - private func taskRow(indent: Int, done: Bool, text: String, isLast: Bool) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 7) { - CSIconView( - icon: done ? .checkboxOn : .checkboxOff, - size: s - 1, - weight: done ? .semibold : .regular, - color: done ? CSColor.oliveLight : CSColor.textFaint - ) - .frame(minWidth: 14, alignment: .trailing) - inlineText(text, baseFont: CSFont.ui(s), - baseColor: done ? CSColor.textMutedAlt : bodyColor, - fontSize: s, isLast: isLast) - } - .padding(.leading, CGFloat(min(indent, 4)) * 16) - } - - /// A `>` blockquote. Its inner text is parsed recursively so nested lists, - /// code fences and quotes render properly (not half-raw). A quote that opens - /// with a `[!NOTE]`-style marker is promoted to a GitHub-flavored callout. - @ViewBuilder - private func blockquoteView(_ text: String) -> some View { - if let callout = CalloutKind.detect(text) { - calloutView(kind: callout.kind, body: callout.body) - } else { - HStack(alignment: .top, spacing: 9) { - RoundedRectangle(cornerRadius: 1, style: .continuous) - .fill(CSColor.chromeAccent.opacity(0.55)) - .frame(width: 2.5) - blocksView(MDBlock.parse(text)) - .opacity(0.9) - } - .fixedSize(horizontal: false, vertical: true) - } + } + + @ViewBuilder + private func listRow( + marker: String, indent: Int, text: String, isLast: Bool, + deep: Bool + ) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 7) { + Text(marker) + .font(CSFont.mono(deep ? s - 4 : s - 2)) + .foregroundStyle(deep ? CSColor.textFaint : CSColor.textMutedAlt) + .frame(minWidth: 14, alignment: .trailing) + inlineText( + text, baseFont: CSFont.ui(s), baseColor: bodyColor, + fontSize: s, isLast: isLast) } - - /// A GitHub alert callout: tinted icon + label header over the parsed body, - /// a colored left bar and a faint wash of the same tint. Reuses the - /// blockquote parse path, so callout bodies carry lists / code / links. - @ViewBuilder - private func calloutView(kind: CalloutKind, body: String) -> some View { - let blocks = MDBlock.parse(body) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - CSIconView(icon: kind.csIcon, size: s - 2, weight: .semibold) - Text(kind.label) - .font(CSFont.mono(s - 4, .semibold)) - .tracking(0.8) - } - .foregroundStyle(kind.tint) - if !blocks.isEmpty { - blocksView(blocks) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 11) - .padding(.vertical, 9) - .background(kind.tint.opacity(0.08)) - .overlay(alignment: .leading) { - Rectangle() - .fill(kind.tint.opacity(0.8)) - .frame(width: 2.5) - } - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - .fixedSize(horizontal: false, vertical: true) + .padding(.leading, CGFloat(min(indent, 4)) * 16) + } + + /// A `- [x]` / `- [ ]` checklist row: a filled/empty SF Symbol checkbox in + /// place of the literal brackets, olive when done, faint when open. Done + /// items read slightly dimmer so an open task stands out. + @ViewBuilder + private func taskRow(indent: Int, done: Bool, text: String, isLast: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 7) { + CSIconView( + icon: done ? .checkboxOn : .checkboxOff, + size: s - 1, + weight: done ? .semibold : .regular, + color: done ? CSColor.oliveLight : CSColor.textFaint + ) + .frame(minWidth: 14, alignment: .trailing) + inlineText( + text, baseFont: CSFont.ui(s), + baseColor: done ? CSColor.textMutedAlt : bodyColor, + fontSize: s, isLast: isLast) } - - /// A GFM pipe table. Header row is mono + high-contrast over a faint fill; - /// hairline separators between rows. Columns share the bubble width via a - /// custom `Layout` that weights each column by its longest cell but keeps a - /// per-column minimum, so a single very long column can't crush the others - /// down to word-per-line slivers. Cell text wraps normally within its width. - @ViewBuilder - private func tableView(header: [String], rows: [[String]]) -> some View { - let columnCount = max(header.count, rows.map(\.count).max() ?? 0) - let weights = columnWeights(header: header, rows: rows, count: columnCount) - MDTableLayout(columns: columnCount, rowCount: rows.count + 1, weights: weights) { - ForEach(0..` blockquote. Its inner text is parsed recursively so nested lists, + /// code fences and quotes render properly (not half-raw). A quote that opens + /// with a `[!NOTE]`-style marker is promoted to a GitHub-flavored callout. + @ViewBuilder + private func blockquoteView(_ text: String) -> some View { + if let callout = CalloutKind.detect(text) { + calloutView(kind: callout.kind, body: callout.body) + } else { + HStack(alignment: .top, spacing: 9) { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(CSColor.chromeAccent.opacity(0.55)) + .frame(width: 2.5) + blocksView(MDBlock.parse(text)) + .opacity(0.9) + } + .fixedSize(horizontal: false, vertical: true) } - - private func cell(_ row: [String], _ column: Int) -> String { - column < row.count ? row[column] : "" + } + + /// A GitHub alert callout: tinted icon + label header over the parsed body, + /// a colored left bar and a faint wash of the same tint. Reuses the + /// blockquote parse path, so callout bodies carry lists / code / links. + @ViewBuilder + private func calloutView(kind: CalloutKind, body: String) -> some View { + let blocks = MDBlock.parse(body) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + CSIconView(icon: kind.csIcon, size: s - 2, weight: .semibold) + Text(kind.label) + .font(CSFont.mono(s - 4, .semibold)) + .tracking(0.8) + } + .foregroundStyle(kind.tint) + if !blocks.isEmpty { + blocksView(blocks) + } } - - /// Per-column weight = its longest cell length, floored so tiny columns stay - /// legible and capped so one long column doesn't monopolize the width. - private func columnWeights(header: [String], rows: [[String]], - count: Int) -> [CGFloat] { - guard count > 0 else { return [] } - var weights = [CGFloat](repeating: 1, count: count) - func consider(_ row: [String]) { - for column in 0.. some View { - let cellSize = isHeader ? s - 2 : s - 1 - let font = isHeader ? CSFont.mono(cellSize, .semibold) : CSFont.ui(cellSize) - let color = isHeader ? CSColor.textHigh : bodyColor - let attr = Self.inlineAttributed(text, fontSize: cellSize, - baseFont: font, baseColor: color) - Text(attr) - .lineSpacing(3) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(isHeader ? CSColor.surfaceRaised(0.05) : Color.clear) - .overlay(alignment: .bottom) { - if !isLastRow { - Rectangle() - .fill(CSColor.hairline(isHeader ? 0.12 : 0.06)) - .frame(height: 1) - } - } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background(kind.tint.opacity(0.08)) + .overlay(alignment: .leading) { + Rectangle() + .fill(kind.tint.opacity(0.8)) + .frame(width: 2.5) } - - @ViewBuilder - private func codeBlock(_ language: String?, _ content: String, isLast: Bool) -> some View { - // A code block is the live stream tail only while it is both the last - // block and the turn is still streaming (caret on). In that state we skip - // highlighting entirely and render plain mono; once the fence closes (the - // turn ends or a later block appears) the block becomes highlightable. - let highlightable = !(isLast && showsCaret) - let block = CodeBlockView(content: content, language: language, size: s, - highlightable: highlightable) - if isLast, showsCaret { - HStack(alignment: .bottom, spacing: 2) { - block - BlinkCaret() - } - } else { - block + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + .fixedSize(horizontal: false, vertical: true) + } + + /// A GFM pipe table. Header row is mono + high-contrast over a faint fill; + /// hairline separators between rows. Columns share the bubble width via a + /// custom `Layout` that weights each column by its longest cell but keeps a + /// per-column minimum, so a single very long column can't crush the others + /// down to word-per-line slivers. Cell text wraps normally within its width. + @ViewBuilder + private func tableView(header: [String], rows: [[String]]) -> some View { + let columnCount = max(header.count, rows.map(\.count).max() ?? 0) + let weights = columnWeights(header: header, rows: rows, count: columnCount) + MDTableLayout(columns: columnCount, rowCount: rows.count + 1, weights: weights) { + ForEach(0.. AttributedString { - let options = AttributedString.MarkdownParsingOptions( - allowsExtendedAttributes: true, - interpretedSyntax: .inlineOnlyPreservingWhitespace, - failurePolicy: .returnPartiallyParsedIfPossible - ) - let escaped = escapingInlineHTML(text) - guard var attr = try? AttributedString(markdown: escaped, options: options) else { - var raw = AttributedString(text) - raw.font = baseFont - raw.foregroundColor = baseColor - return raw - } - attr.font = baseFont - attr.foregroundColor = baseColor - - var codeRanges: [Range] = [] - var linkRanges: [Range] = [] - for run in attr.runs { - if let intent = run.inlinePresentationIntent, intent.contains(.code) { - codeRanges.append(run.range) - } - if run.link != nil { - linkRanges.append(run.range) - } - } - // Inline code: mono + olive over a raised chip so it detaches from prose - // (also inside checkboxes and table cells, which route through here). - for range in codeRanges { - attr[range].font = CSFont.mono(fontSize - 1) - attr[range].foregroundColor = CSColor.oliveLight - attr[range].backgroundColor = CSColor.surfaceRaised(0.10) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .fixedSize(horizontal: false, vertical: true) + } + + private func cell(_ row: [String], _ column: Int) -> String { + column < row.count ? row[column] : "" + } + + /// Per-column weight = its longest cell length, floored so tiny columns stay + /// legible and capped so one long column doesn't monopolize the width. + private func columnWeights( + header: [String], rows: [[String]], + count: Int + ) -> [CGFloat] { + guard count > 0 else { return [] } + var weights = [CGFloat](repeating: 1, count: count) + func consider(_ row: [String]) { + for column in 0.. some View { + let cellSize = isHeader ? s - 2 : s - 1 + let font = isHeader ? CSFont.mono(cellSize, .semibold) : CSFont.ui(cellSize) + let color = isHeader ? CSColor.textHigh : bodyColor + let attr = Self.inlineAttributed( + text, fontSize: cellSize, + baseFont: font, baseColor: color) + Text(attr) + .lineSpacing(3) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isHeader ? CSColor.surfaceRaised(0.05) : Color.clear) + .overlay(alignment: .bottom) { + if !isLastRow { + Rectangle() + .fill(CSColor.hairline(isHeader ? 0.12 : 0.06)) + .frame(height: 1) } - // Links use the operator's system accent and a subtle underline. - for range in linkRanges { - attr[range].foregroundColor = CSColor.chromeAccent - attr[range].underlineStyle = .single + } + } + + @ViewBuilder + private func codeBlock(_ language: String?, _ content: String, isLast: Bool) -> some View { + // A code block is the live stream tail only while it is both the last + // block and the turn is still streaming (caret on). In that state we skip + // highlighting entirely and render plain mono; once the fence closes (the + // turn ends or a later block appears) the block becomes highlightable. + let highlightable = !(isLast && showsCaret) + let block = CodeBlockView( + content: content, language: language, size: s, + highlightable: highlightable) + if isLast, showsCaret { + HStack(alignment: .bottom, spacing: 2) { + block + BlinkCaret() + } + } else { + block + } + } + + /// Inline markdown → styled `AttributedString`. Bold / italic ride the + /// parser's `inlinePresentationIntent` (SwiftUI applies them over our base + /// font); we override `code` runs to mono + olive and `link` runs to + /// the system accent. On parse failure returns the plain raw string so the caller + /// still shows something. + static func inlineAttributed( + _ text: String, fontSize: CGFloat, + baseFont: Font, baseColor: Color + ) -> AttributedString { + let options = AttributedString.MarkdownParsingOptions( + allowsExtendedAttributes: true, + interpretedSyntax: .inlineOnlyPreservingWhitespace, + failurePolicy: .returnPartiallyParsedIfPossible + ) + let escaped = escapingInlineHTML(text) + guard var attr = try? AttributedString(markdown: escaped, options: options) else { + var raw = AttributedString(text) + raw.font = baseFont + raw.foregroundColor = baseColor + return raw + } + attr.font = baseFont + attr.foregroundColor = baseColor + + var codeRanges: [Range] = [] + var linkRanges: [Range] = [] + for run in attr.runs { + if let intent = run.inlinePresentationIntent, intent.contains(.code) { + codeRanges.append(run.range) + } + if run.link != nil { + linkRanges.append(run.range) + } + } + // Inline code: mono + olive over a raised chip so it detaches from prose + // (also inside checkboxes and table cells, which route through here). + for range in codeRanges { + attr[range].font = CSFont.mono(fontSize - 1) + attr[range].foregroundColor = CSColor.oliveLight + attr[range].backgroundColor = CSColor.surfaceRaised(0.10) + } + // Links use the operator's system accent and a subtle underline. + for range in linkRanges { + attr[range].foregroundColor = CSColor.chromeAccent + attr[range].underlineStyle = .single + } + return attr + } + + /// Backslash-escape `<`/`>` that live outside inline code spans so raw HTML + /// tags always render as literal, predictable text instead of being dropped + /// by the markdown parser. Content inside `` `…` `` code spans is copied + /// verbatim (code keeps its literal angle brackets, no stray backslashes). + static func escapingInlineHTML(_ text: String) -> String { + let chars = Array(text) + var out = "" + out.reserveCapacity(chars.count + 8) + var index = 0 + while index < chars.count { + let char = chars[index] + if char == "`" { + var open = 0 + while index < chars.count, chars[index] == "`" { + open += 1 + index += 1 } - return attr - } - - /// Backslash-escape `<`/`>` that live outside inline code spans so raw HTML - /// tags always render as literal, predictable text instead of being dropped - /// by the markdown parser. Content inside `` `…` `` code spans is copied - /// verbatim (code keeps its literal angle brackets, no stray backslashes). - static func escapingInlineHTML(_ text: String) -> String { - let chars = Array(text) - var out = "" - out.reserveCapacity(chars.count + 8) - var index = 0 + out += String(repeating: "`", count: open) + // Copy verbatim until a backtick run of equal length closes it. while index < chars.count { - let char = chars[index] - if char == "`" { - var open = 0 - while index < chars.count, chars[index] == "`" { open += 1; index += 1 } - out += String(repeating: "`", count: open) - // Copy verbatim until a backtick run of equal length closes it. - while index < chars.count { - if chars[index] == "`" { - var close = 0 - while index < chars.count, chars[index] == "`" { - close += 1; index += 1 - } - out += String(repeating: "`", count: close) - if close == open { break } - } else { - out.append(chars[index]); index += 1 - } - } - continue - } - switch char { - case "<": out += "\\<" - case ">": out += "\\>" - default: out.append(char) + if chars[index] == "`" { + var close = 0 + while index < chars.count, chars[index] == "`" { + close += 1 + index += 1 } + out += String(repeating: "`", count: close) + if close == open { break } + } else { + out.append(chars[index]) index += 1 + } } - return out + continue + } + switch char { + case "<": out += "\\<" + case ">": out += "\\>" + default: out.append(char) + } + index += 1 } + return out + } } /// A GitHub-flavored alert kind parsed from a `> [!NOTE]`-style blockquote head. enum CalloutKind { - case note, tip, important, warning, caution - - init?(tag: String) { - switch tag { - case "NOTE": self = .note - case "TIP": self = .tip - case "IMPORTANT": self = .important - case "WARNING": self = .warning - case "CAUTION": self = .caution - default: return nil - } + case note, tip, important, warning, caution + + init?(tag: String) { + switch tag { + case "NOTE": self = .note + case "TIP": self = .tip + case "IMPORTANT": self = .important + case "WARNING": self = .warning + case "CAUTION": self = .caution + default: return nil } - - var label: String { - switch self { - case .note: return "NOTE" - case .tip: return "TIP" - case .important: return "IMPORTANT" - case .warning: return "WARNING" - case .caution: return "CAUTION" - } + } + + var label: String { + switch self { + case .note: return "NOTE" + case .tip: return "TIP" + case .important: return "IMPORTANT" + case .warning: return "WARNING" + case .caution: return "CAUTION" } - - var csIcon: CSIcon { - switch self { - case .note: return .info - case .tip: return .tip - case .important: return .error - case .warning: return .warning - case .caution: return .caution - } + } + + var csIcon: CSIcon { + switch self { + case .note: return .info + case .tip: return .tip + case .important: return .error + case .warning: return .warning + case .caution: return .caution } - - var tint: Color { - switch self { - case .note: return CSColor.textMuted // neutral (palette carries no blue) - case .tip: return CSColor.oliveLight - case .important: return CSColor.terracottaDeep - case .warning: return CSColor.amber - case .caution: return CSColor.terracotta - } + } + + var tint: Color { + switch self { + case .note: return CSColor.textMuted // neutral (palette carries no blue) + case .tip: return CSColor.oliveLight + case .important: return CSColor.terracottaDeep + case .warning: return CSColor.amber + case .caution: return CSColor.terracotta } - - /// Recognize a `[!TYPE]` marker on the first non-empty line of a blockquote. - /// Returns the kind plus the remaining body (any trailing text on the marker - /// line folds into the body). Nil when the head is not a known alert marker. - static func detect(_ raw: String) -> (kind: CalloutKind, body: String)? { - let lines = raw.components(separatedBy: "\n") - guard let head = lines.firstIndex(where: { - !$0.trimmingCharacters(in: .whitespaces).isEmpty - }) else { return nil } - let first = lines[head].trimmingCharacters(in: .whitespaces) - guard first.hasPrefix("[!"), let close = first.firstIndex(of: "]") else { - return nil - } - let tag = String(first[first.index(first.startIndex, offsetBy: 2).. (kind: CalloutKind, body: String)? { + let lines = raw.components(separatedBy: "\n") + guard + let head = lines.firstIndex(where: { + !$0.trimmingCharacters(in: .whitespaces).isEmpty + }) + else { return nil } + let first = lines[head].trimmingCharacters(in: .whitespaces) + guard first.hasPrefix("[!"), let close = first.firstIndex(of: "]") else { + return nil } + let tag = String(first[first.index(first.startIndex, offsetBy: 2).. Cache { Cache() } - - /// Distribute `total` width across columns proportional to weight, but never - /// below `minColumn`; columns pinned to the floor drop out and the remainder - /// re-shares among the rest. - private func columnWidths(for total: CGFloat) -> [CGFloat] { - guard columns > 0 else { return [] } - var widths = [CGFloat](repeating: minColumn, count: columns) - var active = Array(0.. 0 else { - let each = max(minColumn, remaining / CGFloat(active.count)) - for column in active { widths[column] = each } - break - } - var pinned: [Int] = [] - for column in active where remaining * weights[column] / weightSum < minColumn { - pinned.append(column) - } - if pinned.isEmpty { - for column in active { - widths[column] = remaining * weights[column] / weightSum - } - break - } - for column in pinned { widths[column] = minColumn; remaining -= minColumn } - active.removeAll { pinned.contains($0) } - if remaining <= 0 { break } - } - return widths - } - - private func resolve(_ subviews: Subviews, total: CGFloat, cache: inout Cache) { - if cache.width == total, !cache.columnWidths.isEmpty { return } - let widths = columnWidths(for: total) - var heights = [CGFloat](repeating: 0, count: rowCount) - for (offset, subview) in subviews.enumerated() { - let row = offset / columns - let column = offset % columns - guard row < rowCount, column < columns else { continue } - let height = subview.sizeThatFits( - ProposedViewSize(width: widths[column], height: nil) - ).height - heights[row] = max(heights[row], height) - } - cache.columnWidths = widths - cache.rowHeights = heights - cache.width = total - } - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, - cache: inout Cache) -> CGSize { - let total = proposal.width ?? 320 - resolve(subviews, total: total, cache: &cache) - return CGSize(width: total, height: cache.rowHeights.reduce(0, +)) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, - subviews: Subviews, cache: inout Cache) { - resolve(subviews, total: bounds.width, cache: &cache) - let widths = cache.columnWidths - let heights = cache.rowHeights - var xOffsets = [CGFloat](repeating: 0, count: columns) - var accX: CGFloat = 0 - for column in 0.. Cache { Cache() } + + /// Distribute `total` width across columns proportional to weight, but never + /// below `minColumn`; columns pinned to the floor drop out and the remainder + /// re-shares among the rest. + private func columnWidths(for total: CGFloat) -> [CGFloat] { + guard columns > 0 else { return [] } + var widths = [CGFloat](repeating: minColumn, count: columns) + var active = Array(0.. 0 else { + let each = max(minColumn, remaining / CGFloat(active.count)) + for column in active { widths[column] = each } + break + } + var pinned: [Int] = [] + for column in active where remaining * weights[column] / weightSum < minColumn { + pinned.append(column) + } + if pinned.isEmpty { + for column in active { + widths[column] = remaining * weights[column] / weightSum } + break + } + for column in pinned { + widths[column] = minColumn + remaining -= minColumn + } + active.removeAll { pinned.contains($0) } + if remaining <= 0 { break } + } + return widths + } + + private func resolve(_ subviews: Subviews, total: CGFloat, cache: inout Cache) { + if cache.width == total, !cache.columnWidths.isEmpty { return } + let widths = columnWidths(for: total) + var heights = [CGFloat](repeating: 0, count: rowCount) + for (offset, subview) in subviews.enumerated() { + let row = offset / columns + let column = offset % columns + guard row < rowCount, column < columns else { continue } + let height = subview.sizeThatFits( + ProposedViewSize(width: widths[column], height: nil) + ).height + heights[row] = max(heights[row], height) + } + cache.columnWidths = widths + cache.rowHeights = heights + cache.width = total + } + + func sizeThatFits( + proposal: ProposedViewSize, subviews: Subviews, + cache: inout Cache + ) -> CGSize { + let total = proposal.width ?? 320 + resolve(subviews, total: total, cache: &cache) + return CGSize(width: total, height: cache.rowHeights.reduce(0, +)) + } + + func placeSubviews( + in bounds: CGRect, proposal: ProposedViewSize, + subviews: Subviews, cache: inout Cache + ) { + resolve(subviews, total: bounds.width, cache: &cache) + let widths = cache.columnWidths + let heights = cache.rowHeights + var xOffsets = [CGFloat](repeating: 0, count: columns) + var accX: CGFloat = 0 + for column in 0.. [MDBlock] { - var blocks: [MDBlock] = [] - var paragraph: [String] = [] - - func flush() { - if !paragraph.isEmpty { - blocks.append(.paragraph(paragraph.joined(separator: "\n"))) - paragraph.removeAll(keepingCapacity: true) - } - } - - let lines = raw.components(separatedBy: "\n") - var i = 0 - while i < lines.count { - let line = lines[i] - let trimmed = line.trimmingCharacters(in: .whitespaces) - - // A fenced code block opened with N backticks closes only on a line - // whose backtick run is >= N (CommonMark). This lets a ````md block - // carry inner ```ts fences verbatim instead of closing early. - if let fence = openingFence(trimmed) { - flush() - var body: [String] = [] - i += 1 - while i < lines.count { - let closeTrim = lines[i].trimmingCharacters(in: .whitespaces) - if let closeTicks = closingFence(closeTrim), closeTicks >= fence.ticks { - i += 1 // consume the closing fence - break - } - body.append(lines[i]) - i += 1 - } - blocks.append(.code(language: fence.language, - body.joined(separator: "\n"))) - continue - } - - if trimmed.isEmpty { - flush() - i += 1 - continue - } - - // A thematic break: 3+ of the same `-`/`*`/`_` on a line of nothing - // else (spaces allowed). Table separators are consumed by the table - // branch below before they can reach here, so a lone `---` is an HR. - if isThematicBreak(trimmed) { - flush() - blocks.append(.thematicBreak) - i += 1 - continue - } + case paragraph(String) + case heading(level: Int, text: String) + case bullet(indent: Int, text: String) + case ordered(indent: Int, number: Int, text: String) + case task(indent: Int, done: Bool, text: String) + case blockquote(String) + case table(header: [String], rows: [[String]]) + case code(language: String?, String) + case thematicBreak + + /// Split raw text into blocks. Consecutive plain lines (no blank line + /// between) coalesce into one paragraph, preserving their newlines. + static func parse(_ raw: String) -> [MDBlock] { + var blocks: [MDBlock] = [] + var paragraph: [String] = [] + + func flush() { + if !paragraph.isEmpty { + blocks.append(.paragraph(paragraph.joined(separator: "\n"))) + paragraph.removeAll(keepingCapacity: true) + } + } - // Table: a pipe row immediately followed by a `|---|---|` separator. - // The separator gate keeps stray-pipe prose from misfiring. - if trimmed.contains("|"), i + 1 < lines.count, - isTableSeparator(lines[i + 1]) { - flush() - let header = tableCells(trimmed) - i += 2 // consume the header and separator rows - var rows: [[String]] = [] - while i < lines.count { - let rowLine = lines[i].trimmingCharacters(in: .whitespaces) - guard !rowLine.isEmpty, rowLine.contains("|") else { break } - rows.append(tableCells(rowLine)) - i += 1 - } - blocks.append(.table(header: header, rows: rows)) - continue - } + let lines = raw.components(separatedBy: "\n") + var i = 0 + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix(">") { - flush() - var quote: [String] = [] - while i < lines.count { - let qline = lines[i].trimmingCharacters(in: .whitespaces) - guard qline.hasPrefix(">") else { break } - quote.append(stripQuoteMarker(qline)) - i += 1 - } - blocks.append(.blockquote(quote.joined(separator: "\n"))) - continue - } + // A fenced code block opened with N backticks closes only on a line + // whose backtick run is >= N (CommonMark). This lets a ````md block + // carry inner ```ts fences verbatim instead of closing early. + if let fence = openingFence(trimmed) { + flush() + var body: [String] = [] + i += 1 + while i < lines.count { + let closeTrim = lines[i].trimmingCharacters(in: .whitespaces) + if let closeTicks = closingFence(closeTrim), closeTicks >= fence.ticks { + i += 1 // consume the closing fence + break + } + body.append(lines[i]) + i += 1 + } + blocks.append( + .code( + language: fence.language, + body.joined(separator: "\n"))) + continue + } + + if trimmed.isEmpty { + flush() + i += 1 + continue + } + + // A thematic break: 3+ of the same `-`/`*`/`_` on a line of nothing + // else (spaces allowed). Table separators are consumed by the table + // branch below before they can reach here, so a lone `---` is an HR. + if isThematicBreak(trimmed) { + flush() + blocks.append(.thematicBreak) + i += 1 + continue + } + + // Table: a pipe row immediately followed by a `|---|---|` separator. + // The separator gate keeps stray-pipe prose from misfiring. + if trimmed.contains("|"), i + 1 < lines.count, + isTableSeparator(lines[i + 1]) + { + flush() + let header = tableCells(trimmed) + i += 2 // consume the header and separator rows + var rows: [[String]] = [] + while i < lines.count { + let rowLine = lines[i].trimmingCharacters(in: .whitespaces) + guard !rowLine.isEmpty, rowLine.contains("|") else { break } + rows.append(tableCells(rowLine)) + i += 1 + } + blocks.append(.table(header: header, rows: rows)) + continue + } - if let heading = headingBlock(trimmed) { - flush() - blocks.append(heading) - i += 1 - continue - } + if trimmed.hasPrefix(">") { + flush() + var quote: [String] = [] + while i < lines.count { + let qline = lines[i].trimmingCharacters(in: .whitespaces) + guard qline.hasPrefix(">") else { break } + quote.append(stripQuoteMarker(qline)) + i += 1 + } + blocks.append(.blockquote(quote.joined(separator: "\n"))) + continue + } - if let item = listBlock(line) { - flush() - blocks.append(item) - i += 1 - continue - } + if let heading = headingBlock(trimmed) { + flush() + blocks.append(heading) + i += 1 + continue + } - paragraph.append(trimmed) - i += 1 - } + if let item = listBlock(line) { flush() - return blocks - } + blocks.append(item) + i += 1 + continue + } - private static func headingBlock(_ s: String) -> MDBlock? { - var level = 0 - var idx = s.startIndex - while idx < s.endIndex, s[idx] == "#", level < 6 { - level += 1 - idx = s.index(after: idx) - } - guard level > 0, idx < s.endIndex, s[idx] == " " else { return nil } - let text = String(s[idx...]).trimmingCharacters(in: .whitespaces) - return .heading(level: level, text: text) - } - - private static func listBlock(_ line: String) -> MDBlock? { - let leading = line.prefix { $0 == " " }.count - let indent = leading / 2 - let content = line.drop { $0 == " " } - - if let first = content.first, "-*+".contains(first) { - let after = content.dropFirst() - if after.first == " " { - let body = String(after.dropFirst()).trimmingCharacters(in: .whitespaces) - if let task = taskBlock(indent: indent, body: body) { - return task - } - return .bullet(indent: indent, text: body) - } + paragraph.append(trimmed) + i += 1 + } + flush() + return blocks + } + + private static func headingBlock(_ s: String) -> MDBlock? { + var level = 0 + var idx = s.startIndex + while idx < s.endIndex, s[idx] == "#", level < 6 { + level += 1 + idx = s.index(after: idx) + } + guard level > 0, idx < s.endIndex, s[idx] == " " else { return nil } + let text = String(s[idx...]).trimmingCharacters(in: .whitespaces) + return .heading(level: level, text: text) + } + + private static func listBlock(_ line: String) -> MDBlock? { + let leading = line.prefix { $0 == " " }.count + let indent = leading / 2 + let content = line.drop { $0 == " " } + + if let first = content.first, "-*+".contains(first) { + let after = content.dropFirst() + if after.first == " " { + let body = String(after.dropFirst()).trimmingCharacters(in: .whitespaces) + if let task = taskBlock(indent: indent, body: body) { + return task } + return .bullet(indent: indent, text: body) + } + } - let digits = content.prefix { $0.isNumber } - if !digits.isEmpty { - let rest = content.dropFirst(digits.count) - if rest.first == ".", rest.dropFirst().first == " " { - let text = String(rest.dropFirst(2)).trimmingCharacters(in: .whitespaces) - return .ordered(indent: indent, number: Int(digits) ?? 1, text: text) - } - } - return nil - } - - /// A `[x]` / `[X]` / `[ ]` checkbox prefix on a bullet body → task item. - private static func taskBlock(indent: Int, body: String) -> MDBlock? { - guard body.hasPrefix("[") else { return nil } - let inner = body.dropFirst() - guard let mark = inner.first, inner.dropFirst().first == "]" else { return nil } - let rest = inner.dropFirst(2) - guard rest.isEmpty || rest.first == " " else { return nil } - let done: Bool - switch mark { - case "x", "X": done = true - case " ": done = false - default: return nil - } - return .task(indent: indent, done: done, - text: String(rest).trimmingCharacters(in: .whitespaces)) - } - - /// Strip a single leading `>` marker (plus one optional space). Keeping the - /// remainder intact — including any inner `>` or list indentation — lets the - /// blockquote body be re-parsed recursively (nested quotes, lists, fences). - private static func stripQuoteMarker(_ line: String) -> String { - var slice = Substring(line) - if slice.first == ">" { slice = slice.dropFirst() } - if slice.first == " " { slice = slice.dropFirst() } - return String(slice) - } - - /// Leading backtick count of an opening fence (>= 3) plus its language hint, - /// or nil. The info string after the ticks must not itself contain a backtick - /// (CommonMark), which keeps an inline `` `code` `` run from being read as a - /// fence. The language is the first whitespace-delimited token of the info - /// string (``` ```rust ``` → `rust`; extra info like ``` ```ts title=x ``` - /// keeps only `ts`); an empty info string yields `nil`. - private static func openingFence(_ s: String) -> (ticks: Int, language: String?)? { - let ticks = s.prefix { $0 == "`" }.count - guard ticks >= 3 else { return nil } - let info = s.dropFirst(ticks) - guard !info.contains("`") else { return nil } - let language = info.split(whereSeparator: { $0.isWhitespace }) - .first - .map(String.init) - return (ticks, language) - } - - /// Backtick count of a closing fence line (>= 3, nothing but ticks and - /// trailing spaces), or nil. - private static func closingFence(_ s: String) -> Int? { - let ticks = s.prefix { $0 == "`" }.count - guard ticks >= 3 else { return nil } - return s.dropFirst(ticks).allSatisfy { $0 == " " } ? ticks : nil - } - - /// A CommonMark thematic break: 3+ of a single `-`/`*`/`_`, with only spaces - /// otherwise. Also matches spaced forms like `- - -` and `* * *`. - private static func isThematicBreak(_ s: String) -> Bool { - let core = s.filter { $0 != " " && $0 != "\t" } - guard core.count >= 3, let first = core.first, "-*_".contains(first) else { - return false - } - return core.allSatisfy { $0 == first } - } - - /// Split a pipe row into trimmed cells, dropping the optional outer pipes. - static func tableCells(_ line: String) -> [String] { - var body = line.trimmingCharacters(in: .whitespaces) - if body.hasPrefix("|") { body.removeFirst() } - if body.hasSuffix("|") { body.removeLast() } - return body.components(separatedBy: "|") - .map { $0.trimmingCharacters(in: .whitespaces) } - } - - /// True for a GFM separator row such as `|---|:--:|`: every cell is dashes - /// with optional alignment colons, and at least one cell carries a dash. - static func isTableSeparator(_ line: String) -> Bool { - let cells = tableCells(line) - guard !cells.isEmpty else { return false } - return cells.allSatisfy { cell in - !cell.isEmpty && cell.contains("-") - && cell.allSatisfy { $0 == "-" || $0 == ":" } - } + let digits = content.prefix { $0.isNumber } + if !digits.isEmpty { + let rest = content.dropFirst(digits.count) + if rest.first == ".", rest.dropFirst().first == " " { + let text = String(rest.dropFirst(2)).trimmingCharacters(in: .whitespaces) + return .ordered(indent: indent, number: Int(digits) ?? 1, text: text) + } + } + return nil + } + + /// A `[x]` / `[X]` / `[ ]` checkbox prefix on a bullet body → task item. + private static func taskBlock(indent: Int, body: String) -> MDBlock? { + guard body.hasPrefix("[") else { return nil } + let inner = body.dropFirst() + guard let mark = inner.first, inner.dropFirst().first == "]" else { return nil } + let rest = inner.dropFirst(2) + guard rest.isEmpty || rest.first == " " else { return nil } + let done: Bool + switch mark { + case "x", "X": done = true + case " ": done = false + default: return nil + } + return .task( + indent: indent, done: done, + text: String(rest).trimmingCharacters(in: .whitespaces)) + } + + /// Strip a single leading `>` marker (plus one optional space). Keeping the + /// remainder intact — including any inner `>` or list indentation — lets the + /// blockquote body be re-parsed recursively (nested quotes, lists, fences). + private static func stripQuoteMarker(_ line: String) -> String { + var slice = Substring(line) + if slice.first == ">" { slice = slice.dropFirst() } + if slice.first == " " { slice = slice.dropFirst() } + return String(slice) + } + + /// Leading backtick count of an opening fence (>= 3) plus its language hint, + /// or nil. The info string after the ticks must not itself contain a backtick + /// (CommonMark), which keeps an inline `` `code` `` run from being read as a + /// fence. The language is the first whitespace-delimited token of the info + /// string (``` ```rust ``` → `rust`; extra info like ``` ```ts title=x ``` + /// keeps only `ts`); an empty info string yields `nil`. + private static func openingFence(_ s: String) -> (ticks: Int, language: String?)? { + let ticks = s.prefix { $0 == "`" }.count + guard ticks >= 3 else { return nil } + let info = s.dropFirst(ticks) + guard !info.contains("`") else { return nil } + let language = info.split(whereSeparator: { $0.isWhitespace }) + .first + .map(String.init) + return (ticks, language) + } + + /// Backtick count of a closing fence line (>= 3, nothing but ticks and + /// trailing spaces), or nil. + private static func closingFence(_ s: String) -> Int? { + let ticks = s.prefix { $0 == "`" }.count + guard ticks >= 3 else { return nil } + return s.dropFirst(ticks).allSatisfy { $0 == " " } ? ticks : nil + } + + /// A CommonMark thematic break: 3+ of a single `-`/`*`/`_`, with only spaces + /// otherwise. Also matches spaced forms like `- - -` and `* * *`. + private static func isThematicBreak(_ s: String) -> Bool { + let core = s.filter { $0 != " " && $0 != "\t" } + guard core.count >= 3, let first = core.first, "-*_".contains(first) else { + return false + } + return core.allSatisfy { $0 == first } + } + + /// Split a pipe row into trimmed cells, dropping the optional outer pipes. + static func tableCells(_ line: String) -> [String] { + var body = line.trimmingCharacters(in: .whitespaces) + if body.hasPrefix("|") { body.removeFirst() } + if body.hasSuffix("|") { body.removeLast() } + return body.components(separatedBy: "|") + .map { $0.trimmingCharacters(in: .whitespaces) } + } + + /// True for a GFM separator row such as `|---|:--:|`: every cell is dashes + /// with optional alignment colons, and at least one cell carries a dash. + static func isTableSeparator(_ line: String) -> Bool { + let cells = tableCells(line) + guard !cells.isEmpty else { return false } + return cells.allSatisfy { cell in + !cell.isEmpty && cell.contains("-") + && cell.allSatisfy { $0 == "-" || $0 == ":" } } + } } diff --git a/macos/Codescribe/Screens/AgentChat/CodeHighlighter.swift b/macos/Codescribe/Screens/AgentChat/CodeHighlighter.swift index 2f82bf2c..b3a4c6d5 100644 --- a/macos/Codescribe/Screens/AgentChat/CodeHighlighter.swift +++ b/macos/Codescribe/Screens/AgentChat/CodeHighlighter.swift @@ -1,5 +1,5 @@ -import SwiftUI import HighlightSwift +import SwiftUI // Syntax highlighting for finalized agent-chat code blocks. // @@ -21,32 +21,32 @@ import HighlightSwift // here. enum CodeHighlighter { - // Lazily instantiated on first access (Swift `static let`), so the engine — - // and the JSContext it wraps — never warms during app launch. - private static let engine = Highlight() + // Lazily instantiated on first access (Swift `static let`), so the engine — + // and the JSContext it wraps — never warms during app launch. + private static let engine = Highlight() - /// Highlight `code` with the brand token theme, off the main render path. - /// - /// - Parameters: - /// - language: the fence info-string hint (`rust`, `ts`, …). When present - /// it is passed as a highlight.js language alias; an unknown alias makes - /// HighlightSwift fall back to plain text internally. When `nil`/empty, - /// highlight.js auto-detects the language. - /// - dark: pick the dark or light token CSS. - /// - Returns: the highlighted `AttributedString`, or `nil` on failure so the - /// caller keeps its plain-mono placeholder (never a crash, never empty). - static func attributed(_ code: String, language: String?, dark: Bool) async -> AttributedString? { - let colors = HighlightColors.custom(css: dark ? CodeTheme.darkCSS : CodeTheme.lightCSS) - do { - if let rawHint = language?.trimmingCharacters(in: .whitespaces), !rawHint.isEmpty { - let hint = rawHint.lowercased() - return try await engine.attributedText(code, language: hint, colors: colors) - } - return try await engine.attributedText(code, colors: colors) - } catch { - return nil - } + /// Highlight `code` with the brand token theme, off the main render path. + /// + /// - Parameters: + /// - language: the fence info-string hint (`rust`, `ts`, …). When present + /// it is passed as a highlight.js language alias; an unknown alias makes + /// HighlightSwift fall back to plain text internally. When `nil`/empty, + /// highlight.js auto-detects the language. + /// - dark: pick the dark or light token CSS. + /// - Returns: the highlighted `AttributedString`, or `nil` on failure so the + /// caller keeps its plain-mono placeholder (never a crash, never empty). + static func attributed(_ code: String, language: String?, dark: Bool) async -> AttributedString? { + let colors = HighlightColors.custom(css: dark ? CodeTheme.darkCSS : CodeTheme.lightCSS) + do { + if let rawHint = language?.trimmingCharacters(in: .whitespaces), !rawHint.isEmpty { + let hint = rawHint.lowercased() + return try await engine.attributedText(code, language: hint, colors: colors) + } + return try await engine.attributedText(code, colors: colors) + } catch { + return nil } + } } /// highlight.js token-class → CSColor mapping, emitted as CSS for @@ -54,17 +54,17 @@ enum CodeHighlighter { /// so the code palette tracks the design system. No `background` rules — the code /// block keeps its own `surfaceRaised` fill. enum CodeTheme { - // Dark surface (the agent chat is pinned to .preferredColorScheme(.dark), so - // this is the variant in use today). - // base → textBodyAlt #DFE2DB - // keyword/type → terracotta #D97757 (the one brand accent) - // string/addition → oliveLight #9DB178 - // number/meta → amber #D6B24E - // title/function → terracottaLight #E9B79F - // comment → textFaint #6F7268 - // attr/operator → textMuted #9A9D97 - // doctag/strong → textHigh #F4F2EC - static let darkCSS = """ + // Dark surface (the agent chat is pinned to .preferredColorScheme(.dark), so + // this is the variant in use today). + // base → textBodyAlt #DFE2DB + // keyword/type → terracotta #D97757 (the one brand accent) + // string/addition → oliveLight #9DB178 + // number/meta → amber #D6B24E + // title/function → terracottaLight #E9B79F + // comment → textFaint #6F7268 + // attr/operator → textMuted #9A9D97 + // doctag/strong → textHigh #F4F2EC + static let darkCSS = """ .hljs{color:#DFE2DB} .hljs-comment,.hljs-quote{color:#6F7268} .hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-type,.hljs-tag,.hljs-name,.hljs-template-tag{color:#D97757} @@ -76,12 +76,12 @@ enum CodeTheme { .hljs-deletion,.hljs-link{color:#D97757} """ - // Light surface: a forward-looking variant (dormant while the chat is - // dark-pinned). Reuses darker CSColor tokens so tokens read on a light fill — - // olive #5F6B3E, terracottaDeep #C98A6E, eyebrowOlive #7F8C5E, textFaintAlt - // #5D6058, textMutedAlt #82857F, ink #090A0D — rather than inventing a new - // palette the design system does not carry. - static let lightCSS = """ + // Light surface: a forward-looking variant (dormant while the chat is + // dark-pinned). Reuses darker CSColor tokens so tokens read on a light fill — + // olive #5F6B3E, terracottaDeep #C98A6E, eyebrowOlive #7F8C5E, textFaintAlt + // #5D6058, textMutedAlt #82857F, ink #090A0D — rather than inventing a new + // palette the design system does not carry. + static let lightCSS = """ .hljs{color:#090A0D} .hljs-comment,.hljs-quote{color:#82857F} .hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-type,.hljs-tag,.hljs-name,.hljs-template-tag{color:#C98A6E} diff --git a/macos/Codescribe/Screens/AgentChat/Composer.swift b/macos/Codescribe/Screens/AgentChat/Composer.swift index d431117c..3c252f3b 100644 --- a/macos/Codescribe/Screens/AgentChat/Composer.swift +++ b/macos/Codescribe/Screens/AgentChat/Composer.swift @@ -6,8 +6,8 @@ import UniformTypeIdentifiers /// Diagnostic breadcrumbs for the attachment staging path. Filter with: /// log show --predicate 'subsystem == "com.vetcoders.codescribe"' --info private let attachLog = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "attachments" + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "attachments" ) /// Bottom composer: the 📎 attach button (image picker), staged-attachment chips, @@ -16,800 +16,814 @@ private let attachLog = Logger( /// capability hints. Images stage through three converging paths — picker, /// drag & drop, and ⌘V paste — all landing in `store.addAttachments`. struct Composer: View { - @ObservedObject var store: AgentChatStore - @State private var fieldFocused = false - /// Chat text scale (⌘+/-/0) — applied to the message field + placeholder so the - /// composer input tracks the message bodies. Chrome (chips, affordance hints, - /// icons) keeps its intrinsic size. - @Environment(\.csTextScale) private var textScale - @Environment(\.openSettings) private var openSettings - @State private var fieldHeight = ComposerTextLayout.minimumHeight(fontSize: 13.5) - @AppStorage("AgentChat.dictationPreviewExpanded.v1") private var dictationPreviewExpanded = false - @State private var previewAttachment: PendingAttachment? - - // ⌘V interception. The native text editor consumes `paste:` before any - // SwiftUI `.onPasteCommand` gets a look-in, so pasting an image needs a local - // key monitor (same pattern as the ⌘+/-/0 monitor in App.swift). Scoped hard: - // only fires when the composer field itself is focused in this view's own - // window, so ⌘V in the thread-rail search, Settings, or any other field is - // untouched. - @State private var pasteMonitor: Any? - /// Window hosting this composer — resolved via `hostWindowReader` so the - /// monitor can ignore key events belonging to other windows. - @State private var hostWindow: NSWindow? - - // Drag-over is tracked by two OR'd targets so it stays stable as the pointer - // crosses from the composer padding onto the text field. The outer target - // (whole strip) bootstraps the drag; the inner clear overlay (above the - // text view) actually intercepts a drop that lands *on* the field, so the - // field editor never eats it as pasted path text. See `dropCatcher`. - @State private var overOuter = false - @State private var overInner = false - /// True while an image is being dragged anywhere over the composer. - private var isDragging: Bool { overOuter || overInner } - - var body: some View { - VStack(alignment: .leading, spacing: 9) { - agenticGate - - // Palette sits ABOVE the field: the list grows upward from the - // caret like every other completion popup on the platform, and the - // field never jumps down as rows appear. - paletteOverlay - - if !store.pendingAttachments.isEmpty { - attachmentChips - } - - HStack(alignment: .bottom, spacing: 10) { - // Attach images (NSOpenPanel → staged chips → vision FFI on send). - Button(action: pickAttachments) { - CSIconView( - icon: .attach, - size: ComposerControlMetrics.glyphSize, - color: store.pendingAttachments.isEmpty ? CSColor.textFaint : CSColor.chromeAccent - ) - .frame( - width: ComposerControlMetrics.hitTargetSize, - height: ComposerControlMetrics.hitTargetSize - ) - } - .csFocusRing(cornerRadius: 8) - .help("Attach an image (PNG, JPEG, GIF, WebP)") - - ComposerTextView( - text: $store.draft, - height: $fieldHeight, - textScale: textScale, - isFocused: $fieldFocused, - history: store.selectedThreadID.map { store.composerHistory(in: $0) } ?? [], - onSend: { store.send() } - ) - .frame(height: fieldHeight) - .accessibilityIdentifier(ComposerAccessibility.textViewIdentifier) - - micButton - - Button(action: performPrimaryAction) { - ZStack { - CSIconView( - icon: primaryAction.icon, - size: ComposerControlMetrics.glyphSize, - weight: primaryAction.iconWeight, - color: CSColor.chromeAccent - ) - if primaryAction == .stopping { - ProgressView() - .controlSize(.small) - .scaleEffect(0.5) - .tint(CSColor.chromeAccent) - } - } - .frame( - width: ComposerControlMetrics.hitTargetSize, - height: ComposerControlMetrics.hitTargetSize - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(!primaryAction.isEnabled) - .opacity(primaryAction == .stopping ? 0.72 : 1) - .help(primaryAction.accessibilityLabel) - .accessibilityIdentifier(ComposerActionAccessibility.identifier) - .accessibilityLabel(Text(primaryAction.accessibilityLabel)) - } - .padding(.leading, 13) - .padding(.trailing, 11) - .padding(.vertical, 9) - .background(CSColor.surfaceRaised(isDragging ? 0.07 : 0.04)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder( - isDragging ? CSColor.chromeAccent : CSColor.hairline(0.09), - lineWidth: isDragging ? 1.5 : 1 - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - // Sits above the NSTextField and swallows a drop that lands *on* the - // field, so the field editor never pastes the path as text. Only - // hit-testable mid-drag (isDragging) so typing/clicks pass through - // the rest of the time. - .overlay(dropCatcher) - .animation(.easeOut(duration: 0.12), value: isDragging) - - dictationPreview - dictationFeedback - - // Affordance row - HStack(spacing: 16) { - ForEach(affordances, id: \.self) { item in - Text(item) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - } - } - .padding(.horizontal, 18) - .padding(.vertical, 14) - .overlay(alignment: .top) { - Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) - } - // Drag an image from Finder onto the composer to stage it (same path as - // the 📎 picker). The whole bottom strip is the outer drop area — it - // bootstraps the drag-over state and catches drops that miss the field; - // `dropCatcher` handles drops on the field itself. - .onDrop(of: [.fileURL], isTargeted: $overOuter) { providers in - handleDrop(providers) + @ObservedObject var store: AgentChatStore + @State private var fieldFocused = false + /// Chat text scale (⌘+/-/0) — applied to the message field + placeholder so the + /// composer input tracks the message bodies. Chrome (chips, affordance hints, + /// icons) keeps its intrinsic size. + @Environment(\.csTextScale) private var textScale + @Environment(\.openSettings) private var openSettings + @State private var fieldHeight = ComposerTextLayout.minimumHeight(fontSize: 13.5) + @AppStorage("AgentChat.dictationPreviewExpanded.v1") private var dictationPreviewExpanded = false + @State private var previewAttachment: PendingAttachment? + + // ⌘V interception. The native text editor consumes `paste:` before any + // SwiftUI `.onPasteCommand` gets a look-in, so pasting an image needs a local + // key monitor (same pattern as the ⌘+/-/0 monitor in App.swift). Scoped hard: + // only fires when the composer field itself is focused in this view's own + // window, so ⌘V in the thread-rail search, Settings, or any other field is + // untouched. + @State private var pasteMonitor: Any? + /// Window hosting this composer — resolved via `hostWindowReader` so the + /// monitor can ignore key events belonging to other windows. + @State private var hostWindow: NSWindow? + + // Drag-over is tracked by two OR'd targets so it stays stable as the pointer + // crosses from the composer padding onto the text field. The outer target + // (whole strip) bootstraps the drag; the inner clear overlay (above the + // text view) actually intercepts a drop that lands *on* the field, so the + // field editor never eats it as pasted path text. See `dropCatcher`. + @State private var overOuter = false + @State private var overInner = false + /// True while an image is being dragged anywhere over the composer. + private var isDragging: Bool { overOuter || overInner } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + agenticGate + + // Palette sits ABOVE the field: the list grows upward from the + // caret like every other completion popup on the platform, and the + // field never jumps down as rows appear. + paletteOverlay + + if !store.pendingAttachments.isEmpty { + attachmentChips + } + + HStack(alignment: .bottom, spacing: 10) { + // Attach images (NSOpenPanel → staged chips → vision FFI on send). + Button(action: pickAttachments) { + CSIconView( + icon: .attach, + size: ComposerControlMetrics.glyphSize, + color: store.pendingAttachments.isEmpty ? CSColor.textFaint : CSColor.chromeAccent + ) + .frame( + width: ComposerControlMetrics.hitTargetSize, + height: ComposerControlMetrics.hitTargetSize + ) } - .background(hostWindowReader) - .onAppear(perform: installPasteMonitor) - .onDisappear(perform: removePasteMonitor) - .task(id: store.composerFocusRequest) { - guard store.composerFocusRequest > 0 else { return } - // The first summon can create the window and request focus in the - // same run-loop turn. Yield once so the native field editor exists. - await Task.yield() - focusNativeComposer() - } - } - - private var primaryAction: ComposerActionVisualState { - ComposerActionVisualState.resolve( - canSend: store.canSend, - activePhase: store.selectedComposerTurnPhase + .csFocusRing(cornerRadius: 8) + .help("Attach an image (PNG, JPEG, GIF, WebP)") + + ComposerTextView( + text: $store.draft, + height: $fieldHeight, + textScale: textScale, + isFocused: $fieldFocused, + history: store.selectedThreadID.map { store.composerHistory(in: $0) } ?? [], + onSend: { store.send() } ) - } - - @ViewBuilder - private var agenticGate: some View { - if let message = store.agenticBlockMessage { - HStack(spacing: 9) { - CSIconView(icon: .warning, size: 11, color: CSColor.amber) - Text(message) - .font(CSFont.ui(11.5, .medium)) - .foregroundStyle(CSColor.textBodyAlt) - Spacer(minLength: 8) - Button("Enter license") { - SettingsDeepLink.pendingSection = .license - openSettings() - } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - } - .padding(.horizontal, 11) - .padding(.vertical, 8) - .background(CSColor.surfaceRaised(0.04)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.amber.opacity(0.28), lineWidth: 1) + .frame(height: fieldHeight) + .accessibilityIdentifier(ComposerAccessibility.textViewIdentifier) + + micButton + + Button(action: performPrimaryAction) { + ZStack { + CSIconView( + icon: primaryAction.icon, + size: ComposerControlMetrics.glyphSize, + weight: primaryAction.iconWeight, + color: CSColor.chromeAccent ) - } - } - - /// Slash-command palette. Rendered only while the draft parses as a command, - /// so an ordinary message never sees it. - @ViewBuilder - private var paletteOverlay: some View { - switch ComposerPaletteQuery.parse(store.draft) { - case .inactive: - EmptyView() - case .commands(let prefix): - let commands = ComposerPaletteCommand.matching(prefix: prefix) - if !commands.isEmpty { - paletteList { - ForEach(commands, id: \.self) { command in - paletteRow( - title: "/\(command.keyword)", - subtitle: command.summary, - isCurrent: false - ) { - store.draft = ComposerPalette.draft(afterPicking: command) - } - } - } - } - case .entries(let command, let filter): - let entries = ComposerPalette.filter(store.paletteEntries(for: command), by: filter) - paletteList { - if entries.isEmpty { - Text("Brak pozycji") - .font(CSFont.ui(11.5, .regular)) - .foregroundStyle(CSColor.textFaintAlt) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } else { - ForEach(entries) { entry in - paletteRow( - title: entry.title, - subtitle: entry.subtitle, - isCurrent: entry.isCurrent - ) { - store.applyPaletteEntry(entry, for: command) - store.draft = ComposerPalette.draftAfterApplyingEntry - } - } - } + if primaryAction == .stopping { + ProgressView() + .controlSize(.small) + .scaleEffect(0.5) + .tint(CSColor.chromeAccent) } - } - } - - private func paletteList(@ViewBuilder content: () -> Content) -> some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { content() } - } - .frame(maxHeight: 190) - .background(CSColor.surfaceRaised(0.05)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.chromeAccent.opacity(0.28), lineWidth: 1) - ) - } - - private func paletteRow( - title: String, - subtitle: String?, - isCurrent: Bool, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(CSFont.mono(11.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(CSFont.ui(11, .regular)) - .foregroundStyle(CSColor.textFaintAlt) - } - } - Spacer(minLength: 8) - if isCurrent { - Text("aktywny") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.chromeAccent) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 7) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) + } + .frame( + width: ComposerControlMetrics.hitTargetSize, + height: ComposerControlMetrics.hitTargetSize + ) } .csFocusRing(cornerRadius: 8) - } - - private func performPrimaryAction() { - switch primaryAction { - case .send: - store.send() - case .stop: - store.stopActiveTurn() - case .stopping: - break - } - } - - /// W1-B moved the composer onto a native `NSTextView`. Keep the SwiftUI - /// focus binding in sync, then explicitly make that view first responder so - /// a newly-created Agent window and an already-visible window behave alike. - @MainActor - private func focusNativeComposer() { - fieldFocused = true - let window = hostWindow - DispatchQueue.main.async { - guard let textView = Self.nativeComposer(in: window?.contentView) else { return } - window?.makeFirstResponder(textView) - } - } - - @MainActor - private static func nativeComposer(in view: NSView?) -> NSTextView? { - guard let view else { return nil } - if let textView = view as? NSTextView, - textView.accessibilityIdentifier() == "agent-composer-text" { - return textView - } - for child in view.subviews { - if let textView = nativeComposer(in: child) { return textView } - } - return nil - } - - // MARK: Voice-note mic - - /// The composer mic: click to start a voice note, click again to stop and - /// insert the transcript into the draft. The ripple lives only while - /// recording; a spinner shows the preparing transition. Disabled (and dimmed) - /// while a hotkey/overlay dictation session owns the microphone. - private var micButton: some View { - Button(action: { store.toggleDictation() }) { - micVisual - .frame( - width: ComposerControlMetrics.hitTargetSize, - height: ComposerControlMetrics.hitTargetSize - ) - .contentShape(Rectangle()) - .opacity(micState == .blocked ? 0.35 : micState == .preparing ? 0.68 : 1) + .disabled(!primaryAction.isEnabled) + .opacity(primaryAction == .stopping ? 0.72 : 1) + .help(primaryAction.accessibilityLabel) + .accessibilityIdentifier(ComposerActionAccessibility.identifier) + .accessibilityLabel(Text(primaryAction.accessibilityLabel)) + } + .padding(.leading, 13) + .padding(.trailing, 11) + .padding(.vertical, 9) + .background(CSColor.surfaceRaised(isDragging ? 0.07 : 0.04)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder( + isDragging ? CSColor.chromeAccent : CSColor.hairline(0.09), + lineWidth: isDragging ? 1.5 : 1 + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + // Sits above the NSTextField and swallows a drop that lands *on* the + // field, so the field editor never pastes the path as text. Only + // hit-testable mid-drag (isDragging) so typing/clicks pass through + // the rest of the time. + .overlay(dropCatcher) + .animation(.easeOut(duration: 0.12), value: isDragging) + + dictationPreview + dictationFeedback + + // Affordance row + HStack(spacing: 16) { + ForEach(affordances, id: \.self) { item in + Text(item) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .overlay(alignment: .top) { + Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) + } + // Drag an image from Finder onto the composer to stage it (same path as + // the 📎 picker). The whole bottom strip is the outer drop area — it + // bootstraps the drag-over state and catches drops that miss the field; + // `dropCatcher` handles drops on the field itself. + .onDrop(of: [.fileURL], isTargeted: $overOuter) { providers in + handleDrop(providers) + } + .background(hostWindowReader) + .onAppear(perform: installPasteMonitor) + .onDisappear(perform: removePasteMonitor) + .task(id: store.composerFocusRequest) { + guard store.composerFocusRequest > 0 else { return } + // The first summon can create the window and request focus in the + // same run-loop turn. Yield once so the native field editor exists. + await Task.yield() + focusNativeComposer() + } + } + + private var primaryAction: ComposerActionVisualState { + ComposerActionVisualState.resolve( + canSend: store.canSend, + activePhase: store.selectedComposerTurnPhase + ) + } + + @ViewBuilder + private var agenticGate: some View { + if let message = store.agenticBlockMessage { + HStack(spacing: 9) { + CSIconView(icon: .warning, size: 11, color: CSColor.amber) + Text(message) + .font(CSFont.ui(11.5, .medium)) + .foregroundStyle(CSColor.textBodyAlt) + Spacer(minLength: 8) + Button("Enter license") { + SettingsDeepLink.pendingSection = .license + openSettings() } .csFocusRing(cornerRadius: 8) - .disabled(!micState.isEnabled) - .help(micState.accessibilityLabel) - .accessibilityIdentifier(ComposerAccessibility.micIdentifier) - .accessibilityLabel(Text(micState.accessibilityLabel)) - .animation(.easeOut(duration: 0.15), value: store.dictationPhase) - } - - private var micState: ComposerMicVisualState { - if store.dictationBlocked { return .blocked } - switch store.dictationPhase { - case .preparing: return .preparing - case .recording: return .recording - case .idle, .failed: return .idle - } - } - - private var micVisual: some View { - ZStack(alignment: .bottomTrailing) { - RippleMic(state: micState) - if micState == .preparing { - Circle() - .fill(CSColor.glassUnder) - .frame(width: 10, height: 10) - .overlay { - ProgressView() - .controlSize(.small) - .scaleEffect(0.42) - } - .offset(x: 2, y: 2) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background(CSColor.surfaceRaised(0.04)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.amber.opacity(0.28), lineWidth: 1) + ) + } + } + + /// Slash-command palette. Rendered only while the draft parses as a command, + /// so an ordinary message never sees it. + @ViewBuilder + private var paletteOverlay: some View { + switch ComposerPaletteQuery.parse(store.draft) { + case .inactive: + EmptyView() + case .commands(let prefix): + let commands = ComposerPaletteCommand.matching(prefix: prefix) + if !commands.isEmpty { + paletteList { + ForEach(commands, id: \.self) { command in + paletteRow( + title: "/\(command.keyword)", + subtitle: command.summary, + isCurrent: false + ) { + store.draft = ComposerPalette.draft(afterPicking: command) } - } - } - - /// Small non-modal error line under the input box (permission off, no speech, - /// STT failure). Self-clears via the store after a few seconds. - @ViewBuilder - private var dictationFeedback: some View { - if case let .failed(message) = store.dictationPhase { - HStack(spacing: 6) { - CSIconView(icon: .error, size: 10.5) - Text(message) - .font(CSFont.mono(10.5, .medium)) - } - .foregroundStyle(CSColor.amber) - .padding(.leading, 2) - .transition(.opacity) - } - } - - @ViewBuilder - private var dictationPreview: some View { - if !store.dictationPreview.isEmpty { - VStack(alignment: .leading, spacing: 7) { - HStack(spacing: 7) { - CSIconView(icon: .mic, size: 10.5, color: CSColor.terracottaLight) - Text(dictationPhaseLabel) - Text(store.dictationVadActive ? "speech" : "silence") - .foregroundStyle(store.dictationVadActive ? CSColor.oliveLight : CSColor.textFaintAlt) - if store.dictationFinalChangedText { - Text("final differs · \(store.dictationDeliverySource.label)") - .foregroundStyle(CSColor.amber) - } - if store.dictationPreviewUserEdited { - Text("edited · auto-send off") - .foregroundStyle(CSColor.chromeAccent) - } - Spacer(minLength: 8) - Button(dictationPreviewExpanded ? "Collapse" : "Expand") { - dictationPreviewExpanded.toggle() - } - .csFocusRing(cornerRadius: 8) - .accessibilityLabel(dictationPreviewExpanded ? "Collapse transcript preview" : "Expand transcript preview") - } - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - - TextEditor(text: Binding( - get: { store.dictationPreview }, - set: { store.editDictationPreview($0) } - )) - .font(CSFont.ui(12.5 * textScale)) - .foregroundStyle(CSColor.textBodyAlt) - .scrollContentBackground(.hidden) - .frame(minHeight: dictationPreviewExpanded ? 150 : 58, - maxHeight: dictationPreviewExpanded ? 260 : 96) - .accessibilityLabel("Live transcript preview") - - if store.dictationFinalChangedText, - let final = store.dictationFinalPreview, - final != store.dictationLivePreview { - DisclosureGroup(store.dictationDeliverySource == .final ? "Live capture" : "Final-pass alternative") { - // Bounded on purpose: an unbounded Text with a long - // transcript grew the composer past the window and - // wrecked the split-view layout on expand (operator - // screenshot, 2026-08-09). The alternative scrolls - // inside its own strip instead. - ScrollView { - Text(store.dictationDeliverySource == .final ? store.dictationLivePreview : final) - .font(CSFont.ui(11.5 * textScale)) - .foregroundStyle(CSColor.textFaint) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - } - .frame(maxHeight: 140) - } - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - } - .padding(10) - .background(CSColor.surfaceRaised(0.04)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - private var dictationPhaseLabel: String { - switch store.dictationPhase { - case .idle: return "captured" - case .preparing: return "preparing" - case .recording: return "recording" - case .failed: return "stopped" - } - } - - /// Transparent drop target layered over the input box. Hit-testable only - /// while a drag is in progress, so it intercepts a field drop (beating the - /// native text editor) without blocking clicks/typing at rest. Its own - /// `isTargeted` binding keeps `isDragging` true while the pointer is over the - /// field even as the outer target reports false — no highlight flicker. - private var dropCatcher: some View { - Color.clear - .contentShape(Rectangle()) - .onDrop(of: [.fileURL], isTargeted: $overInner) { providers in - handleDrop(providers) - } - .allowsHitTesting(isDragging) - } - - // MARK: Attachment chips - - private var attachmentChips: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(store.pendingAttachments) { attachment in - HStack(spacing: 6) { - Button { - previewAttachment = attachment - } label: { - HStack(spacing: 6) { - CSIconView(icon: .photo, size: 11, color: CSColor.chromeAccent) - Text(attachment.name) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textBodyAlt) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: 160) - } - } - .csFocusRing(cornerRadius: 8) - .help("Preview attachment") - Button(action: { store.removeAttachment(attachment.id) }) { - CSIconView(icon: .close, size: 9, weight: .bold, color: CSColor.textFaint) - } - .csFocusRing(cornerRadius: 8) - .help("Remove attachment") - } - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(CSColor.surfaceRaised(0.05)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) - .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) - } + } + } + } + case .entries(let command, let filter): + let entries = ComposerPalette.filter(store.paletteEntries(for: command), by: filter) + paletteList { + if entries.isEmpty { + Text("Brak pozycji") + .font(CSFont.ui(11.5, .regular)) + .foregroundStyle(CSColor.textFaintAlt) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } else { + ForEach(entries) { entry in + paletteRow( + title: entry.title, + subtitle: entry.subtitle, + isCurrent: entry.isCurrent + ) { + store.applyPaletteEntry(entry, for: command) + store.draft = ComposerPalette.draftAfterApplyingEntry } - .padding(.horizontal, 2) - } - .frame(maxHeight: 30) - .sheet(item: $previewAttachment) { attachment in - AttachmentPreviewSheet( - attachment: attachment.previewAttachment, - onRemove: { - store.removeAttachment(attachment.id) - previewAttachment = nil - } - ) - } - } - - // MARK: Image picker - - private func pickAttachments() { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = true - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.prompt = "Attach" - panel.message = "Attach images to send to the agent" - // Restrict to the vision-supported image types the bridge actually loads. - panel.allowedContentTypes = [.png, .jpeg, .gif, .webP, .bmp, .tiff] - attachLog.info("pickAttachments: presenting NSOpenPanel (modeless begin)") - panel.begin { response in - let ok = response == .OK - let urls = ok ? panel.urls : [] - let names = urls.map { $0.lastPathComponent }.joined(separator: ", ") - attachLog.info( - "pickAttachments completion: response=\(ok ? "OK" : "cancel", privacy: .public) urls=\(urls.count, privacy: .public) files=[\(names, privacy: .public)]" - ) - guard ok else { return } - Task { @MainActor in store.addAttachments(urls) } - } - } - - // MARK: Drag & drop - - /// Image types accepted by the drop area — mirrors the NSOpenPanel picker so - /// the two staging paths agree on what counts as an image. - private static let acceptedImageTypes: [UTType] = [.png, .jpeg, .gif, .webP, .bmp, .tiff] - - /// Handle a batch of dropped file providers. Loads each file URL off-main, - /// then stages the image ones via the existing `store.addAttachments` path - /// (chips + send parity). Non-image files are rejected with a breadcrumb. - /// Returns true when at least one file-URL provider was accepted for loading. - private func handleDrop(_ providers: [NSItemProvider]) -> Bool { - let fileProviders = providers.filter { - $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) - } - attachLog.info( - "onDrop: providers=\(providers.count, privacy: .public) fileURL=\(fileProviders.count, privacy: .public)" + } + } + } + } + } + + private func paletteList(@ViewBuilder content: () -> Content) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { content() } + } + .frame(maxHeight: 190) + .background(CSColor.surfaceRaised(0.05)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.chromeAccent.opacity(0.28), lineWidth: 1) + ) + } + + private func paletteRow( + title: String, + subtitle: String?, + isCurrent: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(CSFont.mono(11.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(CSFont.ui(11, .regular)) + .foregroundStyle(CSColor.textFaintAlt) + } + } + Spacer(minLength: 8) + if isCurrent { + Text("aktywny") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.chromeAccent) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .csFocusRing(cornerRadius: 8) + } + + private func performPrimaryAction() { + switch primaryAction { + case .send: + store.send() + case .stop: + store.stopActiveTurn() + case .stopping: + break + } + } + + /// W1-B moved the composer onto a native `NSTextView`. Keep the SwiftUI + /// focus binding in sync, then explicitly make that view first responder so + /// a newly-created Agent window and an already-visible window behave alike. + @MainActor + private func focusNativeComposer() { + fieldFocused = true + let window = hostWindow + DispatchQueue.main.async { + guard let textView = Self.nativeComposer(in: window?.contentView) else { return } + window?.makeFirstResponder(textView) + } + } + + @MainActor + private static func nativeComposer(in view: NSView?) -> NSTextView? { + guard let view else { return nil } + if let textView = view as? NSTextView, + textView.accessibilityIdentifier() == "agent-composer-text" + { + return textView + } + for child in view.subviews { + if let textView = nativeComposer(in: child) { return textView } + } + return nil + } + + // MARK: Voice-note mic + + /// The composer mic: click to start a voice note, click again to stop and + /// insert the transcript into the draft. The ripple lives only while + /// recording; a spinner shows the preparing transition. Disabled (and dimmed) + /// while a hotkey/overlay dictation session owns the microphone. + private var micButton: some View { + Button(action: { store.toggleDictation() }) { + micVisual + .frame( + width: ComposerControlMetrics.hitTargetSize, + height: ComposerControlMetrics.hitTargetSize ) - guard !fileProviders.isEmpty else { return false } - - for provider in fileProviders { - _ = provider.loadObject(ofClass: URL.self) { url, error in - guard let url else { - attachLog.error( - "onDrop: failed to load dropped URL: \(error?.localizedDescription ?? "nil", privacy: .public)" - ) - return - } - Task { @MainActor in ingestFileURL(url, source: "onDrop") } - } - } - return true - } - - /// Stage a single dropped/pasted file if it is an accepted image type, - /// otherwise log the rejection. Called on the main actor per resolved URL — - /// the shared convergence point for the drag & drop and ⌘V staging paths. - @MainActor - private func ingestFileURL(_ url: URL, source: String) { - let type = UTType(filenameExtension: url.pathExtension) - let isImage = type.map { candidate in - Self.acceptedImageTypes.contains { candidate.conforms(to: $0) } - } ?? false - guard isImage else { - attachLog.info( - "\(source, privacy: .public): rejected non-image name=\(url.lastPathComponent, privacy: .public) ext=\(url.pathExtension, privacy: .public)" - ) - return - } - store.addAttachments([url]) - } - - // MARK: ⌘V paste - - /// Where a ⌘V in the composer should route, decided from what the pasteboard - /// holds. Pure so the 8-combination matrix is unit-testable. - enum PasteDisposition: Equatable { - /// File URLs on the pasteboard (Finder copy) → stage the image ones. - case stageFiles - /// A bare image with no text (screenshot ⌘⇧⌃4) → save to a temp file, stage. - case stageImage - /// Text present (or nothing usable) → let the field editor paste normally. - case passthroughText - } - - /// Decision table for a composer paste. File URLs win outright; a pasteboard - /// image only stages when there is no text alongside it (copying from a - /// browser puts image + text on the pasteboard, and the user expects TEXT). - static func pasteDisposition(hasFileURLs: Bool, hasImage: Bool, hasText: Bool) -> PasteDisposition { - if hasFileURLs { return .stageFiles } - if hasImage, !hasText { return .stageImage } - return .passthroughText - } - - /// One local key monitor for ⌘V. Installed while the composer is on screen; - /// events for other windows or without composer-field focus pass through - /// untouched, so search fields and Settings keep native paste behaviour. - private func installPasteMonitor() { - guard pasteMonitor == nil else { return } - pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - // Local key monitors always deliver on the main thread; assume the - // main actor statically so the store calls stay isolation-checked. - MainActor.assumeIsolated { - guard isComposerPasteEvent(event) else { return event } - return handlePaste(NSPasteboard.general) ? nil : event - } - } - } - - private func removePasteMonitor() { - if let pasteMonitor { NSEvent.removeMonitor(pasteMonitor) } - pasteMonitor = nil - } - - /// True only for a plain ⌘V aimed at this composer: field focused, event in - /// our own window, no other modifiers (⌘⇧V paste-and-match-style passes on). - @MainActor - private func isComposerPasteEvent(_ event: NSEvent) -> Bool { - guard fieldFocused, let hostWindow, event.window === hostWindow else { return false } - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - // ⌘ alone — shift/option/control bail (⌘⇧V stays native), but stray - // state flags like capsLock must not defeat the match. - guard flags.contains(.command), - flags.isDisjoint(with: [.shift, .option, .control]) else { return false } - return event.charactersIgnoringModifiers?.lowercased() == "v" - } - - /// Route a composer ⌘V. Returns true when the event was consumed by staging - /// (the field editor must not also paste), false to pass it through as text. - @MainActor - private func handlePaste(_ pasteboard: NSPasteboard) -> Bool { - let fileURLs = (pasteboard.readObjects( - forClasses: [NSURL.self], - options: [.urlReadingFileURLsOnly: true] - ) as? [URL]) ?? [] - let hasImage = pasteboard.availableType(from: [.png, .tiff]) != nil - let hasText = pasteboard.availableType(from: [.string]) != nil - let disposition = Self.pasteDisposition( - hasFileURLs: !fileURLs.isEmpty, hasImage: hasImage, hasText: hasText + .contentShape(Rectangle()) + .opacity(micState == .blocked ? 0.35 : micState == .preparing ? 0.68 : 1) + } + .csFocusRing(cornerRadius: 8) + .disabled(!micState.isEnabled) + .help(micState.accessibilityLabel) + .accessibilityIdentifier(ComposerAccessibility.micIdentifier) + .accessibilityLabel(Text(micState.accessibilityLabel)) + .animation(.easeOut(duration: 0.15), value: store.dictationPhase) + } + + private var micState: ComposerMicVisualState { + if store.dictationBlocked { return .blocked } + switch store.dictationPhase { + case .preparing: return .preparing + case .recording: return .recording + case .idle, .failed: return .idle + } + } + + private var micVisual: some View { + ZStack(alignment: .bottomTrailing) { + RippleMic(state: micState) + if micState == .preparing { + Circle() + .fill(CSColor.glassUnder) + .frame(width: 10, height: 10) + .overlay { + ProgressView() + .controlSize(.small) + .scaleEffect(0.42) + } + .offset(x: 2, y: 2) + } + } + } + + /// Small non-modal error line under the input box (permission off, no speech, + /// STT failure). Self-clears via the store after a few seconds. + @ViewBuilder + private var dictationFeedback: some View { + if case .failed(let message) = store.dictationPhase { + HStack(spacing: 6) { + CSIconView(icon: .error, size: 10.5) + Text(message) + .font(CSFont.mono(10.5, .medium)) + } + .foregroundStyle(CSColor.amber) + .padding(.leading, 2) + .transition(.opacity) + } + } + + @ViewBuilder + private var dictationPreview: some View { + if !store.dictationPreview.isEmpty { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + CSIconView(icon: .mic, size: 10.5, color: CSColor.terracottaLight) + Text(dictationPhaseLabel) + Text(store.dictationVadActive ? "speech" : "silence") + .foregroundStyle(store.dictationVadActive ? CSColor.oliveLight : CSColor.textFaintAlt) + if store.dictationFinalChangedText { + Text("final differs · \(store.dictationDeliverySource.label)") + .foregroundStyle(CSColor.amber) + } + if store.dictationPreviewUserEdited { + Text("edited · auto-send off") + .foregroundStyle(CSColor.chromeAccent) + } + Spacer(minLength: 8) + Button(dictationPreviewExpanded ? "Collapse" : "Expand") { + dictationPreviewExpanded.toggle() + } + .csFocusRing(cornerRadius: 8) + .accessibilityLabel( + dictationPreviewExpanded ? "Collapse transcript preview" : "Expand transcript preview") + } + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + + TextEditor( + text: Binding( + get: { store.dictationPreview }, + set: { store.editDictationPreview($0) } + ) ) - attachLog.info( - "paste: fileURLs=\(fileURLs.count, privacy: .public) hasImage=\(hasImage, privacy: .public) hasText=\(hasText, privacy: .public) disposition=\(String(describing: disposition), privacy: .public)" + .font(CSFont.ui(12.5 * textScale)) + .foregroundStyle(CSColor.textBodyAlt) + .scrollContentBackground(.hidden) + .frame( + minHeight: dictationPreviewExpanded ? 150 : 58, + maxHeight: dictationPreviewExpanded ? 260 : 96 ) - switch disposition { - case .stageFiles: - for url in fileURLs { ingestFileURL(url, source: "paste") } - return true - case .stageImage: - stagePastedImage(pasteboard) - return true - case .passthroughText: - return false - } - } - - /// Persist a bare pasteboard image (screenshot-style TIFF/PNG) to a readable - /// temp file and stage it through the shared attachments path. - @MainActor - private func stagePastedImage(_ pasteboard: NSPasteboard) { - let pngData: Data? - if let png = pasteboard.data(forType: .png) { - pngData = png - } else if let tiff = pasteboard.data(forType: .tiff), - let bitmap = NSBitmapImageRep(data: tiff) { - pngData = bitmap.representation(using: .png, properties: [:]) - } else { - pngData = nil - } - guard let pngData else { - attachLog.error("paste: pasteboard image had no decodable PNG/TIFF payload") - return - } - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("pasted-\(Self.pastedNameFormatter.string(from: Date())).png") - do { - try pngData.write(to: url) - attachLog.info( - "paste: staged clipboard image bytes=\(pngData.count, privacy: .public) file=\(url.lastPathComponent, privacy: .public)" - ) - store.addAttachments([url]) - } catch { - attachLog.error( - "paste: failed to write clipboard image: \(error.localizedDescription, privacy: .public)" - ) - } - } - - /// Readable, collision-safe temp-file stamp (`pasted-20260715-140233-421.png`). - private static let pastedNameFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" - return formatter - }() - - /// Invisible probe resolving the NSWindow this composer lives in, so the - /// paste monitor can discriminate our window from Settings / the overlay. - /// Identity-guarded so re-reporting the same window can't loop view updates. - private var hostWindowReader: some View { - WindowReader { window in - if hostWindow !== window { hostWindow = window } - } - } - - private let affordances = [ - "· streaming", - "· attach file / image", - ] + .accessibilityLabel("Live transcript preview") + + if store.dictationFinalChangedText, + let final = store.dictationFinalPreview, + final != store.dictationLivePreview + { + DisclosureGroup( + store.dictationDeliverySource == .final ? "Live capture" : "Final-pass alternative" + ) { + // Bounded on purpose: an unbounded Text with a long + // transcript grew the composer past the window and + // wrecked the split-view layout on expand (operator + // screenshot, 2026-08-09). The alternative scrolls + // inside its own strip instead. + ScrollView { + Text(store.dictationDeliverySource == .final ? store.dictationLivePreview : final) + .font(CSFont.ui(11.5 * textScale)) + .foregroundStyle(CSColor.textFaint) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + } + .frame(maxHeight: 140) + } + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + } + .padding(10) + .background(CSColor.surfaceRaised(0.04)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + private var dictationPhaseLabel: String { + switch store.dictationPhase { + case .idle: return "captured" + case .preparing: return "preparing" + case .recording: return "recording" + case .failed: return "stopped" + } + } + + /// Transparent drop target layered over the input box. Hit-testable only + /// while a drag is in progress, so it intercepts a field drop (beating the + /// native text editor) without blocking clicks/typing at rest. Its own + /// `isTargeted` binding keeps `isDragging` true while the pointer is over the + /// field even as the outer target reports false — no highlight flicker. + private var dropCatcher: some View { + Color.clear + .contentShape(Rectangle()) + .onDrop(of: [.fileURL], isTargeted: $overInner) { providers in + handleDrop(providers) + } + .allowsHitTesting(isDragging) + } + + // MARK: Attachment chips + + private var attachmentChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(store.pendingAttachments) { attachment in + HStack(spacing: 6) { + Button { + previewAttachment = attachment + } label: { + HStack(spacing: 6) { + CSIconView(icon: .photo, size: 11, color: CSColor.chromeAccent) + Text(attachment.name) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textBodyAlt) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 160) + } + } + .csFocusRing(cornerRadius: 8) + .help("Preview attachment") + Button(action: { store.removeAttachment(attachment.id) }) { + CSIconView(icon: .close, size: 9, weight: .bold, color: CSColor.textFaint) + } + .csFocusRing(cornerRadius: 8) + .help("Remove attachment") + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(CSColor.surfaceRaised(0.05)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) + .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) + } + } + .padding(.horizontal, 2) + } + .frame(maxHeight: 30) + .sheet(item: $previewAttachment) { attachment in + AttachmentPreviewSheet( + attachment: attachment.previewAttachment, + onRemove: { + store.removeAttachment(attachment.id) + previewAttachment = nil + } + ) + } + } + + // MARK: Image picker + + private func pickAttachments() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.prompt = "Attach" + panel.message = "Attach images to send to the agent" + // Restrict to the vision-supported image types the bridge actually loads. + panel.allowedContentTypes = [.png, .jpeg, .gif, .webP, .bmp, .tiff] + attachLog.info("pickAttachments: presenting NSOpenPanel (modeless begin)") + panel.begin { response in + let ok = response == .OK + let urls = ok ? panel.urls : [] + let names = urls.map { $0.lastPathComponent }.joined(separator: ", ") + attachLog.info( + "pickAttachments completion: response=\(ok ? "OK" : "cancel", privacy: .public) urls=\(urls.count, privacy: .public) files=[\(names, privacy: .public)]" + ) + guard ok else { return } + Task { @MainActor in store.addAttachments(urls) } + } + } + + // MARK: Drag & drop + + /// Image types accepted by the drop area — mirrors the NSOpenPanel picker so + /// the two staging paths agree on what counts as an image. + private static let acceptedImageTypes: [UTType] = [.png, .jpeg, .gif, .webP, .bmp, .tiff] + + /// Handle a batch of dropped file providers. Loads each file URL off-main, + /// then stages the image ones via the existing `store.addAttachments` path + /// (chips + send parity). Non-image files are rejected with a breadcrumb. + /// Returns true when at least one file-URL provider was accepted for loading. + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + let fileProviders = providers.filter { + $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) + } + attachLog.info( + "onDrop: providers=\(providers.count, privacy: .public) fileURL=\(fileProviders.count, privacy: .public)" + ) + guard !fileProviders.isEmpty else { return false } + + for provider in fileProviders { + _ = provider.loadObject(ofClass: URL.self) { url, error in + guard let url else { + attachLog.error( + "onDrop: failed to load dropped URL: \(error?.localizedDescription ?? "nil", privacy: .public)" + ) + return + } + Task { @MainActor in ingestFileURL(url, source: "onDrop") } + } + } + return true + } + + /// Stage a single dropped/pasted file if it is an accepted image type, + /// otherwise log the rejection. Called on the main actor per resolved URL — + /// the shared convergence point for the drag & drop and ⌘V staging paths. + @MainActor + private func ingestFileURL(_ url: URL, source: String) { + let type = UTType(filenameExtension: url.pathExtension) + let isImage = + type.map { candidate in + Self.acceptedImageTypes.contains { candidate.conforms(to: $0) } + } ?? false + guard isImage else { + attachLog.info( + "\(source, privacy: .public): rejected non-image name=\(url.lastPathComponent, privacy: .public) ext=\(url.pathExtension, privacy: .public)" + ) + return + } + store.addAttachments([url]) + } + + // MARK: ⌘V paste + + /// Where a ⌘V in the composer should route, decided from what the pasteboard + /// holds. Pure so the 8-combination matrix is unit-testable. + enum PasteDisposition: Equatable { + /// File URLs on the pasteboard (Finder copy) → stage the image ones. + case stageFiles + /// A bare image with no text (screenshot ⌘⇧⌃4) → save to a temp file, stage. + case stageImage + /// Text present (or nothing usable) → let the field editor paste normally. + case passthroughText + } + + /// Decision table for a composer paste. File URLs win outright; a pasteboard + /// image only stages when there is no text alongside it (copying from a + /// browser puts image + text on the pasteboard, and the user expects TEXT). + static func pasteDisposition(hasFileURLs: Bool, hasImage: Bool, hasText: Bool) -> PasteDisposition + { + if hasFileURLs { return .stageFiles } + if hasImage, !hasText { return .stageImage } + return .passthroughText + } + + /// One local key monitor for ⌘V. Installed while the composer is on screen; + /// events for other windows or without composer-field focus pass through + /// untouched, so search fields and Settings keep native paste behaviour. + private func installPasteMonitor() { + guard pasteMonitor == nil else { return } + pasteMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + // Local key monitors always deliver on the main thread; assume the + // main actor statically so the store calls stay isolation-checked. + MainActor.assumeIsolated { + guard isComposerPasteEvent(event) else { return event } + return handlePaste(NSPasteboard.general) ? nil : event + } + } + } + + private func removePasteMonitor() { + if let pasteMonitor { NSEvent.removeMonitor(pasteMonitor) } + pasteMonitor = nil + } + + /// True only for a plain ⌘V aimed at this composer: field focused, event in + /// our own window, no other modifiers (⌘⇧V paste-and-match-style passes on). + @MainActor + private func isComposerPasteEvent(_ event: NSEvent) -> Bool { + guard fieldFocused, let hostWindow, event.window === hostWindow else { return false } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + // ⌘ alone — shift/option/control bail (⌘⇧V stays native), but stray + // state flags like capsLock must not defeat the match. + guard flags.contains(.command), + flags.isDisjoint(with: [.shift, .option, .control]) + else { return false } + return event.charactersIgnoringModifiers?.lowercased() == "v" + } + + /// Route a composer ⌘V. Returns true when the event was consumed by staging + /// (the field editor must not also paste), false to pass it through as text. + @MainActor + private func handlePaste(_ pasteboard: NSPasteboard) -> Bool { + let fileURLs = + (pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL]) ?? [] + let hasImage = pasteboard.availableType(from: [.png, .tiff]) != nil + let hasText = pasteboard.availableType(from: [.string]) != nil + let disposition = Self.pasteDisposition( + hasFileURLs: !fileURLs.isEmpty, hasImage: hasImage, hasText: hasText + ) + attachLog.info( + "paste: fileURLs=\(fileURLs.count, privacy: .public) hasImage=\(hasImage, privacy: .public) hasText=\(hasText, privacy: .public) disposition=\(String(describing: disposition), privacy: .public)" + ) + switch disposition { + case .stageFiles: + for url in fileURLs { ingestFileURL(url, source: "paste") } + return true + case .stageImage: + stagePastedImage(pasteboard) + return true + case .passthroughText: + return false + } + } + + /// Persist a bare pasteboard image (screenshot-style TIFF/PNG) to a readable + /// temp file and stage it through the shared attachments path. + @MainActor + private func stagePastedImage(_ pasteboard: NSPasteboard) { + let pngData: Data? + if let png = pasteboard.data(forType: .png) { + pngData = png + } else if let tiff = pasteboard.data(forType: .tiff), + let bitmap = NSBitmapImageRep(data: tiff) + { + pngData = bitmap.representation(using: .png, properties: [:]) + } else { + pngData = nil + } + guard let pngData else { + attachLog.error("paste: pasteboard image had no decodable PNG/TIFF payload") + return + } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("pasted-\(Self.pastedNameFormatter.string(from: Date())).png") + do { + try pngData.write(to: url) + attachLog.info( + "paste: staged clipboard image bytes=\(pngData.count, privacy: .public) file=\(url.lastPathComponent, privacy: .public)" + ) + store.addAttachments([url]) + } catch { + attachLog.error( + "paste: failed to write clipboard image: \(error.localizedDescription, privacy: .public)" + ) + } + } + + /// Readable, collision-safe temp-file stamp (`pasted-20260715-140233-421.png`). + private static let pastedNameFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss-SSS" + return formatter + }() + + /// Invisible probe resolving the NSWindow this composer lives in, so the + /// paste monitor can discriminate our window from Settings / the overlay. + /// Identity-guarded so re-reporting the same window can't loop view updates. + private var hostWindowReader: some View { + WindowReader { window in + if hostWindow !== window { hostWindow = window } + } + } + + private let affordances = [ + "· streaming", + "· attach file / image", + ] } enum ComposerControlMetrics { - static let glyphSize: CGFloat = 15 - static let hitTargetSize: CGFloat = 22 + static let glyphSize: CGFloat = 15 + static let hitTargetSize: CGFloat = 22 } enum ComposerActionAccessibility { - static let identifier = "agent-composer-primary-action" + static let identifier = "agent-composer-primary-action" } /// Pure Send/Stop/Stopping projection used by the view and focused tests. enum ComposerActionVisualState: Equatable { - case send(enabled: Bool) - case stop - case stopping - - static func resolve(canSend: Bool, activePhase: ComposerTurnPhase?) -> ComposerActionVisualState { - switch activePhase { - case .thinking?, .streaming?: return .stop - case .cancelling?: return .stopping - case nil: return .send(enabled: canSend) - } + case send(enabled: Bool) + case stop + case stopping + + static func resolve(canSend: Bool, activePhase: ComposerTurnPhase?) -> ComposerActionVisualState { + switch activePhase { + case .thinking?, .streaming?: return .stop + case .cancelling?: return .stopping + case nil: return .send(enabled: canSend) } + } - var isEnabled: Bool { - switch self { - case .send(let enabled): return enabled - case .stop: return true - case .stopping: return false - } + var isEnabled: Bool { + switch self { + case .send(let enabled): return enabled + case .stop: return true + case .stopping: return false } + } - var accessibilityLabel: String { - switch self { - case .send: return "Send message" - case .stop: return "Stop response" - case .stopping: return "Stopping response" - } + var accessibilityLabel: String { + switch self { + case .send: return "Send message" + case .stop: return "Stop response" + case .stopping: return "Stopping response" } + } - var icon: CSIcon { - switch self { - case .send: return .send - case .stop, .stopping: return .stop - } + var icon: CSIcon { + switch self { + case .send: return .send + case .stop, .stopping: return .stop } + } - var iconWeight: CSIconWeight { - switch self { - case .send: return .semibold - case .stop, .stopping: return .fill - } + var iconWeight: CSIconWeight { + switch self { + case .send: return .semibold + case .stop, .stopping: return .fill } + } } /// Minimal probe reporting the `NSWindow` that hosts a SwiftUI hierarchy. The @@ -817,15 +831,15 @@ enum ComposerActionVisualState: Equatable { /// resolve callback fires async because `window` is nil until the view lands /// in a window, and mutating view state mid-update is illegal. private struct WindowReader: NSViewRepresentable { - var onResolve: (NSWindow?) -> Void + var onResolve: (NSWindow?) -> Void - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { [weak view] in onResolve(view?.window) } - return view - } + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { [weak view] in onResolve(view?.window) } + return view + } - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { [weak nsView] in onResolve(nsView?.window) } - } + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { [weak nsView] in onResolve(nsView?.window) } + } } diff --git a/macos/Codescribe/Screens/AgentChat/ComposerPalette.swift b/macos/Codescribe/Screens/AgentChat/ComposerPalette.swift index 2e70dcac..00e96bdf 100644 --- a/macos/Codescribe/Screens/AgentChat/ComposerPalette.swift +++ b/macos/Codescribe/Screens/AgentChat/ComposerPalette.swift @@ -14,106 +14,106 @@ import Foundation /// One selectable palette entry. struct ComposerPaletteEntry: Identifiable, Equatable { - /// Stable identity within its command (a model id, a grant key, …). - let id: String - let title: String - let subtitle: String? - /// True for the entry matching the current setting, so the UI can mark it. - var isCurrent: Bool = false + /// Stable identity within its command (a model id, a grant key, …). + let id: String + let title: String + let subtitle: String? + /// True for the entry matching the current setting, so the UI can mark it. + var isCurrent: Bool = false } /// The commands the palette exposes. Adding one means adding a case plus its /// row in `allCases` — the parser and the filter need no changes. enum ComposerPaletteCommand: String, CaseIterable, Equatable { - case model - case grants + case model + case grants - var keyword: String { rawValue } + var keyword: String { rawValue } - var summary: String { - switch self { - case .model: "Wybierz model asystenta" - case .grants: "Narzędzia z „zawsze zezwalaj”" - } - } - - /// Longest keyword prefix shared with `text`, used for ranking. - static func matching(prefix: String) -> [ComposerPaletteCommand] { - let needle = prefix.lowercased() - guard !needle.isEmpty else { return allCases } - return allCases.filter { $0.keyword.hasPrefix(needle) } + var summary: String { + switch self { + case .model: "Wybierz model asystenta" + case .grants: "Narzędzia z „zawsze zezwalaj”" } + } + + /// Longest keyword prefix shared with `text`, used for ranking. + static func matching(prefix: String) -> [ComposerPaletteCommand] { + let needle = prefix.lowercased() + guard !needle.isEmpty else { return allCases } + return allCases.filter { $0.keyword.hasPrefix(needle) } + } } /// What the current draft means to the palette. enum ComposerPaletteQuery: Equatable { - /// Not a palette draft — send as a normal message. - case inactive - /// `/` or a partial keyword: offer commands. - case commands(prefix: String) - /// A complete keyword plus optional argument: offer that command's entries. - case entries(command: ComposerPaletteCommand, filter: String) - - /// Parse a draft. Only a leading `/` (after whitespace) activates the - /// palette, and only while the draft holds a single line — a multi-line - /// draft is prose the user is composing, never a command. - static func parse(_ draft: String) -> ComposerPaletteQuery { - let trimmed = draft.trimmingCharacters(in: .whitespaces) - guard trimmed.hasPrefix("/"), !trimmed.contains("\n") else { return .inactive } - - let body = String(trimmed.dropFirst()) - guard let separator = body.firstIndex(of: " ") else { - // Still typing the keyword. An exact keyword with no space yet also - // shows its entries, so "/model" lists models before the space. - if let command = ComposerPaletteCommand(rawValue: body.lowercased()) { - return .entries(command: command, filter: "") - } - return .commands(prefix: body) - } - let keyword = String(body[body.startIndex.. ComposerPaletteQuery { + let trimmed = draft.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("/"), !trimmed.contains("\n") else { return .inactive } + + let body = String(trimmed.dropFirst()) + guard let separator = body.firstIndex(of: " ") else { + // Still typing the keyword. An exact keyword with no space yet also + // shows its entries, so "/model" lists models before the space. + if let command = ComposerPaletteCommand(rawValue: body.lowercased()) { + return .entries(command: command, filter: "") + } + return .commands(prefix: body) + } + let keyword = String(body[body.startIndex.. [ComposerPaletteEntry] - { - let needle = filter.trimmingCharacters(in: .whitespaces).lowercased() - guard !needle.isEmpty else { return entries } - return entries.filter { - $0.title.lowercased().contains(needle) || $0.id.lowercased().contains(needle) - } + /// Case-insensitive substring match over title and id, preserving the + /// caller's ordering (providers already rank their catalogs). + static func filter(_ entries: [ComposerPaletteEntry], by filter: String) + -> [ComposerPaletteEntry] + { + let needle = filter.trimmingCharacters(in: .whitespaces).lowercased() + guard !needle.isEmpty else { return entries } + return entries.filter { + $0.title.lowercased().contains(needle) || $0.id.lowercased().contains(needle) } - - /// Draft text after picking a command from the command list: the keyword - /// plus a trailing space, so the entry list opens and typing filters it. - static func draft(afterPicking command: ComposerPaletteCommand) -> String { - "/\(command.keyword) " - } - - /// The palette consumes the draft when an entry is applied — the command was - /// an instruction to the app, not a message to the agent, so leaving its - /// text behind would send "/model gpt-5" to the model on the next Enter. - static let draftAfterApplyingEntry = "" + } + + /// Draft text after picking a command from the command list: the keyword + /// plus a trailing space, so the entry list opens and typing filters it. + static func draft(afterPicking command: ComposerPaletteCommand) -> String { + "/\(command.keyword) " + } + + /// The palette consumes the draft when an entry is applied — the command was + /// an instruction to the app, not a message to the agent, so leaving its + /// text behind would send "/model gpt-5" to the model on the next Enter. + static let draftAfterApplyingEntry = "" } /// Data behind the palette. One protocol so the store stays testable and /// previews render without a live bridge. protocol ComposerPaletteSourcing { - func entries(for command: ComposerPaletteCommand) -> [ComposerPaletteEntry] - func apply(_ entry: ComposerPaletteEntry, for command: ComposerPaletteCommand) throws + func entries(for command: ComposerPaletteCommand) -> [ComposerPaletteEntry] + func apply(_ entry: ComposerPaletteEntry, for command: ComposerPaletteCommand) throws } /// Live palette source over the settings + MCP-admin bridges. @@ -123,69 +123,69 @@ protocol ComposerPaletteSourcing { /// reopened after a change — a per-keystroke network call while filtering would /// be both slow and rude to the provider. final class RealComposerPaletteSource: ComposerPaletteSourcing { - private let settings: SettingsEngine - private let mcpAdmin: MCPAdminEngine - private var cachedModels: [ComposerPaletteEntry]? - - init(settings: SettingsEngine, mcpAdmin: MCPAdminEngine) { - self.settings = settings - self.mcpAdmin = mcpAdmin + private let settings: SettingsEngine + private let mcpAdmin: MCPAdminEngine + private var cachedModels: [ComposerPaletteEntry]? + + init(settings: SettingsEngine, mcpAdmin: MCPAdminEngine) { + self.settings = settings + self.mcpAdmin = mcpAdmin + } + + func entries(for command: ComposerPaletteCommand) -> [ComposerPaletteEntry] { + switch command { + case .model: models() + case .grants: grants() } - - func entries(for command: ComposerPaletteCommand) -> [ComposerPaletteEntry] { - switch command { - case .model: models() - case .grants: grants() - } + } + + func apply(_ entry: ComposerPaletteEntry, for command: ComposerPaletteCommand) throws { + switch command { + case .model: + try settings.updateConfig(key: "LLM_ASSISTIVE_MODEL", value: entry.id) + cachedModels = nil + case .grants: + try mcpAdmin.revokeToolGrant(key: entry.id) } - - func apply(_ entry: ComposerPaletteEntry, for command: ComposerPaletteCommand) throws { - switch command { - case .model: - try settings.updateConfig(key: "LLM_ASSISTIVE_MODEL", value: entry.id) - cachedModels = nil - case .grants: - try mcpAdmin.revokeToolGrant(key: entry.id) - } + } + + private func models() -> [ComposerPaletteEntry] { + if let cachedModels { return cachedModels } + let snapshot = settings.loadSettings() + let current = snapshot.llmAssistiveModel + let providerID = snapshot.llmAssistiveProvider ?? "openai-responses" + let discovery = settings.discoverModels(providerId: providerID) + let entries: [ComposerPaletteEntry] = discovery.models.map { model in + let subtitle: String? = model.id == model.displayName ? nil : model.id + return ComposerPaletteEntry( + id: model.id, + title: model.displayName, + subtitle: subtitle, + isCurrent: model.id == current + ) } - - private func models() -> [ComposerPaletteEntry] { - if let cachedModels { return cachedModels } - let snapshot = settings.loadSettings() - let current = snapshot.llmAssistiveModel - let providerID = snapshot.llmAssistiveProvider ?? "openai-responses" - let discovery = settings.discoverModels(providerId: providerID) - let entries: [ComposerPaletteEntry] = discovery.models.map { model in - let subtitle: String? = model.id == model.displayName ? nil : model.id - return ComposerPaletteEntry( - id: model.id, - title: model.displayName, - subtitle: subtitle, - isCurrent: model.id == current - ) - } - cachedModels = entries - return entries + cachedModels = entries + return entries + } + + private func grants() -> [ComposerPaletteEntry] { + // A revoke list that silently swallowed its error would tell the + // operator "nothing is granted" while grants keep letting tools run. + guard let grants = try? mcpAdmin.listToolGrants() else { + return [ + ComposerPaletteEntry( + id: "", + title: "Nie udało się odczytać uprawnień", + subtitle: "sprawdź ~/.codescribe/tool_grants.json" + ) + ] } - - private func grants() -> [ComposerPaletteEntry] { - // A revoke list that silently swallowed its error would tell the - // operator "nothing is granted" while grants keep letting tools run. - guard let grants = try? mcpAdmin.listToolGrants() else { - return [ - ComposerPaletteEntry( - id: "", - title: "Nie udało się odczytać uprawnień", - subtitle: "sprawdź ~/.codescribe/tool_grants.json" - ) - ] - } - return grants.map { grant in - ComposerPaletteEntry( - id: grant.key, - title: grant.key, - subtitle: "nadane \(grant.grantedAt) · wybierz, aby cofnąć" - ) - } + return grants.map { grant in + ComposerPaletteEntry( + id: grant.key, + title: grant.key, + subtitle: "nadane \(grant.grantedAt) · wybierz, aby cofnąć" + ) } + } } diff --git a/macos/Codescribe/Screens/AgentChat/ComposerTextView.swift b/macos/Codescribe/Screens/AgentChat/ComposerTextView.swift index 6a143b9c..1447eda4 100644 --- a/macos/Codescribe/Screens/AgentChat/ComposerTextView.swift +++ b/macos/Codescribe/Screens/AgentChat/ComposerTextView.swift @@ -2,369 +2,373 @@ import AppKit import SwiftUI enum ComposerAccessibility { - static let textViewIdentifier = "agent-composer-text" - static let micIdentifier = "agent-composer-mic" + static let textViewIdentifier = "agent-composer-text" + static let micIdentifier = "agent-composer-mic" } enum ComposerTextKeyDisposition: Equatable { - case send - case insertNewline - case previousHistory - case nextHistory - case native - - /// Return and keypad-enter share the same contract. IME confirmation and - /// modified shortcuts stay native so composing text is never sent early. - static func resolve( - keyCode: UInt16, - modifiers: NSEvent.ModifierFlags, - hasMarkedText: Bool - ) -> ComposerTextKeyDisposition { - guard !hasMarkedText else { return .native } - let flags = modifiers.intersection(.deviceIndependentFlagsMask) - guard flags.isDisjoint(with: [.command, .control, .option]) else { return .native } - if keyCode == 126 { return flags.contains(.shift) ? .native : .previousHistory } - if keyCode == 125 { return flags.contains(.shift) ? .native : .nextHistory } - guard keyCode == 36 || keyCode == 76 else { return .native } - return flags.contains(.shift) ? .insertNewline : .send - } + case send + case insertNewline + case previousHistory + case nextHistory + case native + + /// Return and keypad-enter share the same contract. IME confirmation and + /// modified shortcuts stay native so composing text is never sent early. + static func resolve( + keyCode: UInt16, + modifiers: NSEvent.ModifierFlags, + hasMarkedText: Bool + ) -> ComposerTextKeyDisposition { + guard !hasMarkedText else { return .native } + let flags = modifiers.intersection(.deviceIndependentFlagsMask) + guard flags.isDisjoint(with: [.command, .control, .option]) else { return .native } + if keyCode == 126 { return flags.contains(.shift) ? .native : .previousHistory } + if keyCode == 125 { return flags.contains(.shift) ? .native : .nextHistory } + guard keyCode == 36 || keyCode == 76 else { return .native } + return flags.contains(.shift) ? .insertNewline : .send + } } struct ComposerDraftHistoryState: Equatable { - private(set) var index: Int? - private(set) var scratch = "" - - mutating func previous(history: [String], current: String) -> String? { - guard !history.isEmpty else { return nil } - if index == nil { - scratch = current - index = history.count - 1 - } else { - index = max(0, (index ?? 0) - 1) - } - return history[index ?? 0] - } - - mutating func next(history: [String]) -> String? { - guard let index else { return nil } - if index + 1 < history.count { - self.index = index + 1 - return history[index + 1] - } - self.index = nil - return scratch + private(set) var index: Int? + private(set) var scratch = "" + + mutating func previous(history: [String], current: String) -> String? { + guard !history.isEmpty else { return nil } + if index == nil { + scratch = current + index = history.count - 1 + } else { + index = max(0, (index ?? 0) - 1) } - - mutating func reset() { - index = nil - scratch = "" + return history[index ?? 0] + } + + mutating func next(history: [String]) -> String? { + guard let index else { return nil } + if index + 1 < history.count { + self.index = index + 1 + return history[index + 1] } + self.index = nil + return scratch + } + + mutating func reset() { + index = nil + scratch = "" + } } struct ComposerTextLayout: Equatable { - static let minimumLines = 1 - static let maximumLines = 8 - static let verticalPadding: CGFloat = 6 - - let height: CGFloat - let isVerticallyScrollable: Bool - - static func resolve( - contentHeight: CGFloat, - lineHeight: CGFloat, - minimumLines: Int = ComposerTextLayout.minimumLines, - maximumLines: Int = ComposerTextLayout.maximumLines, - verticalPadding: CGFloat = ComposerTextLayout.verticalPadding - ) -> ComposerTextLayout { - let safeLineHeight = max(1, lineHeight) - let safeMinimum = max(1, minimumLines) - let safeMaximum = max(safeMinimum, maximumLines) - let minimumHeight = safeLineHeight * CGFloat(safeMinimum) + verticalPadding - let maximumHeight = safeLineHeight * CGFloat(safeMaximum) + verticalPadding - let measured = max(contentHeight, minimumHeight) - return ComposerTextLayout( - height: min(measured, maximumHeight), - isVerticallyScrollable: measured > maximumHeight - ) - } - - static func minimumHeight(fontSize: CGFloat) -> CGFloat { - resolve(contentHeight: 0, lineHeight: lineHeight(fontSize: fontSize)).height - } - - static func lineHeight(fontSize: CGFloat) -> CGFloat { - ceil(NSLayoutManager().defaultLineHeight(for: composerFont(size: fontSize))) - } - - /// AppKit-backed measurement used by tests and by the representable's live - /// text system. Newlines and visual wrapping both participate. - static func contentHeight(text: String, width: CGFloat, fontSize: CGFloat) -> CGFloat { - let storage = NSTextStorage(string: text.isEmpty ? " " : text) - let layoutManager = NSLayoutManager() - let container = NSTextContainer( - containerSize: NSSize( - width: max(1, width), - height: CGFloat.greatestFiniteMagnitude - ) - ) - container.lineFragmentPadding = 5 - container.widthTracksTextView = false - storage.addAttribute( - .font, - value: composerFont(size: fontSize), - range: NSRange(location: 0, length: storage.length) - ) - layoutManager.addTextContainer(container) - storage.addLayoutManager(layoutManager) - layoutManager.ensureLayout(for: container) - return ceil(layoutManager.usedRect(for: container).height + verticalPadding) - } - - fileprivate static func composerFont(size: CGFloat) -> NSFont { - NSFont(name: FontLoader.spaceGrotesk, size: size) ?? .systemFont(ofSize: size) - } + static let minimumLines = 1 + static let maximumLines = 8 + static let verticalPadding: CGFloat = 6 + + let height: CGFloat + let isVerticallyScrollable: Bool + + static func resolve( + contentHeight: CGFloat, + lineHeight: CGFloat, + minimumLines: Int = ComposerTextLayout.minimumLines, + maximumLines: Int = ComposerTextLayout.maximumLines, + verticalPadding: CGFloat = ComposerTextLayout.verticalPadding + ) -> ComposerTextLayout { + let safeLineHeight = max(1, lineHeight) + let safeMinimum = max(1, minimumLines) + let safeMaximum = max(safeMinimum, maximumLines) + let minimumHeight = safeLineHeight * CGFloat(safeMinimum) + verticalPadding + let maximumHeight = safeLineHeight * CGFloat(safeMaximum) + verticalPadding + let measured = max(contentHeight, minimumHeight) + return ComposerTextLayout( + height: min(measured, maximumHeight), + isVerticallyScrollable: measured > maximumHeight + ) + } + + static func minimumHeight(fontSize: CGFloat) -> CGFloat { + resolve(contentHeight: 0, lineHeight: lineHeight(fontSize: fontSize)).height + } + + static func lineHeight(fontSize: CGFloat) -> CGFloat { + ceil(NSLayoutManager().defaultLineHeight(for: composerFont(size: fontSize))) + } + + /// AppKit-backed measurement used by tests and by the representable's live + /// text system. Newlines and visual wrapping both participate. + static func contentHeight(text: String, width: CGFloat, fontSize: CGFloat) -> CGFloat { + let storage = NSTextStorage(string: text.isEmpty ? " " : text) + let layoutManager = NSLayoutManager() + let container = NSTextContainer( + containerSize: NSSize( + width: max(1, width), + height: CGFloat.greatestFiniteMagnitude + ) + ) + container.lineFragmentPadding = 5 + container.widthTracksTextView = false + storage.addAttribute( + .font, + value: composerFont(size: fontSize), + range: NSRange(location: 0, length: storage.length) + ) + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + layoutManager.ensureLayout(for: container) + return ceil(layoutManager.usedRect(for: container).height + verticalPadding) + } + + fileprivate static func composerFont(size: CGFloat) -> NSFont { + NSFont(name: FontLoader.spaceGrotesk, size: size) ?? .systemFont(ofSize: size) + } } /// Native multiline composer. NSTextView owns text input, selection, undo, /// marked-text/IME behavior and vertical scrolling; SwiftUI owns only the /// measured one-to-eight-line frame. struct ComposerTextView: NSViewRepresentable { - @Binding var text: String - @Binding var height: CGFloat - let textScale: CGFloat - @Binding var isFocused: Bool - let history: [String] - let onSend: () -> Void - - func makeCoordinator() -> Coordinator { Coordinator(parent: self) } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = ComposerScrollView() - scrollView.borderType = .noBorder - scrollView.drawsBackground = false - scrollView.hasHorizontalScroller = false - scrollView.hasVerticalScroller = false - scrollView.autohidesScrollers = true - scrollView.horizontalScrollElasticity = .none - scrollView.verticalScrollElasticity = .automatic - - let textView = ComposerNativeTextView() - textView.delegate = context.coordinator - textView.isRichText = false - textView.importsGraphics = false - textView.allowsUndo = true - textView.drawsBackground = false - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - textView.textContainerInset = NSSize(width: 0, height: 3) - textView.textContainer?.lineFragmentPadding = 5 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize( - width: 0, - height: CGFloat.greatestFiniteMagnitude - ) - textView.string = text - textView.placeholder = "Type a message…" - textView.font = ComposerTextLayout.composerFont(size: 13.5 * textScale) - textView.textColor = NSColor(srgbRed: 0xE9 / 255, green: 0xE7 / 255, blue: 0xE0 / 255, alpha: 1) - textView.insertionPointColor = NSColor(srgbRed: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255, alpha: 1) - textView.setAccessibilityIdentifier(ComposerAccessibility.textViewIdentifier) - textView.setAccessibilityLabel("Message") - textView.onKeyDown = { [weak coordinator = context.coordinator, weak textView] event in - guard let coordinator, let textView else { return false } - return coordinator.handleKeyDown(event, in: textView) - } - - scrollView.documentView = textView - scrollView.onContentWidthChange = { [weak coordinator = context.coordinator, weak textView, weak scrollView] in - guard let coordinator, let textView, let scrollView else { return } - coordinator.refreshLayout(textView, in: scrollView) - } - context.coordinator.refreshLayout(textView, in: scrollView) - return scrollView + @Binding var text: String + @Binding var height: CGFloat + let textScale: CGFloat + @Binding var isFocused: Bool + let history: [String] + let onSend: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = ComposerScrollView() + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = false + scrollView.autohidesScrollers = true + scrollView.horizontalScrollElasticity = .none + scrollView.verticalScrollElasticity = .automatic + + let textView = ComposerNativeTextView() + textView.delegate = context.coordinator + textView.isRichText = false + textView.importsGraphics = false + textView.allowsUndo = true + textView.drawsBackground = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainerInset = NSSize(width: 0, height: 3) + textView.textContainer?.lineFragmentPadding = 5 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, + height: CGFloat.greatestFiniteMagnitude + ) + textView.string = text + textView.placeholder = "Type a message…" + textView.font = ComposerTextLayout.composerFont(size: 13.5 * textScale) + textView.textColor = NSColor(srgbRed: 0xE9 / 255, green: 0xE7 / 255, blue: 0xE0 / 255, alpha: 1) + textView.insertionPointColor = NSColor( + srgbRed: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255, alpha: 1) + textView.setAccessibilityIdentifier(ComposerAccessibility.textViewIdentifier) + textView.setAccessibilityLabel("Message") + textView.onKeyDown = { [weak coordinator = context.coordinator, weak textView] event in + guard let coordinator, let textView else { return false } + return coordinator.handleKeyDown(event, in: textView) } - func updateNSView(_ scrollView: NSScrollView, context: Context) { - context.coordinator.updateParent(self) - guard let textView = scrollView.documentView as? ComposerNativeTextView else { return } - - let font = ComposerTextLayout.composerFont(size: 13.5 * textScale) - if textView.font != font { - textView.font = font - textView.typingAttributes[.font] = font - } - if textView.string != text { - let selection = textView.selectedRange() - textView.string = text - let utf16Count = (text as NSString).length - let location = min(selection.location, utf16Count) - let length = min(selection.length, utf16Count - location) - textView.setSelectedRange(NSRange(location: location, length: length)) - } - textView.needsDisplay = true - context.coordinator.refreshLayout(textView, in: scrollView) - - DispatchQueue.main.async { [weak textView] in - guard let textView, let window = textView.window else { return } - if isFocused, window.firstResponder !== textView { - window.makeFirstResponder(textView) - } else if !isFocused, window.firstResponder === textView { - window.makeFirstResponder(nil) - } - } + scrollView.documentView = textView + scrollView.onContentWidthChange = { + [weak coordinator = context.coordinator, weak textView, weak scrollView] in + guard let coordinator, let textView, let scrollView else { return } + coordinator.refreshLayout(textView, in: scrollView) + } + context.coordinator.refreshLayout(textView, in: scrollView) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.updateParent(self) + guard let textView = scrollView.documentView as? ComposerNativeTextView else { return } + + let font = ComposerTextLayout.composerFont(size: 13.5 * textScale) + if textView.font != font { + textView.font = font + textView.typingAttributes[.font] = font + } + if textView.string != text { + let selection = textView.selectedRange() + textView.string = text + let utf16Count = (text as NSString).length + let location = min(selection.location, utf16Count) + let length = min(selection.length, utf16Count - location) + textView.setSelectedRange(NSRange(location: location, length: length)) } + textView.needsDisplay = true + context.coordinator.refreshLayout(textView, in: scrollView) + + DispatchQueue.main.async { [weak textView] in + guard let textView, let window = textView.window else { return } + if isFocused, window.firstResponder !== textView { + window.makeFirstResponder(textView) + } else if !isFocused, window.firstResponder === textView { + window.makeFirstResponder(nil) + } + } + } - @MainActor - final class Coordinator: NSObject, NSTextViewDelegate { - var parent: ComposerTextView - private var historyState = ComposerDraftHistoryState() - private var applyingHistory = false + @MainActor + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: ComposerTextView + private var historyState = ComposerDraftHistoryState() + private var applyingHistory = false - init(parent: ComposerTextView) { - self.parent = parent - } + init(parent: ComposerTextView) { + self.parent = parent + } - func updateParent(_ parent: ComposerTextView) { - if self.parent.history != parent.history { historyState.reset() } - self.parent = parent - } + func updateParent(_ parent: ComposerTextView) { + if self.parent.history != parent.history { historyState.reset() } + self.parent = parent + } - func textDidChange(_ notification: Notification) { - guard let textView = notification.object as? ComposerNativeTextView, - let scrollView = textView.enclosingScrollView else { return } - if parent.text != textView.string { parent.text = textView.string } - if !applyingHistory { historyState.reset() } - textView.needsDisplay = true - refreshLayout(textView, in: scrollView) - } + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? ComposerNativeTextView, + let scrollView = textView.enclosingScrollView + else { return } + if parent.text != textView.string { parent.text = textView.string } + if !applyingHistory { historyState.reset() } + textView.needsDisplay = true + refreshLayout(textView, in: scrollView) + } - func textDidBeginEditing(_ notification: Notification) { - parent.isFocused = true - } + func textDidBeginEditing(_ notification: Notification) { + parent.isFocused = true + } - func textDidEndEditing(_ notification: Notification) { - parent.isFocused = false - } + func textDidEndEditing(_ notification: Notification) { + parent.isFocused = false + } - fileprivate func handleKeyDown( - _ event: NSEvent, - in textView: ComposerNativeTextView - ) -> Bool { - switch ComposerTextKeyDisposition.resolve( - keyCode: event.keyCode, - modifiers: event.modifierFlags, - hasMarkedText: textView.hasMarkedText() - ) { - case .send: - parent.onSend() - return true - case .insertNewline: - textView.insertNewline(nil) - return true - case .previousHistory: - guard canNavigatePrevious(in: textView), - let recalled = historyState.previous(history: parent.history, current: textView.string) - else { return false } - applyHistory(recalled, to: textView) - return true - case .nextHistory: - guard canNavigateNext(in: textView), - let recalled = historyState.next(history: parent.history) - else { return false } - applyHistory(recalled, to: textView) - return true - case .native: - return false - } - } + fileprivate func handleKeyDown( + _ event: NSEvent, + in textView: ComposerNativeTextView + ) -> Bool { + switch ComposerTextKeyDisposition.resolve( + keyCode: event.keyCode, + modifiers: event.modifierFlags, + hasMarkedText: textView.hasMarkedText() + ) { + case .send: + parent.onSend() + return true + case .insertNewline: + textView.insertNewline(nil) + return true + case .previousHistory: + guard canNavigatePrevious(in: textView), + let recalled = historyState.previous(history: parent.history, current: textView.string) + else { return false } + applyHistory(recalled, to: textView) + return true + case .nextHistory: + guard canNavigateNext(in: textView), + let recalled = historyState.next(history: parent.history) + else { return false } + applyHistory(recalled, to: textView) + return true + case .native: + return false + } + } - private func canNavigatePrevious(in textView: NSTextView) -> Bool { - !textView.string.contains("\n") || textView.selectedRange().location == 0 - } + private func canNavigatePrevious(in textView: NSTextView) -> Bool { + !textView.string.contains("\n") || textView.selectedRange().location == 0 + } - private func canNavigateNext(in textView: NSTextView) -> Bool { - let selection = textView.selectedRange() - return !textView.string.contains("\n") - || selection.location + selection.length == (textView.string as NSString).length - } + private func canNavigateNext(in textView: NSTextView) -> Bool { + let selection = textView.selectedRange() + return !textView.string.contains("\n") + || selection.location + selection.length == (textView.string as NSString).length + } - private func applyHistory(_ text: String, to textView: NSTextView) { - applyingHistory = true - textView.string = text - textView.setSelectedRange(NSRange(location: (text as NSString).length, length: 0)) - parent.text = text - textView.didChangeText() - applyingHistory = false - } + private func applyHistory(_ text: String, to textView: NSTextView) { + applyingHistory = true + textView.string = text + textView.setSelectedRange(NSRange(location: (text as NSString).length, length: 0)) + parent.text = text + textView.didChangeText() + applyingHistory = false + } - func refreshLayout(_ textView: NSTextView, in scrollView: NSScrollView) { - guard scrollView.contentSize.width > 0, - let layoutManager = textView.layoutManager, - let textContainer = textView.textContainer, - let font = textView.font else { return } - layoutManager.ensureLayout(for: textContainer) - let contentHeight = ceil( - layoutManager.usedRect(for: textContainer).height - + textView.textContainerInset.height * 2 - ) - let lineHeight = ceil(layoutManager.defaultLineHeight(for: font)) - let layout = ComposerTextLayout.resolve( - contentHeight: contentHeight, - lineHeight: lineHeight - ) - if scrollView.hasVerticalScroller != layout.isVerticallyScrollable { - scrollView.hasVerticalScroller = layout.isVerticallyScrollable - } - if abs(parent.height - layout.height) > 0.5 { - DispatchQueue.main.async { [weak self] in - guard let self, abs(self.parent.height - layout.height) > 0.5 else { return } - self.parent.height = layout.height - } - } - textView.scrollRangeToVisible(textView.selectedRange()) + func refreshLayout(_ textView: NSTextView, in scrollView: NSScrollView) { + guard scrollView.contentSize.width > 0, + let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer, + let font = textView.font + else { return } + layoutManager.ensureLayout(for: textContainer) + let contentHeight = ceil( + layoutManager.usedRect(for: textContainer).height + + textView.textContainerInset.height * 2 + ) + let lineHeight = ceil(layoutManager.defaultLineHeight(for: font)) + let layout = ComposerTextLayout.resolve( + contentHeight: contentHeight, + lineHeight: lineHeight + ) + if scrollView.hasVerticalScroller != layout.isVerticallyScrollable { + scrollView.hasVerticalScroller = layout.isVerticallyScrollable + } + if abs(parent.height - layout.height) > 0.5 { + DispatchQueue.main.async { [weak self] in + guard let self, abs(self.parent.height - layout.height) > 0.5 else { return } + self.parent.height = layout.height } + } + textView.scrollRangeToVisible(textView.selectedRange()) } + } } private final class ComposerScrollView: NSScrollView { - var onContentWidthChange: (() -> Void)? - private var measuredContentWidth: CGFloat = -1 - - override func layout() { - super.layout() - let width = contentSize.width - guard abs(width - measuredContentWidth) > 0.5 else { return } - measuredContentWidth = width - onContentWidthChange?() - } + var onContentWidthChange: (() -> Void)? + private var measuredContentWidth: CGFloat = -1 + + override func layout() { + super.layout() + let width = contentSize.width + guard abs(width - measuredContentWidth) > 0.5 else { return } + measuredContentWidth = width + onContentWidthChange?() + } } private final class ComposerNativeTextView: NSTextView { - var placeholder = "" - var onKeyDown: ((NSEvent) -> Bool)? - - override func keyDown(with event: NSEvent) { - if onKeyDown?(event) == true { return } - super.keyDown(with: event) - } - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - guard string.isEmpty, !placeholder.isEmpty, let font else { return } - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: NSColor( - srgbRed: 0x6F / 255, - green: 0x72 / 255, - blue: 0x68 / 255, - alpha: 1 - ), - ] - let origin = NSPoint( - x: textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0), - y: textContainerInset.height - ) - placeholder.draw(at: origin, withAttributes: attributes) - } + var placeholder = "" + var onKeyDown: ((NSEvent) -> Bool)? + + override func keyDown(with event: NSEvent) { + if onKeyDown?(event) == true { return } + super.keyDown(with: event) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard string.isEmpty, !placeholder.isEmpty, let font else { return } + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor( + srgbRed: 0x6F / 255, + green: 0x72 / 255, + blue: 0x68 / 255, + alpha: 1 + ), + ] + let origin = NSPoint( + x: textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0), + y: textContainerInset.height + ) + placeholder.draw(at: origin, withAttributes: attributes) + } } diff --git a/macos/Codescribe/Screens/AgentChat/MessageList.swift b/macos/Codescribe/Screens/AgentChat/MessageList.swift index 9fff44b0..5f200efc 100644 --- a/macos/Codescribe/Screens/AgentChat/MessageList.swift +++ b/macos/Codescribe/Screens/AgentChat/MessageList.swift @@ -1,9 +1,9 @@ -import SwiftUI import AppKit +import SwiftUI enum StreamScrollFollowAction: Equatable { - case none - case scrollToLiveEdge + case none + case scrollToLiveEdge } /// Operator-facing chat column density. Persisted via `ChatLayoutPolicy.defaultsKey`. @@ -13,41 +13,41 @@ enum StreamScrollFollowAction: Equatable { /// how aggressively the column fills available space and when the prose cap /// kicks in so code fences / tables can claim room on wide monitors. enum ChatWidthMode: String, CaseIterable, Identifiable { - case comfortable - case wide - case full + case comfortable + case wide + case full - var id: String { rawValue } + var id: String { rawValue } - var label: String { - switch self { - case .comfortable: return "Comfortable" - case .wide: return "Wide" - case .full: return "Full" - } + var label: String { + switch self { + case .comfortable: return "Comfortable" + case .wide: return "Wide" + case .full: return "Full" } - - /// You-bubble share of usable width (chat-style trailing bubble). - var youFraction: CGFloat { - switch self { - case .comfortable: return 0.58 - case .wide: return 0.72 - case .full: return 0.88 - } + } + + /// You-bubble share of usable width (chat-style trailing bubble). + var youFraction: CGFloat { + switch self { + case .comfortable: return 0.58 + case .wide: return 0.72 + case .full: return 0.88 } - - /// Soft upper bound for leading (assistant/tool) prose. `nil` = fill usable. - var proseComfortCap: CGFloat? { - switch self { - case .comfortable: return 720 - case .wide: return 920 - case .full: return nil - } + } + + /// Soft upper bound for leading (assistant/tool) prose. `nil` = fill usable. + var proseComfortCap: CGFloat? { + switch self { + case .comfortable: return 720 + case .wide: return 920 + case .full: return nil } + } - static func resolve(_ raw: String) -> ChatWidthMode { - ChatWidthMode(rawValue: raw) ?? .wide - } + static func resolve(_ raw: String) -> ChatWidthMode { + ChatWidthMode(rawValue: raw) ?? .wide + } } /// Container-relative bubble widths for AgentChat. @@ -57,54 +57,54 @@ enum ChatWidthMode: String, CaseIterable, Identifiable { /// constant: You stays a readable bubble; assistant/tool use the full usable /// column (subject to `ChatWidthMode`) so code fences and tables stop clipping. enum ChatLayoutPolicy { - /// `UserDefaults` / `@AppStorage` key for the operator width preference. - static let defaultsKey = "codescribe.chatWidthMode" - /// Horizontal padding applied by `MessageList` around the LazyVStack. - static let listPadding: CGFloat = 20 - /// Minimum readable bubble width on a narrow window. - static let minimumReadable: CGFloat = 280 - /// Default mode when the preference is missing or unknown. - static let defaultMode: ChatWidthMode = .wide - - /// Usable content width after list padding (both sides). - static func contentWidth(for containerWidth: CGFloat) -> CGFloat { - max(0, containerWidth - listPadding * 2) - } - - /// Max width for a You bubble given the scroll viewport width. - static func youBubbleMaxWidth( - containerWidth: CGFloat, - mode: ChatWidthMode = defaultMode - ) -> CGFloat { - let usable = contentWidth(for: containerWidth) - guard usable > 0 else { return minimumReadable } - let proportional = usable * mode.youFraction - return max(minimumReadable, min(usable, proportional)) - } - - /// Max width for assistant / tool turns. Fills the column up to the mode's - /// prose comfort cap (Full has no cap) so ultrawide windows do not produce - /// unreadable body lines while still giving code/tables more room than the - /// old 900pt hard cap on typical laptop widths. - static func leadingColumnMaxWidth( - containerWidth: CGFloat, - mode: ChatWidthMode = defaultMode - ) -> CGFloat { - let usable = contentWidth(for: containerWidth) - guard usable > 0 else { return minimumReadable } - guard let cap = mode.proseComfortCap else { - return max(minimumReadable, usable) - } - return max(minimumReadable, min(usable, cap)) - } - - /// Hard ceiling for any turn's laid-out width inside the scroll document. - /// Prevents a single long unbreakable token (or a mis-parsed wire dump) - /// from growing the ScrollView's content width past the viewport — the - /// class of bug that floats glyphs like `)"` outside the Agent window. - static func documentWidth(for containerWidth: CGFloat) -> CGFloat { - max(minimumReadable, containerWidth > 0 ? containerWidth : minimumReadable) + /// `UserDefaults` / `@AppStorage` key for the operator width preference. + static let defaultsKey = "codescribe.chatWidthMode" + /// Horizontal padding applied by `MessageList` around the LazyVStack. + static let listPadding: CGFloat = 20 + /// Minimum readable bubble width on a narrow window. + static let minimumReadable: CGFloat = 280 + /// Default mode when the preference is missing or unknown. + static let defaultMode: ChatWidthMode = .wide + + /// Usable content width after list padding (both sides). + static func contentWidth(for containerWidth: CGFloat) -> CGFloat { + max(0, containerWidth - listPadding * 2) + } + + /// Max width for a You bubble given the scroll viewport width. + static func youBubbleMaxWidth( + containerWidth: CGFloat, + mode: ChatWidthMode = defaultMode + ) -> CGFloat { + let usable = contentWidth(for: containerWidth) + guard usable > 0 else { return minimumReadable } + let proportional = usable * mode.youFraction + return max(minimumReadable, min(usable, proportional)) + } + + /// Max width for assistant / tool turns. Fills the column up to the mode's + /// prose comfort cap (Full has no cap) so ultrawide windows do not produce + /// unreadable body lines while still giving code/tables more room than the + /// old 900pt hard cap on typical laptop widths. + static func leadingColumnMaxWidth( + containerWidth: CGFloat, + mode: ChatWidthMode = defaultMode + ) -> CGFloat { + let usable = contentWidth(for: containerWidth) + guard usable > 0 else { return minimumReadable } + guard let cap = mode.proseComfortCap else { + return max(minimumReadable, usable) } + return max(minimumReadable, min(usable, cap)) + } + + /// Hard ceiling for any turn's laid-out width inside the scroll document. + /// Prevents a single long unbreakable token (or a mis-parsed wire dump) + /// from growing the ScrollView's content width past the viewport — the + /// class of bug that floats glyphs like `)"` outside the Agent window. + static func documentWidth(for containerWidth: CGFloat) -> CGFloat { + max(minimumReadable, containerWidth > 0 ? containerWidth : minimumReadable) + } } /// Render disposition for one bubble's text (bolączka #3 residual, hang report @@ -114,278 +114,282 @@ enum ChatLayoutPolicy { /// contained text view with its own selection — never under the list-wide /// `.textSelection(.enabled)`. enum BubbleTextDisposition: Equatable { - /// Full text inline, sharing the list selection surface. - case inline - /// Oversized: render only the first `headUTF8` bytes inline; full text - /// behind an explicit reveal in a selection-contained view. - case headPreview(headUTF8: Int) - - /// Whether this bubble's text participates in the list-wide selection - /// overlay — the mechanism the 2026-08-04 hang livelocked on. - var sharesListSelectionOverlay: Bool { - switch self { - case .inline: return true - case .headPreview: return false - } + /// Full text inline, sharing the list selection surface. + case inline + /// Oversized: render only the first `headUTF8` bytes inline; full text + /// behind an explicit reveal in a selection-contained view. + case headPreview(headUTF8: Int) + + /// Whether this bubble's text participates in the list-wide selection + /// overlay — the mechanism the 2026-08-04 hang livelocked on. + var sharesListSelectionOverlay: Bool { + switch self { + case .inline: return true + case .headPreview: return false } + } } enum OversizedBubblePolicy { - /// UTF-8 length past which a bubble stops rendering inline. 64 KiB: four - /// times the agent tool-output spill limit, well below the 100k paste that - /// reproduced the livelock. - static let inlineUTF8Cap = 65_536 - /// UTF-8 length of the head shown inline for an oversized bubble. - static let headPreviewUTF8 = 16_384 - - static func disposition(utf8Count: Int) -> BubbleTextDisposition { - utf8Count <= inlineUTF8Cap - ? .inline - : .headPreview(headUTF8: headPreviewUTF8) - } + /// UTF-8 length past which a bubble stops rendering inline. 64 KiB: four + /// times the agent tool-output spill limit, well below the 100k paste that + /// reproduced the livelock. + static let inlineUTF8Cap = 65_536 + /// UTF-8 length of the head shown inline for an oversized bubble. + static let headPreviewUTF8 = 16_384 + + static func disposition(utf8Count: Int) -> BubbleTextDisposition { + utf8Count <= inlineUTF8Cap + ? .inline + : .headPreview(headUTF8: headPreviewUTF8) + } } /// Explicit state machine for the message viewport. Content growth is allowed /// to move the viewport only while the operator is following the live edge. struct StreamScrollFollowState: Equatable { - enum Event: Equatable { - case contentChanged - case userScrollBegan - case userViewportChanged(isAtLiveEdge: Bool) - case userScrollEnded(isAtLiveEdge: Bool) - case jumpToCurrent - case threadChanged - case streamFinished - } - - private(set) var followingLive = true - var showsJumpToCurrent: Bool { !followingLive } - - mutating func handle(_ event: Event) -> StreamScrollFollowAction { - switch event { - case .contentChanged: - return followingLive ? .scrollToLiveEdge : .none - case .userScrollBegan: - followingLive = false - return .none - case .userViewportChanged: - // Geometry changes during an active gesture may still report the - // old live-edge position. Never let that stale signal steal the - // viewport back from the operator. - return .none - case let .userScrollEnded(isAtLiveEdge): - followingLive = isAtLiveEdge - return .none - case .jumpToCurrent, .threadChanged: - followingLive = true - return .scrollToLiveEdge - case .streamFinished: - return .none - } + enum Event: Equatable { + case contentChanged + case userScrollBegan + case userViewportChanged(isAtLiveEdge: Bool) + case userScrollEnded(isAtLiveEdge: Bool) + case jumpToCurrent + case threadChanged + case streamFinished + } + + private(set) var followingLive = true + var showsJumpToCurrent: Bool { !followingLive } + + mutating func handle(_ event: Event) -> StreamScrollFollowAction { + switch event { + case .contentChanged: + return followingLive ? .scrollToLiveEdge : .none + case .userScrollBegan: + followingLive = false + return .none + case .userViewportChanged: + // Geometry changes during an active gesture may still report the + // old live-edge position. Never let that stale signal steal the + // viewport back from the operator. + return .none + case .userScrollEnded(let isAtLiveEdge): + followingLive = isAtLiveEdge + return .none + case .jumpToCurrent, .threadChanged: + followingLive = true + return .scrollToLiveEdge + case .streamFinished: + return .none } + } } /// Scrolling turn list: You (terracotta bubble, right) · Tool activity /// (DisclosureGroup, mono) · Assistant (amber "reasoned · Xs" chip + body, /// last turn streams with a blink caret). Auto-scrolls to the newest turn. struct MessageList: View { - let threadID: UUID - let messages: [ChatMessage] - /// Flips a bubble between raw mono and rich markdown. State lives in the - /// store (per-message `renderMode`), never in this view. - var onToggleRenderMode: (UUID) -> Void = { _ in } - - /// Follow-tail with pause-on-scroll (the overlay transcript pattern): auto-scroll - /// to the newest turn only while the user is already at the bottom. Scrolling up - /// during a stream pauses the follow; returning to the bottom resumes it, so the - /// view stops fighting the user's manual scroll on a long streamed message. - @State private var followState = StreamScrollFollowState() - /// Hang guard (2026-08-04 livelock): LazyVStack pays O(items) layout phases - /// per view-graph transaction, so an unbounded multi-hour thread eventually - /// outruns the run loop even with healthy per-item costs. Only the newest - /// window renders; "Show earlier" pages the history in on demand. - @State private var visibleTurnBudget = MessageList.turnWindow - /// Operator width density — shared with the header picker via `@AppStorage`. - @AppStorage(ChatLayoutPolicy.defaultsKey) private var widthModeRaw = ChatLayoutPolicy.defaultMode.rawValue - private let scrollSpace = "chatMessageScroll" - private let bottomAnchor = "chatMessageBottom" - - private var widthMode: ChatWidthMode { ChatWidthMode.resolve(widthModeRaw) } - - /// Newest turns per page of the render window (`visibleTurnBudget` grows by - /// this step on every "Show earlier"). Sized so a normal working session - /// never sees the affordance while a 49-hour thread stays bounded. - static let turnWindow = 120 - - /// The rendered slice: newest `visibleTurnBudget` turns. - private var visibleMessages: ArraySlice { - messages.suffix(visibleTurnBudget) - } - - private var hiddenTurnCount: Int { - max(0, messages.count - visibleTurnBudget) - } - - var body: some View { - GeometryReader { viewport in - let containerWidth = viewport.size.width - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 16) { - if hiddenTurnCount > 0 { - ShowEarlierButton(hiddenCount: hiddenTurnCount) { - visibleTurnBudget += Self.turnWindow - } - } - ForEach(visibleMessages) { message in - turn(message, containerWidth: containerWidth, mode: widthMode) - .frame(maxWidth: .infinity, alignment: alignment(message.role)) - .id(message.id) - } - Color.clear - .frame(height: 1) - .id(bottomAnchor) - } - // Pin the document to the viewport width so a single - // long-line bubble cannot widen the scroll content and - // paint outside the Agent window chrome (R1 collapse). - .frame( - maxWidth: ChatLayoutPolicy.documentWidth(for: containerWidth), - alignment: .topLeading - ) - .padding(ChatLayoutPolicy.listPadding) - .clipped() - .background( - GeometryReader { content in - Color.clear.preference( - key: ChatBottomKey.self, - value: content.frame(in: .named(scrollSpace)).maxY - ) - } - ) - // Lives inside the document view so `enclosingScrollView` - // resolves to this message list rather than an outer pane. - .background( - ChatLiveScrollObserver { event in - _ = followState.handle(event) - } - ) - } - .coordinateSpace(name: scrollSpace) - .scrollContentBackground(.hidden) - // NO list-wide `.textSelection(.enabled)` here — the shared - // SelectionOverlay spanning the whole LazyVStack is the exact - // mechanism the 2026-08-04 livelock spun on (every view-graph - // flush re-walked the full transcript's selection geometry). - // Selection lives per body instead: MarkdownText root, RawText, - // tool rows, reasoning and context chips all enable it locally, - // so drag-select + Cmd+C keep working inside any bubble. - .onPreferenceChange(ChatBottomKey.self) { contentBottom in - let isAtLiveEdge = Self.followTailAfterScroll( - contentBottom: contentBottom, - viewportHeight: viewport.size.height - ) - _ = followState.handle(.userViewportChanged(isAtLiveEdge: isAtLiveEdge)) - } - .onChange(of: Self.tailSignature(messages)) { _, _ in - perform(followState.handle(.contentChanged), with: proxy) - } - .onChange(of: messages.last?.isStreaming == true) { wasStreaming, isStreaming in - if wasStreaming, !isStreaming { - _ = followState.handle(.streamFinished) - } - } - .onChange(of: threadID) { _, _ in - // A fresh thread starts back at the bounded window; an - // expanded budget must not leak across conversations. - visibleTurnBudget = Self.turnWindow - perform(followState.handle(.threadChanged), with: proxy) - } - .onAppear { - perform(.scrollToLiveEdge, with: proxy, animated: false) - } - .overlay(alignment: .bottom) { - let pillVisible = followState.showsJumpToCurrent - ZStack { - if pillVisible { - JumpToCurrentButton { - perform(followState.handle(.jumpToCurrent), with: proxy) - } - .padding(.bottom, 10) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .animation(.easeOut(duration: 0.18), value: pillVisible) - } + let threadID: UUID + let messages: [ChatMessage] + /// Flips a bubble between raw mono and rich markdown. State lives in the + /// store (per-message `renderMode`), never in this view. + var onToggleRenderMode: (UUID) -> Void = { _ in } + + /// Follow-tail with pause-on-scroll (the overlay transcript pattern): auto-scroll + /// to the newest turn only while the user is already at the bottom. Scrolling up + /// during a stream pauses the follow; returning to the bottom resumes it, so the + /// view stops fighting the user's manual scroll on a long streamed message. + @State private var followState = StreamScrollFollowState() + /// Hang guard (2026-08-04 livelock): LazyVStack pays O(items) layout phases + /// per view-graph transaction, so an unbounded multi-hour thread eventually + /// outruns the run loop even with healthy per-item costs. Only the newest + /// window renders; "Show earlier" pages the history in on demand. + @State private var visibleTurnBudget = MessageList.turnWindow + /// Operator width density — shared with the header picker via `@AppStorage`. + @AppStorage(ChatLayoutPolicy.defaultsKey) private var widthModeRaw = ChatLayoutPolicy.defaultMode + .rawValue + private let scrollSpace = "chatMessageScroll" + private let bottomAnchor = "chatMessageBottom" + + private var widthMode: ChatWidthMode { ChatWidthMode.resolve(widthModeRaw) } + + /// Newest turns per page of the render window (`visibleTurnBudget` grows by + /// this step on every "Show earlier"). Sized so a normal working session + /// never sees the affordance while a 49-hour thread stays bounded. + static let turnWindow = 120 + + /// The rendered slice: newest `visibleTurnBudget` turns. + private var visibleMessages: ArraySlice { + messages.suffix(visibleTurnBudget) + } + + private var hiddenTurnCount: Int { + max(0, messages.count - visibleTurnBudget) + } + + var body: some View { + GeometryReader { viewport in + let containerWidth = viewport.size.width + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 16) { + if hiddenTurnCount > 0 { + ShowEarlierButton(hiddenCount: hiddenTurnCount) { + visibleTurnBudget += Self.turnWindow + } + } + ForEach(visibleMessages) { message in + turn(message, containerWidth: containerWidth, mode: widthMode) + .frame(maxWidth: .infinity, alignment: alignment(message.role)) + .id(message.id) } + Color.clear + .frame(height: 1) + .id(bottomAnchor) + } + // Pin the document to the viewport width so a single + // long-line bubble cannot widen the scroll content and + // paint outside the Agent window chrome (R1 collapse). + .frame( + maxWidth: ChatLayoutPolicy.documentWidth(for: containerWidth), + alignment: .topLeading + ) + .padding(ChatLayoutPolicy.listPadding) + .clipped() + .background( + GeometryReader { content in + Color.clear.preference( + key: ChatBottomKey.self, + value: content.frame(in: .named(scrollSpace)).maxY + ) + } + ) + // Lives inside the document view so `enclosingScrollView` + // resolves to this message list rather than an outer pane. + .background( + ChatLiveScrollObserver { event in + _ = followState.handle(event) + } + ) } - } - - private func perform( - _ action: StreamScrollFollowAction, - with proxy: ScrollViewProxy, - animated: Bool = true - ) { - guard action == .scrollToLiveEdge else { return } - if animated { - withAnimation(.easeOut(duration: 0.25)) { - proxy.scrollTo(bottomAnchor, anchor: .bottom) + .coordinateSpace(name: scrollSpace) + .scrollContentBackground(.hidden) + // NO list-wide `.textSelection(.enabled)` here — the shared + // SelectionOverlay spanning the whole LazyVStack is the exact + // mechanism the 2026-08-04 livelock spun on (every view-graph + // flush re-walked the full transcript's selection geometry). + // Selection lives per body instead: MarkdownText root, RawText, + // tool rows, reasoning and context chips all enable it locally, + // so drag-select + Cmd+C keep working inside any bubble. + .onPreferenceChange(ChatBottomKey.self) { contentBottom in + let isAtLiveEdge = Self.followTailAfterScroll( + contentBottom: contentBottom, + viewportHeight: viewport.size.height + ) + _ = followState.handle(.userViewportChanged(isAtLiveEdge: isAtLiveEdge)) + } + .onChange(of: Self.tailSignature(messages)) { _, _ in + perform(followState.handle(.contentChanged), with: proxy) + } + .onChange(of: messages.last?.isStreaming == true) { wasStreaming, isStreaming in + if wasStreaming, !isStreaming { + _ = followState.handle(.streamFinished) + } + } + .onChange(of: threadID) { _, _ in + // A fresh thread starts back at the bounded window; an + // expanded budget must not leak across conversations. + visibleTurnBudget = Self.turnWindow + perform(followState.handle(.threadChanged), with: proxy) + } + .onAppear { + perform(.scrollToLiveEdge, with: proxy, animated: false) + } + .overlay(alignment: .bottom) { + let pillVisible = followState.showsJumpToCurrent + ZStack { + if pillVisible { + JumpToCurrentButton { + perform(followState.handle(.jumpToCurrent), with: proxy) + } + .padding(.bottom, 10) + .transition(.opacity.combined(with: .move(edge: .bottom))) } - } else { - proxy.scrollTo(bottomAnchor, anchor: .bottom) + } + .animation(.easeOut(duration: 0.18), value: pillVisible) } + } } - - // MARK: Pure scroll/pill logic (XCTest-covered, see MessageListFollowTailTests) - - /// At-bottom decision: the content's bottom edge sits within `slack` of the - /// viewport's bottom. Drives follow on/off from the scroll preference. - static func followTailAfterScroll(contentBottom: CGFloat, viewportHeight: CGFloat, - slack: CGFloat = 40) -> Bool { - contentBottom <= viewportHeight + slack - } - - /// Changes whenever a new turn lands or the streaming tail grows — the - /// auto-scroll trigger. Deliberately cheap for the per-delta hot path: - /// `utf8.count` is O(1) on native strings (grapheme `count` walks the whole - /// text — 100k steps per tick on a large pasted turn), only the last two - /// turns matter (the tool row + the streaming bubble; `messages.count` - /// catches insertions), and no tool detail strings are concatenated. - /// `renderMode` is excluded on purpose: a raw↔rich flip must not scroll. - static func tailSignature(_ messages: [ChatMessage]) -> String { - var signature = "\(messages.count)" - for message in messages.suffix(2) { - let running = message.toolLines.lazy.filter { $0.state == .running }.count - signature += "|\(message.id)-\(message.text.utf8.count)" - + "-\(message.reasoning.utf8.count)-\(message.toolLines.count)-\(running)" - } - return signature + } + + private func perform( + _ action: StreamScrollFollowAction, + with proxy: ScrollViewProxy, + animated: Bool = true + ) { + guard action == .scrollToLiveEdge else { return } + if animated { + withAnimation(.easeOut(duration: 0.25)) { + proxy.scrollTo(bottomAnchor, anchor: .bottom) + } + } else { + proxy.scrollTo(bottomAnchor, anchor: .bottom) } - - private func alignment(_ role: ChatRole) -> Alignment { - role == .you ? .trailing : .leading + } + + // MARK: Pure scroll/pill logic (XCTest-covered, see MessageListFollowTailTests) + + /// At-bottom decision: the content's bottom edge sits within `slack` of the + /// viewport's bottom. Drives follow on/off from the scroll preference. + static func followTailAfterScroll( + contentBottom: CGFloat, viewportHeight: CGFloat, + slack: CGFloat = 40 + ) -> Bool { + contentBottom <= viewportHeight + slack + } + + /// Changes whenever a new turn lands or the streaming tail grows — the + /// auto-scroll trigger. Deliberately cheap for the per-delta hot path: + /// `utf8.count` is O(1) on native strings (grapheme `count` walks the whole + /// text — 100k steps per tick on a large pasted turn), only the last two + /// turns matter (the tool row + the streaming bubble; `messages.count` + /// catches insertions), and no tool detail strings are concatenated. + /// `renderMode` is excluded on purpose: a raw↔rich flip must not scroll. + static func tailSignature(_ messages: [ChatMessage]) -> String { + var signature = "\(messages.count)" + for message in messages.suffix(2) { + let running = message.toolLines.lazy.filter { $0.state == .running }.count + signature += + "|\(message.id)-\(message.text.utf8.count)" + + "-\(message.reasoning.utf8.count)-\(message.toolLines.count)-\(running)" } - - @ViewBuilder - private func turn( - _ message: ChatMessage, - containerWidth: CGFloat, - mode: ChatWidthMode - ) -> some View { - switch message.role { - case .you: - YouTurn(message: message, containerWidth: containerWidth, mode: mode) - case .tool: - ToolTurn(message: message, containerWidth: containerWidth, mode: mode) - case .assistant: - AssistantTurn( - message: message, - containerWidth: containerWidth, - mode: mode, - onToggleRenderMode: onToggleRenderMode - ) - } + return signature + } + + private func alignment(_ role: ChatRole) -> Alignment { + role == .you ? .trailing : .leading + } + + @ViewBuilder + private func turn( + _ message: ChatMessage, + containerWidth: CGFloat, + mode: ChatWidthMode + ) -> some View { + switch message.role { + case .you: + YouTurn(message: message, containerWidth: containerWidth, mode: mode) + case .tool: + ToolTurn(message: message, containerWidth: containerWidth, mode: mode) + case .assistant: + AssistantTurn( + message: message, + containerWidth: containerWidth, + mode: mode, + onToggleRenderMode: onToggleRenderMode + ) } + } } /// Top-of-list pager for the bounded render window: names how much history is @@ -393,347 +397,352 @@ struct MessageList: View { /// not infinite scroll — paging must stay an explicit operator action so the /// hang guard cannot be defeated by an idle scroll position. private struct ShowEarlierButton: View { - let hiddenCount: Int - let action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - CSIconView(icon: .chevronRight, size: 8, weight: .semibold, - color: CSColor.textFaintAlt) - Text("Show earlier · \(hiddenCount) turn\(hiddenCount == 1 ? "" : "s")") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(hovering ? CSColor.textBody : CSColor.textFaintAlt) - } - .padding(.horizontal, 11) - .padding(.vertical, 6) - .background(CSColor.surfaceRaised(0.04)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) - .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) - .contentShape(Rectangle()) - } - .csFocusRing(cornerRadius: CSRadius.pill) - .onHover { hovering = $0 } - .accessibilityLabel("Show earlier messages") - .help("Render the previous \(MessageList.turnWindow) turns") + let hiddenCount: Int + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + CSIconView( + icon: .chevronRight, size: 8, weight: .semibold, + color: CSColor.textFaintAlt) + Text("Show earlier · \(hiddenCount) turn\(hiddenCount == 1 ? "" : "s")") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(hovering ? CSColor.textBody : CSColor.textFaintAlt) + } + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background(CSColor.surfaceRaised(0.04)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) + .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) + .contentShape(Rectangle()) } + .csFocusRing(cornerRadius: CSRadius.pill) + .onHover { hovering = $0 } + .accessibilityLabel("Show earlier messages") + .help("Render the previous \(MessageList.turnWindow) turns") + } } /// Floating return affordance. It remains available after a stream settles: /// finishing generation never takes the operator's chosen reading position. private struct JumpToCurrentButton: View { - let action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 5) { - CSIconView(icon: .chevronDown, size: 9, weight: .semibold, - color: CSColor.chromeAccent) - Text("Current") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(hovering ? CSColor.textHigh : CSColor.textBody) - } - .padding(.horizontal, 11) - .padding(.vertical, 6) - .background(CSColor.glassUnder.opacity(0.92)) - .background(CSColor.surfaceRaised(0.05)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) - .strokeBorder(CSColor.hairline(0.12), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) - } - .csFocusRing(cornerRadius: CSRadius.pill) - .onHover { hovering = $0 } - .accessibilityLabel("Jump to current") - .help("Jump to the current reply") + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + CSIconView( + icon: .chevronDown, size: 9, weight: .semibold, + color: CSColor.chromeAccent) + Text("Current") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(hovering ? CSColor.textHigh : CSColor.textBody) + } + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background(CSColor.glassUnder.opacity(0.92)) + .background(CSColor.surfaceRaised(0.05)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) + .strokeBorder(CSColor.hairline(0.12), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) } + .csFocusRing(cornerRadius: CSRadius.pill) + .onHover { hovering = $0 } + .accessibilityLabel("Jump to current") + .help("Jump to the current reply") + } } /// Carries the message list content's bottom-edge Y (in the scroll's coordinate /// space) up to the follow-tail detector. Mirrors the overlay transcript's key. private struct ChatBottomKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() - } + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } } /// macOS 14-compatible user-intent detector. SwiftUI's geometry preference /// reports position but cannot distinguish a wheel/trackpad/scrollbar gesture /// from `ScrollViewProxy.scrollTo`; AppKit live-scroll notifications can. private struct ChatLiveScrollObserver: NSViewRepresentable { - let onEvent: (StreamScrollFollowState.Event) -> Void + let onEvent: (StreamScrollFollowState.Event) -> Void - func makeCoordinator() -> Coordinator { - Coordinator(onEvent: onEvent) - } + func makeCoordinator() -> Coordinator { + Coordinator(onEvent: onEvent) + } - func makeNSView(context: Context) -> AttachmentView { - let view = AttachmentView() - view.onAttach = { [weak coordinator = context.coordinator] scrollView in - coordinator?.attach(to: scrollView) - } - return view + func makeNSView(context: Context) -> AttachmentView { + let view = AttachmentView() + view.onAttach = { [weak coordinator = context.coordinator] scrollView in + coordinator?.attach(to: scrollView) } + return view + } - func updateNSView(_ nsView: AttachmentView, context: Context) { - context.coordinator.onEvent = onEvent - nsView.attachWhenReady() - } + func updateNSView(_ nsView: AttachmentView, context: Context) { + context.coordinator.onEvent = onEvent + nsView.attachWhenReady() + } - static func dismantleNSView(_ nsView: AttachmentView, coordinator: Coordinator) { - coordinator.detach() - } + static func dismantleNSView(_ nsView: AttachmentView, coordinator: Coordinator) { + coordinator.detach() + } - final class AttachmentView: NSView { - var onAttach: ((NSScrollView) -> Void)? + final class AttachmentView: NSView { + var onAttach: ((NSScrollView) -> Void)? - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - attachWhenReady() - } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + attachWhenReady() + } - func attachWhenReady() { - DispatchQueue.main.async { [weak self] in - guard let self, let scrollView = enclosingScrollView else { return } - onAttach?(scrollView) - } - } + func attachWhenReady() { + DispatchQueue.main.async { [weak self] in + guard let self, let scrollView = enclosingScrollView else { return } + onAttach?(scrollView) + } } + } - final class Coordinator { - var onEvent: (StreamScrollFollowState.Event) -> Void - private weak var scrollView: NSScrollView? - private var observers: [NSObjectProtocol] = [] + final class Coordinator { + var onEvent: (StreamScrollFollowState.Event) -> Void + private weak var scrollView: NSScrollView? + private var observers: [NSObjectProtocol] = [] - init(onEvent: @escaping (StreamScrollFollowState.Event) -> Void) { - self.onEvent = onEvent - } + init(onEvent: @escaping (StreamScrollFollowState.Event) -> Void) { + self.onEvent = onEvent + } - func attach(to scrollView: NSScrollView) { - guard self.scrollView !== scrollView else { return } - detach() - self.scrollView = scrollView - let center = NotificationCenter.default - observers = [ - center.addObserver( - forName: NSScrollView.willStartLiveScrollNotification, - object: scrollView, - queue: .main - ) { [weak self] _ in - self?.onEvent(.userScrollBegan) - }, - center.addObserver( - forName: NSScrollView.didLiveScrollNotification, - object: scrollView, - queue: .main - ) { [weak self] _ in - self?.reportViewport(asScrollEnd: true) - }, - center.addObserver( - forName: NSScrollView.didEndLiveScrollNotification, - object: scrollView, - queue: .main - ) { [weak self] _ in - self?.reportViewport() - }, - ] - } + func attach(to scrollView: NSScrollView) { + guard self.scrollView !== scrollView else { return } + detach() + self.scrollView = scrollView + let center = NotificationCenter.default + observers = [ + center.addObserver( + forName: NSScrollView.willStartLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.onEvent(.userScrollBegan) + }, + center.addObserver( + forName: NSScrollView.didLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.reportViewport(asScrollEnd: true) + }, + center.addObserver( + forName: NSScrollView.didEndLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.reportViewport() + }, + ] + } - func detach() { - let center = NotificationCenter.default - observers.forEach(center.removeObserver) - observers.removeAll() - scrollView = nil - } + func detach() { + let center = NotificationCenter.default + observers.forEach(center.removeObserver) + observers.removeAll() + scrollView = nil + } - private func reportViewport(asScrollEnd: Bool = false) { - guard let scrollView, let documentView = scrollView.documentView else { return } - let visibleBottom = scrollView.contentView.documentVisibleRect.maxY - let distanceFromLiveEdge = documentView.bounds.maxY - visibleBottom - let isAtLiveEdge = distanceFromLiveEdge <= 40 - onEvent(asScrollEnd - ? .userScrollEnded(isAtLiveEdge: isAtLiveEdge) - : .userViewportChanged(isAtLiveEdge: isAtLiveEdge)) - } + private func reportViewport(asScrollEnd: Bool = false) { + guard let scrollView, let documentView = scrollView.documentView else { return } + let visibleBottom = scrollView.contentView.documentVisibleRect.maxY + let distanceFromLiveEdge = documentView.bounds.maxY - visibleBottom + let isAtLiveEdge = distanceFromLiveEdge <= 40 + onEvent( + asScrollEnd + ? .userScrollEnded(isAtLiveEdge: isAtLiveEdge) + : .userViewportChanged(isAtLiveEdge: isAtLiveEdge)) + } - deinit { - detach() - } + deinit { + detach() } + } } // MARK: - You private struct YouTurn: View { - let message: ChatMessage - let containerWidth: CGFloat - let mode: ChatWidthMode - - /// Copies the raw prompt text; for a text-less image turn falls back to the - /// attachment filenames so the button still does something useful. - private var copyText: String { - message.text.isEmpty - ? message.attachments.map(\.name).joined(separator: "\n") - : message.text - } - - private var hasContext: Bool { - message.contextSelection != nil || message.contextApp != nil - } - - var body: some View { - VStack(alignment: .trailing, spacing: 5) { - HStack(spacing: 8) { - Text("You · \(message.timestamp)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.terracottaDeep.opacity(0.85)) - CopyMessageButton(text: copyText) - } - VStack(alignment: .leading, spacing: 9) { - if !message.attachments.isEmpty { - WrapLayout(spacing: 6) { - ForEach(message.attachments) { AttachmentChip(attachment: $0) } - } - } - if !message.text.isEmpty { - // Oversized paste (bolączka #3): fold above the display - // budget so the list's selection overlay never carries the - // whole payload. Copy above still exports the full text. - if OversizedBubblePolicy.isOversized(message.text) { - OversizedMessageBody(fullText: message.text) { head in - MarkdownText(raw: head, bodyColor: ChatPalette.nameActive) - } - } else { - MarkdownText(raw: message.text, bodyColor: ChatPalette.nameActive) - } - } - if hasContext { - ContextChip( - selection: message.contextSelection, - app: message.contextApp - ) - } - } - .padding(.horizontal, 15) - .padding(.vertical, 12) - // Calm surface, not an alarm plate (U17): the bubble sits on the - // shared raised surface; terracotta stays on ACCENTS only — the - // timestamp above and this thin border. - .background(CSColor.surfaceRaised(0.06)) - .overlay( - UnevenRoundedRectangle( - topLeadingRadius: 14, bottomLeadingRadius: 14, - bottomTrailingRadius: 4, topTrailingRadius: 14, - style: .continuous - ) - .strokeBorder(CSColor.terracotta.opacity(0.18), lineWidth: 1) - ) - .clipShape(UnevenRoundedRectangle( - topLeadingRadius: 14, bottomLeadingRadius: 14, - bottomTrailingRadius: 4, topTrailingRadius: 14, - style: .continuous - )) - .contextMenu { - CopyButton(text: message.text) - if let wire = message.wireText { - // Debug affordance: the exact prompt the model received, - // skeleton and all. - Button("Copy full prompt") { chatCopy(wire) } - } + let message: ChatMessage + let containerWidth: CGFloat + let mode: ChatWidthMode + + /// Copies the raw prompt text; for a text-less image turn falls back to the + /// attachment filenames so the button still does something useful. + private var copyText: String { + message.text.isEmpty + ? message.attachments.map(\.name).joined(separator: "\n") + : message.text + } + + private var hasContext: Bool { + message.contextSelection != nil || message.contextApp != nil + } + + var body: some View { + VStack(alignment: .trailing, spacing: 5) { + HStack(spacing: 8) { + Text("You · \(message.timestamp)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.terracottaDeep.opacity(0.85)) + CopyMessageButton(text: copyText) + } + VStack(alignment: .leading, spacing: 9) { + if !message.attachments.isEmpty { + WrapLayout(spacing: 6) { + ForEach(message.attachments) { AttachmentChip(attachment: $0) } + } + } + if !message.text.isEmpty { + // Oversized paste (bolączka #3): fold above the display + // budget so the list's selection overlay never carries the + // whole payload. Copy above still exports the full text. + if OversizedBubblePolicy.isOversized(message.text) { + OversizedMessageBody(fullText: message.text) { head in + MarkdownText(raw: head, bodyColor: ChatPalette.nameActive) } - // Clip the chrome so markdown/code/selection never paints past the - // rounded bubble into the window chrome (R1 `)"` fragment class). - .clipped() + } else { + MarkdownText(raw: message.text, bodyColor: ChatPalette.nameActive) + } } - .frame( - maxWidth: ChatLayoutPolicy.youBubbleMaxWidth( - containerWidth: containerWidth, - mode: mode - ), - alignment: .trailing + if hasContext { + ContextChip( + selection: message.contextSelection, + app: message.contextApp + ) + } + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + // Calm surface, not an alarm plate (U17): the bubble sits on the + // shared raised surface; terracotta stays on ACCENTS only — the + // timestamp above and this thin border. + .background(CSColor.surfaceRaised(0.06)) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: 14, bottomLeadingRadius: 14, + bottomTrailingRadius: 4, topTrailingRadius: 14, + style: .continuous + ) + .strokeBorder(CSColor.terracotta.opacity(0.18), lineWidth: 1) + ) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 14, bottomLeadingRadius: 14, + bottomTrailingRadius: 4, topTrailingRadius: 14, + style: .continuous ) - .clipped() + ) + .contextMenu { + CopyButton(text: message.text) + if let wire = message.wireText { + // Debug affordance: the exact prompt the model received, + // skeleton and all. + Button("Copy full prompt") { chatCopy(wire) } + } + } + // Clip the chrome so markdown/code/selection never paints past the + // rounded bubble into the window chrome (R1 `)"` fragment class). + .clipped() } + .frame( + maxWidth: ChatLayoutPolicy.youBubbleMaxWidth( + containerWidth: containerWidth, + mode: mode + ), + alignment: .trailing + ) + .clipped() + } } /// Collapsed "context ▸" disclosure inside the You bubble: reveals the selection /// and frontmost app that rode along with an assistive voice turn. Collapsed by /// default so the bubble reads as just the spoken instruction. private struct ContextChip: View { - let selection: String? - let app: String? - @State private var expanded = false - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Button { - withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } - } label: { - HStack(spacing: 4) { - CSIconView( - icon: expanded ? .chevronDown : .chevronRight, - size: 8, - weight: .semibold, - color: CSColor.textFaintAlt - ) - Text("context") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - .contentShape(Rectangle()) - } - .csFocusRing(cornerRadius: 8) - .help("Selection and app captured with this voice turn") - - if expanded { - VStack(alignment: .leading, spacing: 5) { - if let app { - Text("app · \(app)") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textMuted) - } - if let selection { - // Huge pasted selections (legacy assistive wires) must - // not expand the You bubble unboundedly — fold them - // through the same oversized policy as message bodies. - Group { - if OversizedBubblePolicy.isOversized(selection) { - OversizedMessageBody(fullText: selection) { head in - Text(head) - .font(CSFont.mono(10.5)) - .foregroundStyle(CSColor.textBodyAlt) - .lineSpacing(3) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } else { - Text(selection) - .font(CSFont.mono(10.5)) - .foregroundStyle(CSColor.textBodyAlt) - .textSelection(.enabled) - .lineSpacing(3) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(8) - .background(CSColor.surfaceRaised(0.05)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - .clipped() - } + let selection: String? + let app: String? + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } + } label: { + HStack(spacing: 4) { + CSIconView( + icon: expanded ? .chevronDown : .chevronRight, + size: 8, + weight: .semibold, + color: CSColor.textFaintAlt + ) + Text("context") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + .contentShape(Rectangle()) + } + .csFocusRing(cornerRadius: 8) + .help("Selection and app captured with this voice turn") + + if expanded { + VStack(alignment: .leading, spacing: 5) { + if let app { + Text("app · \(app)") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textMuted) + } + if let selection { + // Huge pasted selections (legacy assistive wires) must + // not expand the You bubble unboundedly — fold them + // through the same oversized policy as message bodies. + Group { + if OversizedBubblePolicy.isOversized(selection) { + OversizedMessageBody(fullText: selection) { head in + Text(head) + .font(CSFont.mono(10.5)) + .foregroundStyle(CSColor.textBodyAlt) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } + } else { + Text(selection) + .font(CSFont.mono(10.5)) + .foregroundStyle(CSColor.textBodyAlt) + .textSelection(.enabled) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } } + .padding(8) + .background(CSColor.surfaceRaised(0.05)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + .clipped() + } } - .frame(maxWidth: .infinity, alignment: .leading) - .clipped() + } } + .frame(maxWidth: .infinity, alignment: .leading) + .clipped() + } } /// Attachment chip for a sent You turn — mirrors the composer's staged-chip @@ -743,255 +752,262 @@ private struct ContextChip: View { /// Click opens an in-app preview sheet (metadata + zoom + Reveal/Copy path); /// restored turns with a nil URL surface an honest missing-asset state. private struct AttachmentChip: View { - let attachment: MessageAttachment - @State private var thumbnail: NSImage? - @State private var showPreview = false - - var body: some View { - Button { - showPreview = true - } label: { - HStack(spacing: 6) { - if let thumbnail { - Image(nsImage: thumbnail) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 18, height: 18) - .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) - } else { - CSIconView(icon: .photo, size: 11, color: CSColor.chromeAccent) - } - Text(attachment.name) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textBodyAlt) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: 160) - } - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(CSColor.surfaceRaised(0.05)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) - .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) - } - .csFocusRing(cornerRadius: CSRadius.pill) - .help(attachment.url == nil ? "Preview attachment (original file may be missing)" : "Preview attachment") - .onAppear { - if thumbnail == nil, let url = attachment.url { - thumbnail = NSImage(contentsOf: url) - } - } - .sheet(isPresented: $showPreview) { - AttachmentPreviewSheet(attachment: attachment) + let attachment: MessageAttachment + @State private var thumbnail: NSImage? + @State private var showPreview = false + + var body: some View { + Button { + showPreview = true + } label: { + HStack(spacing: 6) { + if let thumbnail { + Image(nsImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 18, height: 18) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } else { + CSIconView(icon: .photo, size: 11, color: CSColor.chromeAccent) } + Text(attachment.name) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textBodyAlt) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 160) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(CSColor.surfaceRaised(0.05)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous) + .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.pill, style: .continuous)) } + .csFocusRing(cornerRadius: CSRadius.pill) + .help( + attachment.url == nil + ? "Preview attachment (original file may be missing)" : "Preview attachment" + ) + .onAppear { + if thumbnail == nil, let url = attachment.url { + thumbnail = NSImage(contentsOf: url) + } + } + .sheet(isPresented: $showPreview) { + AttachmentPreviewSheet(attachment: attachment) + } + } } /// In-app attachment inspector: image zoom when bytes still load, honest /// fallback when the source path is gone (restored threads), plus Reveal in /// Finder / Copy path / Open with default app. struct AttachmentPreviewSheet: View { - let attachment: MessageAttachment - var onRemove: (() -> Void)? = nil - @Environment(\.dismiss) private var dismiss - @State private var image: NSImage? - @State private var zoom: CGFloat = 1.0 - - private var pathText: String { - attachment.url?.path ?? "(no source path — restored turn keeps name only)" - } - - private var fileExists: Bool { - guard let url = attachment.url else { return false } - return FileManager.default.fileExists(atPath: url.path) - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 4) { - Text(attachment.name) - .font(CSFont.mono(13, .semibold)) - .foregroundStyle(CSColor.textBodyAlt) - .textSelection(.enabled) - Text(attachment.type) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - Spacer(minLength: 8) - Button("Close") { dismiss() } - .keyboardShortcut(.cancelAction) - } - - Group { - if let image { - ScrollView([.horizontal, .vertical]) { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame( - width: max(240, image.size.width * zoom), - height: max(160, image.size.height * zoom) - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(minHeight: 280, maxHeight: 480) - .background(CSColor.surfaceRaised(0.04)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) - - HStack(spacing: 10) { - Text("Zoom") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - Slider(value: $zoom, in: 0.5...3.0, step: 0.1) - Text(String(format: "%.0f%%", zoom * 100)) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textMuted) - .frame(width: 44, alignment: .trailing) - } - } else if attachment.url == nil { - missingBanner( - title: "Original file not available", - detail: "This turn was restored from history. Codescribe kept the filename but not the bytes or path on disk." - ) - } else if !fileExists { - missingBanner( - title: "File missing on disk", - detail: pathText - ) - } else { - missingBanner( - title: "No inline preview", - detail: "This type is not rendered in-app. Use Open to hand it to the system default app." - ) - } - } - - VStack(alignment: .leading, spacing: 4) { - Text("Path") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - Text(pathText) - .font(CSFont.mono(10.5)) - .foregroundStyle(CSColor.textBodyAlt) - .textSelection(.enabled) - .lineLimit(3) - .truncationMode(.middle) - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(CSColor.surfaceRaised(0.04)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - - HStack(spacing: 10) { - if let onRemove { - Button("Remove", role: .destructive) { - onRemove() - dismiss() - } - } - - Button("Copy path") { - chatCopy(pathText) - } - .disabled(attachment.url == nil) - - Button("Reveal in Finder") { - if let url = attachment.url { - NSWorkspace.shared.activateFileViewerSelecting([url]) - } - } - .disabled(!fileExists) - - Button("Open") { - if let url = attachment.url { - NSWorkspace.shared.open(url) - } - } - .disabled(!fileExists) - .keyboardShortcut(.defaultAction) + let attachment: MessageAttachment + var onRemove: (() -> Void)? = nil + @Environment(\.dismiss) private var dismiss + @State private var image: NSImage? + @State private var zoom: CGFloat = 1.0 + + private var pathText: String { + attachment.url?.path ?? "(no source path — restored turn keeps name only)" + } + + private var fileExists: Bool { + guard let url = attachment.url else { return false } + return FileManager.default.fileExists(atPath: url.path) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(attachment.name) + .font(CSFont.mono(13, .semibold)) + .foregroundStyle(CSColor.textBodyAlt) + .textSelection(.enabled) + Text(attachment.type) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + Spacer(minLength: 8) + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + } + + Group { + if let image { + ScrollView([.horizontal, .vertical]) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + width: max(240, image.size.width * zoom), + height: max(160, image.size.height * zoom) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minHeight: 280, maxHeight: 480) + .background(CSColor.surfaceRaised(0.04)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + + HStack(spacing: 10) { + Text("Zoom") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + Slider(value: $zoom, in: 0.5...3.0, step: 0.1) + Text(String(format: "%.0f%%", zoom * 100)) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textMuted) + .frame(width: 44, alignment: .trailing) + } + } else if attachment.url == nil { + missingBanner( + title: "Original file not available", + detail: + "This turn was restored from history. Codescribe kept the filename but not the bytes or path on disk." + ) + } else if !fileExists { + missingBanner( + title: "File missing on disk", + detail: pathText + ) + } else { + missingBanner( + title: "No inline preview", + detail: + "This type is not rendered in-app. Use Open to hand it to the system default app." + ) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("Path") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + Text(pathText) + .font(CSFont.mono(10.5)) + .foregroundStyle(CSColor.textBodyAlt) + .textSelection(.enabled) + .lineLimit(3) + .truncationMode(.middle) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(CSColor.surfaceRaised(0.04)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + + HStack(spacing: 10) { + if let onRemove { + Button("Remove", role: .destructive) { + onRemove() + dismiss() + } + } - Spacer(minLength: 0) - } + Button("Copy path") { + chatCopy(pathText) } - .padding(18) - .frame(minWidth: 520, idealWidth: 640, minHeight: 420) - .onAppear { - if image == nil, let url = attachment.url, fileExists { - image = NSImage(contentsOf: url) - } + .disabled(attachment.url == nil) + + Button("Reveal in Finder") { + if let url = attachment.url { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } } - } + .disabled(!fileExists) - private func missingBanner(title: String, detail: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(CSFont.mono(12, .semibold)) - .foregroundStyle(CSColor.terracottaLight) - Text(detail) - .font(CSFont.mono(11)) - .foregroundStyle(CSColor.textMuted) - .fixedSize(horizontal: false, vertical: true) + Button("Open") { + if let url = attachment.url { + NSWorkspace.shared.open(url) + } } - .padding(14) - .frame(maxWidth: .infinity, minHeight: 160, alignment: .leading) - .background(CSColor.surfaceRaised(0.04)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.terracotta.opacity(0.22), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + .disabled(!fileExists) + .keyboardShortcut(.defaultAction) + + Spacer(minLength: 0) + } + } + .padding(18) + .frame(minWidth: 520, idealWidth: 640, minHeight: 420) + .onAppear { + if image == nil, let url = attachment.url, fileExists { + image = NSImage(contentsOf: url) + } + } + } + + private func missingBanner(title: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(CSFont.mono(12, .semibold)) + .foregroundStyle(CSColor.terracottaLight) + Text(detail) + .font(CSFont.mono(11)) + .foregroundStyle(CSColor.textMuted) + .fixedSize(horizontal: false, vertical: true) } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 160, alignment: .leading) + .background(CSColor.surfaceRaised(0.04)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.terracotta.opacity(0.22), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + } } /// Minimal wrapping layout: lays chips left→right, wrapping to a new row when the /// next would exceed the proposed width. Hugs its content so the You bubble stays /// tight around 1–N attachment chips instead of overflowing or forcing full width. private struct WrapLayout: Layout { - var spacing: CGFloat = 6 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { - let maxWidth = proposal.width ?? .infinity - var rowWidth: CGFloat = 0 - var rowHeight: CGFloat = 0 - var totalWidth: CGFloat = 0 - var totalHeight: CGFloat = 0 - for subview in subviews { - let size = subview.sizeThatFits(.unspecified) - if rowWidth > 0, rowWidth + spacing + size.width > maxWidth { - totalWidth = max(totalWidth, rowWidth) - totalHeight += rowHeight + spacing - rowWidth = size.width - rowHeight = size.height - } else { - rowWidth += (rowWidth > 0 ? spacing : 0) + size.width - rowHeight = max(rowHeight, size.height) - } - } + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var rowWidth: CGFloat = 0 + var rowHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if rowWidth > 0, rowWidth + spacing + size.width > maxWidth { totalWidth = max(totalWidth, rowWidth) - totalHeight += rowHeight - return CGSize(width: min(totalWidth, maxWidth), height: totalHeight) + totalHeight += rowHeight + spacing + rowWidth = size.width + rowHeight = size.height + } else { + rowWidth += (rowWidth > 0 ? spacing : 0) + size.width + rowHeight = max(rowHeight, size.height) + } } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { - var x = bounds.minX - var y = bounds.minY - var rowHeight: CGFloat = 0 - for subview in subviews { - let size = subview.sizeThatFits(.unspecified) - if x > bounds.minX, x + size.width - bounds.minX > bounds.width { - x = bounds.minX - y += rowHeight + spacing - rowHeight = 0 - } - subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) - x += size.width + spacing - rowHeight = max(rowHeight, size.height) - } + totalWidth = max(totalWidth, rowWidth) + totalHeight += rowHeight + return CGSize(width: min(totalWidth, maxWidth), height: totalHeight) + } + + func placeSubviews( + in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void + ) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width - bounds.minX > bounds.width { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) } + } } // MARK: - Tool activity @@ -1001,434 +1017,439 @@ private struct WrapLayout: Layout { /// (status, duration, call id, result/error, copy technical). Collapsed by /// default so the list stays scannable. private struct ToolLineRow: View { - let line: ToolLine - @State private var showInspect = false - - private var isRunning: Bool { line.state == .running } - private var isQuiet: Bool { line.state == .unknown || line.state == .cancelled } - private var canInspect: Bool { line.hasInspectPayload } - private var rowColor: Color { - switch line.state { - case .running: - return CSColor.amber - case .failed: - return CSColor.terracottaLight - case .cancelled, .unknown: - return CSColor.textFaintAlt - case .succeeded: - return CSColor.oliveLight - } + let line: ToolLine + @State private var showInspect = false + + private var isRunning: Bool { line.state == .running } + private var isQuiet: Bool { line.state == .unknown || line.state == .cancelled } + private var canInspect: Bool { line.hasInspectPayload } + private var rowColor: Color { + switch line.state { + case .running: + return CSColor.amber + case .failed: + return CSColor.terracottaLight + case .cancelled, .unknown: + return CSColor.textFaintAlt + case .succeeded: + return CSColor.oliveLight } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Button { - if canInspect { showInspect.toggle() } - } label: { - HStack(alignment: .firstTextBaseline, spacing: 6) { - if isRunning { - PulseDot() - } - (Text(line.verb).foregroundColor(rowColor) - + Text(" \(line.detail)\(isRunning ? " running..." : "")").foregroundColor(isQuiet ? CSColor.textFaintAlt : ChatPalette.toolBody)) - .font(CSFont.mono(11.5, .medium)) - .lineSpacing(4) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - if let duration = ToolInspectPresentation.durationLabel(ms: line.durationMs), !showInspect { - Text(duration) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - if canInspect { - CSIconView( - icon: showInspect ? .chevronDown : .chevronRight, - size: 8, - weight: .semibold, - color: rowColor.opacity(0.75) - ) - } - } - .contentShape(Rectangle()) - } - .csFocusRing(cornerRadius: 8) - .disabled(!canInspect) - - if canInspect, showInspect { - ToolInspectPanel(line: line) - .padding(.leading, 10) - } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + if canInspect { showInspect.toggle() } + } label: { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if isRunning { + PulseDot() + } + (Text(line.verb).foregroundColor(rowColor) + + Text(" \(line.detail)\(isRunning ? " running..." : "")").foregroundColor( + isQuiet ? CSColor.textFaintAlt : ChatPalette.toolBody)) + .font(CSFont.mono(11.5, .medium)) + .lineSpacing(4) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + if let duration = ToolInspectPresentation.durationLabel(ms: line.durationMs), !showInspect + { + Text(duration) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + if canInspect { + CSIconView( + icon: showInspect ? .chevronDown : .chevronRight, + size: 8, + weight: .semibold, + color: rowColor.opacity(0.75) + ) + } } + .contentShape(Rectangle()) + } + .csFocusRing(cornerRadius: 8) + .disabled(!canInspect) + + if canInspect, showInspect { + ToolInspectPanel(line: line) + .padding(.leading, 10) + } } + } } /// Expanded inspect surface for one tool call (no chain-of-thought — only /// operator-useful fields already present on the UI line). private struct ToolInspectPanel: View { - let line: ToolLine - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - inspectRow(label: "status", value: ToolInspectPresentation.statusLabel(for: line.state)) - if let duration = ToolInspectPresentation.durationLabel(ms: line.durationMs) { - inspectRow(label: "duration", value: duration) - } - if let callID = line.callID, !callID.isEmpty { - inspectRow(label: "call id", value: callID) - } - if let reason = line.reason, !reason.isEmpty { - VStack(alignment: .leading, spacing: 2) { - Text(line.state == .failed ? "error" : "result") - .font(CSFont.mono(9.5, .semibold)) - .foregroundStyle(CSColor.textFaintAlt) - .textCase(.uppercase) - Text(reason) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(line.state == .failed ? CSColor.terracottaLight : CSColor.textBodyAlt) - .textSelection(.enabled) - .lineSpacing(2) - .fixedSize(horizontal: false, vertical: true) - } - } else { - Text("No result summary was stored for this call.") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - // Honest residual: full request/response bodies and artifact store - // links need bridge event fields beyond the current ToolLine contract. - HStack(spacing: 10) { - CopyMessageButton(text: line.technicalCopyText) - Text("request/response bodies not on this event") - .font(CSFont.mono(9.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - .lineLimit(1) - } - .padding(.top, 2) + let line: ToolLine + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + inspectRow(label: "status", value: ToolInspectPresentation.statusLabel(for: line.state)) + if let duration = ToolInspectPresentation.durationLabel(ms: line.durationMs) { + inspectRow(label: "duration", value: duration) + } + if let callID = line.callID, !callID.isEmpty { + inspectRow(label: "call id", value: callID) + } + if let reason = line.reason, !reason.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text(line.state == .failed ? "error" : "result") + .font(CSFont.mono(9.5, .semibold)) + .foregroundStyle(CSColor.textFaintAlt) + .textCase(.uppercase) + Text(reason) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(line.state == .failed ? CSColor.terracottaLight : CSColor.textBodyAlt) + .textSelection(.enabled) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) - ) + } else { + Text("No result summary was stored for this call.") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + // Honest residual: full request/response bodies and artifact store + // links need bridge event fields beyond the current ToolLine contract. + HStack(spacing: 10) { + CopyMessageButton(text: line.technicalCopyText) + Text("request/response bodies not on this event") + .font(CSFont.mono(9.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + .lineLimit(1) + } + .padding(.top, 2) } - - private func inspectRow(label: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(label) - .font(CSFont.mono(9.5, .semibold)) - .foregroundStyle(CSColor.textFaintAlt) - .textCase(.uppercase) - .frame(width: 64, alignment: .leading) - Text(value) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textBodyAlt) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) + ) + } + + private func inspectRow(label: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(label) + .font(CSFont.mono(9.5, .semibold)) + .foregroundStyle(CSColor.textFaintAlt) + .textCase(.uppercase) + .frame(width: 64, alignment: .leading) + Text(value) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textBodyAlt) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) } + } } private struct ToolTurn: View { - let message: ChatMessage - let containerWidth: CGFloat - let mode: ChatWidthMode - @State private var expanded = false - - /// Whole-card plain-text export: one technical block per tool line. - private var copyText: String { - message.toolLines.map(\.technicalCopyText).joined(separator: "\n---\n") - } - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 8) { - Text("Tool activity · \(message.timestamp)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - CopyMessageButton(text: copyText) - Spacer(minLength: 0) - } - - DisclosureGroup(isExpanded: $expanded) { - VStack(alignment: .leading, spacing: 3) { - ForEach(message.toolLines) { line in - ToolLineRow(line: line) - } - } - .padding(.horizontal, 13) - .padding(.vertical, 11) - } label: { - HStack(spacing: 8) { - let hasRunning = message.toolLines.contains(where: { $0.state == .running }) - let hasCancelled = message.toolLines.contains(where: { $0.state == .cancelled }) - CSIconView( - icon: hasRunning ? .more : hasCancelled ? .stop : .success, - size: 11, - color: hasRunning ? CSColor.amber : hasCancelled ? CSColor.textFaintAlt : CSColor.oliveLight - ) - Text(message.toolTitle) - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(ChatPalette.nameInactive) - Spacer(minLength: 0) - } - .padding(.horizontal, 13) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .disclosureGroupStyle(FlatDisclosureStyle()) - .background(CSColor.surfaceRaised(0.025)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) - .clipped() + let message: ChatMessage + let containerWidth: CGFloat + let mode: ChatWidthMode + @State private var expanded = false + + /// Whole-card plain-text export: one technical block per tool line. + private var copyText: String { + message.toolLines.map(\.technicalCopyText).joined(separator: "\n---\n") + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text("Tool activity · \(message.timestamp)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + CopyMessageButton(text: copyText) + Spacer(minLength: 0) + } + + DisclosureGroup(isExpanded: $expanded) { + VStack(alignment: .leading, spacing: 3) { + ForEach(message.toolLines) { line in + ToolLineRow(line: line) + } } - .frame( - maxWidth: ChatLayoutPolicy.leadingColumnMaxWidth( - containerWidth: containerWidth, - mode: mode - ), - alignment: .leading - ) - .clipped() + .padding(.horizontal, 13) + .padding(.vertical, 11) + } label: { + HStack(spacing: 8) { + let hasRunning = message.toolLines.contains(where: { $0.state == .running }) + let hasCancelled = message.toolLines.contains(where: { $0.state == .cancelled }) + CSIconView( + icon: hasRunning ? .more : hasCancelled ? .stop : .success, + size: 11, + color: hasRunning + ? CSColor.amber : hasCancelled ? CSColor.textFaintAlt : CSColor.oliveLight + ) + Text(message.toolTitle) + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(ChatPalette.nameInactive) + Spacer(minLength: 0) + } + .padding(.horizontal, 13) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .disclosureGroupStyle(FlatDisclosureStyle()) + .background(CSColor.surfaceRaised(0.025)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + .clipped() } + .frame( + maxWidth: ChatLayoutPolicy.leadingColumnMaxWidth( + containerWidth: containerWidth, + mode: mode + ), + alignment: .leading + ) + .clipped() + } } /// DisclosureGroup without the default chevron/indent — the label IS the header /// row, with a hairline divider above the content when expanded. private struct FlatDisclosureStyle: DisclosureGroupStyle { - func makeBody(configuration: Configuration) -> some View { - VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation(.easeOut(duration: 0.18)) { - configuration.isExpanded.toggle() - } - } label: { - configuration.label - } - .csFocusRing(cornerRadius: 8) - - if configuration.isExpanded { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) - configuration.content - } + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeOut(duration: 0.18)) { + configuration.isExpanded.toggle() } + } label: { + configuration.label + } + .csFocusRing(cornerRadius: 8) + + if configuration.isExpanded { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + configuration.content + } } + } } // MARK: - Assistant private struct AssistantTurn: View { - let message: ChatMessage - let containerWidth: CGFloat - let mode: ChatWidthMode - let onToggleRenderMode: (UUID) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 8) { - Text("Assistant · \(message.timestamp)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - if !message.isThinking { - CopyMessageButton(text: message.text) - if !message.text.isEmpty { - RenderModeButton(mode: message.renderMode) { - onToggleRenderMode(message.id) - } - } - } - Spacer(minLength: 0) + let message: ChatMessage + let containerWidth: CGFloat + let mode: ChatWidthMode + let onToggleRenderMode: (UUID) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text("Assistant · \(message.timestamp)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + if !message.isThinking { + CopyMessageButton(text: message.text) + if !message.text.isEmpty { + RenderModeButton(mode: message.renderMode) { + onToggleRenderMode(message.id) } - - VStack(alignment: .leading, spacing: 9) { - if !message.reasoning.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - ReasoningDisclosure( - text: message.reasoning, - isLive: message.isThinking || message.isStreaming - ) - } - if message.isThinking { - HStack(spacing: 8) { - PulseDot() - Text("thinking…") - .font(CSFont.mono(12, .medium)) - .foregroundStyle(ChatPalette.thinking) - } - } else { - if let secs = message.reasonedSeconds { - ReasonedChip(seconds: secs) - } - // Stream in the cheap raw view; settled messages render rich - // by default. The per-bubble toggle remains available when - // exact source text is more useful than presentation. - if message.wasStopped, message.text == "Stopped" { - Text("Stopped") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } else if message.isStreaming { - // A runaway stream keeps only its live tail in the - // SwiftUI text stack — bounds both the per-delta - // re-layout and the shared selection overlay. - if OversizedBubblePolicy.isOversized(message.text) { - StreamWindowNote(fullText: message.text) - } - RawText( - raw: OversizedBubblePolicy.streamingWindow(message.text), - showsCaret: true - ) - } else if !message.text.isEmpty { - if OversizedBubblePolicy.isOversized(message.text) { - OversizedMessageBody(fullText: message.text) { head in - switch message.renderMode { - case .raw: - RawText(raw: head) - case .rich: - MarkdownText(raw: head) - } - } - } else { - switch message.renderMode { - case .raw: - RawText(raw: message.text) - case .rich: - MarkdownText(raw: message.text) - } - } - } - } + } + } + Spacer(minLength: 0) + } + + VStack(alignment: .leading, spacing: 9) { + if !message.reasoning.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ReasoningDisclosure( + text: message.reasoning, + isLive: message.isThinking || message.isStreaming + ) + } + if message.isThinking { + HStack(spacing: 8) { + PulseDot() + Text("thinking…") + .font(CSFont.mono(12, .medium)) + .foregroundStyle(ChatPalette.thinking) + } + } else { + if let secs = message.reasonedSeconds { + ReasonedChip(seconds: secs) + } + // Stream in the cheap raw view; settled messages render rich + // by default. The per-bubble toggle remains available when + // exact source text is more useful than presentation. + if message.wasStopped, message.text == "Stopped" { + Text("Stopped") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } else if message.isStreaming { + // A runaway stream keeps only its live tail in the + // SwiftUI text stack — bounds both the per-delta + // re-layout and the shared selection overlay. + if OversizedBubblePolicy.isOversized(message.text) { + StreamWindowNote(fullText: message.text) } - .padding(.horizontal, 15) - .padding(.vertical, 13) - .background(CSColor.surfaceRaised(0.03)) - .overlay( - UnevenRoundedRectangle( - topLeadingRadius: 14, bottomLeadingRadius: 4, - bottomTrailingRadius: 14, topTrailingRadius: 14, - style: .continuous - ) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + RawText( + raw: OversizedBubblePolicy.streamingWindow(message.text), + showsCaret: true ) - .clipShape(UnevenRoundedRectangle( - topLeadingRadius: 14, bottomLeadingRadius: 4, - bottomTrailingRadius: 14, topTrailingRadius: 14, - style: .continuous - )) - .contextMenu { CopyButton(text: message.text) } - .clipped() + } else if !message.text.isEmpty { + if OversizedBubblePolicy.isOversized(message.text) { + OversizedMessageBody(fullText: message.text) { head in + switch message.renderMode { + case .raw: + RawText(raw: head) + case .rich: + MarkdownText(raw: head) + } + } + } else { + switch message.renderMode { + case .raw: + RawText(raw: message.text) + case .rich: + MarkdownText(raw: message.text) + } + } + } } - .frame( - maxWidth: ChatLayoutPolicy.leadingColumnMaxWidth( - containerWidth: containerWidth, - mode: mode - ), - alignment: .leading + } + .padding(.horizontal, 15) + .padding(.vertical, 13) + .background(CSColor.surfaceRaised(0.03)) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: 14, bottomLeadingRadius: 4, + bottomTrailingRadius: 14, topTrailingRadius: 14, + style: .continuous + ) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 14, bottomLeadingRadius: 4, + bottomTrailingRadius: 14, topTrailingRadius: 14, + style: .continuous ) - .clipped() + ) + .contextMenu { CopyButton(text: message.text) } + .clipped() } + .frame( + maxWidth: ChatLayoutPolicy.leadingColumnMaxWidth( + containerWidth: containerWidth, + mode: mode + ), + alignment: .leading + ) + .clipped() + } } private struct ReasoningDisclosure: View { - let text: String - let isLive: Bool - @State private var expanded: Bool - - init(text: String, isLive: Bool) { - self.text = text - self.isLive = isLive - // A live summary is status, not optional archaeology: show it as soon - // as the provider emits it. Settled turns remain compact and reopenable. - _expanded = State(initialValue: isLive) - } - - var body: some View { - DisclosureGroup(isExpanded: $expanded) { - // Reasoning shares the display budget: long chains fold like any - // other oversized body instead of feeding the selection overlay. - Group { - if OversizedBubblePolicy.isOversized(text) { - OversizedMessageBody(fullText: text) { head in - reasoningBody(head) - } - } else { - reasoningBody(text) - } - } - .padding(.horizontal, 11) - .padding(.vertical, 9) - } label: { - HStack(spacing: 7) { - CSIconView( - icon: expanded ? .chevronDown : .chevronRight, - size: 8, - weight: .semibold, - color: ChatPalette.thinking.opacity(0.75) - ) - Text(isLive ? "thinking..." : "reasoning summary") - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(ChatPalette.thinking) - Spacer(minLength: 0) - } - .padding(.horizontal, 11) - .padding(.vertical, 8) - .contentShape(Rectangle()) - } - .disclosureGroupStyle(FlatDisclosureStyle()) - .onChange(of: isLive) { _, live in - if live { expanded = true } + let text: String + let isLive: Bool + @State private var expanded: Bool + + init(text: String, isLive: Bool) { + self.text = text + self.isLive = isLive + // A live summary is status, not optional archaeology: show it as soon + // as the provider emits it. Settled turns remain compact and reopenable. + _expanded = State(initialValue: isLive) + } + + var body: some View { + DisclosureGroup(isExpanded: $expanded) { + // Reasoning shares the display budget: long chains fold like any + // other oversized body instead of feeding the selection overlay. + Group { + if OversizedBubblePolicy.isOversized(text) { + OversizedMessageBody(fullText: text) { head in + reasoningBody(head) + } + } else { + reasoningBody(text) } - .background(CSColor.surfaceRaised(0.018)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.055), lineWidth: 1) + } + .padding(.horizontal, 11) + .padding(.vertical, 9) + } label: { + HStack(spacing: 7) { + CSIconView( + icon: expanded ? .chevronDown : .chevronRight, + size: 8, + weight: .semibold, + color: ChatPalette.thinking.opacity(0.75) ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + Text(isLive ? "thinking..." : "reasoning summary") + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(ChatPalette.thinking) + Spacer(minLength: 0) + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .contentShape(Rectangle()) } - - private func reasoningBody(_ content: String) -> some View { - Text(content) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(ChatPalette.thinking.opacity(0.86)) - .textSelection(.enabled) - .lineSpacing(3) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + .disclosureGroupStyle(FlatDisclosureStyle()) + .onChange(of: isLive) { _, live in + if live { expanded = true } } + .background(CSColor.surfaceRaised(0.018)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.055), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) + } + + private func reasoningBody(_ content: String) -> some View { + Text(content) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(ChatPalette.thinking.opacity(0.86)) + .textSelection(.enabled) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } } /// Plain mono body — the raw render mode. Exactly what streamed in, no markdown /// pass at all, so a growing turn costs a plain `Text` re-eval per delta and the /// settled turn is byte-for-byte the same view (no finalize re-render). private struct RawText: View { - let raw: String - var showsCaret: Bool = false - @Environment(\.csTextScale) private var textScale - - var body: some View { - // Selection is per-body (the list-wide overlay was the livelock fuel); - // raw mode must stay copyable like the markdown render. - let content = Text(raw) - .font(CSFont.mono(13 * textScale)) - .foregroundStyle(CSColor.textBodyAlt) - .lineSpacing(4) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - if showsCaret { - HStack(alignment: .bottom, spacing: 2) { - content - BlinkCaret() - } - } else { - content.frame(maxWidth: .infinity, alignment: .leading) - } + let raw: String + var showsCaret: Bool = false + @Environment(\.csTextScale) private var textScale + + var body: some View { + // Selection is per-body (the list-wide overlay was the livelock fuel); + // raw mode must stay copyable like the markdown render. + let content = Text(raw) + .font(CSFont.mono(13 * textScale)) + .foregroundStyle(CSColor.textBodyAlt) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + if showsCaret { + HStack(alignment: .bottom, spacing: 2) { + content + BlinkCaret() + } + } else { + content.frame(maxWidth: .infinity, alignment: .leading) } + } } /// Inline raw↔rich toggle in the assistant meta row, next to "copy". The label @@ -1436,38 +1457,38 @@ private struct RawText: View { /// style). Mutation goes through the store via `onToggleRenderMode` — the view /// holds no render-mode state. private struct RenderModeButton: View { - let mode: MessageRenderMode - let action: () -> Void - @State private var hovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 4) { - CSIconView(icon: .setupWizard, size: 9) - Text(mode == .raw ? "rich" : "raw") - .font(CSFont.mono(10, .medium)) - } - .foregroundStyle(hovering ? CSColor.textMuted : CSColor.textFaintAlt) - } - .csFocusRing(cornerRadius: 8) - .onHover { hovering = $0 } - .help(mode == .raw ? "Render as markdown" : "Show raw text") + let mode: MessageRenderMode + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + CSIconView(icon: .setupWizard, size: 9) + Text(mode == .raw ? "rich" : "raw") + .font(CSFont.mono(10, .medium)) + } + .foregroundStyle(hovering ? CSColor.textMuted : CSColor.textFaintAlt) } + .csFocusRing(cornerRadius: 8) + .onHover { hovering = $0 } + .help(mode == .raw ? "Render as markdown" : "Show raw text") + } } /// Puts a message's raw text on the general pasteboard — the single copy path /// shared by the bubble context menu and the inline copy button. private func chatCopy(_ text: String) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) } /// Right-click "Copy" that puts a message's raw text on the pasteboard. private struct CopyButton: View { - let text: String - var body: some View { - Button("Copy") { chatCopy(text) } - } + let text: String + var body: some View { + Button("Copy") { chatCopy(text) } + } } /// Subtle inline "copy" affordance in a turn's meta row (mono 10, faint until @@ -1475,61 +1496,61 @@ private struct CopyButton: View { /// context menu uses, then flips to a green "copied" for ~1.5s. Disabled when /// there is nothing to copy. private struct CopyMessageButton: View { - let text: String - @State private var copied = false - @State private var hovering = false - - var body: some View { - Button { - chatCopy(text) - copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } - } label: { - HStack(spacing: 4) { - CSIconView(icon: copied ? .check : .copy, size: 9) - Text(copied ? "copied" : "copy") - .font(CSFont.mono(10, .medium)) - } - .foregroundStyle(labelColor) - } - .csFocusRing(cornerRadius: 8) - .disabled(text.isEmpty) - .onHover { hovering = $0 } - .help("Copy message") - } - - private var labelColor: Color { - if copied { return CSColor.oliveLight } - return hovering ? CSColor.textMuted : CSColor.textFaintAlt + let text: String + @State private var copied = false + @State private var hovering = false + + var body: some View { + Button { + chatCopy(text) + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false } + } label: { + HStack(spacing: 4) { + CSIconView(icon: copied ? .check : .copy, size: 9) + Text(copied ? "copied" : "copy") + .font(CSFont.mono(10, .medium)) + } + .foregroundStyle(labelColor) } + .csFocusRing(cornerRadius: 8) + .disabled(text.isEmpty) + .onHover { hovering = $0 } + .help("Copy message") + } + + private var labelColor: Color { + if copied { return CSColor.oliveLight } + return hovering ? CSColor.textMuted : CSColor.textFaintAlt + } } /// Amber "reasoned · Xs" pill. private struct ReasonedChip: View { - let seconds: Double - var body: some View { - Text("worked · \(String(format: "%.1f", seconds))s") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.amber) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(CSColor.amber.opacity(0.1)) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(CSColor.amber.opacity(0.22), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - } + let seconds: Double + var body: some View { + Text("worked · \(String(format: "%.1f", seconds))s") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.amber) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(CSColor.amber.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(CSColor.amber.opacity(0.22), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } } /// Amber softpulsing dot for the "thinking…" state. private struct PulseDot: View { - @State private var pulse = false - var body: some View { - Circle() - .fill(CSColor.amber) - .frame(width: 6, height: 6) - .opacity(pulse ? 1 : 0.6) - .onAppear { withAnimation(CSMotion.softpulse) { pulse = true } } - } + @State private var pulse = false + var body: some View { + Circle() + .fill(CSColor.amber) + .frame(width: 6, height: 6) + .opacity(pulse ? 1 : 0.6) + .onAppear { withAnimation(CSMotion.softpulse) { pulse = true } } + } } diff --git a/macos/Codescribe/Screens/AgentChat/OversizedText.swift b/macos/Codescribe/Screens/AgentChat/OversizedText.swift index 2b7d1304..b312ca87 100644 --- a/macos/Codescribe/Screens/AgentChat/OversizedText.swift +++ b/macos/Codescribe/Screens/AgentChat/OversizedText.swift @@ -10,66 +10,67 @@ import SwiftUI // by OversizedTextSliceTests. extension OversizedBubblePolicy { - /// Window (in characters) scanned backwards from the head cut for a - /// newline, so the fold lands on a line boundary when one is near — - /// folding mid-line reads like data loss, folding at a break like a fold. - static let newlineSearchWindow = 400 - - static func isOversized(_ text: String) -> Bool { - disposition(utf8Count: text.utf8.count) != .inline + /// Window (in characters) scanned backwards from the head cut for a + /// newline, so the fold lands on a line boundary when one is near — + /// folding mid-line reads like data loss, folding at a break like a fold. + static let newlineSearchWindow = 400 + + static func isOversized(_ text: String) -> Bool { + disposition(utf8Count: text.utf8.count) != .inline + } + + /// Grapheme-safe head of at most `headPreviewUTF8` bytes. Walks whole + /// `Character`s so emoji and combining sequences never split; only the + /// head region is ever visited, not the full payload. + static func head(of text: String) -> String { + guard case .headPreview(let headUTF8) = disposition(utf8Count: text.utf8.count) else { + return text } - - /// Grapheme-safe head of at most `headPreviewUTF8` bytes. Walks whole - /// `Character`s so emoji and combining sequences never split; only the - /// head region is ever visited, not the full payload. - static func head(of text: String) -> String { - guard case .headPreview(let headUTF8) = disposition(utf8Count: text.utf8.count) else { - return text - } - var bytes = 0 - var cut = text.startIndex - while cut < text.endIndex { - let next = text.index(after: cut) - bytes += text[cut].utf8.count - if bytes > headUTF8 { break } - cut = next - } - let hardCut = String(text[.. headUTF8 { break } + cut = next } - - /// Live streaming window: the TAIL, not the head — a streaming turn is - /// followed at its live edge, and the window also caps what the per-delta - /// plain-`Text` re-evaluation has to lay out. - static func streamingWindow(_ text: String) -> String { - guard isOversized(text) else { return text } - var bytes = 0 - var cut = text.endIndex - while cut > text.startIndex { - let prev = text.index(before: cut) - bytes += text[prev].utf8.count - if bytes > headPreviewUTF8 { break } - cut = prev - } - return String(text[cut...]) + let hardCut = String(text[.. String { - let bytes = text.utf8.count - if bytes < 1024 * 1024 { - return "\(max(1, bytes / 1024)) KB" - } - return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) + return hardCut + } + + /// Live streaming window: the TAIL, not the head — a streaming turn is + /// followed at its live edge, and the window also caps what the per-delta + /// plain-`Text` re-evaluation has to lay out. + static func streamingWindow(_ text: String) -> String { + guard isOversized(text) else { return text } + var bytes = 0 + var cut = text.endIndex + while cut > text.startIndex { + let prev = text.index(before: cut) + bytes += text[prev].utf8.count + if bytes > headPreviewUTF8 { break } + cut = prev + } + return String(text[cut...]) + } + + /// Human size for the fold chip ("Show full text · 142 KB"). + static func byteSummary(_ text: String) -> String { + let bytes = text.utf8.count + if bytes < 1024 * 1024 { + return "\(max(1, bytes / 1024)) KB" } + return String(format: "%.1f MB", Double(bytes) / (1024 * 1024)) + } } // MARK: - Degraded bubble body @@ -81,65 +82,70 @@ extension OversizedBubblePolicy { /// what takes it out of the list-wide selection overlay /// (`BubbleTextDisposition.sharesListSelectionOverlay == false`). struct OversizedMessageBody: View { - let fullText: String - @ViewBuilder let head: (String) -> Head - @State private var showFull = false - @Environment(\.csTextScale) private var textScale - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if showFull { - FullTextView( - text: fullText, - font: CSFont.nsMono(13 * textScale) - ) - .frame(maxWidth: .infinity) - .frame(height: 380) - .background(CSColor.surfaceRaised(0.04)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - } else { - head(OversizedBubblePolicy.head(of: fullText)) - } - foldChip - } + let fullText: String + @ViewBuilder let head: (String) -> Head + @State private var showFull = false + @Environment(\.csTextScale) private var textScale + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if showFull { + FullTextView( + text: fullText, + font: CSFont.nsMono(13 * textScale) + ) + .frame(maxWidth: .infinity) + .frame(height: 380) + .background(CSColor.surfaceRaised(0.04)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + } else { + head(OversizedBubblePolicy.head(of: fullText)) + } + foldChip } - - private var foldChip: some View { - Button { - showFull.toggle() - } label: { - HStack(spacing: 5) { - CSIconView( - icon: showFull ? .chevronDown : .chevronRight, - size: 8, - weight: .semibold, - color: CSColor.textFaintAlt - ) - Text(showFull - ? "Collapse" - : "Show full text · \(OversizedBubblePolicy.byteSummary(fullText))") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textMuted) - } - .contentShape(Rectangle()) - } - .csFocusRing(cornerRadius: 8) - .help(showFull - ? "Fold this message back to its head" - : "Open the full text in a scrollable, selectable view") + } + + private var foldChip: some View { + Button { + showFull.toggle() + } label: { + HStack(spacing: 5) { + CSIconView( + icon: showFull ? .chevronDown : .chevronRight, + size: 8, + weight: .semibold, + color: CSColor.textFaintAlt + ) + Text( + showFull + ? "Collapse" + : "Show full text · \(OversizedBubblePolicy.byteSummary(fullText))" + ) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textMuted) + } + .contentShape(Rectangle()) } + .csFocusRing(cornerRadius: 8) + .help( + showFull + ? "Fold this message back to its head" + : "Open the full text in a scrollable, selectable view") + } } /// One-line honesty note above a windowed live stream: the display shows the /// tail, the data keeps everything. struct StreamWindowNote: View { - let fullText: String - - var body: some View { - Text("live view shows the newest output · full text kept (\(OversizedBubblePolicy.byteSummary(fullText)))") - .font(CSFont.mono(9.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } + let fullText: String + + var body: some View { + Text( + "live view shows the newest output · full text kept (\(OversizedBubblePolicy.byteSummary(fullText)))" + ) + .font(CSFont.mono(9.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } } // MARK: - AppKit full-text host @@ -149,43 +155,43 @@ struct StreamWindowNote: View { /// inside this view — the chat list's shared SwiftUI selection overlay never /// touches the oversized payload. struct FullTextView: NSViewRepresentable { - let text: String - let font: NSFont - - func makeNSView(context: Context) -> NSScrollView { - // Explicit TextKit 2 stack (viewport-based layout); the convenience - // `NSTextView.scrollableTextView()` can still wire up TextKit 1. - let textView = NSTextView(usingTextLayoutManager: true) - textView.isEditable = false - textView.isSelectable = true - textView.isRichText = false - textView.drawsBackground = false - textView.textContainerInset = NSSize(width: 8, height: 8) - textView.autoresizingMask = [.width] - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.textContainer?.widthTracksTextView = true - apply(to: textView) - - let scrollView = NSScrollView() - scrollView.documentView = textView - scrollView.hasVerticalScroller = true - scrollView.drawsBackground = false - return scrollView + let text: String + let font: NSFont + + func makeNSView(context: Context) -> NSScrollView { + // Explicit TextKit 2 stack (viewport-based layout); the convenience + // `NSTextView.scrollableTextView()` can still wire up TextKit 1. + let textView = NSTextView(usingTextLayoutManager: true) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.autoresizingMask = [.width] + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.widthTracksTextView = true + apply(to: textView) + + let scrollView = NSScrollView() + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + // The bubble is settled when this view exists; the identity check + // keeps redundant SwiftUI update passes from re-laying-out the payload. + if textView.string != text || textView.font != font { + apply(to: textView) } + } - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { return } - // The bubble is settled when this view exists; the identity check - // keeps redundant SwiftUI update passes from re-laying-out the payload. - if textView.string != text || textView.font != font { - apply(to: textView) - } - } - - private func apply(to textView: NSTextView) { - textView.font = font - textView.textColor = NSColor(CSColor.textBodyAlt) - textView.string = text - } + private func apply(to textView: NSTextView) { + textView.font = font + textView.textColor = NSColor(CSColor.textBodyAlt) + textView.string = text + } } diff --git a/macos/Codescribe/Screens/AgentChat/ThreadRail.swift b/macos/Codescribe/Screens/AgentChat/ThreadRail.swift index 9a80a6b6..43cd383d 100644 --- a/macos/Codescribe/Screens/AgentChat/ThreadRail.swift +++ b/macos/Codescribe/Screens/AgentChat/ThreadRail.swift @@ -5,528 +5,534 @@ import SwiftUI /// keeps thread switching and "+ New thread" one click away — the rail is never /// removed from the split view, so the window can't show an empty band. struct ThreadRail: View { - @ObservedObject var store: AgentChatStore - var mode: AgentSidebarMode = .expanded - @State private var search: String = "" - @State private var deleteCandidate: ChatThread? - @State private var editingThreadID: UUID? - @State private var renameDraft: String = "" - - var body: some View { - Group { - if mode.isExpanded { - expandedRail - } else { - compactRail - } - } - .background(Color.white.opacity(0.015)) - .overlay(alignment: .trailing) { - Rectangle().fill(CSColor.hairline(0.06)).frame(width: 1) - } - .onChange(of: search) { _, newValue in - store.searchThreads(newValue) - } - .confirmationDialog( - "Delete this thread?", - isPresented: Binding( - get: { deleteCandidate != nil }, - set: { if !$0 { deleteCandidate = nil } } - ), - titleVisibility: .visible - ) { - Button("Delete Thread", role: .destructive) { - if let deleteCandidate { - store.delete(deleteCandidate) - self.deleteCandidate = nil - } - } - Button("Cancel", role: .cancel) { - deleteCandidate = nil - } - } message: { - Text("This removes the persisted conversation from the thread store.") + @ObservedObject var store: AgentChatStore + var mode: AgentSidebarMode = .expanded + @State private var search: String = "" + @State private var deleteCandidate: ChatThread? + @State private var editingThreadID: UUID? + @State private var renameDraft: String = "" + + var body: some View { + Group { + if mode.isExpanded { + expandedRail + } else { + compactRail + } + } + .background(Color.white.opacity(0.015)) + .overlay(alignment: .trailing) { + Rectangle().fill(CSColor.hairline(0.06)).frame(width: 1) + } + .onChange(of: search) { _, newValue in + store.searchThreads(newValue) + } + .confirmationDialog( + "Delete this thread?", + isPresented: Binding( + get: { deleteCandidate != nil }, + set: { if !$0 { deleteCandidate = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete Thread", role: .destructive) { + if let deleteCandidate { + store.delete(deleteCandidate) + self.deleteCandidate = nil } + } + Button("Cancel", role: .cancel) { + deleteCandidate = nil + } + } message: { + Text("This removes the persisted conversation from the thread store.") } - - /// Narrow icon strip: brand dot, one dot per recent thread (active tinted), - /// and a "+" footer. No fixed frame — the column width owns the geometry. - private var compactRail: some View { - VStack(spacing: 0) { - ModeDot(color: CSColor.terracotta, size: 9) - .padding(.top, 18) - .padding(.bottom, 14) - - ScrollView { - LazyVStack(spacing: 6) { - ForEach(filteredThreads) { thread in - let title = ThreadRowTitle.displayTitle(for: thread) - let isActive = thread.id == store.selectedThreadID - Button { - store.select(thread.id) - } label: { - Text(ThreadRowTitle.compactMonogram(for: thread)) - .font(CSFont.ui(11, .semibold)) - .foregroundStyle( - isActive ? CSColor.chromeAccent : CSColor.textMuted - ) - .lineLimit(1) - .minimumScaleFactor(0.7) - .frame(width: 28, height: 28) - .background( - isActive - ? CSColor.chromeAccent.opacity(0.12) - : CSColor.surfaceRaised(0.03) - ) - .clipShape( - RoundedRectangle(cornerRadius: 8, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder( - isActive - ? CSColor.chromeAccent.opacity(0.45) - : CSColor.hairline(0.08), - lineWidth: 1 - ) - ) - .contentShape(Rectangle()) - } - .csFocusRing(cornerRadius: 8) - .help(title) - .accessibilityLabel(title) - .accessibilityAddTraits(isActive ? [.isSelected] : []) - } - } - .padding(.vertical, 4) - } - .scrollContentBackground(.hidden) - - Button(action: { store.newThread() }) { - Text("+") - .font(CSFont.ui(15, .semibold)) - .foregroundStyle(CSColor.textMuted) - .frame(width: 30, height: 30) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder( - CSColor.hairline(0.14), - style: StrokeStyle(lineWidth: 1, dash: [4, 3]) - ) + } + + /// Narrow icon strip: brand dot, one dot per recent thread (active tinted), + /// and a "+" footer. No fixed frame — the column width owns the geometry. + private var compactRail: some View { + VStack(spacing: 0) { + ModeDot(color: CSColor.terracotta, size: 9) + .padding(.top, 18) + .padding(.bottom, 14) + + ScrollView { + LazyVStack(spacing: 6) { + ForEach(filteredThreads) { thread in + let title = ThreadRowTitle.displayTitle(for: thread) + let isActive = thread.id == store.selectedThreadID + Button { + store.select(thread.id) + } label: { + Text(ThreadRowTitle.compactMonogram(for: thread)) + .font(CSFont.ui(11, .semibold)) + .foregroundStyle( + isActive ? CSColor.chromeAccent : CSColor.textMuted + ) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(width: 28, height: 28) + .background( + isActive + ? CSColor.chromeAccent.opacity(0.12) + : CSColor.surfaceRaised(0.03) + ) + .clipShape( + RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder( + isActive + ? CSColor.chromeAccent.opacity(0.45) + : CSColor.hairline(0.08), + lineWidth: 1 ) - .contentShape(Rectangle()) + ) + .contentShape(Rectangle()) } .csFocusRing(cornerRadius: 8) - .help("New thread") - .accessibilityLabel("New thread") - .padding(.vertical, 12) - .overlay(alignment: .top) { - Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) - } + .help(title) + .accessibilityLabel(title) + .accessibilityAddTraits(isActive ? [.isSelected] : []) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 4) + } + .scrollContentBackground(.hidden) + + Button(action: { store.newThread() }) { + Text("+") + .font(CSFont.ui(15, .semibold)) + .foregroundStyle(CSColor.textMuted) + .frame(width: 30, height: 30) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder( + CSColor.hairline(0.14), + style: StrokeStyle(lineWidth: 1, dash: [4, 3]) + ) + ) + .contentShape(Rectangle()) + } + .csFocusRing(cornerRadius: 8) + .help("New thread") + .accessibilityLabel("New thread") + .padding(.vertical, 12) + .overlay(alignment: .top) { + Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) + } } - - private var expandedRail: some View { - VStack(spacing: 0) { - // Wordmark header - HStack(spacing: 9) { - Wordmark(size: 15) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - - // Search field - HStack(spacing: 8) { - CSIconView(icon: .search, size: 12, color: CSColor.textFaintAlt) - TextField("", text: $search, prompt: - Text("search threads") - .font(CSFont.mono(12, .medium)) - .foregroundColor(CSColor.textFaint) - ) - .textFieldStyle(.plain) - .font(CSFont.mono(12, .medium)) - .foregroundStyle(CSColor.textBody) - } - .padding(.horizontal, 11) - .padding(.vertical, 8) - .background(CSColor.surfaceRaised(0.04)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - .padding(.horizontal, 12) - .padding(.bottom, 10) - - // Section eyebrow + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var expandedRail: some View { + VStack(spacing: 0) { + // Wordmark header + HStack(spacing: 9) { + Wordmark(size: 15) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + // Search field + HStack(spacing: 8) { + CSIconView(icon: .search, size: 12, color: CSColor.textFaintAlt) + TextField( + "", text: $search, + prompt: + Text("search threads") + .font(CSFont.mono(12, .medium)) + .foregroundColor(CSColor.textFaint) + ) + .textFieldStyle(.plain) + .font(CSFont.mono(12, .medium)) + .foregroundStyle(CSColor.textBody) + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background(CSColor.surfaceRaised(0.04)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + .padding(.horizontal, 12) + .padding(.bottom, 10) + + // Section eyebrow + HStack { + Text("THREADS") + .font(CSFont.mono(10, .semibold)) + .tracking(1.0) + .foregroundStyle(CSColor.textFaintAlt) + Spacer() + } + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, 4) + + // Thread list — search-filtered first, then grouped by recency + ScrollView { + LazyVStack(spacing: 4) { + ForEach(sectionedThreads, id: \.section) { group in HStack { - Text("THREADS") - .font(CSFont.mono(10, .semibold)) - .tracking(1.0) - .foregroundStyle(CSColor.textFaintAlt) - Spacer() + Text(group.section.title) + .font(CSFont.mono(9, .semibold)) + .tracking(0.8) + .foregroundStyle(CSColor.textFaintAlt) + Spacer() } - .padding(.horizontal, 12) - .padding(.top, 6) - .padding(.bottom, 4) - - // Thread list — search-filtered first, then grouped by recency - ScrollView { - LazyVStack(spacing: 4) { - ForEach(sectionedThreads, id: \.section) { group in - HStack { - Text(group.section.title) - .font(CSFont.mono(9, .semibold)) - .tracking(0.8) - .foregroundStyle(CSColor.textFaintAlt) - Spacer() - } - .padding(.horizontal, 2) - .padding(.top, 8) - .padding(.bottom, 2) - ForEach(group.threads) { thread in - ThreadRow( - thread: thread, - isActive: thread.id == store.selectedThreadID, - isEditing: editingThreadID == thread.id, - renameDraft: $renameDraft, - onToggleFavorite: { store.toggleFavorite(thread) }, - onRequestDelete: { deleteCandidate = thread }, - onBeginRename: { beginRename(thread) }, - onCommitRename: { commitRename(thread) }, - onCancelRename: { cancelRename(thread) } - ) - .contentShape(Rectangle()) - .onTapGesture { - if editingThreadID != thread.id { store.select(thread.id) } - } - } - } - } - .padding(.horizontal, 10) - } - .scrollContentBackground(.hidden) - - // New thread footer - VStack { - Button(action: { store.newThread() }) { - HStack(spacing: 7) { - Text("+ New thread") - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(CSColor.textMuted) - } - .frame(maxWidth: .infinity) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder( - CSColor.hairline(0.14), - style: StrokeStyle(lineWidth: 1, dash: [4, 3]) - ) - ) - } - .csFocusRing(cornerRadius: 8) - } - .padding(12) - .overlay(alignment: .top) { - Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 2) + ForEach(group.threads) { thread in + ThreadRow( + thread: thread, + isActive: thread.id == store.selectedThreadID, + isEditing: editingThreadID == thread.id, + renameDraft: $renameDraft, + onToggleFavorite: { store.toggleFavorite(thread) }, + onRequestDelete: { deleteCandidate = thread }, + onBeginRename: { beginRename(thread) }, + onCommitRename: { commitRename(thread) }, + onCancelRename: { cancelRename(thread) } + ) + .contentShape(Rectangle()) + .onTapGesture { + if editingThreadID != thread.id { store.select(thread.id) } + } } + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var filteredThreads: [ChatThread] { - let q = search.trimmingCharacters(in: .whitespaces).lowercased() - guard !q.isEmpty else { return store.threads } - if store.usesRealThreadSearch { return store.threads } - return store.threads.filter { - ThreadRowTitle.displayTitle(for: $0).lowercased().contains(q) + .padding(.horizontal, 10) + } + .scrollContentBackground(.hidden) + + // New thread footer + VStack { + Button(action: { store.newThread() }) { + HStack(spacing: 7) { + Text("+ New thread") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.textMuted) + } + .frame(maxWidth: .infinity) + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder( + CSColor.hairline(0.14), + style: StrokeStyle(lineWidth: 1, dash: [4, 3]) + ) + ) } + .csFocusRing(cornerRadius: 8) + } + .padding(12) + .overlay(alignment: .top) { + Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) + } } - - /// Groups the (already search-filtered) threads into recency sections, - /// preserving the store's updated-desc order inside each group. Local-only - /// drafts carry no `updatedAt` and group under Today. - private var sectionedThreads: [(section: ThreadSection, threads: [ChatThread])] { - let now = Date() - var groups: [ThreadSection: [ChatThread]] = [:] - for thread in filteredThreads { - groups[ThreadSection.section(for: thread.updatedAt ?? now, now: now), default: []] - .append(thread) - } - return ThreadSection.allCases.compactMap { section in - groups[section].map { (section, $0) } - } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var filteredThreads: [ChatThread] { + let q = search.trimmingCharacters(in: .whitespaces).lowercased() + guard !q.isEmpty else { return store.threads } + if store.usesRealThreadSearch { return store.threads } + return store.threads.filter { + ThreadRowTitle.displayTitle(for: $0).lowercased().contains(q) } - - // MARK: Rename (inline edit) - - private func beginRename(_ thread: ChatThread) { - guard editingThreadID != thread.id else { return } - renameDraft = ThreadRowTitle.displayTitle(for: thread) - editingThreadID = thread.id - } - - /// Persist the typed title. Clearing `editingThreadID` first makes any - /// trailing focus-loss commit a no-op (see ThreadRow's blur handling). - private func commitRename(_ thread: ChatThread) { - guard editingThreadID == thread.id else { return } - let value = renameDraft - editingThreadID = nil - store.rename(thread, to: value) + } + + /// Groups the (already search-filtered) threads into recency sections, + /// preserving the store's updated-desc order inside each group. Local-only + /// drafts carry no `updatedAt` and group under Today. + private var sectionedThreads: [(section: ThreadSection, threads: [ChatThread])] { + let now = Date() + var groups: [ThreadSection: [ChatThread]] = [:] + for thread in filteredThreads { + groups[ThreadSection.section(for: thread.updatedAt ?? now, now: now), default: []] + .append(thread) } - - private func cancelRename(_ thread: ChatThread) { - guard editingThreadID == thread.id else { return } - editingThreadID = nil + return ThreadSection.allCases.compactMap { section in + groups[section].map { (section, $0) } } + } + + // MARK: Rename (inline edit) + + private func beginRename(_ thread: ChatThread) { + guard editingThreadID != thread.id else { return } + renameDraft = ThreadRowTitle.displayTitle(for: thread) + editingThreadID = thread.id + } + + /// Persist the typed title. Clearing `editingThreadID` first makes any + /// trailing focus-loss commit a no-op (see ThreadRow's blur handling). + private func commitRename(_ thread: ChatThread) { + guard editingThreadID == thread.id else { return } + let value = renameDraft + editingThreadID = nil + store.rename(thread, to: value) + } + + private func cancelRename(_ thread: ChatThread) { + guard editingThreadID == thread.id else { return } + editingThreadID = nil + } } /// Pure row-view model: a transport placeholder can exist in a corrupt/stale /// input object, but it can never become visible text. Prefer the first user /// excerpt and fall back to a relative date label when messages are still lazy. enum ThreadRowTitle { - static func displayTitle( - for thread: ChatThread, - now: Date = Date(), - calendar: Calendar = .current - ) -> String { - if let title = ThreadTitlePolicy.normalized(thread.title) { - return title - } - if let excerpt = ThreadTitlePolicy.firstUserExcerpt(in: thread.messages) { - return excerpt - } - return ThreadRailMeta.fallbackTitle( - updatedAt: thread.updatedAt, - now: now, - calendar: calendar - ) + static func displayTitle( + for thread: ChatThread, + now: Date = Date(), + calendar: Calendar = .current + ) -> String { + if let title = ThreadTitlePolicy.normalized(thread.title) { + return title } - - /// Identity for the collapsed rail. The strip used to draw one anonymous - /// 7pt dot per thread — a vertical row of identical dots that told the user - /// nothing and forced a hover-and-wait tooltip to pick a conversation - /// (UI_DIVERGENCE_AUDIT pkt 2). Two initials from the display title carry - /// enough identity at 28pt; the digit/letter scan keeps titles that open - /// with punctuation or an emoji from yielding a blank tile. - static func compactMonogram( - for thread: ChatThread, - now: Date = Date(), - calendar: Calendar = .current - ) -> String { - let title = displayTitle(for: thread, now: now, calendar: calendar) - let words = title - .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) - .prefix(2) - let initials = words.compactMap { $0.first }.map(String.init).joined() - // `displayTitle` always resolves to a string carrying a letter or digit - // (ThreadTitlePolicy rejects the rest and the date fallback never is), - // so the dot is a guard against a future title source, not a live case. - return initials.isEmpty ? "•" : initials.uppercased() + if let excerpt = ThreadTitlePolicy.firstUserExcerpt(in: thread.messages) { + return excerpt } + return ThreadRailMeta.fallbackTitle( + updatedAt: thread.updatedAt, + now: now, + calendar: calendar + ) + } + + /// Identity for the collapsed rail. The strip used to draw one anonymous + /// 7pt dot per thread — a vertical row of identical dots that told the user + /// nothing and forced a hover-and-wait tooltip to pick a conversation + /// (UI_DIVERGENCE_AUDIT pkt 2). Two initials from the display title carry + /// enough identity at 28pt; the digit/letter scan keeps titles that open + /// with punctuation or an emoji from yielding a blank tile. + static func compactMonogram( + for thread: ChatThread, + now: Date = Date(), + calendar: Calendar = .current + ) -> String { + let title = displayTitle(for: thread, now: now, calendar: calendar) + let words = + title + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .prefix(2) + let initials = words.compactMap { $0.first }.map(String.init).joined() + // `displayTitle` always resolves to a string carrying a letter or digit + // (ThreadTitlePolicy rejects the rest and the date fallback never is), + // so the dot is a guard against a future title source, not a live case. + return initials.isEmpty ? "•" : initials.uppercased() + } } private struct ThreadRow: View { - let thread: ChatThread - let isActive: Bool - let isEditing: Bool - @Binding var renameDraft: String - let onToggleFavorite: () -> Void - let onRequestDelete: () -> Void - let onBeginRename: () -> Void - let onCommitRename: () -> Void - let onCancelRename: () -> Void - - @FocusState private var renameFieldFocused: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 7) { - if isActive { - Circle().fill(CSColor.chromeAccent).frame(width: 6, height: 6) - } - if isEditing { - TextField("", text: $renameDraft) - .textFieldStyle(.plain) - .font(CSFont.ui(13, .semibold)) - .foregroundStyle(ChatPalette.nameActive) - .focused($renameFieldFocused) - .onSubmit { onCommitRename() } - .onExitCommand { onCancelRename() } - .onAppear { DispatchQueue.main.async { renameFieldFocused = true } } - .onChange(of: renameFieldFocused) { _, focused in - // Click-away commits the typed value; Enter/Esc already - // cleared editing, so those paths make this a no-op. - if !focused, isEditing { onCommitRename() } - } - } else { - Text(ThreadRowTitle.displayTitle(for: thread)) - .font(CSFont.ui(13, isActive ? .semibold : .medium)) - .foregroundStyle(isActive ? ChatPalette.nameActive : ChatPalette.nameInactive) - .lineLimit(1) - .onTapGesture(count: 2) { onBeginRename() } - } - Spacer(minLength: 4) - Button(action: onToggleFavorite) { - CSIconView( - icon: thread.isFavorite ? .starFill : .star, - size: 11, - weight: .semibold, - color: thread.isFavorite ? CSColor.oliveLight : CSColor.textFaintAlt - ) - .frame(width: 18, height: 18) - .contentShape(Rectangle()) - } - .csFocusRing(cornerRadius: 8) - .opacity(thread.isFavorite || isActive ? 1 : 0.38) - .help(thread.isFavorite ? "Unfavorite thread" : "Favorite thread") - } - Text(thread.meta) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(isActive ? ChatPalette.activeThreadSub : CSColor.textFaintAlt) + let thread: ChatThread + let isActive: Bool + let isEditing: Bool + @Binding var renameDraft: String + let onToggleFavorite: () -> Void + let onRequestDelete: () -> Void + let onBeginRename: () -> Void + let onCommitRename: () -> Void + let onCancelRename: () -> Void + + @FocusState private var renameFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + if isActive { + Circle().fill(CSColor.chromeAccent).frame(width: 6, height: 6) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 11) - .background(isActive ? CSColor.chromeAccent.opacity(0.12) : .clear) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(isActive ? CSColor.chromeAccent.opacity(0.28) : .clear, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .contextMenu { - Button("Rename") { - onBeginRename() - } - Button(thread.isFavorite ? "Unfavorite" : "Favorite") { - onToggleFavorite() - } - Divider() - Button("Delete Thread", role: .destructive) { - onRequestDelete() + if isEditing { + TextField("", text: $renameDraft) + .textFieldStyle(.plain) + .font(CSFont.ui(13, .semibold)) + .foregroundStyle(ChatPalette.nameActive) + .focused($renameFieldFocused) + .onSubmit { onCommitRename() } + .onExitCommand { onCancelRename() } + .onAppear { DispatchQueue.main.async { renameFieldFocused = true } } + .onChange(of: renameFieldFocused) { _, focused in + // Click-away commits the typed value; Enter/Esc already + // cleared editing, so those paths make this a no-op. + if !focused, isEditing { onCommitRename() } } + } else { + Text(ThreadRowTitle.displayTitle(for: thread)) + .font(CSFont.ui(13, isActive ? .semibold : .medium)) + .foregroundStyle(isActive ? ChatPalette.nameActive : ChatPalette.nameInactive) + .lineLimit(1) + .onTapGesture(count: 2) { onBeginRename() } + } + Spacer(minLength: 4) + Button(action: onToggleFavorite) { + CSIconView( + icon: thread.isFavorite ? .starFill : .star, + size: 11, + weight: .semibold, + color: thread.isFavorite ? CSColor.oliveLight : CSColor.textFaintAlt + ) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) } + .csFocusRing(cornerRadius: 8) + .opacity(thread.isFavorite || isActive ? 1 : 0.38) + .help(thread.isFavorite ? "Unfavorite thread" : "Favorite thread") + } + Text(thread.meta) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(isActive ? ChatPalette.activeThreadSub : CSColor.textFaintAlt) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 11) + .background(isActive ? CSColor.chromeAccent.opacity(0.12) : .clear) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(isActive ? CSColor.chromeAccent.opacity(0.28) : .clear, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .contextMenu { + Button("Rename") { + onBeginRename() + } + Button(thread.isFavorite ? "Unfavorite" : "Favorite") { + onToggleFavorite() + } + Divider() + Button("Delete Thread", role: .destructive) { + onRequestDelete() + } } + } } // MARK: - Recency sections (pure, unit-tested) /// Time buckets for the rail's section headers, ordered newest-first. enum ThreadSection: CaseIterable, Hashable { - case today, yesterday, thisWeek, older - - var title: String { - switch self { - case .today: "Today" - case .yesterday: "Yesterday" - case .thisWeek: "This week" - case .older: "Older" - } + case today, yesterday, thisWeek, older + + var title: String { + switch self { + case .today: "Today" + case .yesterday: "Yesterday" + case .thisWeek: "This week" + case .older: "Older" } - - /// Buckets by whole calendar days between `updatedAt` and `now`: - /// 0 → today, 1 → yesterday, 2–6 → this week, 7+ → older. Future dates - /// (clock skew) clamp to today. - static func section( - for updatedAt: Date, now: Date, calendar: Calendar = .current - ) -> ThreadSection { - let days = calendar.dateComponents( - [.day], - from: calendar.startOfDay(for: updatedAt), - to: calendar.startOfDay(for: now) - ).day ?? 0 - switch days { - case ..<1: return .today - case 1: return .yesterday - case 2...6: return .thisWeek - default: return .older - } + } + + /// Buckets by whole calendar days between `updatedAt` and `now`: + /// 0 → today, 1 → yesterday, 2–6 → this week, 7+ → older. Future dates + /// (clock skew) clamp to today. + static func section( + for updatedAt: Date, now: Date, calendar: Calendar = .current + ) -> ThreadSection { + let days = + calendar.dateComponents( + [.day], + from: calendar.startOfDay(for: updatedAt), + to: calendar.startOfDay(for: now) + ).day ?? 0 + switch days { + case ..<1: return .today + case 1: return .yesterday + case 2...6: return .thisWeek + default: return .older } + } } // MARK: - Row metadata formatter (pure, unit-tested) enum ThreadRailMeta { - static func fallbackTitle( - updatedAt: Date?, - now: Date = Date(), - calendar: Calendar = .current - ) -> String { - guard let updatedAt else { return "Untitled thread" } - let relative = relativeTime(updatedAt, now: now, calendar: calendar) - return relative.prefix(1).uppercased() + relative.dropFirst() + static func fallbackTitle( + updatedAt: Date?, + now: Date = Date(), + calendar: Calendar = .current + ) -> String { + guard let updatedAt else { return "Untitled thread" } + let relative = relativeTime(updatedAt, now: now, calendar: calendar) + return relative.prefix(1).uppercased() + relative.dropFirst() + } + + /// "relative time · model · tokens", skipping whatever is missing — nils + /// never leave dangling separators. All inputs absent → empty string. + static func drawerSubtitle( + model: String?, + tokens: UInt64?, + updatedAt: Date?, + now: Date = Date(), + calendar: Calendar = .current + ) -> String { + var parts: [String] = [] + if let updatedAt { + parts.append(relativeTime(updatedAt, now: now, calendar: calendar)) } - - /// "relative time · model · tokens", skipping whatever is missing — nils - /// never leave dangling separators. All inputs absent → empty string. - static func drawerSubtitle( - model: String?, - tokens: UInt64?, - updatedAt: Date?, - now: Date = Date(), - calendar: Calendar = .current - ) -> String { - var parts: [String] = [] - if let updatedAt { - parts.append(relativeTime(updatedAt, now: now, calendar: calendar)) - } - if let model, !model.isEmpty { - // "openai/gpt-5" → "gpt-5"; plain names pass through. - parts.append(String(model.split(separator: "/").last ?? Substring(model))) - } - if let tokens, tokens > 0 { - parts.append(tokenLabel(tokens)) - } - return parts.joined(separator: " · ") + if let model, !model.isEmpty { + // "openai/gpt-5" → "gpt-5"; plain names pass through. + parts.append(String(model.split(separator: "/").last ?? Substring(model))) } - - /// "today HH:mm" / "yesterday" / "MMM d" — same shape the rail always used. - /// - /// Formatters are cached: `DateFormatter()` construction is a full ICU - /// engine init, and this runs once per rail row per refresh — a fresh - /// instance here pinned the main thread for whole refresh storms (sample - /// 2026-08-07 10:43, 42/93 samples under NSDateFormatter init). Main - /// thread only, like every rail meta path. - private static let todayFormatter = makeFormatter("'today' HH:mm") - private static let monthDayFormatter = makeFormatter("MMM d") - - private static func makeFormatter(_ format: String) -> DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = format - return formatter + if let tokens, tokens > 0 { + parts.append(tokenLabel(tokens)) } - - private static func relativeTime(_ date: Date, now: Date, calendar: Calendar) -> String { - switch ThreadSection.section(for: date, now: now, calendar: calendar) { - case .yesterday: - return "yesterday" - case .today: - return string(from: date, via: todayFormatter, calendar: calendar) - case .thisWeek, .older: - return string(from: date, via: monthDayFormatter, calendar: calendar) - } + return parts.joined(separator: " · ") + } + + /// "today HH:mm" / "yesterday" / "MMM d" — same shape the rail always used. + /// + /// Formatters are cached: `DateFormatter()` construction is a full ICU + /// engine init, and this runs once per rail row per refresh — a fresh + /// instance here pinned the main thread for whole refresh storms (sample + /// 2026-08-07 10:43, 42/93 samples under NSDateFormatter init). Main + /// thread only, like every rail meta path. + private static let todayFormatter = makeFormatter("'today' HH:mm") + private static let monthDayFormatter = makeFormatter("MMM d") + + private static func makeFormatter(_ format: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = format + return formatter + } + + private static func relativeTime(_ date: Date, now: Date, calendar: Calendar) -> String { + switch ThreadSection.section(for: date, now: now, calendar: calendar) { + case .yesterday: + return "yesterday" + case .today: + return string(from: date, via: todayFormatter, calendar: calendar) + case .thisWeek, .older: + return string(from: date, via: monthDayFormatter, calendar: calendar) } - - private static func string(from date: Date, via formatter: DateFormatter, calendar: Calendar) -> String { - // Reassigning calendar forces an ICU regenerate on next use — only - // touch it when a caller (tests inject fixed calendars) differs. - if formatter.calendar != calendar { - formatter.calendar = calendar - formatter.timeZone = calendar.timeZone - } - return formatter.string(from: date) + } + + private static func string(from date: Date, via formatter: DateFormatter, calendar: Calendar) + -> String + { + // Reassigning calendar forces an ICU regenerate on next use — only + // touch it when a caller (tests inject fixed calendars) differs. + if formatter.calendar != calendar { + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone } - - private static func tokenLabel(_ tokens: UInt64) -> String { - switch tokens { - case ..<1_000: "\(tokens) tok" - case ..<1_000_000: String(format: "%.1fk tok", Double(tokens) / 1_000) - default: String(format: "%.1fM tok", Double(tokens) / 1_000_000) - } + return formatter.string(from: date) + } + + private static func tokenLabel(_ tokens: UInt64) -> String { + switch tokens { + case ..<1_000: "\(tokens) tok" + case ..<1_000_000: String(format: "%.1fk tok", Double(tokens) / 1_000) + default: String(format: "%.1fM tok", Double(tokens) / 1_000_000) } + } } diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingEngine.swift b/macos/Codescribe/Screens/Onboarding/OnboardingEngine.swift index 4f0a64d2..ea59c062 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingEngine.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingEngine.swift @@ -14,30 +14,30 @@ import Foundation /// Subset of the codescribe config surface the onboarding wizard consumes. protocol OnboardingEngine { - // First-run gate + resume/completion markers. - func shouldShowOnboarding() -> Bool - func onboardingProgress() -> UInt32 - func saveOnboardingProgress(step: UInt32) - func markOnboardingDone() - - // Mode step: first-run operating lane (Basic / Agentic). Reader + setter - // route to the promoted `ONBOARDING_MODE` key in settings.json. - func onboardingMode() -> String? - func setOnboardingMode(_ mode: String) throws - - // Language step: current dictation language (seeds the picker). Writes reuse - // the shared `updateConfig("WHISPER_LANGUAGE", …)` path below — no new mechanism. - func currentLanguage() -> CsLanguage - - // API-key step (shared with the Settings Keys panel). - /// Persisted assistive-lane provider (`LLM_ASSISTIVE_PROVIDER`), or nil when - /// unset. Seeds the wizard's provider selection on resume so a returning user - /// sees — and keys — the provider they picked, not the catalog default. - func assistiveProvider() -> String? - func keyStatus() -> CsKeyStatus - func availableProviders() -> [CsProviderOption] - func setApiKey(account: String, secret: String) throws - func updateConfig(key: String, value: String) throws + // First-run gate + resume/completion markers. + func shouldShowOnboarding() -> Bool + func onboardingProgress() -> UInt32 + func saveOnboardingProgress(step: UInt32) + func markOnboardingDone() + + // Mode step: first-run operating lane (Basic / Agentic). Reader + setter + // route to the promoted `ONBOARDING_MODE` key in settings.json. + func onboardingMode() -> String? + func setOnboardingMode(_ mode: String) throws + + // Language step: current dictation language (seeds the picker). Writes reuse + // the shared `updateConfig("WHISPER_LANGUAGE", …)` path below — no new mechanism. + func currentLanguage() -> CsLanguage + + // API-key step (shared with the Settings Keys panel). + /// Persisted assistive-lane provider (`LLM_ASSISTIVE_PROVIDER`), or nil when + /// unset. Seeds the wizard's provider selection on resume so a returning user + /// sees — and keys — the provider they picked, not the catalog default. + func assistiveProvider() -> String? + func keyStatus() -> CsKeyStatus + func availableProviders() -> [CsProviderOption] + func setApiKey(account: String, secret: String) throws + func updateConfig(key: String, value: String) throws } // MARK: - Real engine (UniFFI bridge adapter) @@ -46,26 +46,26 @@ protocol OnboardingEngine { /// call reads/writes live on-disk truth (config dir markers / settings.json / /// Keychain). Injected by App.swift for the live app. final class RealOnboardingEngine: OnboardingEngine { - private let config = CodescribeConfig() - - func shouldShowOnboarding() -> Bool { config.shouldShowOnboarding() } - func onboardingProgress() -> UInt32 { config.onboardingProgress() } - func saveOnboardingProgress(step: UInt32) { config.saveOnboardingProgress(step: step) } - func markOnboardingDone() { config.markOnboardingDone() } - - func onboardingMode() -> String? { config.onboardingMode() } - func setOnboardingMode(_ mode: String) throws { try config.setOnboardingMode(mode: mode) } - func currentLanguage() -> CsLanguage { config.loadSettings().whisperLanguage } - - func assistiveProvider() -> String? { config.loadSettings().llmAssistiveProvider } - func keyStatus() -> CsKeyStatus { config.keyStatus() } - func availableProviders() -> [CsProviderOption] { config.availableProviders() } - func setApiKey(account: String, secret: String) throws { - try config.setApiKey(account: account, secret: secret) - } - func updateConfig(key: String, value: String) throws { - try config.updateConfig(key: key, value: value) - } + private let config = CodescribeConfig() + + func shouldShowOnboarding() -> Bool { config.shouldShowOnboarding() } + func onboardingProgress() -> UInt32 { config.onboardingProgress() } + func saveOnboardingProgress(step: UInt32) { config.saveOnboardingProgress(step: step) } + func markOnboardingDone() { config.markOnboardingDone() } + + func onboardingMode() -> String? { config.onboardingMode() } + func setOnboardingMode(_ mode: String) throws { try config.setOnboardingMode(mode: mode) } + func currentLanguage() -> CsLanguage { config.loadSettings().whisperLanguage } + + func assistiveProvider() -> String? { config.loadSettings().llmAssistiveProvider } + func keyStatus() -> CsKeyStatus { config.keyStatus() } + func availableProviders() -> [CsProviderOption] { config.availableProviders() } + func setApiKey(account: String, secret: String) throws { + try config.setApiKey(account: account, secret: secret) + } + func updateConfig(key: String, value: String) throws { + try config.updateConfig(key: key, value: value) + } } // MARK: - Mock engine (previews) @@ -73,27 +73,27 @@ final class RealOnboardingEngine: OnboardingEngine { /// In-memory stand-in for #Preview and standalone rendering. Progress writes are /// captured so a preview can be seeded at a specific step; key writes are no-ops. final class MockOnboardingEngine: OnboardingEngine { - var showOnboarding: Bool = true - var progress: UInt32 = 0 - var status: CsKeyStatus = .sampleAllSet - var mode: String? - var language: CsLanguage = .auto - var savedProvider: String? - - init(progress: UInt32 = 0) { self.progress = progress } - - func shouldShowOnboarding() -> Bool { showOnboarding } - func onboardingProgress() -> UInt32 { progress } - func saveOnboardingProgress(step: UInt32) { progress = step } - func markOnboardingDone() { showOnboarding = false } - - func onboardingMode() -> String? { mode } - func setOnboardingMode(_ mode: String) throws { self.mode = mode } - func currentLanguage() -> CsLanguage { language } - - func assistiveProvider() -> String? { savedProvider } - func keyStatus() -> CsKeyStatus { status } - func availableProviders() -> [CsProviderOption] { CsProviderOption.sampleProviders } - func setApiKey(account: String, secret: String) throws {} - func updateConfig(key: String, value: String) throws {} + var showOnboarding: Bool = true + var progress: UInt32 = 0 + var status: CsKeyStatus = .sampleAllSet + var mode: String? + var language: CsLanguage = .auto + var savedProvider: String? + + init(progress: UInt32 = 0) { self.progress = progress } + + func shouldShowOnboarding() -> Bool { showOnboarding } + func onboardingProgress() -> UInt32 { progress } + func saveOnboardingProgress(step: UInt32) { progress = step } + func markOnboardingDone() { showOnboarding = false } + + func onboardingMode() -> String? { mode } + func setOnboardingMode(_ mode: String) throws { self.mode = mode } + func currentLanguage() -> CsLanguage { language } + + func assistiveProvider() -> String? { savedProvider } + func keyStatus() -> CsKeyStatus { status } + func availableProviders() -> [CsProviderOption] { CsProviderOption.sampleProviders } + func setApiKey(account: String, secret: String) throws {} + func updateConfig(key: String, value: String) throws {} } diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingStep.swift b/macos/Codescribe/Screens/Onboarding/OnboardingStep.swift index 3fd9019d..f6b78b3f 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingStep.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingStep.swift @@ -16,46 +16,46 @@ import Foundation /// One step of the first-run onboarding wizard. enum OnboardingStep: Equatable { - case welcome - /// Basic vs Agentic operating lane. - case mode - /// Privacy scopes in `PERMISSION_STEP_ORDER` (mic → … → speech → full-disk). - case permission(PermissionKind) - /// Dictation language choice. - case language - case apiKey - /// Hold / toggle / hybrid hotkey lane. - case hotkeyMode - /// Agentic-lane readiness verdict. - case agenticReadiness - case done + case welcome + /// Basic vs Agentic operating lane. + case mode + /// Privacy scopes in `PERMISSION_STEP_ORDER` (mic → … → speech → full-disk). + case permission(PermissionKind) + /// Dictation language choice. + case language + case apiKey + /// Hold / toggle / hybrid hotkey lane. + case hotkeyMode + /// Agentic-lane readiness verdict. + case agenticReadiness + case done - /// Fixed 13-step flow. Indices are the persisted resume contract — see the - /// file header. Permission order matches `PERMISSION_STEP_ORDER`. - static let flow: [OnboardingStep] = [ - .welcome, - .mode, - .permission(.microphone), - .permission(.accessibility), - .permission(.inputMonitoring), - .permission(.screenRecording), - .permission(.speechRecognition), - .permission(.fullDiskAccess), - .language, - .apiKey, - .hotkeyMode, - .agenticReadiness, - .done, - ] + /// Fixed 13-step flow. Indices are the persisted resume contract — see the + /// file header. Permission order matches `PERMISSION_STEP_ORDER`. + static let flow: [OnboardingStep] = [ + .welcome, + .mode, + .permission(.microphone), + .permission(.accessibility), + .permission(.inputMonitoring), + .permission(.screenRecording), + .permission(.speechRecognition), + .permission(.fullDiskAccess), + .language, + .apiKey, + .hotkeyMode, + .agenticReadiness, + .done, + ] - /// Total number of steps (13). Kept in sync with the Rust - /// `TOTAL_ONBOARDING_STEPS` clamp in app/os/onboarding.rs. - static var count: Int { flow.count } + /// Total number of steps (13). Kept in sync with the Rust + /// `TOTAL_ONBOARDING_STEPS` clamp in app/os/onboarding.rs. + static var count: Int { flow.count } - /// Step at a persisted resume index, clamped to the valid range so a stale - /// or out-of-range marker can never crash the wizard (falls back to Welcome). - static func step(at index: Int) -> OnboardingStep { - guard flow.indices.contains(index) else { return .welcome } - return flow[index] - } + /// Step at a persisted resume index, clamped to the valid range so a stale + /// or out-of-range marker can never crash the wizard (falls back to Welcome). + static func step(at index: Int) -> OnboardingStep { + guard flow.indices.contains(index) else { return .welcome } + return flow[index] + } } diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift index 6512f1de..b1dfcd6b 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift @@ -10,25 +10,25 @@ import SwiftUI // MARK: - Welcome struct WelcomeStepView: View { - var body: some View { - VStack(alignment: .leading, spacing: 16) { - EyebrowLabel(text: "Welcome") - Text("Codescribe turns your voice into text — anywhere.") - .font(CSFont.ui(28, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .fixedSize(horizontal: false, vertical: true) - Text( - "This quick setup grants the macOS permissions Codescribe needs, " - + "picks your language and hotkeys, and optionally wires up an AI " - + "provider. You can change everything later in Settings." - ) - .font(CSFont.ui(14)) - .lineSpacing(3) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + EyebrowLabel(text: "Welcome") + Text("Codescribe turns your voice into text — anywhere.") + .font(CSFont.ui(28, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .fixedSize(horizontal: false, vertical: true) + Text( + "This quick setup grants the macOS permissions Codescribe needs, " + + "picks your language and hotkeys, and optionally wires up an AI " + + "provider. You can change everything later in Settings." + ) + .font(CSFont.ui(14)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) } + } } // MARK: - Step scaffold + selectable choice card (shared by Mode / Language / Hotkey) @@ -36,629 +36,640 @@ struct WelcomeStepView: View { /// Shared heading (eyebrow + title + blurb) for the choice steps, matching the /// Welcome/Permission typography. private struct OnboardingStepHeader: View { - let eyebrow: String - let title: String - let blurb: String - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - EyebrowLabel(text: eyebrow) - Text(title) - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .fixedSize(horizontal: false, vertical: true) - Text(blurb) - .font(CSFont.ui(14)) - .lineSpacing(3) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - } + let eyebrow: String + let title: String + let blurb: String + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + EyebrowLabel(text: eyebrow) + Text(title) + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .fixedSize(horizontal: false, vertical: true) + Text(blurb) + .font(CSFont.ui(14)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) } + } } /// A single radio-style selectable card: title + optional subtitle, with a /// System-accent ring + filled dot when selected. Reused by the choice steps. struct OnboardingChoiceCard: View { - let title: String - let subtitle: String? - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(alignment: .top, spacing: 12) { - ZStack { - Circle() - .strokeBorder( - isSelected ? CSColor.chromeAccent.opacity(0.9) : CSColor.hairline(0.18), - lineWidth: 1.5 - ) - .frame(width: 16, height: 16) - if isSelected { - Circle().fill(CSColor.chromeAccent).frame(width: 8, height: 8) - } - } - .padding(.top, 1) - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(CSFont.ui(13.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - if let subtitle { - Text(subtitle) - .font(CSFont.ui(12)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - } - } - Spacer(minLength: 0) - } - .padding(.horizontal, 15) - .padding(.vertical, 13) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.chromeAccent.opacity(isSelected ? 0.07 : 0)) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.surfaceRaised(isSelected ? 0 : 0.03)) - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder( - isSelected ? CSColor.chromeAccent.opacity(0.28) : CSColor.hairline(0.08), - lineWidth: 1 - ) + let title: String + let subtitle: String? + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 12) { + ZStack { + Circle() + .strokeBorder( + isSelected ? CSColor.chromeAccent.opacity(0.9) : CSColor.hairline(0.18), + lineWidth: 1.5 ) + .frame(width: 16, height: 16) + if isSelected { + Circle().fill(CSColor.chromeAccent).frame(width: 8, height: 8) + } + } + .padding(.top, 1) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + if let subtitle { + Text(subtitle) + .font(CSFont.ui(12)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + } } - .csFocusRing(cornerRadius: 8) + Spacer(minLength: 0) + } + .padding(.horizontal, 15) + .padding(.vertical, 13) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.chromeAccent.opacity(isSelected ? 0.07 : 0)) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.surfaceRaised(isSelected ? 0 : 0.03)) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder( + isSelected ? CSColor.chromeAccent.opacity(0.28) : CSColor.hairline(0.08), + lineWidth: 1 + ) + ) } + .csFocusRing(cornerRadius: 8) + } } /// A shared note line ("full editing lives in Settings") under a choice step. private struct OnboardingStepNote: View { - let text: String - - var body: some View { - Text(text) - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - } + let text: String + + var body: some View { + Text(text) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } } // MARK: - Mode (Basic vs Agentic operating lane) struct ModeStepView: View { - @ObservedObject var model: OnboardingViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - OnboardingStepHeader( - eyebrow: "Operating lane", - title: "Basic or Agentic.", - blurb: "Choose how codescribe works. You can switch lanes later in Settings.") - - VStack(spacing: 10) { - OnboardingChoiceCard( - title: "Basic — dictation only", - subtitle: "Voice-to-text anywhere. The simplest, fastest setup.", - isSelected: model.onboardingMode == .basic - ) { model.selectMode(.basic) } - - OnboardingChoiceCard( - title: "Agentic — dictation + AI agent", - subtitle: "Unlocks the agent chat and MCP tool substrate, " - + "so your voice can drive an AI assistant, not just type.", - isSelected: model.onboardingMode == .agentic - ) { model.selectMode(.agentic) } - } - .padding(.top, 4) - - OnboardingStepNote( - text: "Agentic adds one more setup step (readiness check). Basic skips it.") - } + @ObservedObject var model: OnboardingViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + OnboardingStepHeader( + eyebrow: "Operating lane", + title: "Basic or Agentic.", + blurb: "Choose how codescribe works. You can switch lanes later in Settings.") + + VStack(spacing: 10) { + OnboardingChoiceCard( + title: "Basic — dictation only", + subtitle: "Voice-to-text anywhere. The simplest, fastest setup.", + isSelected: model.onboardingMode == .basic + ) { model.selectMode(.basic) } + + OnboardingChoiceCard( + title: "Agentic — dictation + AI agent", + subtitle: "Unlocks the agent chat and MCP tool substrate, " + + "so your voice can drive an AI assistant, not just type.", + isSelected: model.onboardingMode == .agentic + ) { model.selectMode(.agentic) } + } + .padding(.top, 4) + + OnboardingStepNote( + text: "Agentic adds one more setup step (readiness check). Basic skips it.") } + } } // MARK: - Language (dictation language) struct LanguageStepView: View { - @ObservedObject var model: OnboardingViewModel - - private let choices: [CsLanguage] = [.auto, .english, .polish] - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - OnboardingStepHeader( - eyebrow: "Language", - title: "Pick your dictation language.", - blurb: "Sets the transcription language. Auto-detect handles mixed or " - + "multilingual speech. Change it any time in Settings.") - - VStack(spacing: 10) { - ForEach(choices, id: \.self) { language in - OnboardingChoiceCard( - title: languageTitle(language), - subtitle: languageSubtitle(language), - isSelected: model.selectedLanguage == language - ) { model.selectLanguage(language) } - } - } - .padding(.top, 4) + @ObservedObject var model: OnboardingViewModel + + private let choices: [CsLanguage] = [.auto, .english, .polish] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + OnboardingStepHeader( + eyebrow: "Language", + title: "Pick your dictation language.", + blurb: "Sets the transcription language. Auto-detect handles mixed or " + + "multilingual speech. Change it any time in Settings.") + + VStack(spacing: 10) { + ForEach(choices, id: \.self) { language in + OnboardingChoiceCard( + title: languageTitle(language), + subtitle: languageSubtitle(language), + isSelected: model.selectedLanguage == language + ) { model.selectLanguage(language) } } + } + .padding(.top, 4) } + } - private func languageTitle(_ language: CsLanguage) -> String { - switch language { - case .auto: return "Auto-detect" - case .english: return "English" - case .polish: return "Polish" - } + private func languageTitle(_ language: CsLanguage) -> String { + switch language { + case .auto: return "Auto-detect" + case .english: return "English" + case .polish: return "Polish" } + } - private func languageSubtitle(_ language: CsLanguage) -> String? { - switch language { - case .auto: return "Multilingual — detects the language as you speak." - default: return nil - } + private func languageSubtitle(_ language: CsLanguage) -> String? { + switch language { + case .auto: return "Multilingual — detects the language as you speak." + default: return nil } + } } // MARK: - Hotkey mode (recording-trigger preset) struct HotkeyModeStepView: View { - @ObservedObject var model: OnboardingViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - OnboardingStepHeader( - eyebrow: "Hotkeys", - title: "How do you trigger recording?", - blurb: "Pick a starting preset. This sets the Dictation, Formatting, " - + "and Assistive shortcuts for you.") - - VStack(spacing: 10) { - ForEach(HotkeyModeChoice.allCases, id: \.self) { mode in - OnboardingChoiceCard( - title: mode.label, - subtitle: mode.summary, - isSelected: model.hotkeyMode == mode - ) { model.selectHotkeyMode(mode) } - } - } - .padding(.top, 4) - - OnboardingStepNote( - text: "Fine-tune the exact keys later in Settings › Shortcuts.") + @ObservedObject var model: OnboardingViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + OnboardingStepHeader( + eyebrow: "Hotkeys", + title: "How do you trigger recording?", + blurb: "Pick a starting preset. This sets the Dictation, Formatting, " + + "and Assistive shortcuts for you.") + + VStack(spacing: 10) { + ForEach(HotkeyModeChoice.allCases, id: \.self) { mode in + OnboardingChoiceCard( + title: mode.label, + subtitle: mode.summary, + isSelected: model.hotkeyMode == mode + ) { model.selectHotkeyMode(mode) } } + } + .padding(.top, 4) + + OnboardingStepNote( + text: "Fine-tune the exact keys later in Settings › Shortcuts.") } + } } // MARK: - Agentic readiness (agentic lane only — informational) struct AgenticReadinessStepView: View { - @ObservedObject var model: OnboardingViewModel - // macOS 14+ action to open the app's Settings scene. This accessory / - // LSUIElement app has no responder for the private `showSettingsWindow:` - // selector, so the SwiftUI environment action is the only reliable open path - // (matching TrayMenuView / AgentChatView). The Settings scene activates the - // app and orders its window front, above the wizard. - @Environment(\.openSettings) private var openSettings - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - OnboardingStepHeader( - eyebrow: "Agentic readiness", - title: "Your agentic substrate.", - blurb: "A read-only check of what the agent lane needs: an AI " - + "provider + key, native tools, and any MCP servers you've wired.") - Spacer(minLength: 0) - } - - if let readiness = model.readiness { - readinessPill(ready: readiness.ready) - statusCard(rows: readiness.rows) - } - - if let mcp = model.mcpStatus, mcp.configured { - Text("MCP servers") - .font(CSFont.mono(10, .semibold)) - .tracking(0.4) - .foregroundStyle(CSColor.textFaint) - .padding(.top, 4) - statusCard(rows: mcp.rows) - } else if !model.mcpSetupDismissed { - mcpSetupPrompt - .padding(.top, 4) - } - - OnboardingButton(title: "Refresh", kind: .secondary) { - model.refreshReadiness() - } - .padding(.top, 2) - - OnboardingStepNote( - text: "Informational — press Continue whether or not everything is green.") - } + @ObservedObject var model: OnboardingViewModel + // macOS 14+ action to open the app's Settings scene. This accessory / + // LSUIElement app has no responder for the private `showSettingsWindow:` + // selector, so the SwiftUI environment action is the only reliable open path + // (matching TrayMenuView / AgentChatView). The Settings scene activates the + // app and orders its window front, above the wizard. + @Environment(\.openSettings) private var openSettings + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + OnboardingStepHeader( + eyebrow: "Agentic readiness", + title: "Your agentic substrate.", + blurb: "A read-only check of what the agent lane needs: an AI " + + "provider + key, native tools, and any MCP servers you've wired.") + Spacer(minLength: 0) + } + + if let readiness = model.readiness { + readinessPill(ready: readiness.ready) + statusCard(rows: readiness.rows) + } + + if let mcp = model.mcpStatus, mcp.configured { + Text("MCP servers") + .font(CSFont.mono(10, .semibold)) + .tracking(0.4) + .foregroundStyle(CSColor.textFaint) + .padding(.top, 4) + statusCard(rows: mcp.rows) + } else if !model.mcpSetupDismissed { + mcpSetupPrompt + .padding(.top, 4) + } + + OnboardingButton(title: "Refresh", kind: .secondary) { + model.refreshReadiness() + } + .padding(.top, 2) + + OnboardingStepNote( + text: "Informational — press Continue whether or not everything is green.") } - - /// Shown on the readiness step when no MCP server is configured yet: a short, - /// human explainer plus a route into the real setup surface and a no-guilt skip. - /// Replaces the old dead end where a missing `mcp.json` showed nothing at all. - private var mcpSetupPrompt: some View { - VStack(alignment: .leading, spacing: 10) { - Text("MCP servers (optional)") - .font(CSFont.mono(10, .semibold)) - .tracking(0.4) - .foregroundStyle(CSColor.textFaint) - Text("MCP servers give the agent extra tools — things like code search, " - + "PR review, or web search. It's entirely optional: skip it now and " - + "wire servers any time from Settings › Engine.") - .font(CSFont.ui(13)) - .lineSpacing(3) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 10) { - OnboardingButton(title: "Set up MCP servers", kind: .primary) { - model.prepareMcpSettingsDeepLink() - openSettings() - } - OnboardingButton(title: "Skip for now", kind: .secondary) { - model.dismissMcpSetupPrompt() - } - } + } + + /// Shown on the readiness step when no MCP server is configured yet: a short, + /// human explainer plus a route into the real setup surface and a no-guilt skip. + /// Replaces the old dead end where a missing `mcp.json` showed nothing at all. + private var mcpSetupPrompt: some View { + VStack(alignment: .leading, spacing: 10) { + Text("MCP servers (optional)") + .font(CSFont.mono(10, .semibold)) + .tracking(0.4) + .foregroundStyle(CSColor.textFaint) + Text( + "MCP servers give the agent extra tools — things like code search, " + + "PR review, or web search. It's entirely optional: skip it now and " + + "wire servers any time from Settings › Engine." + ) + .font(CSFont.ui(13)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + OnboardingButton(title: "Set up MCP servers", kind: .primary) { + model.prepareMcpSettingsDeepLink() + openSettings() } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(CSColor.surfaceRaised(0.02))) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1)) - } - - private func readinessPill(ready: Bool) -> some View { - let accent = ready ? CSColor.olive : CSColor.terracotta - let accentLight = ready ? CSColor.oliveLight : CSColor.terracottaLight - return Text(ready ? "READY" : "NOT READY") - .font(CSFont.mono(9, .semibold)) - .tracking(0.4) - .foregroundStyle(accentLight) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(accent.opacity(0.12))) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(accent.opacity(0.24), lineWidth: 1)) + OnboardingButton(title: "Skip for now", kind: .secondary) { + model.dismissMcpSetupPrompt() + } + } } - - @ViewBuilder - private func statusCard(rows: [CsMcpStatusRow]) -> some View { - VStack(spacing: 0) { - ForEach(Array(rows.enumerated()), id: \.offset) { index, row in - if index > 0 { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) - } - HStack(spacing: 12) { - Text(row.label) - .font(CSFont.mono(11.5, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .frame(width: 150, alignment: .leading) - Text(row.value) - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(CSColor.textHigh) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - Circle().fill(row.tone.dotColor).frame(width: 7, height: 7) - } - .padding(.horizontal, 14) - .padding(.vertical, 11) - } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CSColor.surfaceRaised(0.02)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1)) + } + + private func readinessPill(ready: Bool) -> some View { + let accent = ready ? CSColor.olive : CSColor.terracotta + let accentLight = ready ? CSColor.oliveLight : CSColor.terracottaLight + return Text(ready ? "READY" : "NOT READY") + .font(CSFont.mono(9, .semibold)) + .tracking(0.4) + .foregroundStyle(accentLight) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(accent.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(accent.opacity(0.24), lineWidth: 1)) + } + + @ViewBuilder + private func statusCard(rows: [CsMcpStatusRow]) -> some View { + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + if index > 0 { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + } + HStack(spacing: 12) { + Text(row.label) + .font(CSFont.mono(11.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .frame(width: 150, alignment: .leading) + Text(row.value) + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.textHigh) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Circle().fill(row.tone.dotColor).frame(width: 7, height: 7) } - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1)) + .padding(.horizontal, 14) + .padding(.vertical, 11) + } } + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1)) + } } // MARK: - Permission (mic → … → speech → full-disk) struct PermissionStepView: View { - let kind: PermissionKind - @ObservedObject var model: OnboardingViewModel + let kind: PermissionKind + @ObservedObject var model: OnboardingViewModel - private var state: PermissionState { model.permissions.state(kind) } + private var state: PermissionState { model.permissions.state(kind) } - /// Primary CTA mirrors Settings matrix: in-app request while undetermined - /// (when the scope supports it), System Settings deep-link once determined. - private var primaryTitle: String { - if state.isGranted { return "Granted" } - if state == .notDetermined, kind.supportsInAppPermissionRequest { - return "Allow \(kind.rawValue)" - } - return "Open System Settings" + /// Primary CTA mirrors Settings matrix: in-app request while undetermined + /// (when the scope supports it), System Settings deep-link once determined. + private var primaryTitle: String { + if state.isGranted { return "Granted" } + if state == .notDetermined, kind.supportsInAppPermissionRequest { + return "Allow \(kind.rawValue)" } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - EyebrowLabel(text: "Permission · \(kind.rawValue)") - Text(kind.onboardingTitle) - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .fixedSize(horizontal: false, vertical: true) - Text(kind.onboardingReason) - .font(CSFont.ui(14)) - .lineSpacing(3) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - - statusRow - .padding(.top, 4) - - HStack(spacing: 10) { - OnboardingButton(title: primaryTitle, kind: .primary) { - guard !state.isGranted else { return } - model.grantPermission(for: kind) - } - OnboardingButton(title: "Refresh status", kind: .secondary) { - model.refreshPermissions() - } - } - .padding(.top, 4) - - if kind == .fullDiskAccess { - Text("Optional — skip it to limit file-aware features only.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - } else if kind == .speechRecognition { - Text("Required for Apple live dictation. Without it Codescribe cannot run on-device Speech.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - } else { - Text("You can continue without granting this, but the matching feature stays off until you do.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - } + return "Open System Settings" + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + EyebrowLabel(text: "Permission · \(kind.rawValue)") + Text(kind.onboardingTitle) + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .fixedSize(horizontal: false, vertical: true) + Text(kind.onboardingReason) + .font(CSFont.ui(14)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + + statusRow + .padding(.top, 4) + + HStack(spacing: 10) { + OnboardingButton(title: primaryTitle, kind: .primary) { + guard !state.isGranted else { return } + model.grantPermission(for: kind) } - } - - private var statusRow: some View { - HStack(spacing: 10) { - Circle().fill(statusColor.opacity(0.9)).frame(width: 8, height: 8) - Text(state.label) - .font(CSFont.mono(12, .semibold)) - .foregroundStyle(statusColor) + OnboardingButton(title: "Refresh status", kind: .secondary) { + model.refreshPermissions() } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) + } + .padding(.top, 4) + + if kind == .fullDiskAccess { + Text("Optional — skip it to limit file-aware features only.") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + } else if kind == .speechRecognition { + Text( + "Required for Apple live dictation. Without it Codescribe cannot run on-device Speech." ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + } else { + Text( + "You can continue without granting this, but the matching feature stays off until you do." ) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + } } - - private var statusColor: Color { - switch state { - case .granted: return CSColor.oliveLight - case .denied: return CSColor.terracottaLight - case .notDetermined: return CSColor.textMutedAlt - } + } + + private var statusRow: some View { + HStack(spacing: 10) { + Circle().fill(statusColor.opacity(0.9)).frame(width: 8, height: 8) + Text(state.label) + .font(CSFont.mono(12, .semibold)) + .foregroundStyle(statusColor) } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + + private var statusColor: Color { + switch state { + case .granted: return CSColor.oliveLight + case .denied: return CSColor.terracottaLight + case .notDetermined: return CSColor.textMutedAlt + } + } } // MARK: - API key struct ApiKeyStepView: View { - @ObservedObject var model: OnboardingViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - EyebrowLabel(text: "AI provider") - Text("Connect an AI provider.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - Text( - "Powers AI formatting and the agent lane. Stored in the macOS " - + "Keychain — write-only, never shown back. Optional: skip and add " - + "it later in Settings › Keys." - ) - .font(CSFont.ui(14)) - .lineSpacing(3) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - - providerPicker - .padding(.top, 4) - - keyField - } + @ObservedObject var model: OnboardingViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + EyebrowLabel(text: "AI provider") + Text("Connect an AI provider.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + Text( + "Powers AI formatting and the agent lane. Stored in the macOS " + + "Keychain — write-only, never shown back. Optional: skip and add " + + "it later in Settings › Keys." + ) + .font(CSFont.ui(14)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + + providerPicker + .padding(.top, 4) + + keyField } - - private var providerPicker: some View { - HStack(spacing: 12) { - Text("Provider") - .font(CSFont.mono(12, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .frame(width: 72, alignment: .leading) - Menu { - ForEach(model.providers, id: \.id) { provider in - Button { - model.selectProvider(provider.id) - } label: { - if provider.id == model.selectedProviderId { - Label(provider.displayName, systemImage: "checkmark") - } else { - Text(provider.displayName) - } - } - } - } label: { - Text(model.selectedProvider?.displayName ?? model.selectedProviderId) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textHigh) + } + + private var providerPicker: some View { + HStack(spacing: 12) { + Text("Provider") + .font(CSFont.mono(12, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .frame(width: 72, alignment: .leading) + Menu { + ForEach(model.providers, id: \.id) { provider in + Button { + model.selectProvider(provider.id) + } label: { + if provider.id == model.selectedProviderId { + Label(provider.displayName, systemImage: "checkmark") + } else { + Text(provider.displayName) } - .menuStyle(.borderlessButton) - Spacer(minLength: 0) + } } - .padding(.horizontal, 15) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) + } label: { + Text(model.selectedProvider?.displayName ?? model.selectedProviderId) + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + } + .menuStyle(.borderlessButton) + Spacer(minLength: 0) } - - private var keyField: some View { - let account = model.selectedProvider?.apiKeyAccount ?? "LLM_ASSISTIVE_API_KEY" - let isSet = model.selectedProviderKeySet - return VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Circle() - .fill((isSet ? CSColor.olive : CSColor.terracotta).opacity(0.85)) - .frame(width: 7, height: 7) - Text(SettingsViewModel.keyLabel(for: account)) - .font(CSFont.ui(13.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text(account) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - Spacer(minLength: 0) - Text(isSet ? "set" : "not set") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(isSet ? CSColor.oliveLight : CSColor.terracottaLight) - } - HStack(spacing: 8) { - SecureField(isSet ? "Replace key…" : "Paste key…", text: $model.apiKeyDraft) - .textFieldStyle(.plain) - .font(CSFont.mono(12)) - .foregroundStyle(CSColor.textBody) - .padding(.horizontal, 11) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - .onSubmit { model.saveApiKey() } - OnboardingButton(title: "Save key", kind: .primary) { model.saveApiKey() } - } - } - .padding(.horizontal, 15) - .padding(.vertical, 13) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill((isSet ? CSColor.olive : CSColor.terracotta).opacity(0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder((isSet ? CSColor.olive : CSColor.terracotta).opacity(0.18), lineWidth: 1) - ) + .padding(.horizontal, 15) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + + private var keyField: some View { + let account = model.selectedProvider?.apiKeyAccount ?? "LLM_ASSISTIVE_API_KEY" + let isSet = model.selectedProviderKeySet + return VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Circle() + .fill((isSet ? CSColor.olive : CSColor.terracotta).opacity(0.85)) + .frame(width: 7, height: 7) + Text(SettingsViewModel.keyLabel(for: account)) + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text(account) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + Spacer(minLength: 0) + Text(isSet ? "set" : "not set") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(isSet ? CSColor.oliveLight : CSColor.terracottaLight) + } + HStack(spacing: 8) { + SecureField(isSet ? "Replace key…" : "Paste key…", text: $model.apiKeyDraft) + .textFieldStyle(.plain) + .font(CSFont.mono(12)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .onSubmit { model.saveApiKey() } + OnboardingButton(title: "Save key", kind: .primary) { model.saveApiKey() } + } } + .padding(.horizontal, 15) + .padding(.vertical, 13) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill((isSet ? CSColor.olive : CSColor.terracotta).opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder((isSet ? CSColor.olive : CSColor.terracotta).opacity(0.18), lineWidth: 1) + ) + } } // MARK: - Done struct DoneStepView: View { - @ObservedObject var model: OnboardingViewModel - - private let summaryOrder: [PermissionKind] = [ - .microphone, .accessibility, .inputMonitoring, .screenRecording, - .speechRecognition, .fullDiskAccess, - ] - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - EyebrowLabel(text: "All set") - Text("You're ready to talk.") - .font(CSFont.ui(28, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - Text("Press Finish to close setup and start using Codescribe. Anything you skipped is available in Settings.") - .font(CSFont.ui(14)) - .lineSpacing(3) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - - VStack(alignment: .leading, spacing: 8) { - ForEach(summaryOrder) { kind in - summaryRow(kind.rawValue, granted: model.permissions.state(kind).isGranted) - } - summaryRow("AI provider key", granted: model.selectedProviderKeySet) - } - .padding(.top, 6) + @ObservedObject var model: OnboardingViewModel + + private let summaryOrder: [PermissionKind] = [ + .microphone, .accessibility, .inputMonitoring, .screenRecording, + .speechRecognition, .fullDiskAccess, + ] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + EyebrowLabel(text: "All set") + Text("You're ready to talk.") + .font(CSFont.ui(28, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + Text( + "Press Finish to close setup and start using Codescribe. Anything you skipped is available in Settings." + ) + .font(CSFont.ui(14)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 8) { + ForEach(summaryOrder) { kind in + summaryRow(kind.rawValue, granted: model.permissions.state(kind).isGranted) } + summaryRow("AI provider key", granted: model.selectedProviderKeySet) + } + .padding(.top, 6) } - - private func summaryRow(_ label: String, granted: Bool) -> some View { - HStack(spacing: 10) { - CSIconView( - icon: granted ? .checkCircleFill : .circleEmpty, - size: 12, - weight: .semibold, - color: granted ? CSColor.oliveLight : CSColor.textFaint - ) - Text(label) - .font(CSFont.ui(13)) - .foregroundStyle(CSColor.textBody) - Spacer(minLength: 0) - Text(granted ? "granted" : "optional") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(granted ? CSColor.oliveLight : CSColor.textFaint) - } + } + + private func summaryRow(_ label: String, granted: Bool) -> some View { + HStack(spacing: 10) { + CSIconView( + icon: granted ? .checkCircleFill : .circleEmpty, + size: 12, + weight: .semibold, + color: granted ? CSColor.oliveLight : CSColor.textFaint + ) + Text(label) + .font(CSFont.ui(13)) + .foregroundStyle(CSColor.textBody) + Spacer(minLength: 0) + Text(granted ? "granted" : "optional") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(granted ? CSColor.oliveLight : CSColor.textFaint) } + } } // MARK: - Permission onboarding copy (ported from app/ui/onboarding/steps.rs) extension PermissionKind { - /// Wizard heading, mirroring the excised AppKit `PermissionKind::title`. - var onboardingTitle: String { - switch self { - case .microphone: return "Microphone Access" - case .accessibility: return "Accessibility Access" - case .inputMonitoring: return "Input Monitoring Access" - case .screenRecording: return "Screen Recording Access" - case .speechRecognition: return "Speech Recognition Access" - case .fullDiskAccess: return "Full Disk Access" - } + /// Wizard heading, mirroring the excised AppKit `PermissionKind::title`. + var onboardingTitle: String { + switch self { + case .microphone: return "Microphone Access" + case .accessibility: return "Accessibility Access" + case .inputMonitoring: return "Input Monitoring Access" + case .screenRecording: return "Screen Recording Access" + case .speechRecognition: return "Speech Recognition Access" + case .fullDiskAccess: return "Full Disk Access" } - - /// Why codescribe needs the scope, mirroring `PermissionKind::reason`. - var onboardingReason: String { - switch self { - case .microphone: - return "Transcribe your voice into text. Audio is processed locally on your Mac." - case .accessibility: - return "Type transcribed text into any application and control text insertion." - case .inputMonitoring: - return "Detect keyboard shortcuts to start and stop voice recording." - case .screenRecording: - return "Capture screen context to give the AI assistant visual awareness of what you're working on." - case .speechRecognition: - return "Power Apple live dictation on-device. Speech never leaves your Mac." - case .fullDiskAccess: - return "Read project files for AI context. Optional — limits file-aware features if skipped." - } + } + + /// Why codescribe needs the scope, mirroring `PermissionKind::reason`. + var onboardingReason: String { + switch self { + case .microphone: + return "Transcribe your voice into text. Audio is processed locally on your Mac." + case .accessibility: + return "Type transcribed text into any application and control text insertion." + case .inputMonitoring: + return "Detect keyboard shortcuts to start and stop voice recording." + case .screenRecording: + return + "Capture screen context to give the AI assistant visual awareness of what you're working on." + case .speechRecognition: + return "Power Apple live dictation on-device. Speech never leaves your Mac." + case .fullDiskAccess: + return "Read project files for AI context. Optional — limits file-aware features if skipped." } + } } diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingView.swift b/macos/Codescribe/Screens/Onboarding/OnboardingView.swift index 744c461f..9cc8ba2f 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingView.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingView.swift @@ -6,224 +6,239 @@ import SwiftUI // OnboardingSteps.swift. struct OnboardingView: View { - @ObservedObject var model: OnboardingViewModel - - var body: some View { - VStack(spacing: 0) { - header - Divider().overlay(CSColor.hairline(0.08)) - ScrollView { - stepBody - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 32) - .padding(.vertical, 26) - } - Divider().overlay(CSColor.hairline(0.08)) - footer - } - .frame(minWidth: 680, minHeight: 560) - .background(SettingsView.windowGradient.ignoresSafeArea()) - .onAppear { model.refreshForCurrentStep() } + @ObservedObject var model: OnboardingViewModel + + var body: some View { + VStack(spacing: 0) { + header + Divider().overlay(CSColor.hairline(0.08)) + ScrollView { + stepBody + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + .padding(.vertical, 26) + } + Divider().overlay(CSColor.hairline(0.08)) + footer } - - // MARK: - Header (brand + progress) - - private var header: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - EyebrowLabel(text: "codescribe · setup") - Spacer() - Text(model.progressLabel) - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - } - OnboardingProgressBar(current: model.stepIndex, total: model.totalSteps) - } - .padding(.horizontal, 32) - .padding(.top, 22) - .padding(.bottom, 16) + .frame(minWidth: 680, minHeight: 560) + .background(SettingsView.windowGradient.ignoresSafeArea()) + .onAppear { model.refreshForCurrentStep() } + } + + // MARK: - Header (brand + progress) + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + EyebrowLabel(text: "codescribe · setup") + Spacer() + Text(model.progressLabel) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + } + OnboardingProgressBar(current: model.stepIndex, total: model.totalSteps) } - - // MARK: - Step dispatch - - @ViewBuilder private var stepBody: some View { - switch model.step { - case .welcome: - WelcomeStepView() - case .mode: - ModeStepView(model: model) - case .permission(let kind): - PermissionStepView(kind: kind, model: model) - case .language: - LanguageStepView(model: model) - case .apiKey: - ApiKeyStepView(model: model) - case .hotkeyMode: - HotkeyModeStepView(model: model) - case .agenticReadiness: - AgenticReadinessStepView(model: model) - case .done: - DoneStepView(model: model) - } + .padding(.horizontal, 32) + .padding(.top, 22) + .padding(.bottom, 16) + } + + // MARK: - Step dispatch + + @ViewBuilder private var stepBody: some View { + switch model.step { + case .welcome: + WelcomeStepView() + case .mode: + ModeStepView(model: model) + case .permission(let kind): + PermissionStepView(kind: kind, model: model) + case .language: + LanguageStepView(model: model) + case .apiKey: + ApiKeyStepView(model: model) + case .hotkeyMode: + HotkeyModeStepView(model: model) + case .agenticReadiness: + AgenticReadinessStepView(model: model) + case .done: + DoneStepView(model: model) } - - // MARK: - Footer (navigation) - - private var footer: some View { - HStack(spacing: 10) { - if model.canGoBack { - OnboardingButton(title: "Back", kind: .secondary) { model.back() } - } - Spacer(minLength: 0) - // The API-key step is skippable — a key can be added later in Settings. - if case .apiKey = model.step { - OnboardingButton(title: "Skip", kind: .secondary) { model.advance() } - } - OnboardingButton(title: model.primaryLabel, kind: .primary) { - model.primaryAction() - } - } - .padding(.horizontal, 32) - .padding(.vertical, 16) + } + + // MARK: - Footer (navigation) + + private var footer: some View { + HStack(spacing: 10) { + if model.canGoBack { + OnboardingButton(title: "Back", kind: .secondary) { model.back() } + } + Spacer(minLength: 0) + // The API-key step is skippable — a key can be added later in Settings. + if case .apiKey = model.step { + OnboardingButton(title: "Skip", kind: .secondary) { model.advance() } + } + OnboardingButton(title: model.primaryLabel, kind: .primary) { + model.primaryAction() + } } + .padding(.horizontal, 32) + .padding(.vertical, 16) + } } // MARK: - Progress bar struct OnboardingProgressBar: View { - let current: Int - let total: Int - - private var fraction: CGFloat { - guard total > 1 else { return 1 } - return CGFloat(current) / CGFloat(total - 1) - } - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(CSColor.surfaceRaised(0.05)) - Capsule() - .fill(CSColor.chromeAccent.opacity(0.85)) - .frame(width: max(6, geo.size.width * fraction)) - } - } - .frame(height: 4) + let current: Int + let total: Int + + private var fraction: CGFloat { + guard total > 1 else { return 1 } + return CGFloat(current) / CGFloat(total - 1) + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(CSColor.surfaceRaised(0.05)) + Capsule() + .fill(CSColor.chromeAccent.opacity(0.85)) + .frame(width: max(6, geo.size.width * fraction)) + } } + .frame(height: 4) + } } // MARK: - Buttons /// Wizard navigation button, matching the Keys panel's accent-on-surface style. struct OnboardingButton: View { - enum Kind { case primary, secondary } - let title: String - var kind: Kind = .primary - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(CSFont.ui(13, .semibold)) - .foregroundStyle(foreground) - .padding(.horizontal, 18) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(fill) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(border, lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: CSRadius.input) - } - - private var foreground: Color { - kind == .primary ? CSColor.chromeAccent : CSColor.textMutedAlt - } - private var fill: Color { - kind == .primary ? CSColor.chromeAccent.opacity(0.16) : CSColor.surfaceRaised(0.03) - } - private var border: Color { - kind == .primary ? CSColor.chromeAccent.opacity(0.30) : CSColor.hairline(0.08) + enum Kind { case primary, secondary } + let title: String + var kind: Kind = .primary + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(CSFont.ui(13, .semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 18) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(fill) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(border, lineWidth: 1) + ) } + .csFocusRing(cornerRadius: CSRadius.input) + } + + private var foreground: Color { + kind == .primary ? CSColor.chromeAccent : CSColor.textMutedAlt + } + private var fill: Color { + kind == .primary ? CSColor.chromeAccent.opacity(0.16) : CSColor.surfaceRaised(0.03) + } + private var border: Color { + kind == .primary ? CSColor.chromeAccent.opacity(0.30) : CSColor.hairline(0.08) + } } #if DEBUG -#Preview("Onboarding — Welcome") { - OnboardingView(model: OnboardingViewModel( + #Preview("Onboarding — Welcome") { + OnboardingView( + model: OnboardingViewModel( engine: MockOnboardingEngine(progress: 0), hotkeys: MockHotkeysEngine(), agentStatus: MockAgentStatusEngine(), - probe: MockPermissionProbe(.allGranted))) - .frame(width: 720, height: 620) - .preferredColorScheme(.dark) -} - -#Preview("Onboarding — Mode") { - OnboardingView(model: OnboardingViewModel( + probe: MockPermissionProbe(.allGranted)) + ) + .frame(width: 720, height: 620) + .preferredColorScheme(.dark) + } + + #Preview("Onboarding — Mode") { + OnboardingView( + model: OnboardingViewModel( engine: MockOnboardingEngine(progress: 1), hotkeys: MockHotkeysEngine(), agentStatus: MockAgentStatusEngine(), - probe: MockPermissionProbe(.allGranted))) - .frame(width: 720, height: 620) - .preferredColorScheme(.dark) -} - -#Preview("Onboarding — Permission") { - OnboardingView(model: OnboardingViewModel( + probe: MockPermissionProbe(.allGranted)) + ) + .frame(width: 720, height: 620) + .preferredColorScheme(.dark) + } + + #Preview("Onboarding — Permission") { + OnboardingView( + model: OnboardingViewModel( engine: MockOnboardingEngine(progress: 2), hotkeys: MockHotkeysEngine(), agentStatus: MockAgentStatusEngine(), - probe: MockPermissionProbe(PermissionSnapshot( + probe: MockPermissionProbe( + PermissionSnapshot( microphone: .denied, accessibility: .granted, inputMonitoring: .notDetermined, screenRecording: .denied, - fullDiskAccess: .notDetermined)))) - .frame(width: 720, height: 620) - .preferredColorScheme(.dark) -} - -#Preview("Onboarding — Language") { - OnboardingView(model: OnboardingViewModel( + fullDiskAccess: .notDetermined))) + ) + .frame(width: 720, height: 620) + .preferredColorScheme(.dark) + } + + #Preview("Onboarding — Language") { + OnboardingView( + model: OnboardingViewModel( engine: MockOnboardingEngine(progress: 8), hotkeys: MockHotkeysEngine(), agentStatus: MockAgentStatusEngine(), - probe: MockPermissionProbe(.allGranted))) - .frame(width: 720, height: 620) - .preferredColorScheme(.dark) -} - -#Preview("Onboarding — API key") { - OnboardingView(model: OnboardingViewModel( + probe: MockPermissionProbe(.allGranted)) + ) + .frame(width: 720, height: 620) + .preferredColorScheme(.dark) + } + + #Preview("Onboarding — API key") { + OnboardingView( + model: OnboardingViewModel( engine: MockOnboardingEngine(progress: 9), hotkeys: MockHotkeysEngine(), agentStatus: MockAgentStatusEngine(), - probe: MockPermissionProbe(.allGranted))) - .frame(width: 720, height: 620) - .preferredColorScheme(.dark) -} - -#Preview("Onboarding — Hotkeys") { - OnboardingView(model: OnboardingViewModel( + probe: MockPermissionProbe(.allGranted)) + ) + .frame(width: 720, height: 620) + .preferredColorScheme(.dark) + } + + #Preview("Onboarding — Hotkeys") { + OnboardingView( + model: OnboardingViewModel( engine: MockOnboardingEngine(progress: 10), hotkeys: MockHotkeysEngine(), agentStatus: MockAgentStatusEngine(), - probe: MockPermissionProbe(.allGranted))) - .frame(width: 720, height: 620) - .preferredColorScheme(.dark) -} + probe: MockPermissionProbe(.allGranted)) + ) + .frame(width: 720, height: 620) + .preferredColorScheme(.dark) + } -#Preview("Onboarding — Agentic readiness") { + #Preview("Onboarding — Agentic readiness") { let engine = MockOnboardingEngine(progress: 11) engine.mode = "agentic" - return OnboardingView(model: OnboardingViewModel( + return OnboardingView( + model: OnboardingViewModel( engine: engine, hotkeys: MockHotkeysEngine(), agentStatus: MockAgentStatusEngine(), - probe: MockPermissionProbe(.allGranted))) - .frame(width: 720, height: 620) - .preferredColorScheme(.dark) -} + probe: MockPermissionProbe(.allGranted)) + ) + .frame(width: 720, height: 620) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift index 2306641b..b0e289b8 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift @@ -17,428 +17,429 @@ import SwiftUI /// Readiness step). `basic` is the safe default — a corrupt/forward token can /// never force the agentic lane. Mirrors `OnboardingModeChoice` in state.rs. enum OnboardingModeChoice: String, CaseIterable { - case basic - case agentic + case basic + case agentic - /// Stable token persisted to settings.json (`ONBOARDING_MODE`). - var value: String { rawValue } + /// Stable token persisted to settings.json (`ONBOARDING_MODE`). + var value: String { rawValue } - var label: String { - switch self { - case .basic: return "Basic" - case .agentic: return "Agentic" - } + var label: String { + switch self { + case .basic: return "Basic" + case .agentic: return "Agentic" } + } - /// Decode a persisted token, defaulting to `basic` for unknown values. - static func from(_ value: String?) -> OnboardingModeChoice { - value == "agentic" ? .agentic : .basic - } + /// Decode a persisted token, defaulting to `basic` for unknown values. + static func from(_ value: String?) -> OnboardingModeChoice { + value == "agentic" ? .agentic : .basic + } } /// Recording-trigger preset. Maps onto the three core mode bindings (Dictation / /// Formatting / Assistive) — the exact same triples the AppKit wizard wrote via /// `save_hotkey_mode`. Full per-mode editing stays in Settings › Shortcuts. enum HotkeyModeChoice: String, CaseIterable { - case hold - case toggle - case both - - var label: String { - switch self { - case .hold: return "Hold to talk" - case .toggle: return "Hands-off (toggle)" - case .both: return "Hybrid (both)" - } - } - - var summary: String { - switch self { - case .hold: return "Press and hold Fn/Globe while you speak; release to stop." - case .toggle: return "Double-tap Left/Right Option to start, tap again to stop." - case .both: return "Hold Fn/Globe to dictate, or double-tap Option to toggle." - } - } - - /// Dictation / Formatting / Assistive bindings for this preset, mirroring - /// `actions::save_hotkey_mode` in the excised wizard. - var bindings: (CsShortcutBinding, CsShortcutBinding, CsShortcutBinding) { - switch self { - case .hold: return (.holdFn, .disabled, .disabled) - case .toggle: return (.disabled, .doubleLeftOption, .doubleRightOption) - case .both: return (.holdFn, .doubleLeftOption, .doubleRightOption) - } - } - - /// Derive the closest preset from live bindings, mirroring - /// `initial_hotkey_choice`. Defaults to `both` when nothing hold-like or - /// toggle-like is bound. - static func derive(from bindings: [CsModeBinding]) -> HotkeyModeChoice { - func binding(_ mode: CsWorkMode) -> CsShortcutBinding? { - bindings.first { $0.mode == mode }?.binding - } - let dictation = binding(.dictation) - let holdEnabled: Bool - switch dictation { - case .holdFn, .holdCtrl, .holdCtrlAlt, .holdCtrlShift, .holdCtrlCmd: - holdEnabled = true - default: - holdEnabled = false - } - let toggleEnabled = dictation == .doubleCtrl - || binding(.formatting) == .doubleLeftOption - || binding(.assistive) == .doubleRightOption - switch (holdEnabled, toggleEnabled) { - case (true, false): return .hold - case (false, true): return .toggle - default: return .both - } - } + case hold + case toggle + case both + + var label: String { + switch self { + case .hold: return "Hold to talk" + case .toggle: return "Hands-off (toggle)" + case .both: return "Hybrid (both)" + } + } + + var summary: String { + switch self { + case .hold: return "Press and hold Fn/Globe while you speak; release to stop." + case .toggle: return "Double-tap Left/Right Option to start, tap again to stop." + case .both: return "Hold Fn/Globe to dictate, or double-tap Option to toggle." + } + } + + /// Dictation / Formatting / Assistive bindings for this preset, mirroring + /// `actions::save_hotkey_mode` in the excised wizard. + var bindings: (CsShortcutBinding, CsShortcutBinding, CsShortcutBinding) { + switch self { + case .hold: return (.holdFn, .disabled, .disabled) + case .toggle: return (.disabled, .doubleLeftOption, .doubleRightOption) + case .both: return (.holdFn, .doubleLeftOption, .doubleRightOption) + } + } + + /// Derive the closest preset from live bindings, mirroring + /// `initial_hotkey_choice`. Defaults to `both` when nothing hold-like or + /// toggle-like is bound. + static func derive(from bindings: [CsModeBinding]) -> HotkeyModeChoice { + func binding(_ mode: CsWorkMode) -> CsShortcutBinding? { + bindings.first { $0.mode == mode }?.binding + } + let dictation = binding(.dictation) + let holdEnabled: Bool + switch dictation { + case .holdFn, .holdCtrl, .holdCtrlAlt, .holdCtrlShift, .holdCtrlCmd: + holdEnabled = true + default: + holdEnabled = false + } + let toggleEnabled = + dictation == .doubleCtrl + || binding(.formatting) == .doubleLeftOption + || binding(.assistive) == .doubleRightOption + switch (holdEnabled, toggleEnabled) { + case (true, false): return .hold + case (false, true): return .toggle + default: return .both + } + } } @MainActor final class OnboardingViewModel: ObservableObject { - @Published private(set) var stepIndex: Int - @Published private(set) var permissions: PermissionSnapshot - @Published private(set) var keyStatus: CsKeyStatus - - // Mode step state. - @Published private(set) var onboardingMode: OnboardingModeChoice - - // Language step state. - @Published private(set) var selectedLanguage: CsLanguage - - // Hotkey-mode step state. - @Published private(set) var hotkeyMode: HotkeyModeChoice - - // Agentic-readiness step state (lazy — probed when the step appears). - @Published private(set) var readiness: CsAgenticReadiness? - @Published private(set) var mcpStatus: CsMcpStatusReport? - - /// Whether the user dismissed the "set up MCP" prompt shown when no MCP server - /// is configured. Session-only: skipping keeps the readiness step moving without - /// implying an error, and re-opening the wizard offers the prompt again. - @Published private(set) var mcpSetupDismissed = false - - // API-key step state. - @Published private(set) var providers: [CsProviderOption] = [] - @Published var selectedProviderId: String - @Published var apiKeyDraft: String = "" - - @Published var lastError: String? - - private let engine: OnboardingEngine - private let hotkeys: HotkeysEngine - private let agentStatus: AgentStatusEngine - private let probe: PermissionProbing - - /// Invoked when the wizard is finished (Done confirmed) so the host can close - /// and release the window. - var onFinished: (() -> Void)? - - init( - engine: OnboardingEngine, - hotkeys: HotkeysEngine = RealHotkeysEngine(), - agentStatus: AgentStatusEngine = RealAgentStatusEngine(), - probe: PermissionProbing = NativePermissionProbe() - ) { - self.engine = engine - self.hotkeys = hotkeys - self.agentStatus = agentStatus - self.probe = probe - // Resume from the persisted step; `onboardingProgress` is already clamped - // to a valid index by the Rust side. - self.stepIndex = Int(engine.onboardingProgress()) - self.permissions = probe.snapshot() - self.keyStatus = engine.keyStatus() - self.onboardingMode = OnboardingModeChoice.from(engine.onboardingMode()) - self.selectedLanguage = engine.currentLanguage() - self.hotkeyMode = HotkeyModeChoice.derive(from: hotkeys.modeBindings()) - self.selectedProviderId = - engine.assistiveProvider() - ?? engine.availableProviders().first?.id - ?? "openai-responses" - } - - // MARK: - Derived - - var step: OnboardingStep { OnboardingStep.step(at: stepIndex) } - var totalSteps: Int { OnboardingStep.count } - var canGoBack: Bool { stepIndex > 0 } - - /// Human-readable "Step N of M" — permission steps are still counted by their - /// absolute flow index so the bar never jumps. - var progressLabel: String { "Step \(stepIndex + 1) of \(totalSteps)" } - - var isDone: Bool { step == .done } - - /// Primary-button label: "Finish" on Done, "Continue" everywhere else. - var primaryLabel: String { isDone ? "Finish" : "Continue" } - - var selectedProvider: CsProviderOption? { - providers.first { $0.id == selectedProviderId } ?? providers.first - } - - // MARK: - Lifecycle refresh - - /// Refresh live state when a step (re)appears. Called on view `onAppear` and - /// after each transition so permission rows and key presence stay current - /// without a manual poll. - func refreshForCurrentStep() { - loadProvidersIfNeeded() - switch step { - case .permission: - reprobePermissions() - case .apiKey, .done: - keyStatus = engine.keyStatus() - reprobePermissions() - case .agenticReadiness: - refreshReadiness() - default: - break - } - } - - /// Re-probe live permission state AND re-arm the global hotkey tap if a - /// first-run grant just landed. The CGEventTap reads Accessibility / Input - /// Monitoring only when it is created, so a grant made mid-wizard leaves - /// hotkeys dead until this re-arm (or an app restart). The bridge call is - /// idempotent — a no-op once the tap is already live. - private func reprobePermissions() { - permissions = probe.snapshot() - hotkeys.rearmAfterPermissionGrant() - } - - /// Re-probe the agentic-lane readiness verdict + MCP server status. Called on - /// the readiness step's appear and by its "Refresh" button. Read-only. - func refreshReadiness() { - readiness = agentStatus.agenticReadiness() - mcpStatus = agentStatus.mcpStatus() - } - - /// Arm the one-shot deep-link so the Settings window lands on the MCP surface - /// (Settings › Agent). The view owns the actual open via SwiftUI's - /// `@Environment(\.openSettings)` — the only reliable path in this accessory / - /// LSUIElement app, where the private `showSettingsWindow:` selector has no - /// responder (matching TrayMenuView / AgentChatView). Call this immediately - /// before `openSettings()`; the wizard stays open behind Settings so the user - /// can wire a server and return to continue. - func prepareMcpSettingsDeepLink() { - SettingsDeepLink.pendingSection = SettingsDeepLink.agentConfigurationSection - } - - /// Dismiss the MCP setup prompt for this session so onboarding proceeds without - /// implying MCP is required. - func dismissMcpSetupPrompt() { - mcpSetupDismissed = true - } - - private func loadProvidersIfNeeded() { - guard providers.isEmpty else { return } - providers = engine.availableProviders() - if selectedProvider == nil, let first = providers.first { - selectedProviderId = first.id - } - } - - // MARK: - Navigation - - /// Advance to the next VISIBLE step (also the Skip action for the API-key - /// step). Commits the leaving step's choice first so a default (untouched - /// radio) still lands in config, mirroring the AppKit wizard's save-on-confirm. - /// The Agentic Readiness step is skipped in the Basic lane. Advancing off the - /// end is treated as finishing so we never index past the flow. - func advance() { - commitCurrentChoice() - guard let next = nextVisibleIndex(after: stepIndex) else { - finish() - return - } - stepIndex = next - persistProgress() - refreshForCurrentStep() - } - - func back() { - guard let prev = prevVisibleIndex(before: stepIndex) else { return } - stepIndex = prev - persistProgress() - refreshForCurrentStep() - } - - /// Whether a step participates in the active lane. Only the Agentic Readiness - /// verdict is lane-dependent — hidden in Basic so a plain-dictation install - /// never blocks on the agent substrate. Mirrors `actions::step_is_visible`. - private func isVisible(_ step: OnboardingStep) -> Bool { - switch step { - case .agenticReadiness: return onboardingMode == .agentic - default: return true - } - } - - /// Next flow index visible in the current lane, or nil past the end. - private func nextVisibleIndex(after index: Int) -> Int? { - var candidate = index + 1 - while candidate < totalSteps { - if isVisible(OnboardingStep.step(at: candidate)) { return candidate } - candidate += 1 - } - return nil - } - - /// Previous flow index visible in the current lane, or nil before the start. - private func prevVisibleIndex(before index: Int) -> Int? { - var candidate = index - while candidate > 0 { - candidate -= 1 - if isVisible(OnboardingStep.step(at: candidate)) { return candidate } - } - return nil - } - - /// Persist the leaving step's choice. Idempotent so re-committing an unchanged - /// value is a safe no-op; guarantees the Basic default lands even if the user - /// pressed Continue without touching a radio. - private func commitCurrentChoice() { - switch step { - case .mode: persistMode() - case .language: persistLanguage() - case .hotkeyMode: persistHotkeyMode() - case .apiKey: - // Continue with a pasted-but-unsaved key would drop it; commit the draft - // so the key persists (saveApiKey no-ops on an empty draft). - if !apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - saveApiKey() - } - default: break - } - } - - /// Primary-button action: finish on Done, otherwise advance. - func primaryAction() { - if isDone { finish() } else { advance() } - } - - private func persistProgress() { - engine.saveOnboardingProgress(step: UInt32(stepIndex)) - } - - func finish() { - engine.markOnboardingDone() - onFinished?() - } - - // MARK: - Permission step actions - - func openSystemSettings(for kind: PermissionKind) { - kind.openSystemSettings() - } - - /// Same grant path as Settings › Dictation / Creator checklist: - /// - undetermined + in-app-requestable → system dialog from this process - /// - otherwise → Privacy deep-link (macOS never re-prompts once determined) - /// Always re-probes (and re-arms hotkeys) after the attempt settles. - func grantPermission(for kind: PermissionKind) { - let state = permissions.state(kind) - if state == .notDetermined, kind.supportsInAppPermissionRequest { - kind.requestInApp { [weak self] _ in - self?.reprobePermissions() - } - return - } - kind.openSystemSettings() - // User may flip the toggle and return without leaving the step — refresh - // once after the deep-link so a fast grant is visible immediately. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in - self?.reprobePermissions() - } - } - - func refreshPermissions() { - reprobePermissions() - } - - // MARK: - Mode step actions - - /// Select the operating lane and persist it immediately (also flips whether - /// the Agentic Readiness step is visible for the rest of the flow). - func selectMode(_ mode: OnboardingModeChoice) { - onboardingMode = mode - persistMode() - } - - private func persistMode() { - do { - try engine.setOnboardingMode(onboardingMode.value) - } catch { - lastError = error.localizedDescription - } - } - - // MARK: - Language step actions - - /// Select the dictation language and persist it through the shared config - /// router key (`WHISPER_LANGUAGE`) — the same path Settings › Creator uses. - func selectLanguage(_ language: CsLanguage) { - selectedLanguage = language - persistLanguage() - } - - private func persistLanguage() { - do { - try engine.updateConfig(key: "WHISPER_LANGUAGE", value: selectedLanguage.shortCode) - } catch { - lastError = error.localizedDescription - } - } - - // MARK: - Hotkey-mode step actions - - /// Select a recording-trigger preset and persist it by writing the three core - /// mode bindings (Dictation / Formatting / Assistive) through the shared - /// HotkeysEngine — the same seam and live-reload the Shortcuts panel uses. - func selectHotkeyMode(_ mode: HotkeyModeChoice) { - hotkeyMode = mode - persistHotkeyMode() - } - - private func persistHotkeyMode() { - let (dictation, formatting, assistive) = hotkeyMode.bindings - do { - try hotkeys.setModeBinding(mode: .dictation, binding: dictation) - try hotkeys.setModeBinding(mode: .formatting, binding: formatting) - try hotkeys.setModeBinding(mode: .assistive, binding: assistive) - } catch { - lastError = error.localizedDescription - } - } - - // MARK: - API-key step actions - - func selectProvider(_ id: String) { - selectedProviderId = id - do { - try engine.updateConfig(key: "LLM_ASSISTIVE_PROVIDER", value: id) - } catch { - lastError = error.localizedDescription - } - } - - /// True when the currently selected provider's key is present in the Keychain. - var selectedProviderKeySet: Bool { - guard let account = selectedProvider?.apiKeyAccount else { return false } - return keyStatus.isSet(account: account) - } - - func saveApiKey() { - let trimmed = apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let account = selectedProvider?.apiKeyAccount else { return } - do { - try engine.setApiKey(account: account, secret: trimmed) - apiKeyDraft = "" - keyStatus = engine.keyStatus() - } catch { - lastError = error.localizedDescription - } - } + @Published private(set) var stepIndex: Int + @Published private(set) var permissions: PermissionSnapshot + @Published private(set) var keyStatus: CsKeyStatus + + // Mode step state. + @Published private(set) var onboardingMode: OnboardingModeChoice + + // Language step state. + @Published private(set) var selectedLanguage: CsLanguage + + // Hotkey-mode step state. + @Published private(set) var hotkeyMode: HotkeyModeChoice + + // Agentic-readiness step state (lazy — probed when the step appears). + @Published private(set) var readiness: CsAgenticReadiness? + @Published private(set) var mcpStatus: CsMcpStatusReport? + + /// Whether the user dismissed the "set up MCP" prompt shown when no MCP server + /// is configured. Session-only: skipping keeps the readiness step moving without + /// implying an error, and re-opening the wizard offers the prompt again. + @Published private(set) var mcpSetupDismissed = false + + // API-key step state. + @Published private(set) var providers: [CsProviderOption] = [] + @Published var selectedProviderId: String + @Published var apiKeyDraft: String = "" + + @Published var lastError: String? + + private let engine: OnboardingEngine + private let hotkeys: HotkeysEngine + private let agentStatus: AgentStatusEngine + private let probe: PermissionProbing + + /// Invoked when the wizard is finished (Done confirmed) so the host can close + /// and release the window. + var onFinished: (() -> Void)? + + init( + engine: OnboardingEngine, + hotkeys: HotkeysEngine = RealHotkeysEngine(), + agentStatus: AgentStatusEngine = RealAgentStatusEngine(), + probe: PermissionProbing = NativePermissionProbe() + ) { + self.engine = engine + self.hotkeys = hotkeys + self.agentStatus = agentStatus + self.probe = probe + // Resume from the persisted step; `onboardingProgress` is already clamped + // to a valid index by the Rust side. + self.stepIndex = Int(engine.onboardingProgress()) + self.permissions = probe.snapshot() + self.keyStatus = engine.keyStatus() + self.onboardingMode = OnboardingModeChoice.from(engine.onboardingMode()) + self.selectedLanguage = engine.currentLanguage() + self.hotkeyMode = HotkeyModeChoice.derive(from: hotkeys.modeBindings()) + self.selectedProviderId = + engine.assistiveProvider() + ?? engine.availableProviders().first?.id + ?? "openai-responses" + } + + // MARK: - Derived + + var step: OnboardingStep { OnboardingStep.step(at: stepIndex) } + var totalSteps: Int { OnboardingStep.count } + var canGoBack: Bool { stepIndex > 0 } + + /// Human-readable "Step N of M" — permission steps are still counted by their + /// absolute flow index so the bar never jumps. + var progressLabel: String { "Step \(stepIndex + 1) of \(totalSteps)" } + + var isDone: Bool { step == .done } + + /// Primary-button label: "Finish" on Done, "Continue" everywhere else. + var primaryLabel: String { isDone ? "Finish" : "Continue" } + + var selectedProvider: CsProviderOption? { + providers.first { $0.id == selectedProviderId } ?? providers.first + } + + // MARK: - Lifecycle refresh + + /// Refresh live state when a step (re)appears. Called on view `onAppear` and + /// after each transition so permission rows and key presence stay current + /// without a manual poll. + func refreshForCurrentStep() { + loadProvidersIfNeeded() + switch step { + case .permission: + reprobePermissions() + case .apiKey, .done: + keyStatus = engine.keyStatus() + reprobePermissions() + case .agenticReadiness: + refreshReadiness() + default: + break + } + } + + /// Re-probe live permission state AND re-arm the global hotkey tap if a + /// first-run grant just landed. The CGEventTap reads Accessibility / Input + /// Monitoring only when it is created, so a grant made mid-wizard leaves + /// hotkeys dead until this re-arm (or an app restart). The bridge call is + /// idempotent — a no-op once the tap is already live. + private func reprobePermissions() { + permissions = probe.snapshot() + hotkeys.rearmAfterPermissionGrant() + } + + /// Re-probe the agentic-lane readiness verdict + MCP server status. Called on + /// the readiness step's appear and by its "Refresh" button. Read-only. + func refreshReadiness() { + readiness = agentStatus.agenticReadiness() + mcpStatus = agentStatus.mcpStatus() + } + + /// Arm the one-shot deep-link so the Settings window lands on the MCP surface + /// (Settings › Agent). The view owns the actual open via SwiftUI's + /// `@Environment(\.openSettings)` — the only reliable path in this accessory / + /// LSUIElement app, where the private `showSettingsWindow:` selector has no + /// responder (matching TrayMenuView / AgentChatView). Call this immediately + /// before `openSettings()`; the wizard stays open behind Settings so the user + /// can wire a server and return to continue. + func prepareMcpSettingsDeepLink() { + SettingsDeepLink.pendingSection = SettingsDeepLink.agentConfigurationSection + } + + /// Dismiss the MCP setup prompt for this session so onboarding proceeds without + /// implying MCP is required. + func dismissMcpSetupPrompt() { + mcpSetupDismissed = true + } + + private func loadProvidersIfNeeded() { + guard providers.isEmpty else { return } + providers = engine.availableProviders() + if selectedProvider == nil, let first = providers.first { + selectedProviderId = first.id + } + } + + // MARK: - Navigation + + /// Advance to the next VISIBLE step (also the Skip action for the API-key + /// step). Commits the leaving step's choice first so a default (untouched + /// radio) still lands in config, mirroring the AppKit wizard's save-on-confirm. + /// The Agentic Readiness step is skipped in the Basic lane. Advancing off the + /// end is treated as finishing so we never index past the flow. + func advance() { + commitCurrentChoice() + guard let next = nextVisibleIndex(after: stepIndex) else { + finish() + return + } + stepIndex = next + persistProgress() + refreshForCurrentStep() + } + + func back() { + guard let prev = prevVisibleIndex(before: stepIndex) else { return } + stepIndex = prev + persistProgress() + refreshForCurrentStep() + } + + /// Whether a step participates in the active lane. Only the Agentic Readiness + /// verdict is lane-dependent — hidden in Basic so a plain-dictation install + /// never blocks on the agent substrate. Mirrors `actions::step_is_visible`. + private func isVisible(_ step: OnboardingStep) -> Bool { + switch step { + case .agenticReadiness: return onboardingMode == .agentic + default: return true + } + } + + /// Next flow index visible in the current lane, or nil past the end. + private func nextVisibleIndex(after index: Int) -> Int? { + var candidate = index + 1 + while candidate < totalSteps { + if isVisible(OnboardingStep.step(at: candidate)) { return candidate } + candidate += 1 + } + return nil + } + + /// Previous flow index visible in the current lane, or nil before the start. + private func prevVisibleIndex(before index: Int) -> Int? { + var candidate = index + while candidate > 0 { + candidate -= 1 + if isVisible(OnboardingStep.step(at: candidate)) { return candidate } + } + return nil + } + + /// Persist the leaving step's choice. Idempotent so re-committing an unchanged + /// value is a safe no-op; guarantees the Basic default lands even if the user + /// pressed Continue without touching a radio. + private func commitCurrentChoice() { + switch step { + case .mode: persistMode() + case .language: persistLanguage() + case .hotkeyMode: persistHotkeyMode() + case .apiKey: + // Continue with a pasted-but-unsaved key would drop it; commit the draft + // so the key persists (saveApiKey no-ops on an empty draft). + if !apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + saveApiKey() + } + default: break + } + } + + /// Primary-button action: finish on Done, otherwise advance. + func primaryAction() { + if isDone { finish() } else { advance() } + } + + private func persistProgress() { + engine.saveOnboardingProgress(step: UInt32(stepIndex)) + } + + func finish() { + engine.markOnboardingDone() + onFinished?() + } + + // MARK: - Permission step actions + + func openSystemSettings(for kind: PermissionKind) { + kind.openSystemSettings() + } + + /// Same grant path as Settings › Dictation / Creator checklist: + /// - undetermined + in-app-requestable → system dialog from this process + /// - otherwise → Privacy deep-link (macOS never re-prompts once determined) + /// Always re-probes (and re-arms hotkeys) after the attempt settles. + func grantPermission(for kind: PermissionKind) { + let state = permissions.state(kind) + if state == .notDetermined, kind.supportsInAppPermissionRequest { + kind.requestInApp { [weak self] _ in + self?.reprobePermissions() + } + return + } + kind.openSystemSettings() + // User may flip the toggle and return without leaving the step — refresh + // once after the deep-link so a fast grant is visible immediately. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + self?.reprobePermissions() + } + } + + func refreshPermissions() { + reprobePermissions() + } + + // MARK: - Mode step actions + + /// Select the operating lane and persist it immediately (also flips whether + /// the Agentic Readiness step is visible for the rest of the flow). + func selectMode(_ mode: OnboardingModeChoice) { + onboardingMode = mode + persistMode() + } + + private func persistMode() { + do { + try engine.setOnboardingMode(onboardingMode.value) + } catch { + lastError = error.localizedDescription + } + } + + // MARK: - Language step actions + + /// Select the dictation language and persist it through the shared config + /// router key (`WHISPER_LANGUAGE`) — the same path Settings › Creator uses. + func selectLanguage(_ language: CsLanguage) { + selectedLanguage = language + persistLanguage() + } + + private func persistLanguage() { + do { + try engine.updateConfig(key: "WHISPER_LANGUAGE", value: selectedLanguage.shortCode) + } catch { + lastError = error.localizedDescription + } + } + + // MARK: - Hotkey-mode step actions + + /// Select a recording-trigger preset and persist it by writing the three core + /// mode bindings (Dictation / Formatting / Assistive) through the shared + /// HotkeysEngine — the same seam and live-reload the Shortcuts panel uses. + func selectHotkeyMode(_ mode: HotkeyModeChoice) { + hotkeyMode = mode + persistHotkeyMode() + } + + private func persistHotkeyMode() { + let (dictation, formatting, assistive) = hotkeyMode.bindings + do { + try hotkeys.setModeBinding(mode: .dictation, binding: dictation) + try hotkeys.setModeBinding(mode: .formatting, binding: formatting) + try hotkeys.setModeBinding(mode: .assistive, binding: assistive) + } catch { + lastError = error.localizedDescription + } + } + + // MARK: - API-key step actions + + func selectProvider(_ id: String) { + selectedProviderId = id + do { + try engine.updateConfig(key: "LLM_ASSISTIVE_PROVIDER", value: id) + } catch { + lastError = error.localizedDescription + } + } + + /// True when the currently selected provider's key is present in the Keychain. + var selectedProviderKeySet: Bool { + guard let account = selectedProvider?.apiKeyAccount else { return false } + return keyStatus.isSet(account: account) + } + + func saveApiKey() { + let trimmed = apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let account = selectedProvider?.apiKeyAccount else { return } + do { + try engine.setApiKey(account: account, secret: trimmed) + apiKeyDraft = "" + keyStatus = engine.keyStatus() + } catch { + lastError = error.localizedDescription + } + } } diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingWindow.swift b/macos/Codescribe/Screens/Onboarding/OnboardingWindow.swift index 07407f95..50afa467 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingWindow.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingWindow.swift @@ -13,41 +13,41 @@ import SwiftUI @MainActor final class OnboardingWindowController { - private var window: NSWindow? - private let engine: OnboardingEngine + private var window: NSWindow? + private let engine: OnboardingEngine - init(engine: OnboardingEngine) { - self.engine = engine - } + init(engine: OnboardingEngine) { + self.engine = engine + } - /// Present the wizard only when the live gate says onboarding is due. - func presentIfNeeded() { - guard engine.shouldShowOnboarding() else { return } - present() - } + /// Present the wizard only when the live gate says onboarding is due. + func presentIfNeeded() { + guard engine.shouldShowOnboarding() else { return } + present() + } - /// Build (once) and front the wizard window. Idempotent — a second call just - /// re-fronts the existing window. - func present() { - if window == nil { - let model = OnboardingViewModel(engine: engine) - model.onFinished = { [weak self] in self?.close() } - let hosting = NSHostingController(rootView: OnboardingView(model: model)) - let window = NSWindow(contentViewController: hosting) - window.title = "Welcome to codescribe" - window.setContentSize(NSSize(width: 720, height: 620)) - window.styleMask = [.titled, .closable, .fullSizeContentView] - window.titlebarAppearsTransparent = true - window.isReleasedWhenClosed = false - window.center() - self.window = window - } - NSApp.activate(ignoringOtherApps: true) - window?.makeKeyAndOrderFront(nil) + /// Build (once) and front the wizard window. Idempotent — a second call just + /// re-fronts the existing window. + func present() { + if window == nil { + let model = OnboardingViewModel(engine: engine) + model.onFinished = { [weak self] in self?.close() } + let hosting = NSHostingController(rootView: OnboardingView(model: model)) + let window = NSWindow(contentViewController: hosting) + window.title = "Welcome to codescribe" + window.setContentSize(NSSize(width: 720, height: 620)) + window.styleMask = [.titled, .closable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + window.center() + self.window = window } + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } - private func close() { - window?.close() - window = nil - } + private func close() { + window?.close() + window = nil + } } diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift index dae6c152..a94c2bca 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift @@ -14,743 +14,767 @@ import SwiftUI // // A transient toast (no-speech / error) floats over the bottom edge. struct DictationOverlayView: View { - @ObservedObject var state: OverlayState - - // Mock-derived geometry constants (not design tokens — local to this surface). - // The window is user-resizable; content flows to fill whatever frame it gets, - // never narrower than `windowMinWidth`. Below `actionIconOnlyThreshold`, the - // action row switches to fixed icon buttons so the old full-label intrinsic width - // no longer dictates the window floor. `DictationOverlayWindow.minSize.height` - // MUST stay ≥ the chrome + `bodyMinHeight` sum — otherwise the content column - // overflows the window frame and GlassPanel paints its rounded background past - // the window rect, squaring the visible corners (see DictationOverlayWindow's - // corner note). - private let windowMinWidth: CGFloat = 320 - private let actionIconOnlyThreshold: CGFloat = 380 - // U22 diet: the action row used to eat ~1/3 of the overlay (38pt content + - // 10pt vertical padding + 10pt button padding). Trimmed to 30/6/6 with a - // 12pt semibold label — the ~16pt saved is handed to the transcript via - // `bodyMinHeight` below (lockstep, window minSize unchanged). - private let actionRowContentHeight: CGFloat = 30 - private let actionIconButtonSize: CGFloat = 28 - // `bodyMinHeight` reserves the body floor at the min window size: the listening - // body needs the waveform block (~46) PLUS `transcriptMinHeight` so the growing - // transcript keeps ~3 legible lines instead of collapsing to a clipped sliver. - // 114 → 130: the vertical space reclaimed from the slimmer action row stays - // with the transcript. `DictationOverlayWindow.minSize.height` (300) still - // covers chrome + this floor — the content column stays ≤ the window frame - // (see the corner-clip note above). - private let bodyMinHeight: CGFloat = 130 - private let transcriptMinHeight: CGFloat = 84 - private let buttonRadius: CGFloat = 10 - - /// Anchor id for the live transcript's tail. `scrollTo` pins it to the bottom on - /// every append so the newest text stays visible without any user interaction. - private let transcriptBottomAnchor = "overlayTranscriptBottom" - - var body: some View { - GlassPanel(cornerRadius: CSRadius.window) { - VStack(alignment: .leading, spacing: 0) { - header - hairline(0.06) - modeMetaRow - bodySection - hairline(0.06) - actionRow - hairline(0.05) - footer - } - } - .csFocusPolicy() - .frame(minWidth: windowMinWidth, maxWidth: .infinity, maxHeight: .infinity) - // Terminal corner clip (U22): GlassPanel paints its background from the - // CONTENT column's size, not the window's. Whenever the column outgrows - // the window frame — a mid-edge-drag beat, a stale persisted size below - // the chrome+body sum — that background used to spill past the window - // rect and surface as a SQUARE corner under the rounded glass. Clipping - // the whole panel to the window-frame rounded rect closes that class of - // regression regardless of the height arithmetic. The GlassPanel shadow - // already falls outside the borderless window (never rendered), so this - // clip costs nothing visually. - .clipShape(RoundedRectangle(cornerRadius: CSRadius.window, style: .continuous)) - .overlay(alignment: .bottom) { - if let toast = state.toast { - ToastPill(text: toast) - .padding(.bottom, 14) - .transition(.opacity.combined(with: .offset(y: 8))) - } - } - .animation(CSMotion.floatIn, value: state.toast) - .onHover { inside in - state.setPointerHovering(inside) - } - .onAppear { - FontLoader.register() - } + @ObservedObject var state: OverlayState + + // Mock-derived geometry constants (not design tokens — local to this surface). + // The window is user-resizable; content flows to fill whatever frame it gets, + // never narrower than `windowMinWidth`. Below `actionIconOnlyThreshold`, the + // action row switches to fixed icon buttons so the old full-label intrinsic width + // no longer dictates the window floor. `DictationOverlayWindow.minSize.height` + // MUST stay ≥ the chrome + `bodyMinHeight` sum — otherwise the content column + // overflows the window frame and GlassPanel paints its rounded background past + // the window rect, squaring the visible corners (see DictationOverlayWindow's + // corner note). + private let windowMinWidth: CGFloat = 320 + private let actionIconOnlyThreshold: CGFloat = 380 + // U22 diet: the action row used to eat ~1/3 of the overlay (38pt content + + // 10pt vertical padding + 10pt button padding). Trimmed to 30/6/6 with a + // 12pt semibold label — the ~16pt saved is handed to the transcript via + // `bodyMinHeight` below (lockstep, window minSize unchanged). + private let actionRowContentHeight: CGFloat = 30 + private let actionIconButtonSize: CGFloat = 28 + // `bodyMinHeight` reserves the body floor at the min window size: the listening + // body needs the waveform block (~46) PLUS `transcriptMinHeight` so the growing + // transcript keeps ~3 legible lines instead of collapsing to a clipped sliver. + // 114 → 130: the vertical space reclaimed from the slimmer action row stays + // with the transcript. `DictationOverlayWindow.minSize.height` (300) still + // covers chrome + this floor — the content column stays ≤ the window frame + // (see the corner-clip note above). + private let bodyMinHeight: CGFloat = 130 + private let transcriptMinHeight: CGFloat = 84 + private let buttonRadius: CGFloat = 10 + + /// Anchor id for the live transcript's tail. `scrollTo` pins it to the bottom on + /// every append so the newest text stays visible without any user interaction. + private let transcriptBottomAnchor = "overlayTranscriptBottom" + + var body: some View { + GlassPanel(cornerRadius: CSRadius.window) { + VStack(alignment: .leading, spacing: 0) { + header + hairline(0.06) + modeMetaRow + bodySection + hairline(0.06) + actionRow + hairline(0.05) + footer + } } - - /// 1px separator matching the mock's hairline borders. - private func hairline(_ alpha: Double) -> some View { - CSColor.hairline(alpha).frame(height: 1) + .csFocusPolicy() + .frame(minWidth: windowMinWidth, maxWidth: .infinity, maxHeight: .infinity) + // Terminal corner clip (U22): GlassPanel paints its background from the + // CONTENT column's size, not the window's. Whenever the column outgrows + // the window frame — a mid-edge-drag beat, a stale persisted size below + // the chrome+body sum — that background used to spill past the window + // rect and surface as a SQUARE corner under the rounded glass. Clipping + // the whole panel to the window-frame rounded rect closes that class of + // regression regardless of the height arithmetic. The GlassPanel shadow + // already falls outside the borderless window (never rendered), so this + // clip costs nothing visually. + .clipShape(RoundedRectangle(cornerRadius: CSRadius.window, style: .continuous)) + .overlay(alignment: .bottom) { + if let toast = state.toast { + ToastPill(text: toast) + .padding(.bottom, 14) + .transition(.opacity.combined(with: .offset(y: 8))) + } } - - // MARK: Header - - private var header: some View { - HStack(spacing: 12) { - // Brand block with a LIVE dot: the orange dot sits in the window's - // traffic-light zone and reads as a control, so it IS one — click - // closes the overlay (same as the Close action). Hover shows the - // familiar "×" glyph; the wordmark text stays inert. - HStack(spacing: 9) { - CloseDot { state.close() } - Text("codescribe") - .font(CSFont.ui(15, .bold)) - .tracking(-0.3) - .foregroundStyle(CSColor.textHigh) - } - // Swap the whole VIEW TYPE on live vs idle, not just a flag: the - // animated pill (with @State + repeatForever) exists ONLY while live, - // and is replaced by a static pill of different identity in idle/final, - // so SwiftUI tears down its animation instead of leaving it ticking. - if state.statusRippling { - StatusPill( - text: state.statusText, - color: state.statusColor, - rippling: true - ) - .padding(.leading, 6) - .accessibilityIdentifier("overlay-phase-status") - } else { - StaticStatusPill(text: state.statusText, color: state.statusColor) - .padding(.leading, 6) - .accessibilityIdentifier("overlay-phase-status") - } - if let badge = state.confidenceBadgeText { - Text(badge) - .csMono(9, .semibold) - .foregroundStyle(CSColor.terracotta) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background(CSColor.terracotta.opacity(0.12)) - .clipShape(Capsule()) - .accessibilityIdentifier("overlay-confidence-badge") - .accessibilityLabel(badge) - } - Spacer(minLength: 0) - if state.autoPasteControlAvailable { - autoPasteControl - } - placementMenu - .foregroundStyle(CSColor.textFaint) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) + .animation(CSMotion.floatIn, value: state.toast) + .onHover { inside in + state.setPointerHovering(inside) } - - /// Compact persisted delivery control. `ViewThatFits` keeps the literal label - /// in normal widths and falls back to the same truthful icon/value control at - /// the 320pt floor. Both variants share one explicit accessibility contract. - private var autoPasteControl: some View { - Button { - state.setAutoPasteEnabled(!state.autoPasteEnabled) - } label: { - ViewThatFits(in: .horizontal) { - autoPasteControlLabel(showTitle: true) - autoPasteControlLabel(showTitle: false) - } - } - .csFocusRing(cornerRadius: CSRadius.pill) - .help("Auto Paste: \(state.autoPasteAccessibilityValue)") - .accessibilityLabel("Auto Paste") - .accessibilityValue(state.autoPasteAccessibilityValue) - .accessibilityHint("Automatically insert completed dictation in the previous app") - .accessibilityIdentifier("overlay-auto-paste") + .onAppear { + FontLoader.register() } - - private func autoPasteControlLabel(showTitle: Bool) -> some View { - HStack(spacing: 5) { - Image(systemName: "arrow.down.doc.fill") - .font(.system(size: 10, weight: .semibold)) - if showTitle { - Text("Auto Paste") - .csMono(9, .semibold) - .lineLimit(1) - } - Circle() - .fill(state.autoPasteEnabled ? CSColor.oliveLight : CSColor.textFaint) - .frame(width: 6, height: 6) - } - .foregroundStyle(CSColor.textFaint) - .padding(.horizontal, showTitle ? 8 : 7) - .padding(.vertical, 5) - .background(CSColor.surfaceRaised(0.04)) - .overlay( - Capsule().strokeBorder(CSColor.hairline(0.12), lineWidth: 1) + } + + /// 1px separator matching the mock's hairline borders. + private func hairline(_ alpha: Double) -> some View { + CSColor.hairline(alpha).frame(height: 1) + } + + // MARK: Header + + private var header: some View { + HStack(spacing: 12) { + // Brand block with a LIVE dot: the orange dot sits in the window's + // traffic-light zone and reads as a control, so it IS one — click + // closes the overlay (same as the Close action). Hover shows the + // familiar "×" glyph; the wordmark text stays inert. + HStack(spacing: 9) { + CloseDot { state.close() } + Text("codescribe") + .font(CSFont.ui(15, .bold)) + .tracking(-0.3) + .foregroundStyle(CSColor.textHigh) + } + // Swap the whole VIEW TYPE on live vs idle, not just a flag: the + // animated pill (with @State + repeatForever) exists ONLY while live, + // and is replaced by a static pill of different identity in idle/final, + // so SwiftUI tears down its animation instead of leaving it ticking. + if state.statusRippling { + StatusPill( + text: state.statusText, + color: state.statusColor, + rippling: true ) - .clipShape(Capsule()) + .padding(.leading, 6) + .accessibilityIdentifier("overlay-phase-status") + } else { + StaticStatusPill(text: state.statusText, color: state.statusColor) + .padding(.leading, 6) + .accessibilityIdentifier("overlay-phase-status") + } + if let badge = state.confidenceBadgeText { + Text(badge) + .csMono(9, .semibold) + .foregroundStyle(CSColor.terracotta) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(CSColor.terracotta.opacity(0.12)) + .clipShape(Capsule()) + .accessibilityIdentifier("overlay-confidence-badge") + .accessibilityLabel(badge) + } + Spacer(minLength: 0) + if state.autoPasteControlAvailable { + autoPasteControl + } + placementMenu + .foregroundStyle(CSColor.textFaint) } - - /// Placement config under the `…` icon: six screen anchors or free motion. - /// Selecting an anchor exits free motion (the pick's intent is "go there"); - /// the reposition itself is orchestrated via `OverlayState.onPlacementChanged`. - private var placementMenu: some View { - Menu { - Picker("Position", selection: $state.placementAnchor) { - ForEach(OverlayAnchor.allCases) { anchor in - Text(anchor.label).tag(anchor) - } - } - .pickerStyle(.inline) - Divider() - Toggle("Free motion", isOn: $state.freeMotion) - } label: { - CSIconView(icon: .more, size: 15, weight: .medium) - } - .menuStyle(.button) - .csFocusRing(cornerRadius: 8) - .menuIndicator(.hidden) - .fixedSize() - .accessibilityIdentifier("overlay-placement-menu") + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + /// Compact persisted delivery control. `ViewThatFits` keeps the literal label + /// in normal widths and falls back to the same truthful icon/value control at + /// the 320pt floor. Both variants share one explicit accessibility contract. + private var autoPasteControl: some View { + Button { + state.setAutoPasteEnabled(!state.autoPasteEnabled) + } label: { + ViewThatFits(in: .horizontal) { + autoPasteControlLabel(showTitle: true) + autoPasteControlLabel(showTitle: false) + } } - - // MARK: Mode + meta row - - private var modeMetaRow: some View { - HStack(spacing: 10) { - Text(state.tagText) - .csMono(10, .semibold) - .tracking(0.8) - .foregroundStyle(state.tagColor) - .padding(.horizontal, 9) - .padding(.vertical, 3) - .background(state.tagColor.opacity(0.1)) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(state.tagColor.opacity(0.28), lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - Text(state.metaText) - .csMono(11, .medium) - .foregroundStyle(CSColor.textFaint) - Spacer(minLength: 0) - sessionTimer - } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 4) + .csFocusRing(cornerRadius: CSRadius.pill) + .help("Auto Paste: \(state.autoPasteAccessibilityValue)") + .accessibilityLabel("Auto Paste") + .accessibilityValue(state.autoPasteAccessibilityValue) + .accessibilityHint("Automatically insert completed dictation in the previous app") + .accessibilityIdentifier("overlay-auto-paste") + } + + private func autoPasteControlLabel(showTitle: Bool) -> some View { + HStack(spacing: 5) { + Image(systemName: "arrow.down.doc.fill") + .font(.system(size: 10, weight: .semibold)) + if showTitle { + Text("Auto Paste") + .csMono(9, .semibold) + .lineLimit(1) + } + Circle() + .fill(state.autoPasteEnabled ? CSColor.oliveLight : CSColor.textFaint) + .frame(width: 6, height: 6) } - - /// Live `00:00` session counter — the absolute reference for audio sync, - /// transcription lag, and stream drift (UI_DIVERGENCE_AUDIT pkt 5). Ticks - /// only while `.listening`; the state freezes the underlying stamp when - /// capture stops, so the final displayed value is the session's true length. - @ViewBuilder - private var sessionTimer: some View { - if state.mode == .listening, state.captureStartedAtUptime != nil { - TimelineView(.periodic(from: .now, by: 1)) { _ in - Text(state.sessionTimerText) - .csMono(11, .semibold) - .foregroundStyle(CSColor.textFaint) - } - .accessibilityIdentifier("overlay-session-timer") - .accessibilityLabel("Recording time") - .accessibilityValue(state.sessionTimerText) + .foregroundStyle(CSColor.textFaint) + .padding(.horizontal, showTitle ? 8 : 7) + .padding(.vertical, 5) + .background(CSColor.surfaceRaised(0.04)) + .overlay( + Capsule().strokeBorder(CSColor.hairline(0.12), lineWidth: 1) + ) + .clipShape(Capsule()) + } + + /// Placement config under the `…` icon: six screen anchors or free motion. + /// Selecting an anchor exits free motion (the pick's intent is "go there"); + /// the reposition itself is orchestrated via `OverlayState.onPlacementChanged`. + private var placementMenu: some View { + Menu { + Picker("Position", selection: $state.placementAnchor) { + ForEach(OverlayAnchor.allCases) { anchor in + Text(anchor.label).tag(anchor) } + } + .pickerStyle(.inline) + Divider() + Toggle("Free motion", isOn: $state.freeMotion) + } label: { + CSIconView(icon: .more, size: 15, weight: .medium) } - - // MARK: Body - - private var bodySection: some View { - Group { - switch state.mode { - case .listening: - listeningBody - .transition(.opacity.combined(with: .offset(y: 8))) - case .formatted: - formattedBody - .transition(.opacity.combined(with: .offset(y: 8))) - case .noSpeech: - noSpeechBody - .transition(.opacity.combined(with: .offset(y: 8))) - case .error: - errorBody - .transition(.opacity.combined(with: .offset(y: 8))) - } - } - .frame(maxWidth: .infinity, minHeight: bodyMinHeight, maxHeight: .infinity, alignment: .topLeading) - .padding(.horizontal, 20) - .padding(.top, 4) - .padding(.bottom, 10) - .animation(CSMotion.floatIn, value: state.mode) + .menuStyle(.button) + .csFocusRing(cornerRadius: 8) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityIdentifier("overlay-placement-menu") + } + + // MARK: Mode + meta row + + private var modeMetaRow: some View { + HStack(spacing: 10) { + Text(state.tagText) + .csMono(10, .semibold) + .tracking(0.8) + .foregroundStyle(state.tagColor) + .padding(.horizontal, 9) + .padding(.vertical, 3) + .background(state.tagColor.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(state.tagColor.opacity(0.28), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + Text(state.metaText) + .csMono(11, .medium) + .foregroundStyle(CSColor.textFaint) + Spacer(minLength: 0) + sessionTimer } - - private var listeningBody: some View { - VStack(alignment: .leading, spacing: 0) { - WaveformView( - active: !state.transcribing && !state.isFinalPass && (state.audioReady || state.vadActive), - transcribing: state.transcribing || state.isFinalPass, - indicatorMode: state.indicatorMode, - meter: state.levelMeter - ) - .padding(.top, 4) - .padding(.bottom, 8) - transcriptScroll - } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 4) + } + + /// Live `00:00` session counter — the absolute reference for audio sync, + /// transcription lag, and stream drift (UI_DIVERGENCE_AUDIT pkt 5). Ticks + /// only while `.listening`; the state freezes the underlying stamp when + /// capture stops, so the final displayed value is the session's true length. + @ViewBuilder + private var sessionTimer: some View { + if state.mode == .listening, state.captureStartedAtUptime != nil { + TimelineView(.periodic(from: .now, by: 1)) { _ in + Text(state.sessionTimerText) + .csMono(11, .semibold) + .foregroundStyle(CSColor.textFaint) + } + .accessibilityIdentifier("overlay-session-timer") + .accessibilityLabel("Recording time") + .accessibilityValue(state.sessionTimerText) } - - /// Scrollable live transcript that ALWAYS follows the tail: every append pins - /// the view to the newest text with no user interaction required. The follow is - /// unconditional and intentional here because this scroll only exists while - /// `.listening` — during a hold-to-talk session the modifier key is held, so the - /// user physically cannot scroll, and pinning to the bottom is the only way the - /// growing transcript stays legible (an earlier "pause on manual scroll up" - /// heuristic mis-read normal content overflow as a scroll gesture and killed the - /// follow exactly when it was needed, hiding the newest chunk). Manual scroll is - /// owned by the terminal `.formatted` TextEditor, which is never driven by this. - /// A `minHeight` reserves ~2–3 lines so the tail is visible even at the min - /// window size instead of collapsing behind the waveform. - private var transcriptScroll: some View { - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .bottom, spacing: 2) { - Text(state.listeningDisplay) - .csFont(15, .medium) - .lineSpacing(5) - .foregroundStyle(CSColor.textBody) - .fixedSize(horizontal: false, vertical: true) - .accessibilityIdentifier("overlay-transcript-live") - BlinkingCaret() - } - Color.clear - .frame(height: 1) - .id(transcriptBottomAnchor) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(minHeight: transcriptMinHeight) - .accessibilityIdentifier("overlay-transcript-area") - .onChange(of: state.listeningDisplay) { _, _ in - scrollToTail(proxy) - } - .onAppear { scrollToTail(proxy) } - } + } + + // MARK: Body + + private var bodySection: some View { + Group { + switch state.mode { + case .listening: + listeningBody + .transition(.opacity.combined(with: .offset(y: 8))) + case .formatted: + formattedBody + .transition(.opacity.combined(with: .offset(y: 8))) + case .noSpeech: + noSpeechBody + .transition(.opacity.combined(with: .offset(y: 8))) + case .error: + errorBody + .transition(.opacity.combined(with: .offset(y: 8))) + } } - - /// Pin the live transcript to its bottom anchor. A short ease keeps rapid - /// word-by-word appends from snapping harshly while still tracking the tail. - private func scrollToTail(_ proxy: ScrollViewProxy) { - // Defer one runloop tick: `onChange` fires before SwiftUI lays out the - // freshly appended text, so scrolling synchronously targets the previous - // content height and clips the newest word. By the next tick the bottom - // anchor sits below the new tail. - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.14)) { - proxy.scrollTo(transcriptBottomAnchor, anchor: .bottom) - } - } + .frame( + maxWidth: .infinity, minHeight: bodyMinHeight, maxHeight: .infinity, alignment: .topLeading + ) + .padding(.horizontal, 20) + .padding(.top, 4) + .padding(.bottom, 10) + .animation(CSMotion.floatIn, value: state.mode) + } + + private var listeningBody: some View { + VStack(alignment: .leading, spacing: 0) { + WaveformView( + active: !state.transcribing && !state.isFinalPass && (state.audioReady || state.vadActive), + transcribing: state.transcribing || state.isFinalPass, + indicatorMode: state.indicatorMode, + meter: state.levelMeter + ) + .padding(.top, 4) + .padding(.bottom, 8) + transcriptScroll } - - private var formattedBody: some View { - VStack(alignment: .leading, spacing: 8) { - TextEditor(text: Binding( - get: { state.formattedText }, - set: { state.userEditedTranscript($0) } - )) - .csFont(15) - .foregroundStyle(CSColor.textHigh) + } + + /// Scrollable live transcript that ALWAYS follows the tail: every append pins + /// the view to the newest text with no user interaction required. The follow is + /// unconditional and intentional here because this scroll only exists while + /// `.listening` — during a hold-to-talk session the modifier key is held, so the + /// user physically cannot scroll, and pinning to the bottom is the only way the + /// growing transcript stays legible (an earlier "pause on manual scroll up" + /// heuristic mis-read normal content overflow as a scroll gesture and killed the + /// follow exactly when it was needed, hiding the newest chunk). Manual scroll is + /// owned by the terminal `.formatted` TextEditor, which is never driven by this. + /// A `minHeight` reserves ~2–3 lines so the tail is visible even at the min + /// window size instead of collapsing behind the waveform. + private var transcriptScroll: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom, spacing: 2) { + if state.highlightsEnabled, !state.highlights.isEmpty { + OverlayHighlightCanvas( + runs: state.highlightCanvasRuns, + selectedId: state.selectedHighlightId, + onSelect: { state.selectHighlight($0) } + ) + } else { + Text(state.listeningDisplay) + .csFont(15, .medium) .lineSpacing(5) - .scrollContentBackground(.hidden) - .background(Color.clear) - .frame(minHeight: bodyMinHeight) - .accessibilityIdentifier("overlay-transcript-formatted") - if let status = state.formatFailureStatus { - Text(status) - .csMono(11, .medium) - .foregroundStyle(CSColor.textFaint) - .accessibilityIdentifier("overlay-format-failure-status") + .foregroundStyle(CSColor.textBody) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("overlay-transcript-live") } + BlinkingCaret() + } + if state.highlightsEnabled { + OverlayHighlightTeachBar( + highlights: state.highlights, + selectedId: state.selectedHighlightId, + onSelect: { state.selectHighlight($0) }, + onTeach: { state.sendHighlightToTeach($0) } + ) + .padding(.top, 8) + } + Color.clear + .frame(height: 1) + .id(transcriptBottomAnchor) } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: transcriptMinHeight) + .accessibilityIdentifier("overlay-transcript-area") + .onChange(of: state.listeningDisplay) { _, _ in + scrollToTail(proxy) + } + .onAppear { scrollToTail(proxy) } } - - /// Terminal outcome for a session that captured no usable speech. Replaces - /// the empty editable FINAL with a calm, non-alarming notice (mic glyph + - /// message). No Copy/Format/Send — there is nothing to act on; only Close - /// remains in the action row. - private var noSpeechBody: some View { - HStack(spacing: 12) { - CSIconView(icon: .mic, size: 18, weight: .regular) - .foregroundStyle(CSColor.textFaint) - VStack(alignment: .leading, spacing: 2) { - Text(state.noSpeechNotice) - .csFont(15, .medium) - .foregroundStyle(CSColor.textBody) - .fixedSize(horizontal: false, vertical: true) - Text("Nothing was captured this session.") - .csMono(11, .medium) - .foregroundStyle(CSColor.textFaint) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, minHeight: bodyMinHeight, alignment: .leading) + } + + /// Pin the live transcript to its bottom anchor. A short ease keeps rapid + /// word-by-word appends from snapping harshly while still tracking the tail. + private func scrollToTail(_ proxy: ScrollViewProxy) { + // Defer one runloop tick: `onChange` fires before SwiftUI lays out the + // freshly appended text, so scrolling synchronously targets the previous + // content height and clips the newest word. By the next tick the bottom + // anchor sits below the new tail. + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.14)) { + proxy.scrollTo(transcriptBottomAnchor, anchor: .bottom) + } } - - /// Terminal outcome for a recording/transcription failure. Unlike a toast, this - /// persists after the session aborts so the overlay does not falsely report - /// "no speech" when the engine actually failed. - private var errorBody: some View { - HStack(spacing: 12) { - CSIconView(icon: .error, size: 18, weight: .regular) - .foregroundStyle(CSColor.terracotta) - VStack(alignment: .leading, spacing: 2) { - Text(state.errorMessage ?? "Transcription failed") - .csFont(15, .medium) - .foregroundStyle(CSColor.textBody) - .fixedSize(horizontal: false, vertical: true) - Text("Recording stopped before a transcript was available.") - .csMono(11, .medium) - .foregroundStyle(CSColor.textFaint) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, minHeight: bodyMinHeight, alignment: .leading) + } + + private var formattedBody: some View { + VStack(alignment: .leading, spacing: 8) { + TextEditor( + text: Binding( + get: { state.formattedText }, + set: { state.userEditedTranscript($0) } + ) + ) + .csFont(15) + .foregroundStyle(CSColor.textHigh) + .lineSpacing(5) + .scrollContentBackground(.hidden) + .background(Color.clear) + .frame(minHeight: bodyMinHeight) + .accessibilityIdentifier("overlay-transcript-formatted") + if let status = state.formatFailureStatus { + Text(status) + .csMono(11, .medium) + .foregroundStyle(CSColor.textFaint) + .accessibilityIdentifier("overlay-format-failure-status") + } } - - // MARK: Action row - - /// U22 semantics: every ACTION (Finish/Copy/Insert/Format/To Agent) is a neutral - /// grey surface — the one exception is Close, the sole destructive control, - /// which wears `CSColor.danger` and must read as red at first glance. - private enum ActionButtonTone { - case neutral - case danger + } + + /// Terminal outcome for a session that captured no usable speech. Replaces + /// the empty editable FINAL with a calm, non-alarming notice (mic glyph + + /// message). No Copy/Format/Send — there is nothing to act on; only Close + /// remains in the action row. + private var noSpeechBody: some View { + HStack(spacing: 12) { + CSIconView(icon: .mic, size: 18, weight: .regular) + .foregroundStyle(CSColor.textFaint) + VStack(alignment: .leading, spacing: 2) { + Text(state.noSpeechNotice) + .csFont(15, .medium) + .foregroundStyle(CSColor.textBody) + .fixedSize(horizontal: false, vertical: true) + Text("Nothing was captured this session.") + .csMono(11, .medium) + .foregroundStyle(CSColor.textFaint) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: bodyMinHeight, alignment: .leading) + } + + /// Terminal outcome for a recording/transcription failure. Unlike a toast, this + /// persists after the session aborts so the overlay does not falsely report + /// "no speech" when the engine actually failed. + private var errorBody: some View { + HStack(spacing: 12) { + CSIconView(icon: .error, size: 18, weight: .regular) + .foregroundStyle(CSColor.terracotta) + VStack(alignment: .leading, spacing: 2) { + Text(state.errorMessage ?? "Transcription failed") + .csFont(15, .medium) + .foregroundStyle(CSColor.textBody) + .fixedSize(horizontal: false, vertical: true) + Text("Recording stopped before a transcript was available.") + .csMono(11, .medium) + .foregroundStyle(CSColor.textFaint) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: bodyMinHeight, alignment: .leading) + } + + // MARK: Action row + + /// U22 semantics: every ACTION (Finish/Copy/Insert/Format/To Agent) is a neutral + /// grey surface — the one exception is Close, the sole destructive control, + /// which wears `CSColor.danger` and must read as red at first glance. + private enum ActionButtonTone { + case neutral + case danger + } + + private var actionRow: some View { + GeometryReader { proxy in + let iconOnly = proxy.size.width < actionIconOnlyThreshold + actionRowContent(iconOnly: iconOnly) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } + .frame(height: actionRowContentHeight) + .padding(.horizontal, 20) + .padding(.vertical, 6) + } + + @ViewBuilder + private func actionRowContent(iconOnly: Bool) -> some View { + HStack(spacing: 8) { + if state.mode == .listening { + actionButton( + title: "Finish", + icon: "checkmark", + tone: .neutral, + iconOnly: iconOnly, + action: { state.stop() } + ) + } else if state.mode == .formatted { + // Terminal empty/error outcomes intentionally show no Copy/Format/Send; + // there is nothing to act on, so only the trailing Close remains. + actionButton( + title: "Copy", + icon: "doc.on.doc", + tone: .neutral, + iconOnly: iconOnly, + action: { state.copyToPasteboard() } + ) + + actionButton( + title: state.insertActionPresentation.title, + help: state.insertActionPresentation.help, + icon: "arrow.down.doc.fill", + tone: .neutral, + iconOnly: iconOnly, + action: { state.pasteToPreviousApp() } + ) - private var actionRow: some View { - GeometryReader { proxy in - let iconOnly = proxy.size.width < actionIconOnlyThreshold - actionRowContent(iconOnly: iconOnly) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + if state.canRevert { + actionButton( + title: "Revert", + help: "Restore the transcript from before the last format", + icon: "arrow.uturn.backward", + tone: .neutral, + iconOnly: iconOnly, + action: { state.revertFormat() } + ) + .accessibilityIdentifier("overlay-format-revert") } - .frame(height: actionRowContentHeight) - .padding(.horizontal, 20) - .padding(.vertical, 6) - } - @ViewBuilder - private func actionRowContent(iconOnly: Bool) -> some View { - HStack(spacing: 8) { - if state.mode == .listening { - actionButton( - title: "Finish", - icon: "checkmark", - tone: .neutral, - iconOnly: iconOnly, - action: { state.stop() } - ) - } else if state.mode == .formatted { - // Terminal empty/error outcomes intentionally show no Copy/Format/Send; - // there is nothing to act on, so only the trailing Close remains. - actionButton( - title: "Copy", - icon: "doc.on.doc", - tone: .neutral, - iconOnly: iconOnly, - action: { state.copyToPasteboard() } - ) - - actionButton( - title: state.insertActionPresentation.title, - help: state.insertActionPresentation.help, - icon: "arrow.down.doc.fill", - tone: .neutral, - iconOnly: iconOnly, - action: { state.pasteToPreviousApp() } - ) - - if state.canRevert { - actionButton( - title: "Revert", - help: "Restore the transcript from before the last format", - icon: "arrow.uturn.backward", - tone: .neutral, - iconOnly: iconOnly, - action: { state.revertFormat() } - ) - .accessibilityIdentifier("overlay-format-revert") - } - - manualFormatMenu(iconOnly: iconOnly) - - actionButton( - title: OverlayActionPresentation.sendTitle, - help: OverlayActionPresentation.sendHelp, - icon: "paperplane.fill", - tone: .neutral, - iconOnly: iconOnly, - action: { state.sendToAgent() } - ) - } + manualFormatMenu(iconOnly: iconOnly) - Spacer(minLength: 0) + actionButton( + title: OverlayActionPresentation.sendTitle, + help: OverlayActionPresentation.sendHelp, + icon: "paperplane.fill", + tone: .neutral, + iconOnly: iconOnly, + action: { state.sendToAgent() } + ) + } - actionButton( - title: "Close", - icon: "xmark", - tone: .danger, - iconOnly: iconOnly, - action: { state.close() } - ) - } - } + Spacer(minLength: 0) - private func manualFormatMenu(iconOnly: Bool) -> some View { - Menu { - ForEach(OverlayActionPresentation.manualFormatLevels) { level in - Button(level.visibleName) { - state.formatTranscript(level: level) - } - } - } label: { - actionButtonLabel( - title: state.isFormatting ? "Formatting..." : OverlayActionPresentation.formatTitle, - icon: "wand.and.stars", - tone: .neutral, - iconOnly: iconOnly - ) - } - .menuStyle(.button) - .csFocusRing(cornerRadius: 8) - .menuIndicator(.hidden) - .help(state.manualFormatHelp) - .disabled(!state.canFormat) - .opacity(state.canFormat ? 1 : 0.45) - .accessibilityLabel(OverlayActionPresentation.formatTitle) - .accessibilityValue(state.autoFormatLevel == .off ? "Auto Format Off" : "Auto Format \(state.autoFormatLevel.visibleName)") - .accessibilityHint(OverlayActionPresentation.formatHelp) - .accessibilityIdentifier("overlay-format-menu") + actionButton( + title: "Close", + icon: "xmark", + tone: .danger, + iconOnly: iconOnly, + action: { state.close() } + ) } + } - private func actionButton( - title: String, - help: String? = nil, - icon: String, - tone: ActionButtonTone, - iconOnly: Bool, - isEnabled: Bool = true, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - actionButtonLabel(title: title, icon: icon, tone: tone, iconOnly: iconOnly) + private func manualFormatMenu(iconOnly: Bool) -> some View { + Menu { + ForEach(OverlayActionPresentation.manualFormatLevels) { level in + Button(level.visibleName) { + state.formatTranscript(level: level) } - .csFocusRing(cornerRadius: 8) - .help(help ?? title) - .accessibilityLabel(title) - .accessibilityHint(help ?? title) - .disabled(!isEnabled) - .opacity(isEnabled ? 1 : 0.45) + } + } label: { + actionButtonLabel( + title: state.isFormatting ? "Formatting..." : OverlayActionPresentation.formatTitle, + icon: "wand.and.stars", + tone: .neutral, + iconOnly: iconOnly + ) } - - @ViewBuilder - private func actionButtonLabel( - title: String, - icon: String, - tone: ActionButtonTone, - iconOnly: Bool - ) -> some View { - let shape = RoundedRectangle(cornerRadius: buttonRadius, style: .continuous) - Group { - if iconOnly { - Image(systemName: icon) - .font(.system(size: 12, weight: .semibold)) - .frame(width: actionIconButtonSize, height: actionIconButtonSize) - } else { - Text(title) - .font(CSFont.ui(12, .semibold)) - .padding(.horizontal, 13) - .padding(.vertical, 6) - } - } - .foregroundStyle(actionForeground(tone)) - .background(actionBackground(tone)) - .overlay { - if let border = actionBorder(tone) { - shape.strokeBorder(border, lineWidth: 1) - } - } - .clipShape(shape) + .menuStyle(.button) + .csFocusRing(cornerRadius: 8) + .menuIndicator(.hidden) + .help(state.manualFormatHelp) + .disabled(!state.canFormat) + .opacity(state.canFormat ? 1 : 0.45) + .accessibilityLabel(OverlayActionPresentation.formatTitle) + .accessibilityValue( + state.autoFormatLevel == .off + ? "Auto Format Off" : "Auto Format \(state.autoFormatLevel.visibleName)" + ) + .accessibilityHint(OverlayActionPresentation.formatHelp) + .accessibilityIdentifier("overlay-format-menu") + } + + private func actionButton( + title: String, + help: String? = nil, + icon: String, + tone: ActionButtonTone, + iconOnly: Bool, + isEnabled: Bool = true, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + actionButtonLabel(title: title, icon: icon, tone: tone, iconOnly: iconOnly) } - - private func actionForeground(_ tone: ActionButtonTone) -> Color { - switch tone { - case .neutral: return CSColor.textBody - case .danger: return CSColor.textHigh - } + .csFocusRing(cornerRadius: 8) + .help(help ?? title) + .accessibilityLabel(title) + .accessibilityHint(help ?? title) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.45) + } + + @ViewBuilder + private func actionButtonLabel( + title: String, + icon: String, + tone: ActionButtonTone, + iconOnly: Bool + ) -> some View { + let shape = RoundedRectangle(cornerRadius: buttonRadius, style: .continuous) + Group { + if iconOnly { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .frame(width: actionIconButtonSize, height: actionIconButtonSize) + } else { + Text(title) + .font(CSFont.ui(12, .semibold)) + .padding(.horizontal, 13) + .padding(.vertical, 6) + } } - - private func actionBackground(_ tone: ActionButtonTone) -> Color { - switch tone { - case .neutral: return CSColor.surfaceRaised(0.04) - case .danger: return CSColor.danger - } + .foregroundStyle(actionForeground(tone)) + .background(actionBackground(tone)) + .overlay { + if let border = actionBorder(tone) { + shape.strokeBorder(border, lineWidth: 1) + } } + .clipShape(shape) + } - private func actionBorder(_ tone: ActionButtonTone) -> Color? { - switch tone { - case .neutral: return CSColor.hairline(0.12) - case .danger: return nil - } + private func actionForeground(_ tone: ActionButtonTone) -> Color { + switch tone { + case .neutral: return CSColor.textBody + case .danger: return CSColor.textHigh } + } - // MARK: Footer - - private var footer: some View { - HStack(spacing: 8) { - HStack(spacing: 6) { - Text("●").foregroundStyle(footerEngineDot) - // Product truth: never hardcode "local whisper". Chip = last serving - // engine when known, else preference (Apple live default). - Text(state.footerEngineLabel).foregroundStyle(CSColor.textFaintAlt) - } - Spacer(minLength: 0) - Text(state.footerRight) - .foregroundStyle(CSColor.textFaintAlt) - .accessibilityIdentifier("overlay-phase-footer") - } - .csMono(10, .medium) - .padding(.horizontal, 20) - .padding(.vertical, 8) + private func actionBackground(_ tone: ActionButtonTone) -> Color { + switch tone { + case .neutral: return CSColor.surfaceRaised(0.04) + case .danger: return CSColor.danger } + } - private var footerEngineDot: Color { - let label = state.footerEngineLabel.lowercased() - if label.contains("apple") { return CSColor.oliveLight } - if label.contains("whisper") { return CSColor.olive } - return CSColor.amber + private func actionBorder(_ tone: ActionButtonTone) -> Color? { + switch tone { + case .neutral: return CSColor.hairline(0.12) + case .danger: return nil + } + } + + // MARK: Footer + + private var footer: some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + Text("●").foregroundStyle(footerEngineDot) + // Product truth: never hardcode "local whisper". Chip = last serving + // engine when known, else preference (Apple live default). + Text(state.footerEngineLabel).foregroundStyle(CSColor.textFaintAlt) + } + Spacer(minLength: 0) + Text(state.footerRight) + .foregroundStyle(CSColor.textFaintAlt) + .accessibilityIdentifier("overlay-phase-footer") } + .csMono(10, .medium) + .padding(.horizontal, 20) + .padding(.vertical, 8) + } + + private var footerEngineDot: Color { + let label = state.footerEngineLabel.lowercased() + if label.contains("apple") { return CSColor.oliveLight } + if label.contains("whisper") { return CSColor.olive } + return CSColor.amber + } } /// Word-reveal caret: 8×18 terracotta block, softpulsing on a 1s cycle (mock). private struct BlinkingCaret: View { - @State private var on = false - var body: some View { - RoundedRectangle(cornerRadius: 1, style: .continuous) - .fill(CSColor.terracotta) - .frame(width: 7, height: 15) - .padding(.bottom, 3) - .opacity(on ? 1 : 0.7) - .onAppear { - withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { - on = true - } - } - } + @State private var on = false + var body: some View { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(CSColor.terracotta) + .frame(width: 7, height: 15) + .padding(.bottom, 3) + .opacity(on ? 1 : 0.7) + .onAppear { + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + on = true + } + } + } } /// Transient notice for no-speech / recoverable engine errors. private struct ToastPill: View { - let text: String - var body: some View { - Text(text) - .font(CSFont.metaMono) - .foregroundStyle(CSColor.textBody) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(CSColor.surfaceRaised(0.06)) - .overlay( - Capsule().strokeBorder(CSColor.hairline(0.14), lineWidth: 1) - ) - .clipShape(Capsule()) - } + let text: String + var body: some View { + Text(text) + .font(CSFont.metaMono) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(CSColor.surfaceRaised(0.06)) + .overlay( + Capsule().strokeBorder(CSColor.hairline(0.14), lineWidth: 1) + ) + .clipShape(Capsule()) + } } #if DEBUG -#Preview("Listening") { + #Preview("Listening") { DictationOverlayView(state: .previewListening()) - .padding(44) - .background( - LinearGradient( - colors: [Color(hex: 0x15110E), CSColor.glassUnder], - startPoint: .topLeading, endPoint: .bottomTrailing - ) + .padding(44) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), CSColor.glassUnder], + startPoint: .topLeading, endPoint: .bottomTrailing ) - .preferredColorScheme(.dark) -} + ) + .preferredColorScheme(.dark) + } -#Preview("Transcribing") { + #Preview("Transcribing") { // Pinned to the window's min content size (320×300) so this preview doubles as // the min-size regression check: "transcribing…" fills the main status slot and // the transcript reserves ~2–3 lines instead of collapsing at the floor. DictationOverlayView(state: .previewTranscribing()) - .frame(width: 320, height: 300) - .padding(44) - .background( - LinearGradient( - colors: [Color(hex: 0x15110E), CSColor.glassUnder], - startPoint: .topLeading, endPoint: .bottomTrailing - ) + .frame(width: 320, height: 300) + .padding(44) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), CSColor.glassUnder], + startPoint: .topLeading, endPoint: .bottomTrailing ) - .preferredColorScheme(.dark) -} + ) + .preferredColorScheme(.dark) + } -#Preview("No speech") { + #Preview("No speech") { // Session ended without usable text: dedicated notice body, no // Copy/Format/Send, only Close. Pinned to the min content size so it also // guards the floor layout for this outcome. DictationOverlayView(state: .previewNoSpeech()) - .frame(width: 320, height: 300) - .padding(44) - .background( - LinearGradient( - colors: [Color(hex: 0x15110E), CSColor.glassUnder], - startPoint: .topLeading, endPoint: .bottomTrailing - ) + .frame(width: 320, height: 300) + .padding(44) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), CSColor.glassUnder], + startPoint: .topLeading, endPoint: .bottomTrailing ) - .preferredColorScheme(.dark) -} + ) + .preferredColorScheme(.dark) + } -#Preview("Formatted") { + #Preview("Formatted") { DictationOverlayView(state: .previewFormatted()) - .padding(44) - .background( - LinearGradient( - colors: [Color(hex: 0x15110E), CSColor.glassUnder], - startPoint: .topLeading, endPoint: .bottomTrailing - ) + .padding(44) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), CSColor.glassUnder], + startPoint: .topLeading, endPoint: .bottomTrailing ) - .preferredColorScheme(.dark) -} + ) + .preferredColorScheme(.dark) + } -#Preview("Formatted · icon actions") { + #Preview("Formatted · icon actions") { DictationOverlayView(state: .previewFormatted()) - .frame(width: 340, height: 300) - .padding(44) - .background( - LinearGradient( - colors: [Color(hex: 0x15110E), CSColor.glassUnder], - startPoint: .topLeading, endPoint: .bottomTrailing - ) + .frame(width: 340, height: 300) + .padding(44) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), CSColor.glassUnder], + startPoint: .topLeading, endPoint: .bottomTrailing ) - .preferredColorScheme(.dark) -} + ) + .preferredColorScheme(.dark) + } -#Preview("Listening · scaled 1.4x") { + #Preview("Listening · scaled 1.4x") { // Exercises `\.csTextScale`: transcript + status render 40% larger while the // window chrome and paddings keep their intrinsic geometry (transcript scrolls // rather than forcing the panel taller). DictationOverlayView(state: .previewListening()) - .environment(\.csTextScale, 1.4) - .frame(width: 470, height: 330) - .padding(44) - .background( - LinearGradient( - colors: [Color(hex: 0x15110E), CSColor.glassUnder], - startPoint: .topLeading, endPoint: .bottomTrailing - ) + .environment(\.csTextScale, 1.4) + .frame(width: 470, height: 330) + .padding(44) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), CSColor.glassUnder], + startPoint: .topLeading, endPoint: .bottomTrailing ) - .preferredColorScheme(.dark) -} + ) + .preferredColorScheme(.dark) + } #endif /// The overlay's brand dot as a real close control. It sits where macOS puts @@ -760,28 +784,28 @@ private struct ToastPill: View { /// to a pointing hand, and neither does this one (U22; reverts 5415e7e's /// pointingHand). Only the dot is live — the wordmark text is inert. private struct CloseDot: View { - var action: () -> Void - @State private var hovered = false - - var body: some View { - Button(action: action) { - ZStack { - ModeDot(color: CSColor.terracotta, size: 9) - if hovered { - Text("\u{00D7}") - .font(.system(size: 9, weight: .heavy)) - .foregroundStyle(Color.black.opacity(0.7)) - .offset(y: -0.5) - } - } - .frame(width: 16, height: 16) - .contentShape(Circle()) - } - .csFocusRing(cornerRadius: 8) - .onHover { inside in - hovered = inside + var action: () -> Void + @State private var hovered = false + + var body: some View { + Button(action: action) { + ZStack { + ModeDot(color: CSColor.terracotta, size: 9) + if hovered { + Text("\u{00D7}") + .font(.system(size: 9, weight: .heavy)) + .foregroundStyle(Color.black.opacity(0.7)) + .offset(y: -0.5) } - .accessibilityLabel("Close overlay") - .accessibilityHint("Closes the dictation overlay") + } + .frame(width: 16, height: 16) + .contentShape(Circle()) + } + .csFocusRing(cornerRadius: 8) + .onHover { inside in + hovered = inside } + .accessibilityLabel("Close overlay") + .accessibilityHint("Closes the dictation overlay") + } } diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift index 17aec644..5b07a109 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI // Borderless floating window host for the dictation overlay. // @@ -12,19 +12,19 @@ import AppKit /// Borderless, non-activating panel that can still become key so the overlay's /// buttons (Copy / Send to Agent / Close) receive clicks without stealing app focus. final class FloatingOverlayPanel: NSPanel, NSWindowDelegate { - var onUserMove: (() -> Void)? - var onUserResize: (() -> Void)? + var onUserMove: (() -> Void)? + var onUserResize: (() -> Void)? - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { false } + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } - func windowDidMove(_ notification: Notification) { - onUserMove?() - } + func windowDidMove(_ notification: Notification) { + onUserMove?() + } - func windowDidResize(_ notification: Notification) { - onUserResize?() - } + func windowDidResize(_ notification: Notification) { + onUserResize?() + } } /// Content container for the overlay panel. Its sole job is to keep the SwiftUI @@ -40,155 +40,155 @@ final class FloatingOverlayPanel: NSPanel, NSWindowDelegate { /// constraints, so the content↔window sizing feedback loop that once hung the app /// stays structurally dead. private final class OverlayContentContainer: NSView { - private let hosting: NSView - - init(hosting: NSView) { - self.hosting = hosting - super.init(frame: .zero) - addSubview(hosting) - hosting.frame = bounds - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - hosting.frame = bounds - } - - override func layout() { - super.layout() - hosting.frame = bounds - } + private let hosting: NSView + + init(hosting: NSView) { + self.hosting = hosting + super.init(frame: .zero) + addSubview(hosting) + hosting.frame = bounds + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + hosting.frame = bounds + } + + override func layout() { + super.layout() + hosting.frame = bounds + } } enum DictationOverlayWindow { - /// Hard floor for the panel's content size — below this the glass chrome and - /// compact action row overlap. Enforced for user edge-drag (`minSize`/`contentMinSize`) - /// AND for every programmatic `setFrame` via `clamp(_:to:)` (AppKit does not - /// apply `minSize` to programmatic frames). - /// Height raised 250 → 300 so the live-transcript body keeps its reserved floor - /// (`DictationOverlayView.bodyMinHeight` = waveform block + ~3 transcript - /// lines) without the content column overflowing the window and squaring the - /// glass corners. U22 kept 300 in lockstep: the action row slimmed by ~16pt - /// and `bodyMinHeight` grew 114 → 130 by the same amount, so the chrome + - /// body sum is unchanged (and the view now carries a terminal window-frame - /// clip as the structural backstop). Width floor (320) is unchanged. - static let minSize = NSSize(width: 320, height: 300) - /// First-launch content size (no persisted value yet). LANDSCAPE rectangle — - /// operator spec: the resting state is a horizontal bar (waveform + a few - /// transcript lines), never a portrait column. Resizing persists, so users - /// who prefer a tall panel drag it once and keep it. - static let defaultSize = NSSize(width: 470, height: 330) - /// Bumped v4 → v5: v4 shipped a portrait default by mistake; the restored - /// landscape default must take effect once over that persisted shape. - private static let sizeDefaultsKey = "DictationOverlayPanel.contentSize.v5" - - /// Build the floating overlay panel around an injected `OverlayState`. - /// The state's `engine`, `onClose`, and `onSendToAgent` are wired by the - /// orchestrator before the panel is shown. - @MainActor - static func make(state: OverlayState, textScale: TextScaleController) -> NSPanel { - // Wrap in TextScaleRoot so ⌘+/-/0 on this panel scale the overlay text - // (transcript + status) via `\.csTextScale`, independently of the chat. - let root = TextScaleRoot(controller: textScale) { DictationOverlayView(state: state) } - let hosting = NSHostingView(rootView: root) - // CRITICAL: the WINDOW owns its size; the SwiftUI content only fills whatever - // frame the window has. An NSHostingView otherwise installs Auto Layout - // min/max/intrinsic constraints derived from its (flexible, constantly - // animating) fitting size and pushes them onto the window every display - // cycle. On a `.resizable` panel that closed a content↔window feedback loop: - // the window resized to the fitting size → the flexible content re-fit to the - // new frame → a different fitting size → … The two chased each other, - // oscillating between two sizes and grinding the main thread in - // `updateConstraintsIfNeeded → NSHostingView.updateConstraints` until the app - // hung. Empty `sizingOptions` removes those constraints entirely; the panel is - // sized only by us (`setFrame`) and by the user's edge-drag. Setting the - // hosting VIEW (not just an NSHostingController) is what actually stops the - // constraint export. - hosting.sizingOptions = [] - // Fill by an ABSOLUTE frame sync (OverlayContentContainer), not an - // autoresizing mask. AppKit's spring mask resizes by deltas from the view's - // initial frame; on a borderless resizable panel those deltas drift the - // hosting view off the window's content bounds after an edge-drag, clipping - // content at the edges and squaring off the rounded glass corners. Frame-based - // layout (no exported constraints) keeps the sizing feedback loop dead while - // the container re-pins the hosting frame to its bounds on every resize step. - hosting.translatesAutoresizingMaskIntoConstraints = true - hosting.autoresizingMask = [] - - let panel = FloatingOverlayPanel( - contentRect: NSRect(origin: .zero, size: restoredContentSize()), - styleMask: [.borderless, .nonactivatingPanel, .resizable], - backing: .buffered, - defer: false - ) - panel.delegate = panel - panel.onUserMove = { [weak state] in state?.userDraggedOverlay() } - panel.onUserResize = { [weak state] in state?.userResizedOverlay() } - panel.contentView = OverlayContentContainer(hosting: hosting) - - // User-resizable: borderless windows still honour edge-drag resize when - // `.resizable` is set. Floor keeps the glass chrome + action row readable. - panel.minSize = minSize - panel.contentMinSize = minSize - // Size is persisted manually (see `persist`/`restoredContentSize`), NOT via - // `setFrameAutosaveName`: autosave on a borderless resizable panel wrote back - // the runaway sizes produced by the old feedback loop and restored a stale, - // oversized frame on relaunch (ghost-outline / clipped-content states). The - // orchestrator re-centres the origin on every show() and clamps the restored - // size to the current screen. - - // Transparent chrome so the SwiftUI glass material is the only surface. - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false // GlassPanel paints its own deep shadow. - - // Float above normal windows, ride along every Space, never take app focus. - panel.level = .floating - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] - panel.isFloatingPanel = true - panel.hidesOnDeactivate = false - panel.isMovableByWindowBackground = true // draggable via background; edges resize - - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.standardWindowButton(.closeButton)?.isHidden = true - panel.standardWindowButton(.miniaturizeButton)?.isHidden = true - panel.standardWindowButton(.zoomButton)?.isHidden = true - - // Size is window-owned (user-resizable) — do NOT resize to fittingSize each frame. - return panel - } - - /// Clamp a content size to the hard floor and to the screen's visible frame, so a - /// programmatic `setFrame` (which AppKit does NOT clamp to `minSize`) or a stale - /// persisted size can never render smaller than the layout minimum or larger than - /// the current display. - static func clamp(_ size: NSSize, to screen: NSScreen? = NSScreen.main) -> NSSize { - var width = max(size.width, minSize.width) - var height = max(size.height, minSize.height) - if let visible = screen?.visibleFrame { - width = min(width, visible.width) - height = min(height, visible.height) - } - return NSSize(width: width, height: height) - } - - /// Restore the user's last content size (clamped), or the default on first launch. - static func restoredContentSize(for screen: NSScreen? = NSScreen.main) -> NSSize { - let defaults = UserDefaults.standard - let width = defaults.double(forKey: sizeDefaultsKey + ".w") - let height = defaults.double(forKey: sizeDefaultsKey + ".h") - let raw = (width > 0 && height > 0) ? NSSize(width: width, height: height) : defaultSize - return clamp(raw, to: screen) - } - - /// Persist the current content size so it survives relaunch. Called on hide(). - static func persist(size: NSSize) { - let defaults = UserDefaults.standard - defaults.set(Double(size.width), forKey: sizeDefaultsKey + ".w") - defaults.set(Double(size.height), forKey: sizeDefaultsKey + ".h") + /// Hard floor for the panel's content size — below this the glass chrome and + /// compact action row overlap. Enforced for user edge-drag (`minSize`/`contentMinSize`) + /// AND for every programmatic `setFrame` via `clamp(_:to:)` (AppKit does not + /// apply `minSize` to programmatic frames). + /// Height raised 250 → 300 so the live-transcript body keeps its reserved floor + /// (`DictationOverlayView.bodyMinHeight` = waveform block + ~3 transcript + /// lines) without the content column overflowing the window and squaring the + /// glass corners. U22 kept 300 in lockstep: the action row slimmed by ~16pt + /// and `bodyMinHeight` grew 114 → 130 by the same amount, so the chrome + + /// body sum is unchanged (and the view now carries a terminal window-frame + /// clip as the structural backstop). Width floor (320) is unchanged. + static let minSize = NSSize(width: 320, height: 300) + /// First-launch content size (no persisted value yet). LANDSCAPE rectangle — + /// operator spec: the resting state is a horizontal bar (waveform + a few + /// transcript lines), never a portrait column. Resizing persists, so users + /// who prefer a tall panel drag it once and keep it. + static let defaultSize = NSSize(width: 470, height: 330) + /// Bumped v4 → v5: v4 shipped a portrait default by mistake; the restored + /// landscape default must take effect once over that persisted shape. + private static let sizeDefaultsKey = "DictationOverlayPanel.contentSize.v5" + + /// Build the floating overlay panel around an injected `OverlayState`. + /// The state's `engine`, `onClose`, and `onSendToAgent` are wired by the + /// orchestrator before the panel is shown. + @MainActor + static func make(state: OverlayState, textScale: TextScaleController) -> NSPanel { + // Wrap in TextScaleRoot so ⌘+/-/0 on this panel scale the overlay text + // (transcript + status) via `\.csTextScale`, independently of the chat. + let root = TextScaleRoot(controller: textScale) { DictationOverlayView(state: state) } + let hosting = NSHostingView(rootView: root) + // CRITICAL: the WINDOW owns its size; the SwiftUI content only fills whatever + // frame the window has. An NSHostingView otherwise installs Auto Layout + // min/max/intrinsic constraints derived from its (flexible, constantly + // animating) fitting size and pushes them onto the window every display + // cycle. On a `.resizable` panel that closed a content↔window feedback loop: + // the window resized to the fitting size → the flexible content re-fit to the + // new frame → a different fitting size → … The two chased each other, + // oscillating between two sizes and grinding the main thread in + // `updateConstraintsIfNeeded → NSHostingView.updateConstraints` until the app + // hung. Empty `sizingOptions` removes those constraints entirely; the panel is + // sized only by us (`setFrame`) and by the user's edge-drag. Setting the + // hosting VIEW (not just an NSHostingController) is what actually stops the + // constraint export. + hosting.sizingOptions = [] + // Fill by an ABSOLUTE frame sync (OverlayContentContainer), not an + // autoresizing mask. AppKit's spring mask resizes by deltas from the view's + // initial frame; on a borderless resizable panel those deltas drift the + // hosting view off the window's content bounds after an edge-drag, clipping + // content at the edges and squaring off the rounded glass corners. Frame-based + // layout (no exported constraints) keeps the sizing feedback loop dead while + // the container re-pins the hosting frame to its bounds on every resize step. + hosting.translatesAutoresizingMaskIntoConstraints = true + hosting.autoresizingMask = [] + + let panel = FloatingOverlayPanel( + contentRect: NSRect(origin: .zero, size: restoredContentSize()), + styleMask: [.borderless, .nonactivatingPanel, .resizable], + backing: .buffered, + defer: false + ) + panel.delegate = panel + panel.onUserMove = { [weak state] in state?.userDraggedOverlay() } + panel.onUserResize = { [weak state] in state?.userResizedOverlay() } + panel.contentView = OverlayContentContainer(hosting: hosting) + + // User-resizable: borderless windows still honour edge-drag resize when + // `.resizable` is set. Floor keeps the glass chrome + action row readable. + panel.minSize = minSize + panel.contentMinSize = minSize + // Size is persisted manually (see `persist`/`restoredContentSize`), NOT via + // `setFrameAutosaveName`: autosave on a borderless resizable panel wrote back + // the runaway sizes produced by the old feedback loop and restored a stale, + // oversized frame on relaunch (ghost-outline / clipped-content states). The + // orchestrator re-centres the origin on every show() and clamps the restored + // size to the current screen. + + // Transparent chrome so the SwiftUI glass material is the only surface. + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false // GlassPanel paints its own deep shadow. + + // Float above normal windows, ride along every Space, never take app focus. + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + panel.isMovableByWindowBackground = true // draggable via background; edges resize + + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.standardWindowButton(.closeButton)?.isHidden = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + + // Size is window-owned (user-resizable) — do NOT resize to fittingSize each frame. + return panel + } + + /// Clamp a content size to the hard floor and to the screen's visible frame, so a + /// programmatic `setFrame` (which AppKit does NOT clamp to `minSize`) or a stale + /// persisted size can never render smaller than the layout minimum or larger than + /// the current display. + static func clamp(_ size: NSSize, to screen: NSScreen? = NSScreen.main) -> NSSize { + var width = max(size.width, minSize.width) + var height = max(size.height, minSize.height) + if let visible = screen?.visibleFrame { + width = min(width, visible.width) + height = min(height, visible.height) } + return NSSize(width: width, height: height) + } + + /// Restore the user's last content size (clamped), or the default on first launch. + static func restoredContentSize(for screen: NSScreen? = NSScreen.main) -> NSSize { + let defaults = UserDefaults.standard + let width = defaults.double(forKey: sizeDefaultsKey + ".w") + let height = defaults.double(forKey: sizeDefaultsKey + ".h") + let raw = (width > 0 && height > 0) ? NSSize(width: width, height: height) : defaultSize + return clamp(raw, to: screen) + } + + /// Persist the current content size so it survives relaunch. Called on hide(). + static func persist(size: NSSize) { + let defaults = UserDefaults.standard + defaults.set(Double(size.width), forKey: sizeDefaultsKey + ".w") + defaults.set(Double(size.height), forKey: sizeDefaultsKey + ".h") + } } diff --git a/macos/Codescribe/Screens/Overlay/OverlayHighlight.swift b/macos/Codescribe/Screens/Overlay/OverlayHighlight.swift new file mode 100644 index 00000000..57274494 --- /dev/null +++ b/macos/Codescribe/Screens/Overlay/OverlayHighlight.swift @@ -0,0 +1,346 @@ +import AppKit +import SwiftUI + +/// One span-based overlay highlight. Sample fields carry W13-3A PCM identity; +/// char offsets are the adapter onto already-committed utterance text. +struct OverlayHighlight: Equatable, Identifiable { + enum Kind: String, Equatable { + case lexiconCorrected = "lexicon_corrected" + case speechGap = "speech_gap" + } + + var id: String { + "\(kind.rawValue):\(utteranceId):\(charStart):\(charEnd):\(sampleStart):\(sampleEnd)" + } + + var kind: Kind + var utteranceId: UInt64 + var charStart: UInt64 + var charEnd: UInt64 + var session: String + var captureEpoch: UInt64 + var sampleStart: UInt64 + var sampleEnd: UInt64 + var before: String + var after: String + var taught: Bool = false + + var teachKind: String { kind.rawValue } + + var teachVariant: String { + switch kind { + case .lexiconCorrected: return before + case .speechGap: return after + } + } + + var teachCanonical: String { + switch kind { + case .lexiconCorrected: return after + case .speechGap: return after + } + } + + var accessibilityLabel: String { + switch kind { + case .lexiconCorrected: + return "Lexicon correction \(before) to \(after)" + case .speechGap: + return "Speech gap, no words landed" + } + } +} + +/// Visual run on the live canvas. Highlights stay buttons so one click +/// sends the span to Teach without a new permission prompt. +enum OverlayCanvasRun: Equatable { + case text(String) + case highlight(OverlayHighlight) +} + +enum OverlayCanvas { + static func lexiconHighlight( + utteranceId: UInt64, + start: UInt64, + replacement: String, + before: String, + session: String = "overlay", + captureEpoch: UInt64 = 0, + sampleStart: UInt64 = 0, + sampleEnd: UInt64 = 0 + ) -> OverlayHighlight? { + let trimmed = replacement.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return OverlayHighlight( + kind: .lexiconCorrected, + utteranceId: utteranceId, + charStart: start, + charEnd: start + UInt64(replacement.count), + session: session, + captureEpoch: captureEpoch, + sampleStart: sampleStart, + sampleEnd: sampleEnd, + before: before, + after: replacement + ) + } + + static func speechGap( + utteranceId: UInt64, + session: String = "overlay", + captureEpoch: UInt64 = 0, + sampleStart: UInt64 = 0, + sampleEnd: UInt64 = 0 + ) -> OverlayHighlight { + OverlayHighlight( + kind: .speechGap, + utteranceId: utteranceId, + charStart: 0, + charEnd: 0, + session: session, + captureEpoch: captureEpoch, + sampleStart: sampleStart, + sampleEnd: sampleEnd, + before: "", + after: "∅" + ) + } + + /// Split committed utterance text on highlight char ranges, then append + /// speech-gap markers and the live preview. Pure; used by the view and tests. + static func runs( + segments: [(utteranceId: UInt64?, text: String)], + highlights: [OverlayHighlight], + preview: String + ) -> [OverlayCanvasRun] { + var out: [OverlayCanvasRun] = [] + for (index, segment) in segments.enumerated() { + if index > 0 { appendText(&out, " ") } + let owned = highlights.filter { highlight in + guard let utteranceId = segment.utteranceId else { return false } + return highlight.utteranceId == utteranceId + } + appendSegmentRuns(&out, text: segment.text, highlights: owned) + } + let preview = preview.trimmingCharacters(in: .whitespacesAndNewlines) + if !preview.isEmpty { + if !out.isEmpty { appendText(&out, " ") } + appendText(&out, preview) + } + return mergeAdjacentText(out) + } + + private static func appendSegmentRuns( + _ out: inout [OverlayCanvasRun], + text: String, + highlights: [OverlayHighlight] + ) { + let lexicon = highlights + .filter { $0.kind == .lexiconCorrected } + .sorted { $0.charStart < $1.charStart } + var cursor = 0 + let chars = Array(text) + for highlight in lexicon { + let start = min(max(Int(highlight.charStart), cursor), chars.count) + let end = min(max(Int(highlight.charEnd), start), chars.count) + if start > cursor { + appendText(&out, String(chars[cursor.. start { + out.append(.highlight(highlight)) + } + cursor = end + } + if cursor < chars.count { + appendText(&out, String(chars[cursor...])) + } + for gap in highlights where gap.kind == .speechGap { + if !out.isEmpty { appendText(&out, " ") } + out.append(.highlight(gap)) + } + } + + private static func appendText(_ out: inout [OverlayCanvasRun], _ text: String) { + guard !text.isEmpty else { return } + out.append(.text(text)) + } + + private static func mergeAdjacentText(_ runs: [OverlayCanvasRun]) -> [OverlayCanvasRun] { + var merged: [OverlayCanvasRun] = [] + for run in runs { + if case .text(let next) = run, case .text(let prev)? = merged.last { + merged[merged.count - 1] = .text(prev + next) + } else { + merged.append(run) + } + } + return merged + } +} + +/// Live transcript with lexicon tints and clickable pustka markers. +struct OverlayHighlightCanvas: View { + let runs: [OverlayCanvasRun] + let selectedId: String? + let onSelect: (OverlayHighlight) -> Void + + var body: some View { + // Wrapping HStack of runs: the live canvas is short enough that a + // flow layout is unnecessary, and Text+Button keeps hit targets honest. + WrappedRuns(runs: runs, selectedId: selectedId, onSelect: onSelect) + .accessibilityIdentifier("overlay-highlight-canvas") + } +} + +private struct WrappedRuns: View { + let runs: [OverlayCanvasRun] + let selectedId: String? + let onSelect: (OverlayHighlight) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + FlowText(runs: runs, selectedId: selectedId, onSelect: onSelect) + } + } +} + +/// Single wrapping text line built from mixed text + highlight buttons. +private struct FlowText: View { + let runs: [OverlayCanvasRun] + let selectedId: String? + let onSelect: (OverlayHighlight) -> Void + + var body: some View { + runs.reduce(Text("")) { acc, run in + acc + rendered(run) + } + .csFont(15, .medium) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + .overlay(alignment: .topLeading) { + // Invisible hit buttons stacked over highlight ranges are brittle. + // A row of explicit chips under the line is the Teach affordance. + EmptyView() + } + .accessibilityIdentifier("overlay-transcript-live") + } + + private func rendered(_ run: OverlayCanvasRun) -> Text { + switch run { + case .text(let text): + return Text(text).foregroundColor(CSColor.textBody) + case .highlight(let highlight): + return Text(highlight.after) + .foregroundColor(highlightColor(highlight)) + .underline(highlight.kind == .speechGap, color: CSColor.amber) + } + } + + private func highlightColor(_ highlight: OverlayHighlight) -> Color { + switch highlight.kind { + case .lexiconCorrected: + return highlight.taught ? CSColor.oliveLight : CSColor.terracottaLight + case .speechGap: + return CSColor.amber + } + } +} + +/// Clickable Teach chips for the highlighted spans. Separate from the +/// transcript text so TextEditor / wrapping Text stay simple. +struct OverlayHighlightTeachBar: View { + let highlights: [OverlayHighlight] + let selectedId: String? + let onSelect: (OverlayHighlight) -> Void + let onTeach: (OverlayHighlight) -> Void + + var body: some View { + if !highlights.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Highlights") + .csMono(10, .medium) + .foregroundStyle(CSColor.textFaint) + .accessibilityIdentifier("overlay-highlight-eyebrow") + ForEach(highlights) { highlight in + HStack(spacing: 8) { + Button { + onSelect(highlight) + } label: { + Text(chipLabel(highlight)) + .csFont(12, .medium) + .foregroundStyle(chipForeground(highlight)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(chipBackground(highlight), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(chipIdentifier(highlight)) + .accessibilityLabel(highlight.accessibilityLabel) + + Button("Teach") { + onTeach(highlight) + } + .buttonStyle(.plain) + .csMono(11, .medium) + .foregroundStyle(CSColor.terracottaLight) + .accessibilityIdentifier("overlay-highlight-teach-\(highlight.id)") + .disabled(highlight.taught) + } + } + } + .accessibilityIdentifier("overlay-highlight-teach-bar") + } + } + + private func chipLabel(_ highlight: OverlayHighlight) -> String { + switch highlight.kind { + case .lexiconCorrected: + return "\(highlight.before) → \(highlight.after)" + case .speechGap: + return "pustka \(highlight.after)" + } + } + + private func chipIdentifier(_ highlight: OverlayHighlight) -> String { + switch highlight.kind { + case .lexiconCorrected: return "overlay-highlight-lexicon-\(highlight.utteranceId)" + case .speechGap: return "overlay-highlight-gap-\(highlight.utteranceId)" + } + } + + private func chipForeground(_ highlight: OverlayHighlight) -> Color { + selectedId == highlight.id ? CSColor.ink : CSColor.textBody + } + + private func chipBackground(_ highlight: OverlayHighlight) -> Color { + if selectedId == highlight.id { return CSColor.terracottaLight } + switch highlight.kind { + case .lexiconCorrected: return CSColor.terracotta.opacity(0.28) + case .speechGap: return CSColor.amber.opacity(0.22) + } + } +} + +/// Compact canvas used for the W13-6B screenshot verifier (no live engine). +struct OverlayHighlightScreenshot: View { + let runs: [OverlayCanvasRun] + let highlights: [OverlayHighlight] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Codescribe · live canvas") + .csMono(10, .medium) + .foregroundStyle(CSColor.textFaint) + OverlayHighlightCanvas(runs: runs, selectedId: highlights.first?.id, onSelect: { _ in }) + OverlayHighlightTeachBar( + highlights: highlights, + selectedId: highlights.first?.id, + onSelect: { _ in }, + onTeach: { _ in } + ) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(CSColor.ink) + } +} diff --git a/macos/Codescribe/Screens/Overlay/OverlayPlacement.swift b/macos/Codescribe/Screens/Overlay/OverlayPlacement.swift index da111c6f..92cb68bc 100644 --- a/macos/Codescribe/Screens/Overlay/OverlayPlacement.swift +++ b/macos/Codescribe/Screens/Overlay/OverlayPlacement.swift @@ -12,110 +12,110 @@ import AppKit // Size is persisted independently of either mode (DictationOverlayWindow). enum OverlayAnchor: String, CaseIterable, Identifiable { - case topLeft = "top-left" - case topCenter = "top-center" - case topRight = "top-right" - case bottomLeft = "bottom-left" - case bottomCenter = "bottom-center" - case bottomRight = "bottom-right" + case topLeft = "top-left" + case topCenter = "top-center" + case topRight = "top-right" + case bottomLeft = "bottom-left" + case bottomCenter = "bottom-center" + case bottomRight = "bottom-right" - var id: String { rawValue } + var id: String { rawValue } - var label: String { - switch self { - case .topLeft: return "Top Left" - case .topCenter: return "Top Center" - case .topRight: return "Top Right" - case .bottomLeft: return "Bottom Left" - case .bottomCenter: return "Bottom Center" - case .bottomRight: return "Bottom Right" - } + var label: String { + switch self { + case .topLeft: return "Top Left" + case .topCenter: return "Top Center" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomCenter: return "Bottom Center" + case .bottomRight: return "Bottom Right" } + } } enum OverlayPlacement { - /// Gap between the panel and the visible-frame edge. The visible frame - /// already excludes the menu bar, so a top anchor sits just under it — - /// `.topRight` lands the panel under the tray icon. - static let margin: CGFloat = 12 + /// Gap between the panel and the visible-frame edge. The visible frame + /// already excludes the menu bar, so a top anchor sits just under it — + /// `.topRight` lands the panel under the tray icon. + static let margin: CGFloat = 12 - static let defaultAnchor: OverlayAnchor = .topRight + static let defaultAnchor: OverlayAnchor = .topRight - private static let anchorKey = "DictationOverlayPanel.anchor.v1" - private static let freeMotionKey = "DictationOverlayPanel.freeMotion.v1" - private static let originKey = "DictationOverlayPanel.origin.v1" + private static let anchorKey = "DictationOverlayPanel.anchor.v1" + private static let freeMotionKey = "DictationOverlayPanel.freeMotion.v1" + private static let originKey = "DictationOverlayPanel.origin.v1" - static var anchor: OverlayAnchor { - get { - guard let raw = UserDefaults.standard.string(forKey: anchorKey), - let stored = OverlayAnchor(rawValue: raw) - else { return defaultAnchor } - return stored - } - set { UserDefaults.standard.set(newValue.rawValue, forKey: anchorKey) } + static var anchor: OverlayAnchor { + get { + guard let raw = UserDefaults.standard.string(forKey: anchorKey), + let stored = OverlayAnchor(rawValue: raw) + else { return defaultAnchor } + return stored } + set { UserDefaults.standard.set(newValue.rawValue, forKey: anchorKey) } + } - static var freeMotion: Bool { - get { UserDefaults.standard.bool(forKey: freeMotionKey) } - set { UserDefaults.standard.set(newValue, forKey: freeMotionKey) } - } + static var freeMotion: Bool { + get { UserDefaults.standard.bool(forKey: freeMotionKey) } + set { UserDefaults.standard.set(newValue, forKey: freeMotionKey) } + } - /// Pure anchor→origin math over a visible frame, split from the NSScreen - /// wrapper so it is unit-testable without a display. - static func origin(for anchor: OverlayAnchor, size: NSSize, in visible: NSRect) -> NSPoint { - let x: CGFloat - switch anchor { - case .topLeft, .bottomLeft: - x = visible.minX + margin - case .topCenter, .bottomCenter: - x = visible.midX - size.width / 2 - case .topRight, .bottomRight: - x = visible.maxX - size.width - margin - } - let y: CGFloat - switch anchor { - case .topLeft, .topCenter, .topRight: - y = visible.maxY - size.height - margin - case .bottomLeft, .bottomCenter, .bottomRight: - y = visible.minY + margin - } - return NSPoint(x: x, y: y) + /// Pure anchor→origin math over a visible frame, split from the NSScreen + /// wrapper so it is unit-testable without a display. + static func origin(for anchor: OverlayAnchor, size: NSSize, in visible: NSRect) -> NSPoint { + let x: CGFloat + switch anchor { + case .topLeft, .bottomLeft: + x = visible.minX + margin + case .topCenter, .bottomCenter: + x = visible.midX - size.width / 2 + case .topRight, .bottomRight: + x = visible.maxX - size.width - margin } - - static func origin(for anchor: OverlayAnchor, size: NSSize, on screen: NSScreen?) -> NSPoint? { - guard let visible = screen?.visibleFrame else { return nil } - return origin(for: anchor, size: size, in: visible) + let y: CGFloat + switch anchor { + case .topLeft, .topCenter, .topRight: + y = visible.maxY - size.height - margin + case .bottomLeft, .bottomCenter, .bottomRight: + y = visible.minY + margin } + return NSPoint(x: x, y: y) + } - /// Free-motion memory: the last dragged origin, restored on show. - static func persistOrigin(_ point: NSPoint) { - let defaults = UserDefaults.standard - defaults.set(Double(point.x), forKey: originKey + ".x") - defaults.set(Double(point.y), forKey: originKey + ".y") - } + static func origin(for anchor: OverlayAnchor, size: NSSize, on screen: NSScreen?) -> NSPoint? { + guard let visible = screen?.visibleFrame else { return nil } + return origin(for: anchor, size: size, in: visible) + } - /// Restore the persisted free-motion origin, clamped so the panel stays - /// fully inside the screen's visible frame (displays may have changed). - static func restoredOrigin(size: NSSize, on screen: NSScreen?) -> NSPoint? { - let defaults = UserDefaults.standard - guard defaults.object(forKey: originKey + ".x") != nil, - defaults.object(forKey: originKey + ".y") != nil - else { return nil } - let raw = NSPoint( - x: defaults.double(forKey: originKey + ".x"), - y: defaults.double(forKey: originKey + ".y") - ) - guard let visible = screen?.visibleFrame else { return raw } - return clampOrigin(raw, size: size, in: visible) - } + /// Free-motion memory: the last dragged origin, restored on show. + static func persistOrigin(_ point: NSPoint) { + let defaults = UserDefaults.standard + defaults.set(Double(point.x), forKey: originKey + ".x") + defaults.set(Double(point.y), forKey: originKey + ".y") + } - /// Pure clamp, testable without a display. - static func clampOrigin(_ origin: NSPoint, size: NSSize, in visible: NSRect) -> NSPoint { - let maxX = visible.maxX - size.width - let maxY = visible.maxY - size.height - return NSPoint( - x: min(max(origin.x, visible.minX), max(visible.minX, maxX)), - y: min(max(origin.y, visible.minY), max(visible.minY, maxY)) - ) - } + /// Restore the persisted free-motion origin, clamped so the panel stays + /// fully inside the screen's visible frame (displays may have changed). + static func restoredOrigin(size: NSSize, on screen: NSScreen?) -> NSPoint? { + let defaults = UserDefaults.standard + guard defaults.object(forKey: originKey + ".x") != nil, + defaults.object(forKey: originKey + ".y") != nil + else { return nil } + let raw = NSPoint( + x: defaults.double(forKey: originKey + ".x"), + y: defaults.double(forKey: originKey + ".y") + ) + guard let visible = screen?.visibleFrame else { return raw } + return clampOrigin(raw, size: size, in: visible) + } + + /// Pure clamp, testable without a display. + static func clampOrigin(_ origin: NSPoint, size: NSSize, in visible: NSRect) -> NSPoint { + let maxX = visible.maxX - size.width + let maxY = visible.maxY - size.height + return NSPoint( + x: min(max(origin.x, visible.minX), max(visible.minX, maxX)), + y: min(max(origin.y, visible.minY), max(visible.minY, maxY)) + ) + } } diff --git a/macos/Codescribe/Screens/Overlay/OverlayState.swift b/macos/Codescribe/Screens/Overlay/OverlayState.swift index a0e02ecf..64acc0f7 100644 --- a/macos/Codescribe/Screens/Overlay/OverlayState.swift +++ b/macos/Codescribe/Screens/Overlay/OverlayState.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI // View model for the dictation overlay, backed by the redesign hotkey/controller // bridge (`CodescribeHotkeys` / `CsTranscriptionListener`). @@ -23,119 +23,121 @@ import AppKit // MARK: - Engine seam (orchestrator injects the real adapter in App.swift) private struct OverlayTranscriptAnnotation: Equatable { - var position: Int - var text: String + var position: Int + var text: String } private struct OverlayContextMarker: Equatable { - var position: Int - var marker: String - var order: Int + var position: Int + var marker: String + var order: Int } private struct OverlayTranscriptSegment: Equatable { - var utteranceId: UInt64? - var text: String - var annotations: [OverlayTranscriptAnnotation] = [] - - var renderedText: String { - guard !annotations.isEmpty else { return text } - var rendered = text - for annotation in annotations.sorted(by: { $0.position > $1.position }) { - let bounded = min(max(annotation.position, 0), rendered.count) - let index = rendered.index(rendered.startIndex, offsetBy: bounded) - rendered.insert(contentsOf: " [\(annotation.text)]", at: index) - } - return rendered - } - - mutating func replaceRange(start: UInt64, end: UInt64, replacement: String) -> Bool { - guard start <= end, - let startOffset = Int(exactly: start), - let endOffset = Int(exactly: end), - endOffset <= text.count else { return false } - let startIndex = text.index(text.startIndex, offsetBy: startOffset) - let endIndex = text.index(text.startIndex, offsetBy: endOffset) - text.replaceSubrange(startIndex.. Int { - let bounded = min(max(offset, 0), text.count) - let shift = annotations - .filter { $0.position <= bounded } - .reduce(0) { $0 + $1.text.count + 3 } - return bounded + shift - } - - mutating func insertAnnotation(position: UInt64, text annotationText: String) -> Bool { - guard let offset = Int(exactly: position), offset <= text.count else { return false } - annotations.append(OverlayTranscriptAnnotation(position: offset, text: annotationText)) - return true - } + var utteranceId: UInt64? + var text: String + var annotations: [OverlayTranscriptAnnotation] = [] + + var renderedText: String { + guard !annotations.isEmpty else { return text } + var rendered = text + for annotation in annotations.sorted(by: { $0.position > $1.position }) { + let bounded = min(max(annotation.position, 0), rendered.count) + let index = rendered.index(rendered.startIndex, offsetBy: bounded) + rendered.insert(contentsOf: " [\(annotation.text)]", at: index) + } + return rendered + } + + mutating func replaceRange(start: UInt64, end: UInt64, replacement: String) -> Bool { + guard start <= end, + let startOffset = Int(exactly: start), + let endOffset = Int(exactly: end), + endOffset <= text.count + else { return false } + let startIndex = text.index(text.startIndex, offsetBy: startOffset) + let endIndex = text.index(text.startIndex, offsetBy: endOffset) + text.replaceSubrange(startIndex.. Int { + let bounded = min(max(offset, 0), text.count) + let shift = + annotations + .filter { $0.position <= bounded } + .reduce(0) { $0 + $1.text.count + 3 } + return bounded + shift + } + + mutating func insertAnnotation(position: UInt64, text annotationText: String) -> Bool { + guard let offset = Int(exactly: position), offset <= text.count else { return false } + annotations.append(OverlayTranscriptAnnotation(position: offset, text: annotationText)) + return true + } } /// Minimal slice of the controller-backed dictation surface the overlay needs. /// Kept as a protocol so the view-model + preview compile without a live Rust core. protocol DictationEngine: AnyObject { - func setListener(_ listener: CsTranscriptionListener) - func startRecording(language: CsLanguage?) async throws - func stopRecording() async throws -> String - func isRecording() async -> Bool - func initModel() async throws - func isModelLoaded() -> Bool - func isFormattingAvailable() -> Bool - func currentOverlayPolicy() -> OverlayPolicySnapshot? - func setAutoPasteEnabled(_ enabled: Bool) - func formatText( - text: String, - language: CsLanguage?, - level: FormattingPolicyOption - ) async throws -> String - func pasteText(text: String) async throws -> CsPasteResult - func deferText(text: String) async throws -> CsPasteResult - func copyTaggedTranscript(text: String) async throws - func pasteTargetAppName() async -> String? - func sendAssistiveTranscript(text: String) async throws -> Bool - func transcribeFile(path: String) async throws -> CsTranscription + func setListener(_ listener: CsTranscriptionListener) + func startRecording(language: CsLanguage?) async throws + func stopRecording() async throws -> String + func isRecording() async -> Bool + func initModel() async throws + func isModelLoaded() -> Bool + func isFormattingAvailable() -> Bool + func currentOverlayPolicy() -> OverlayPolicySnapshot? + func setAutoPasteEnabled(_ enabled: Bool) + func formatText( + text: String, + language: CsLanguage?, + level: FormattingPolicyOption + ) async throws -> String + func pasteText(text: String) async throws -> CsPasteResult + func deferText(text: String) async throws -> CsPasteResult + func copyTaggedTranscript(text: String) async throws + func pasteTargetAppName() async -> String? + func sendAssistiveTranscript(text: String) async throws -> Bool + func transcribeFile(path: String) async throws -> CsTranscription } struct OverlayPolicySnapshot: Equatable { - let autoPasteEnabled: Bool - let autoFormatLevel: FormattingPolicyOption + let autoPasteEnabled: Bool + let autoFormatLevel: FormattingPolicyOption } enum OverlayActionPresentation { - static let manualFormatLevels = FormattingPolicyOption.editablePrompts - static let formatTitle = "Format" - static let formatHelp = "Format transcript once as Correction, Smart, or Max" - static let sendTitle = "To Agent" - static let sendHelp = "Send transcript to the agent" + static let manualFormatLevels = FormattingPolicyOption.editablePrompts + static let formatTitle = "Format" + static let formatHelp = "Format transcript once as Correction, Smart, or Max" + static let sendTitle = "To Agent" + static let sendHelp = "Send transcript to the agent" } struct OverlayInsertActionPresentation: Equatable { - let targetAppName: String? - let title: String - let help: String - - init(targetAppName: String?) { - let normalized = targetAppName?.trimmingCharacters(in: .whitespacesAndNewlines) - let target = normalized.flatMap { $0.isEmpty ? nil : $0 } - self.targetAppName = target - if let target { - title = "Insert → \(target)" - help = "Insert at the cursor in \(target)" - } else { - title = "Insert" - help = "Insert at the cursor in the previous app" - } - } + let targetAppName: String? + let title: String + let help: String + + init(targetAppName: String?) { + let normalized = targetAppName?.trimmingCharacters(in: .whitespacesAndNewlines) + let target = normalized.flatMap { $0.isEmpty ? nil : $0 } + self.targetAppName = target + if let target { + title = "Insert → \(target)" + help = "Insert at the cursor in \(target)" + } else { + title = "Insert" + help = "Insert at the cursor in the previous app" + } + } } /// State machine mirrored from the mock: live dictation, the finalized @@ -145,1741 +147,1956 @@ struct OverlayInsertActionPresentation: Equatable { /// editable FINAL that reads like a crash. `.error` is the terminal outcome for /// engine/controller failures so they are not flattened into "no speech". enum OverlayMode: Equatable { - case listening - case formatted - case noSpeech - case error + case listening + case formatted + case noSpeech + case error } @MainActor final class OverlayState: ObservableObject { - // MARK: Published state - @Published var mode: OverlayMode = .listening - @Published var preview: String = "" // current utterance interim - @Published var committedUtterances: [String] = [] // accumulated finals, one item per utterance - @Published var formattedText: String = "" // finalized transcript after stop - @Published var vadActive: Bool = false // drives the WaveformView pulse - /// Live capture level for the waveform. NOT @Published on purpose — the - /// waveform's TimelineView reads it every frame; see `AudioLevelMeter`. - let levelMeter = AudioLevelMeter() - /// Distinguishes a measured microphone feed from the explicit ambient - /// fallback used by legacy/disconnected engines before any RMS arrives. - @Published private(set) var hasMeasuredAudioLevel = false - @Published var audioReady: Bool = false // recorder confirmed; STT/VAD may still be warming - @Published var warmingUp: Bool = false // true after user intent, before audio/VAD proves life - /// Stop was requested and we are awaiting the final transcript. Distinct from - /// recording: the waveform must NOT keep pulsing like capture, and the status - /// reads "transcribing" so the user can tell recording ended vs. hung. Set only - /// on the Swift-observable stop (`runStop`); cleared by finalize / error / reset - /// / close so it can never stick. See `WaveformView(transcribing:)`. - @Published var transcribing: Bool = false - @Published var toast: String? // transient error notice - /// Utterance-level STT confidence for the open session (lowest avg_logprob wins). - /// Fed by `on_final` confidence args; drives the header low-confidence badge (LL-E). - @Published private(set) var sessionAvgLogprob: Float? - @Published private(set) var sessionSpeechPct: Float? - @Published private(set) var sessionConfidenceFlags: [String] = [] - @Published var errorMessage: String? - @Published var isFormatting: Bool = false - @Published var formatFailureStatus: String? - /// Prompt-free policy snapshot from C02's persisted settings owner. These - /// values are replaced only by a fresh engine read, never by optimistic UI. - @Published private(set) var autoPasteEnabled = true - @Published private(set) var autoFormatLevel: FormattingPolicyOption = .correction - /// Assistive sessions never expose delivery controls. The controller owns - /// that authoritative session gate and updates this presentation fence. - @Published private(set) var autoPasteControlAvailable = true - /// Destination name latched once at overlay session entry. The action row - /// reads this snapshot; it never polls the bridge during rendering. - @Published private(set) var pasteTargetAppName: String? - /// Final pass phase (AI formatting / authoritative assembly after stop). - /// Set on `applySessionFinalised`, cleared on controller finish or reset. - /// Drives "final pass" status while the user still sees the live assembly. - @Published var isFinalPass: Bool = false - /// Human-facing notice shown in the `.noSpeech` outcome body. Set when a - /// session finalizes without usable text; refined by `on_no_speech`'s reason - /// so VAD silence and quality-gate rejection read differently. - @Published var noSpeechNotice: String = OverlayState.defaultNoSpeechNotice - @Published private(set) var indicatorMode: CsIndicatorMode = .hold - - // MARK: Session capture clock (UI_DIVERGENCE_AUDIT pkt 5 — overlay timer) - /// Monotonic uptime stamp of the moment capture began for the open session. - /// The overlay's live `00:00` counter derives from this: the user's absolute - /// reference for audio sync, transcription lag, and stream drift. - @Published private(set) var captureStartedAtUptime: TimeInterval? - /// Freeze stamp — set when capture stops (Finish / native release / abort) so - /// the counter halts at the session's true duration instead of ticking - /// through the final pass. - @Published private(set) var captureEndedAtUptime: TimeInterval? - - // MARK: Panel placement (persisted; the window orchestrator repositions live) - /// Anchored placement: one of six screen anchors, applied on every show(). - /// Picking an anchor exits free motion — the pick's intent is "go there". - @Published var placementAnchor: OverlayAnchor = OverlayPlacement.anchor { - didSet { - guard placementAnchor != oldValue else { return } - OverlayPlacement.anchor = placementAnchor - if freeMotion { freeMotion = false } else { onPlacementChanged?() } - } - } - /// Free motion: the panel keeps (and restores) wherever the user dragged it. - @Published var freeMotion: Bool = OverlayPlacement.freeMotion { - didSet { - guard freeMotion != oldValue else { return } - OverlayPlacement.freeMotion = freeMotion - onPlacementChanged?() - } - } - /// Wired by the orchestrator: re-derive the visible panel's origin now. - var onPlacementChanged: (() -> Void)? - - // MARK: Injected collaborators (all optional so #Preview renders standalone) - /// The recording core. Injected by the orchestrator. Do NOT instantiate here. - var engine: DictationEngine? - /// Handoff to the agent surface — wired by the orchestrator (routes the text - /// into AgentChat, which streams it through `CodescribeAgent.streamReply`). - var onSendToAgent: ((String) -> Void)? - /// Dismiss the floating window — wired by the orchestrator. - var onClose: (() -> Void)? - var onRecordingPreparing: (() -> Void)? - var onRecordingStarted: (() -> Void)? - var onRecordingStopped: (() -> Void)? - /// Content-free success seam. No transcript crosses this callback. - var onSuccessfulDictation: (() -> Void)? - - /// Strong ref so the Rust-side callback (held via the UniFFI handle map) and - /// our hop-to-main bridge stay alive for the lifetime of the overlay. - private lazy var listener: CsTranscriptionListener = DictationListener(state: self) - - static let defaultNoSpeechNotice = "No speech detected" - - private var recording = false - /// Reason from `on_no_speech`, captured before the terminal stop so - /// `finalizeTranscript` can pick the right notice when it resolves to empty. - private var pendingNoSpeechMessage: String? - private var committedSegments: [OverlayTranscriptSegment] = [] - /// Global transcript markers captured by the agent combo. They remain - /// independent from per-utterance semantic annotations so the authoritative - /// final pass cannot erase context references. - @Published private var contextMarkers: [OverlayContextMarker] = [] - /// Authoritative post-stop transcript pushed by the Rust controller - /// (`on_final_transcript_ready` → LocalFinalPass `final_formatted_text`) — the - /// SAME text the delivery/paste and tray "Copy" use. When present it is the - /// FINAL the overlay shows, instead of the raw per-utterance streaming assembly. - private var authoritativeFinalText: String? - /// The delivered (pre-user-edit) text at the moment we entered .formatted. - /// Captured for P0-D quality loop: diff delivered→edited on Copy/Send/close. - private var deliveredText: String = "" - /// Best-effort raw STT transcript text (pre-AI formatting / postprocess) for - /// quality records. D-05: wired from authoritative final / STT assembly so - /// lexicon v2 and quality analytics get the real misheard text, not only - /// the (possibly formatted) delivered. Cleared on reset like deliveredText. - private var sttRawText: String = "" - /// Canonical provenance for the text currently shown in FINAL. Starts from - /// persisted Auto Format truth and is replaced only by a successful manual - /// format. Revert restores the previous level together with the exact bytes. - private var qualityFormattingLevel: FormattingPolicyOption = .off - /// One-step manual-format undo. A successful changed result replaces this - /// source; failures, empty results, and identical no-ops leave it untouched. - private var preFormatText: String? - private var preFormatLevel: FormattingPolicyOption? - /// Once a session is finalized (mode `.formatted` / Idle), the transcript is - /// FROZEN. Late streaming events (Preview/Correction/UtteranceFinal/VAD) that the - /// engine may still emit during/after teardown are DROPPED instead of mutating - /// `@Published` state — otherwise each late apply re-invalidates the hosting view - /// (TextEditor re-layout) and spins the SwiftUI render graph at 100% CPU in Idle. - /// The authoritative `FinalTranscript` is the only post-finalize update allowed. - private var finalized = false - private var agentSessionArmed = false - private var agentFinalTranscriptAppeared = false - private var agentAutoSendCancelled = false - private var agentDeliveryStarted = false - private var toastTask: Task? - /// One-shot guard for the in-place Speech Recognition request+retry. macOS - /// never re-prompts once the scope is determined, so a second attempt in - /// the same app run could only loop on the terminal error. - private var speechAuthRequestAttempted = false - private var mockRevealTask: Task? - /// Belt-and-suspenders guard against an orphaned optimistic "starting" overlay. - /// The Rust bridge now guarantees a terminal event for every preparing it shows - /// (`compensate_orphaned_preparing`); this watchdog is the second layer: if no - /// started/activity/stopped/finish arrives within `warmupWatchdogNanos`, the - /// overlay dismisses itself instead of hanging on "starting" forever. - private var warmupWatchdogTask: Task? - private var pasteTargetRefreshTask: Task? - private static let warmupWatchdogNanos: UInt64 = 4_000_000_000 - - // MARK: Activity-anchored auto-hide for terminal outcomes - private var autoHideTask: Task? - private var autoHideDeadline: TimeInterval? - private var isPointerHovering = false - private let nowProvider: () -> TimeInterval - /// Single source of truth for the operator-dictated terminal lifetime. - /// Five seconds is the comfortable end of the requested 3–5 second range. - static let autoHideDelaySeconds: TimeInterval = 5 - - init(nowProvider: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { - self.nowProvider = nowProvider - } - - func attach() { - engine?.setListener(listener) - } - - // MARK: Derived display (one source of truth for the view) - - var statusText: String { - if mode == .error { return "failed" } - if mode == .formatted { return "done" } - if mode == .noSpeech { return "no speech" } - guard mode == .listening else { return "Idle" } - if isFinalPass { return "final pass" } - if transcribing { return "transcribing" } - if warmingUp { return "starting" } - return hasMeasuredAudioLevel ? "recording" : "recording · ambient" - } - var statusColor: Color { - switch mode { - case .listening: return CSColor.terracotta - case .formatted: return CSColor.oliveLight - case .noSpeech: return CSColor.textMuted - case .error: return CSColor.terracotta - } - } - - /// Mirrors `core/transcript_tagging.rs` confidence_label thresholds. - /// Badge is shown when the utterance-level signal is low or the hallucination flag is set. - var showsLowConfidenceBadge: Bool { - OverlayConfidence.showsLowConfidenceBadge( - avgLogprob: sessionAvgLogprob, - flags: sessionConfidenceFlags - ) - } - - var confidenceBadgeText: String? { - guard showsLowConfidenceBadge else { return nil } - return "low confidence" - } - /// Only the live-capture pill ripples. During `transcribing` / `final pass` we swap - /// to the static pill so its repeatForever animation tears down — a second visual - /// cue that capture has ended and post-processing is in flight. - var statusRippling: Bool { mode == .listening && !transcribing && !isFinalPass && (audioReady || vadActive) } - - var tagText: String { - switch mode { - case .listening: return "DICTATION" - case .formatted: return "FINAL" - case .noSpeech: return "NO SPEECH" - case .error: return "ERROR" - } - } - var tagColor: Color { - switch mode { - case .listening: - return indicatorMode == .assistive ? CSColor.assistiveLight : CSColor.terracottaLight - case .formatted: return CSColor.oliveLight - case .noSpeech: return CSColor.textMuted - case .error: return CSColor.terracotta - } - } - - var metaText: String { - if isFinalPass { return "final pass · formatting" } - switch mode { - case .listening: - if transcribing { return "finalizing · transcript" } - // Honesty (operator 2026-07-27 / mission B): never claim streaming - // text the user cannot see. Apple may be shy (letter-level confidence) - // or Previews may not have drained yet. Empty canvas = waiting cadence, - // not "live preview · raw". Engine chip still reports Apple when live. - let canvas = liveText.trimmingCharacters(in: .whitespacesAndNewlines) - if canvas.isEmpty { return "live preview · waiting" } - return "live preview · raw" - case .formatted: return "final · transcript" - case .noSpeech: return "no speech · nothing captured" - case .error: return "error · recording stopped" - } - } - var footerRight: String { - if isFormatting { return "formatting" } - if isFinalPass { return "final pass" } - if mode == .noSpeech { return "no speech" } - if mode == .error { return "error" } - if mode == .listening && transcribing { return "transcribing" } - if mode == .listening && warmingUp { return "warming up" } - if mode == .listening && liveText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - // Empty canvas: do not imply a visible preview stream. - return audioReady ? "audio live · waiting" : "waiting for audio" - } - return mode == .listening ? "vad-gated preview" : "editable" - } - - /// Footer left engine chip — last stop serving label when available, else - /// configured preference. Never a hardcoded "local whisper" (STT_CONTRACT). - var footerEngineLabel: String { - // Free UniFFI function (same as Settings Active STT). - if let serving = currentServingVerdict() { - let eng = serving.engine.trimmingCharacters(in: .whitespacesAndNewlines) - if !eng.isEmpty { - return Self.displayEngineChip(eng) - } - } - if let pref = try? CodescribeConfig().loadSettings().sttEngine? - .trimmingCharacters(in: .whitespacesAndNewlines), - !pref.isEmpty - { - switch pref.lowercased() { - case "apple": return "local apple" - case "whisper", "candle": return "local whisper" - case "auto": return "auto · apple-first" - default: return pref - } - } - return "local apple" - } - - private static func displayEngineChip(_ engine: String) -> String { - let e = engine.lowercased() - if e.contains("apple") { return "local apple" } - if e.contains("merged") && e.contains("whisper") { return "merged · whisper fill" } - if e.contains("streaming") { return "streaming whisper" } - if e.contains("whisper") { return "local whisper" } - if e.contains("cloud") { return "cloud stt" } - return engine - } - - /// committed finals + the current interim preview, space-joined. - private var rawLiveText: String { - (committedUtterances + [preview]) - .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .joined(separator: " ") - } - - var liveText: String { - insertingContextMarkers(into: rawLiveText) - } - - /// Text shown in the listening body, in the SAME prominent slot that renders - /// "listening…"/"starting…" during capture. - /// - /// CAPTURED WORDS ALWAYS WIN OVER PHASE. The previous shape let the - /// transcribing phase replace the live canvas with "transcribing…", so - /// stopping a recording made the user's own words vanish behind a spinner - /// until the final text swapped in — the operator dictated the bug report - /// into the very canvas that then ate it (2026-08-09 20:13): "wyłączenie - /// nagrywania zastępuje tekst … i podmienia dopiero ostateczny tekst a tego - /// ma nie być". The overlay doctrine forbids exactly this class: never drop - /// visible transcript. Phase placeholders render only on an EMPTY canvas; - /// the header pill carries the phase otherwise. - var listeningDisplay: String { - if !liveText.isEmpty { return liveText } - if isFinalPass { return "final pass…" } - if transcribing { return "transcribing…" } - return warmingUp ? "starting…" : "listening…" - } - - /// Whatever the action row should copy/send for the current state. - var activeText: String { - switch mode { - case .listening: return liveText - case .formatted: return formattedText - case .noSpeech, .error: return "" - } - } - - var canFormat: Bool { - mode == .formatted - && !isFormatting - && engine?.isFormattingAvailable() == true - && !formattedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - var canRevert: Bool { - preFormatText != nil && !isFormatting - } - - var insertActionPresentation: OverlayInsertActionPresentation { - OverlayInsertActionPresentation(targetAppName: pasteTargetAppName) - } - - var autoPasteAccessibilityValue: String { - autoPasteEnabled ? "On" : "Off" - } - - var manualFormatHelp: String { - let automatic = autoFormatLevel == .off - ? "Auto Format is Off." - : "Auto Format is \(autoFormatLevel.visibleName)." - return "\(automatic) \(OverlayActionPresentation.formatHelp)." - } - - // MARK: Recording lifecycle (engine-backed; no-op when engine is absent) - - /// Start mic dictation. Gated on `micPermissionGranted()`; requests access - /// once when undetermined. Fires the async bridge work in a Task so the view - /// can call it from a synchronous context (onAppear / hotkey). - func start(language: CsLanguage? = nil) { - guard engine != nil, !recording else { return } - Task { @MainActor in await self.runStart(language: language) } - } - - /// Whole seconds of capture for the open session; nil before any capture. - /// Reads the frozen end stamp once capture stopped, so the final pass does - /// not keep the clock ticking. - func elapsedCaptureSeconds() -> Int? { - guard let started = captureStartedAtUptime else { return nil } - let end = captureEndedAtUptime ?? nowProvider() - return max(0, Int(end - started)) - } - - /// `mm:ss` (or `h:mm:ss` past the hour) for the overlay's live counter. - var sessionTimerText: String { - let total = elapsedCaptureSeconds() ?? 0 - let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60) - return h > 0 - ? String(format: "%d:%02d:%02d", h, m, s) - : String(format: "%02d:%02d", m, s) - } - - private func beginCaptureClock() { - captureStartedAtUptime = nowProvider() - captureEndedAtUptime = nil - } - - private func freezeCaptureClock() { - guard captureStartedAtUptime != nil, captureEndedAtUptime == nil else { return } - captureEndedAtUptime = nowProvider() - } - - /// Stop the mic and flip to the finalized transcript returned by the core. - /// Ignored while already transcribing so a second Finish tap during the - /// awaited `stopRecording()` cannot re-enter and hit "no active recording". - func stop() { - guard engine != nil, recording, !transcribing else { return } - Task { @MainActor in await self.runStop() } - } - - private func runStart(language: CsLanguage?) async { - guard let engine else { return } - guard micPermissionGranted() || requestMicPermission() else { - showToast("Microphone access denied") - return - } - engine.setListener(listener) - mode = .listening - warmingUp = true - resetTranscript() - formattedText = "" - isFormatting = false - formatFailureStatus = nil - errorMessage = nil - beginCaptureClock() - recording = true - do { - // Whisper is optional gap-fill when Apple is live. initModel soft-fails - // in the bridge for that path; never treat a missing Whisper model as - // a start refusal — recording must still run (degraded: no final gap fill). - if !engine.isModelLoaded() { - do { - try await engine.initModel() - } catch { - // Candle-live still surfaces via startRecording / later final-pass. - // Apple-live continues; bridge already degrades quietly when it can. - NSLog("codescribe: optional Whisper warm skipped: \(error)") - } - } - try await engine.startRecording(language: language) - } catch { - await handleStartFailure(error, language: language) - } - } - - /// A start failure caused by an undetermined Speech Recognition grant is - /// recoverable in place: fire the TCC dialog from the main app process (so - /// the grant lands on the app's identity, which the bridge child inherits) - /// and retry the start once when authorized. Every other failure — and a - /// declined dialog — funnels into the terminal error path, where - /// `speechAuthNotice` rewrites raw `speech_auth_*` markers. - private func handleStartFailure(_ error: Error, language: CsLanguage?) async { - let described = "\(error)" - if described.contains("speech_auth_not_determined"), !speechAuthRequestAttempted { - speechAuthRequestAttempted = true - abortRecordingSession() - let state = await withCheckedContinuation { continuation in - SpeechRecognitionPermission.request { continuation.resume(returning: $0) } - } - if state == .granted { - await runStart(language: language) - return - } - } - presentTerminalError( - message: "Couldn't start recording: \(described)", - toast: "Couldn't start recording" - ) - } - - func formatTranscript(level: FormattingPolicyOption) { - guard let engine, - canFormat, - OverlayActionPresentation.manualFormatLevels.contains(level) else { return } - let source = formattedText - let sourceLevel = qualityFormattingLevel - isFormatting = true - // Format deliberately suspends passive dismissal. Its result stays until - // another user activity explicitly starts a fresh countdown. - cancelAutoHide() - Task { @MainActor in - defer { self.isFormatting = false } - do { - let formatted = try await engine.formatText( - text: source, - language: nil, - level: level - ) - let isUsableChange = !formatted - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty && formatted != source - if isUsableChange { - self.preFormatText = source - self.preFormatLevel = sourceLevel - self.formattedText = formatted - self.qualityFormattingLevel = level - } - self.formatFailureStatus = nil - self.mode = .formatted - self.cancelAutoHide() // User acted (Format); do not auto-hide the result. - } catch { - self.formattedText = source - self.formatFailureStatus = "raw — formatting failed" - self.mode = .formatted - self.cancelAutoHide() - self.errorMessage = "Couldn't format transcript: \(error)" - self.showToast("Couldn't format transcript") - } - } - } - - /// Restore the exact source of the most recent successful changed format. - /// The slot is consumed once and this explicit user activity starts a fresh - /// terminal lifetime from the injected monotonic clock. - func revertFormat() { - guard !isFormatting, let source = preFormatText else { return } - let sourceLevel = preFormatLevel ?? .off - preFormatText = nil - preFormatLevel = nil - formattedText = source - qualityFormattingLevel = sourceLevel - formatFailureStatus = nil - mode = .formatted - restartAutoHideCountdown() - } - - private func runStop() async { - guard let engine else { return } - // Enter the explicit "transcribing" phase for the whole awaited stop: the - // waveform stops pulsing like capture and the status reads "transcribing" - // instead of leaving the recording UI up while the final pass runs. - transcribing = true - warmingUp = false - freezeCaptureClock() - levelMeter.reset() + // MARK: Published state + @Published var mode: OverlayMode = .listening + @Published var preview: String = "" // current utterance interim + @Published var committedUtterances: [String] = [] // accumulated finals, one item per utterance + @Published var formattedText: String = "" // finalized transcript after stop + @Published var vadActive: Bool = false // drives the WaveformView pulse + /// Live capture level for the waveform. NOT @Published on purpose — the + /// waveform's TimelineView reads it every frame; see `AudioLevelMeter`. + let levelMeter = AudioLevelMeter() + /// Distinguishes a measured microphone feed from the explicit ambient + /// fallback used by legacy/disconnected engines before any RMS arrives. + @Published private(set) var hasMeasuredAudioLevel = false + @Published var audioReady: Bool = false // recorder confirmed; STT/VAD may still be warming + @Published var warmingUp: Bool = false // true after user intent, before audio/VAD proves life + /// Stop was requested and we are awaiting the final transcript. Distinct from + /// recording: the waveform must NOT keep pulsing like capture, and the status + /// reads "transcribing" so the user can tell recording ended vs. hung. Set only + /// on the Swift-observable stop (`runStop`); cleared by finalize / error / reset + /// / close so it can never stick. See `WaveformView(transcribing:)`. + @Published var transcribing: Bool = false + @Published var toast: String? // transient error notice + /// Utterance-level STT confidence for the open session (lowest avg_logprob wins). + /// Fed by `on_final` confidence args; drives the header low-confidence badge (LL-E). + @Published private(set) var sessionAvgLogprob: Float? + @Published private(set) var sessionSpeechPct: Float? + @Published private(set) var sessionConfidenceFlags: [String] = [] + @Published var errorMessage: String? + /// W13-6B highlight layer. Default follows the OFF flag; tests inject `true`. + @Published var highlightsEnabled = false + /// Span highlights (lexicon-corrected words + speech-gap pustki). + @Published private(set) var highlights: [OverlayHighlight] = [] + @Published private(set) var selectedHighlightId: String? + /// Last Teach acknowledgement for tests and the toast. + @Published private(set) var lastTeachAcknowledgement: String? + /// Injected Teach writer. Production uses `qualityTeachSpan`; tests replace + /// this so XCTest never writes the operator's live lexicon. + var teachSpan: ((OverlayHighlight) throws -> String)? + @Published var isFormatting: Bool = false + @Published var formatFailureStatus: String? + /// Prompt-free policy snapshot from C02's persisted settings owner. These + /// values are replaced only by a fresh engine read, never by optimistic UI. + @Published private(set) var autoPasteEnabled = true + @Published private(set) var autoFormatLevel: FormattingPolicyOption = .correction + /// Assistive sessions never expose delivery controls. The controller owns + /// that authoritative session gate and updates this presentation fence. + @Published private(set) var autoPasteControlAvailable = true + /// Destination name latched once at overlay session entry. The action row + /// reads this snapshot; it never polls the bridge during rendering. + @Published private(set) var pasteTargetAppName: String? + /// Final pass phase (AI formatting / authoritative assembly after stop). + /// Set on `applySessionFinalised`, cleared on controller finish or reset. + /// Drives "final pass" status while the user still sees the live assembly. + @Published var isFinalPass: Bool = false + /// Human-facing notice shown in the `.noSpeech` outcome body. Set when a + /// session finalizes without usable text; refined by `on_no_speech`'s reason + /// so VAD silence and quality-gate rejection read differently. + @Published var noSpeechNotice: String = OverlayState.defaultNoSpeechNotice + @Published private(set) var indicatorMode: CsIndicatorMode = .hold + + // MARK: Session capture clock (UI_DIVERGENCE_AUDIT pkt 5 — overlay timer) + /// Monotonic uptime stamp of the moment capture began for the open session. + /// The overlay's live `00:00` counter derives from this: the user's absolute + /// reference for audio sync, transcription lag, and stream drift. + @Published private(set) var captureStartedAtUptime: TimeInterval? + /// Freeze stamp — set when capture stops (Finish / native release / abort) so + /// the counter halts at the session's true duration instead of ticking + /// through the final pass. + @Published private(set) var captureEndedAtUptime: TimeInterval? + + // MARK: Panel placement (persisted; the window orchestrator repositions live) + /// Anchored placement: one of six screen anchors, applied on every show(). + /// Picking an anchor exits free motion — the pick's intent is "go there". + @Published var placementAnchor: OverlayAnchor = OverlayPlacement.anchor { + didSet { + guard placementAnchor != oldValue else { return } + OverlayPlacement.anchor = placementAnchor + if freeMotion { freeMotion = false } else { onPlacementChanged?() } + } + } + /// Free motion: the panel keeps (and restores) wherever the user dragged it. + @Published var freeMotion: Bool = OverlayPlacement.freeMotion { + didSet { + guard freeMotion != oldValue else { return } + OverlayPlacement.freeMotion = freeMotion + onPlacementChanged?() + } + } + /// Wired by the orchestrator: re-derive the visible panel's origin now. + var onPlacementChanged: (() -> Void)? + + // MARK: Injected collaborators (all optional so #Preview renders standalone) + /// The recording core. Injected by the orchestrator. Do NOT instantiate here. + var engine: DictationEngine? + /// Handoff to the agent surface — wired by the orchestrator (routes the text + /// into AgentChat, which streams it through `CodescribeAgent.streamReply`). + var onSendToAgent: ((String) -> Void)? + /// Dismiss the floating window — wired by the orchestrator. + var onClose: (() -> Void)? + var onRecordingPreparing: (() -> Void)? + var onRecordingStarted: (() -> Void)? + var onRecordingStopped: (() -> Void)? + /// Content-free success seam. No transcript crosses this callback. + var onSuccessfulDictation: (() -> Void)? + + /// Strong ref so the Rust-side callback (held via the UniFFI handle map) and + /// our hop-to-main bridge stay alive for the lifetime of the overlay. + private lazy var listener: CsTranscriptionListener = DictationListener(state: self) + + static let defaultNoSpeechNotice = "No speech detected" + + private var recording = false + /// True after `on_vad_active(true)` until an empty-or-nonempty final consumes it. + private var speechWasActive = false + /// Reason from `on_no_speech`, captured before the terminal stop so + /// `finalizeTranscript` can pick the right notice when it resolves to empty. + private var pendingNoSpeechMessage: String? + private var committedSegments: [OverlayTranscriptSegment] = [] + /// Global transcript markers captured by the agent combo. They remain + /// independent from per-utterance semantic annotations so the authoritative + /// final pass cannot erase context references. + @Published private var contextMarkers: [OverlayContextMarker] = [] + /// Authoritative post-stop transcript pushed by the Rust controller + /// (`on_final_transcript_ready` → LocalFinalPass `final_formatted_text`) — the + /// SAME text the delivery/paste and tray "Copy" use. When present it is the + /// FINAL the overlay shows, instead of the raw per-utterance streaming assembly. + private var authoritativeFinalText: String? + /// The delivered (pre-user-edit) text at the moment we entered .formatted. + /// Captured for P0-D quality loop: diff delivered→edited on Copy/Send/close. + private var deliveredText: String = "" + /// Best-effort raw STT transcript text (pre-AI formatting / postprocess) for + /// quality records. D-05: wired from authoritative final / STT assembly so + /// lexicon v2 and quality analytics get the real misheard text, not only + /// the (possibly formatted) delivered. Cleared on reset like deliveredText. + private var sttRawText: String = "" + /// Canonical provenance for the text currently shown in FINAL. Starts from + /// persisted Auto Format truth and is replaced only by a successful manual + /// format. Revert restores the previous level together with the exact bytes. + private var qualityFormattingLevel: FormattingPolicyOption = .off + /// One-step manual-format undo. A successful changed result replaces this + /// source; failures, empty results, and identical no-ops leave it untouched. + private var preFormatText: String? + private var preFormatLevel: FormattingPolicyOption? + /// Once a session is finalized (mode `.formatted` / Idle), the transcript is + /// FROZEN. Late streaming events (Preview/Correction/UtteranceFinal/VAD) that the + /// engine may still emit during/after teardown are DROPPED instead of mutating + /// `@Published` state — otherwise each late apply re-invalidates the hosting view + /// (TextEditor re-layout) and spins the SwiftUI render graph at 100% CPU in Idle. + /// The authoritative `FinalTranscript` is the only post-finalize update allowed. + private var finalized = false + private var agentSessionArmed = false + private var agentFinalTranscriptAppeared = false + private var agentAutoSendCancelled = false + private var agentDeliveryStarted = false + private var toastTask: Task? + /// One-shot guard for the in-place Speech Recognition request+retry. macOS + /// never re-prompts once the scope is determined, so a second attempt in + /// the same app run could only loop on the terminal error. + private var speechAuthRequestAttempted = false + private var mockRevealTask: Task? + /// Belt-and-suspenders guard against an orphaned optimistic "starting" overlay. + /// The Rust bridge now guarantees a terminal event for every preparing it shows + /// (`compensate_orphaned_preparing`); this watchdog is the second layer: if no + /// started/activity/stopped/finish arrives within `warmupWatchdogNanos`, the + /// overlay dismisses itself instead of hanging on "starting" forever. + private var warmupWatchdogTask: Task? + private var pasteTargetRefreshTask: Task? + private static let warmupWatchdogNanos: UInt64 = 4_000_000_000 + + // MARK: Activity-anchored auto-hide for terminal outcomes + private var autoHideTask: Task? + private var autoHideDeadline: TimeInterval? + private var isPointerHovering = false + private let nowProvider: () -> TimeInterval + /// Single source of truth for the operator-dictated terminal lifetime. + /// Five seconds is the comfortable end of the requested 3–5 second range. + static let autoHideDelaySeconds: TimeInterval = 5 + + init(nowProvider: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.nowProvider = nowProvider + // Production reads the UniFFI flag (default OFF). XCTest hosts stay off + // unless a test flips `highlightsEnabled` — never inherit a leaked env. + if !QualityCaptureHost.isRunningTests { + highlightsEnabled = overlayHighlightsEnabled() + } + } + + func attach() { + engine?.setListener(listener) + } + + // MARK: Derived display (one source of truth for the view) + + var statusText: String { + if mode == .error { return "failed" } + if mode == .formatted { return "done" } + if mode == .noSpeech { return "no speech" } + guard mode == .listening else { return "Idle" } + if isFinalPass { return "final pass" } + if transcribing { return "transcribing" } + if warmingUp { return "starting" } + return hasMeasuredAudioLevel ? "recording" : "recording · ambient" + } + var statusColor: Color { + switch mode { + case .listening: return CSColor.terracotta + case .formatted: return CSColor.oliveLight + case .noSpeech: return CSColor.textMuted + case .error: return CSColor.terracotta + } + } + + /// Mirrors `core/transcript_tagging.rs` confidence_label thresholds. + /// Badge is shown when the utterance-level signal is low or the hallucination flag is set. + var showsLowConfidenceBadge: Bool { + OverlayConfidence.showsLowConfidenceBadge( + avgLogprob: sessionAvgLogprob, + flags: sessionConfidenceFlags + ) + } + + var confidenceBadgeText: String? { + guard showsLowConfidenceBadge else { return nil } + return "low confidence" + } + /// Only the live-capture pill ripples. During `transcribing` / `final pass` we swap + /// to the static pill so its repeatForever animation tears down — a second visual + /// cue that capture has ended and post-processing is in flight. + var statusRippling: Bool { + mode == .listening && !transcribing && !isFinalPass && (audioReady || vadActive) + } + + var tagText: String { + switch mode { + case .listening: return "DICTATION" + case .formatted: return "FINAL" + case .noSpeech: return "NO SPEECH" + case .error: return "ERROR" + } + } + var tagColor: Color { + switch mode { + case .listening: + return indicatorMode == .assistive ? CSColor.assistiveLight : CSColor.terracottaLight + case .formatted: return CSColor.oliveLight + case .noSpeech: return CSColor.textMuted + case .error: return CSColor.terracotta + } + } + + var metaText: String { + if isFinalPass { return "final pass · formatting" } + switch mode { + case .listening: + if transcribing { return "finalizing · transcript" } + // Honesty (operator 2026-07-27 / mission B): never claim streaming + // text the user cannot see. Apple may be shy (letter-level confidence) + // or Previews may not have drained yet. Empty canvas = waiting cadence, + // not "live preview · raw". Engine chip still reports Apple when live. + let canvas = liveText.trimmingCharacters(in: .whitespacesAndNewlines) + if canvas.isEmpty { return "live preview · waiting" } + return "live preview · raw" + case .formatted: return "final · transcript" + case .noSpeech: return "no speech · nothing captured" + case .error: return "error · recording stopped" + } + } + var footerRight: String { + if isFormatting { return "formatting" } + if isFinalPass { return "final pass" } + if mode == .noSpeech { return "no speech" } + if mode == .error { return "error" } + if mode == .listening && transcribing { return "transcribing" } + if mode == .listening && warmingUp { return "warming up" } + if mode == .listening && liveText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // Empty canvas: do not imply a visible preview stream. + return audioReady ? "audio live · waiting" : "waiting for audio" + } + return mode == .listening ? "vad-gated preview" : "editable" + } + + /// Footer left engine chip — last stop serving label when available, else + /// configured preference. Never a hardcoded "local whisper" (STT_CONTRACT). + var footerEngineLabel: String { + // Free UniFFI function (same as Settings Active STT). + if let serving = currentServingVerdict() { + let eng = serving.engine.trimmingCharacters(in: .whitespacesAndNewlines) + if !eng.isEmpty { + return Self.displayEngineChip(eng) + } + } + if let pref = try? CodescribeConfig().loadSettings().sttEngine? + .trimmingCharacters(in: .whitespacesAndNewlines), + !pref.isEmpty + { + switch pref.lowercased() { + case "apple": return "local apple" + case "whisper", "candle": return "local whisper" + case "auto": return "auto · apple-first" + default: return pref + } + } + return "local apple" + } + + private static func displayEngineChip(_ engine: String) -> String { + let e = engine.lowercased() + if e.contains("apple") { return "local apple" } + if e.contains("merged") && e.contains("whisper") { return "merged · whisper fill" } + if e.contains("streaming") { return "streaming whisper" } + if e.contains("whisper") { return "local whisper" } + if e.contains("cloud") { return "cloud stt" } + return engine + } + + /// committed finals + the current interim preview, space-joined. + private var rawLiveText: String { + (committedUtterances + [preview]) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .joined(separator: " ") + } + + var liveText: String { + insertingContextMarkers(into: rawLiveText) + } + + /// Text shown in the listening body, in the SAME prominent slot that renders + /// "listening…"/"starting…" during capture. + /// + /// CAPTURED WORDS ALWAYS WIN OVER PHASE. The previous shape let the + /// transcribing phase replace the live canvas with "transcribing…", so + /// stopping a recording made the user's own words vanish behind a spinner + /// until the final text swapped in — the operator dictated the bug report + /// into the very canvas that then ate it (2026-08-09 20:13): "wyłączenie + /// nagrywania zastępuje tekst … i podmienia dopiero ostateczny tekst a tego + /// ma nie być". The overlay doctrine forbids exactly this class: never drop + /// visible transcript. Phase placeholders render only on an EMPTY canvas; + /// the header pill carries the phase otherwise. + var listeningDisplay: String { + if !liveText.isEmpty { return liveText } + if isFinalPass { return "final pass…" } + if transcribing { return "transcribing…" } + return warmingUp ? "starting…" : "listening…" + } + + /// Visual runs for the highlight canvas. Empty when the lane is off. + var highlightCanvasRuns: [OverlayCanvasRun] { + guard highlightsEnabled else { return [.text(listeningDisplay)] } + let segments = committedSegments.map { (utteranceId: $0.utteranceId, text: $0.text) } + let runs = OverlayCanvas.runs( + segments: segments, + highlights: highlights, + preview: preview + ) + return runs.isEmpty ? [.text(listeningDisplay)] : runs + } + + /// Whatever the action row should copy/send for the current state. + var activeText: String { + switch mode { + case .listening: return liveText + case .formatted: return formattedText + case .noSpeech, .error: return "" + } + } + + var canFormat: Bool { + mode == .formatted + && !isFormatting + && engine?.isFormattingAvailable() == true + && !formattedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var canRevert: Bool { + preFormatText != nil && !isFormatting + } + + var insertActionPresentation: OverlayInsertActionPresentation { + OverlayInsertActionPresentation(targetAppName: pasteTargetAppName) + } + + var autoPasteAccessibilityValue: String { + autoPasteEnabled ? "On" : "Off" + } + + var manualFormatHelp: String { + let automatic = + autoFormatLevel == .off + ? "Auto Format is Off." + : "Auto Format is \(autoFormatLevel.visibleName)." + return "\(automatic) \(OverlayActionPresentation.formatHelp)." + } + + // MARK: Recording lifecycle (engine-backed; no-op when engine is absent) + + /// Start mic dictation. Gated on `micPermissionGranted()`; requests access + /// once when undetermined. Fires the async bridge work in a Task so the view + /// can call it from a synchronous context (onAppear / hotkey). + func start(language: CsLanguage? = nil) { + guard engine != nil, !recording else { return } + Task { @MainActor in await self.runStart(language: language) } + } + + /// Whole seconds of capture for the open session; nil before any capture. + /// Reads the frozen end stamp once capture stopped, so the final pass does + /// not keep the clock ticking. + func elapsedCaptureSeconds() -> Int? { + guard let started = captureStartedAtUptime else { return nil } + let end = captureEndedAtUptime ?? nowProvider() + return max(0, Int(end - started)) + } + + /// `mm:ss` (or `h:mm:ss` past the hour) for the overlay's live counter. + var sessionTimerText: String { + let total = elapsedCaptureSeconds() ?? 0 + let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60) + return h > 0 + ? String(format: "%d:%02d:%02d", h, m, s) + : String(format: "%02d:%02d", m, s) + } + + private func beginCaptureClock() { + captureStartedAtUptime = nowProvider() + captureEndedAtUptime = nil + } + + private func freezeCaptureClock() { + guard captureStartedAtUptime != nil, captureEndedAtUptime == nil else { return } + captureEndedAtUptime = nowProvider() + } + + /// Stop the mic and flip to the finalized transcript returned by the core. + /// Ignored while already transcribing so a second Finish tap during the + /// awaited `stopRecording()` cannot re-enter and hit "no active recording". + func stop() { + guard engine != nil, recording, !transcribing else { return } + Task { @MainActor in await self.runStop() } + } + + private func runStart(language: CsLanguage?) async { + guard let engine else { return } + guard micPermissionGranted() || requestMicPermission() else { + showToast("Microphone access denied") + return + } + engine.setListener(listener) + mode = .listening + warmingUp = true + resetTranscript() + formattedText = "" + isFormatting = false + formatFailureStatus = nil + errorMessage = nil + beginCaptureClock() + recording = true + do { + // Whisper is optional gap-fill when Apple is live. initModel soft-fails + // in the bridge for that path; never treat a missing Whisper model as + // a start refusal — recording must still run (degraded: no final gap fill). + if !engine.isModelLoaded() { do { - // The controller bridge returns "" here; the authoritative transcript - // is the id-ordered assembly of `UtteranceFinal` events (see liveText). - _ = try await engine.stopRecording() - recording = false - isFinalPass = false - finalizeTranscript() // clears `transcribing` as it flips to `.formatted` + try await engine.initModel() } catch { - presentTerminalError( - message: "Couldn't finalize transcript: \(error)", - toast: "Couldn't finalize transcript" - ) - } - } - - // MARK: Action row - - func copyToPasteboard() { - // P0-D: capture user correction on FINAL for quality loop + lexicon learning. - captureQualityIfEdited(action: "copy") - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(activeText, forType: .string) - restartAutoHideCountdown() - } - - func sendToAgent() { - // P0-D: capture user correction on FINAL for quality loop + lexicon learning. - captureQualityIfEdited(action: "send") - deliverAgentTranscript() - } - - /// Caret-truth probe for the Insert self-paste guard. The overlay is a - /// non-activating panel that can become key WITHOUT the app being - /// frontmost (Spotlight-style), so a synthetic Cmd+V follows OUR key - /// window whenever a Codescribe text view holds the caret — the frontmost - /// app check on the Rust side cannot see that. Injectable so tests can - /// simulate both worlds. - var insertCaretInCodescribeProbe: () -> Bool = { - guard let keyWindow = NSApp.keyWindow else { return false } - return keyWindow.firstResponder is NSTextView - } - - func pasteToPreviousApp() { - captureQualityIfEdited(action: "paste") - // Do not let the previous deadline fire while the async delivery is in - // flight. A successful or failed attempt gets a fresh full countdown. - cancelAutoHide() - let text = activeText - Task { @MainActor in - defer { self.restartAutoHideCountdown() } - do { - let result: CsPasteResult? - if self.insertCaretInCodescribeProbe() { - // The caret sits inside Codescribe (e.g. the overlay's own - // editable FINAL) — a synthetic Cmd+V would paste the - // transcript right back into the overlay. Arm the in-memory - // Paste Here slot without touching the user's clipboard. - result = try await engine?.deferText(text: text) - } else { - result = try await engine?.pasteText(text: text) - } - switch result?.outcome { - case .deferredInsertArmed: - let target = result?.targetAppName ?? "the target app" - let shortcut = result?.deferredInsertShortcut ?? "⌘⌥V" - self.showToast( - "Couldn't reach \(target) — put your cursor where you want the text " - + "and press \(shortcut). Your clipboard is untouched." - ) - case .copiedToClipboard: - self.showToast(self.copiedInsertFallbackToast( - frontmost: result?.frontmostAppName, - target: result?.targetAppName, - failure: result?.deferredInsertFailure - )) - case .accessibilityPermissionNeeded: - self.showToast(self.copiedInsertFallbackToast( - frontmost: result?.frontmostAppName, - target: result?.targetAppName, - failure: result?.deferredInsertFailure - )) - case .pasted, .noop, nil: - break - } - } catch { - self.errorMessage = "Couldn't paste transcript: \(error)" - self.showToast("Couldn't paste transcript") - } - } - } - - private func copiedInsertFallbackToast( - frontmost: String?, - target: String?, - failure: String? - ) -> String { - if let failure { - return "\(failure) — copied with tags instead. " - + "Clipboard replaced; press Cmd+V where you want it." - } - if let frontmost, let target { - return "Copied — your cursor is in \(frontmost), not \(target). " - + "Clipboard replaced; press Cmd+V where you want it." - } - if let target { - return "Copied — focus couldn't be confirmed for \(target). " - + "Clipboard replaced; press Cmd+V where you want it." - } - return "Copied — the target app was lost. " - + "Clipboard replaced; press Cmd+V where you want it." - } - - /// Persist through C02's single config seam, then immediately replace local - /// state with a fresh disk-backed snapshot. A rejected write therefore snaps - /// back to durable truth instead of leaving an optimistic switch behind. - func setAutoPasteEnabled(_ enabled: Bool) { - guard autoPasteControlAvailable, let engine else { return } - engine.setAutoPasteEnabled(enabled) - refreshOverlayPolicyTruth() - restartAutoHideCountdown() - } - - func setAutoPasteControlAvailable(_ available: Bool) { - autoPasteControlAvailable = available - } - - func close() { - // P0-D: capture user correction on FINAL for quality loop + lexicon learning. - captureQualityIfEdited(action: "close") - cancelWarmupWatchdog() - cancelAutoHide() - mockRevealTask?.cancel() - toastTask?.cancel() - pasteTargetRefreshTask?.cancel() - if recording, let engine { - recording = false - Task { @MainActor in _ = try? await engine.stopRecording() } - } - vadActive = false - audioReady = false - warmingUp = false - transcribing = false - isFinalPass = false - onClose?() - } - - private func refreshPasteTargetAppName(reset: Bool) { - pasteTargetRefreshTask?.cancel() - if reset { - pasteTargetAppName = nil - } - guard let engine else { return } - pasteTargetRefreshTask = Task { @MainActor [weak self] in - let target = await engine.pasteTargetAppName() - guard !Task.isCancelled, let self else { return } - self.pasteTargetAppName = OverlayInsertActionPresentation( - targetAppName: target - ).targetAppName - } - } - - private func refreshOverlayPolicyTruth() { - guard let truth = engine?.currentOverlayPolicy() else { return } - autoPasteEnabled = truth.autoPasteEnabled - autoFormatLevel = truth.autoFormatLevel - } - - /// TextEditor writes through this seam so only actual user edits — never a - /// programmatic format/final update — re-anchor the terminal lifetime. - func userEditedTranscript(_ text: String) { - if agentSessionArmed, agentFinalTranscriptAppeared, text != formattedText { - agentAutoSendCancelled = true - } - formattedText = text - restartAutoHideCountdown() - } - - /// Consume the canonical Rust indicator mode. Agent arm is a one-shot - /// session latch; the accepted orange processing phase must not disarm it. - func applyIndicatorMode(_ mode: CsIndicatorMode) { - indicatorMode = mode - if mode == .assistive { - agentSessionArmed = true - autoPasteControlAvailable = false - } - } - - /// AppKit reports window motion separately from SwiftUI content events. - func userDraggedOverlay() { - restartAutoHideCountdown() - } - - /// A live edge-drag resize is activity and therefore receives a fresh window. - func userResizedOverlay() { - restartAutoHideCountdown() - } - - /// Hover pauses dismissal entirely; leaving starts a new full five seconds. - func setPointerHovering(_ hovering: Bool) { - guard hovering != isPointerHovering else { return } - isPointerHovering = hovering - guard isTerminalMode else { return } - if hovering { - cancelAutoHide() - } else { - restartAutoHideCountdown() - } - } - - // MARK: P0-D quality loop (user edits on FINAL → record + lexicon candidate) - - private func captureQualityIfEdited(action: String) { - guard mode == .formatted else { return } - // `commitOverlayQualityRecord` is a free FFI function, not a call on the - // injected `engine` — so a mocked engine does NOT stop it, and the XCTest - // suite was appending two synthetic corrections ("original delivered - // transcript here with user fix") to the OPERATOR'S live - // ~/.codescribe/quality/corrections.jsonl on every run. 276 of 501 rows - // in the real store came from test runs, and they surfaced in Settings › - // Dictionary as if the user had made them (operator screenshot - // 2026-08-09 14:21, three seconds after a suite finished). The keychain - // test-host gate landed earlier did not cover this path. - guard !QualityCaptureHost.isRunningTests else { return } - let delivered = deliveredText.trimmingCharacters(in: .whitespacesAndNewlines) - let edited = formattedText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !edited.isEmpty else { return } - let isEdited = delivered != edited - // Unedited transcripts used to never reach the review queue — but "not - // corrected on the overlay" means "no time right now", not "perfect" - // (operator, 2026-08-09). Capture them once per session, on close, so - // Settings › Dictionary can serve as the deferred correction desk. The - // identical delivered/edited pair teaches the lexicon nothing (word-pair - // extraction over a zero delta yields zero rules), so this fills the - // queue without poisoning learning. - guard isEdited || action == "close" else { return } - let recordedAction = isEdited ? action : "close-unreviewed" - // Bridge FFI (generated by uniffi) appends the quality JSONL and feeds safe - // candidates to lexicon.custom.jsonl. That is blocking disk I/O, so it runs - // off the main actor — Copy/Send/Close must never wait on the disk. - // Raw is best-effort for MVP. - // D-05 over-correct: use sttRawText (wired from applyFinalTranscript / STT finals) - // as raw_text when available so quality records carry the real pre-formatting - // STT text for lexicon v2 consumers. Falls back to delivered (still better than ""). - let rawForRecord = !sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? sttRawText - : delivered - let formattingLevel = qualityFormattingLevel.rawValue - let avgLogprob = sessionAvgLogprob - let speechPct = sessionSpeechPct - let confidenceFlags = sessionConfidenceFlags - Task.detached(priority: .utility) { [weak self] in - // Pass action through to meta (over-correct P2-03). try? because FFI throws on err but - // quality write is best-effort; never block UI action. - let result = try? commitOverlayQualityRecord( - rawText: rawForRecord, - deliveredText: delivered, - editedText: edited, - action: recordedAction, - formattingLevel: formattingLevel, - avgLogprob: avgLogprob, - speechPct: speechPct, - confidenceFlags: confidenceFlags - ) - if let acknowledgement = result?.acknowledgement, !acknowledgement.isEmpty { - await MainActor.run { - self?.showToast(acknowledgement) - } - } - } - } - - /// Pure helpers for XCTest — keep thresholds aligned with `core/transcript_tagging.rs`. - enum OverlayConfidence { - /// Same as `HIGH_CONFIDENCE_AVG_LOGPROB_MIN` / `LOW_CONFIDENCE_AVG_LOGPROB_MAX`. - static let highMin: Float = -0.45 - static let lowMax: Float = -1.20 - /// `POSSIBLE_HALLUCINATION_LOGPROB` in contracts.rs — badge gate. - static let hallucinationThreshold: Float = -1.0 - - static func confidenceLabel(avgLogprob: Float?) -> String { - guard let value = avgLogprob else { return "unknown" } - if value >= highMin { return "high" } - if value <= lowMax { return "low" } - return "medium" - } - - static func showsLowConfidenceBadge(avgLogprob: Float?, flags: [String]) -> Bool { - if flags.contains("possible_hallucination_logprob") { - return true - } - if let avg = avgLogprob, avg <= hallucinationThreshold { - return true - } - return confidenceLabel(avgLogprob: avgLogprob) == "low" - } - } - - /// Keep the most concerning utterance signal for the open session. - private func noteUtteranceConfidence( - avgLogprob: Float?, - speechPct: Float?, - flags: [String] - ) { - if let next = avgLogprob { - if let current = sessionAvgLogprob { - sessionAvgLogprob = min(current, next) - } else { - sessionAvgLogprob = next - } - } - if let speechPct { - sessionSpeechPct = speechPct - } - for flag in flags where !sessionConfidenceFlags.contains(flag) { - sessionConfidenceFlags.append(flag) - } - } - - func prepareForExternalStart() { - handleRecordingPreparing() - } - - func handleRecordingPreparing() { - agentSessionArmed = indicatorMode == .assistive - autoPasteControlAvailable = !agentSessionArmed - finalized = false - isFinalPass = false - mode = .listening - warmingUp = true - audioReady = false - hasMeasuredAudioLevel = false - levelMeter.reset() - if !recording { - resetTranscript() - formattedText = "" - isFormatting = false - errorMessage = nil - beginCaptureClock() - } - recording = true - refreshOverlayPolicyTruth() - refreshPasteTargetAppName(reset: true) - onRecordingPreparing?() - armWarmupWatchdog() - } - - func handleRecordingStarted() { - cancelWarmupWatchdog() - finalized = false - isFinalPass = false - mode = .listening - warmingUp = false - audioReady = true - if !recording { - hasMeasuredAudioLevel = false - levelMeter.reset() - if liveText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - resetTranscript() - } - formattedText = "" - isFormatting = false - formatFailureStatus = nil - errorMessage = nil - beginCaptureClock() - } - if captureStartedAtUptime == nil { - beginCaptureClock() - } - recording = true - refreshOverlayPolicyTruth() - refreshPasteTargetAppName(reset: false) - onRecordingStarted?() - } - - func finishControllerRecording() { - cancelWarmupWatchdog() - recording = false - isFinalPass = false - freezeCaptureClock() - finalizeTranscript() - } - - /// Native hold-release / toggle stop: the controller entered `Busy` (final - /// transcription pass) but no Swift-side `runStop` ran, so nothing had flipped - /// us out of the live-capture UI. Enter the same "transcribing" phase the - /// Finish button uses (waveform stops pulsing like capture, status reads - /// "transcribing"). The terminal `on_recording_stopped` (→ `finalizeTranscript`) - /// clears it, as do error / close / reset. Cancels the warmup watchdog because - /// reaching finalisation proves the session progressed. Idempotent: a repeated - /// `Busy` broadcast (or one arriving after finalize) is a no-op. - func handleRecordingFinalising() { - guard recording, !finalized, !transcribing else { return } - cancelWarmupWatchdog() - warmingUp = false - transcribing = true - freezeCaptureClock() - levelMeter.reset() - hasMeasuredAudioLevel = false - } - - // MARK: Warmup watchdog (orphaned "starting" overlay recovery) - - /// Arm (or re-arm) the warmup watchdog. Called every time an optimistic - /// "preparing" overlay is shown; a re-arm cancels any prior pending fire so - /// rapid repeated preparing events collapse to a single 4s window. - private func armWarmupWatchdog() { - warmupWatchdogTask?.cancel() - warmupWatchdogTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: OverlayState.warmupWatchdogNanos) - guard !Task.isCancelled else { return } - self?.fireWarmupWatchdog() - } - } - - /// Cancel the pending watchdog. Called from every path that proves the session - /// progressed (started / streaming activity / vad) or terminated (stop / - /// finalize / close), so a genuine session never trips the fallback dismiss. - private func cancelWarmupWatchdog() { - warmupWatchdogTask?.cancel() - warmupWatchdogTask = nil - } - - /// Fallback dismiss for a stuck optimistic overlay. Only fires if we are STILL - /// in the "starting" state (`warmingUp`, not finalized) — if any real event - /// already progressed us, `warmingUp` is false and this is a no-op. - private func fireWarmupWatchdog() { - warmupWatchdogTask = nil - guard warmingUp, !finalized else { return } - abortRecordingSession(resetTranscript: true) - mode = .listening - onClose?() - } - - private var isTerminalMode: Bool { - mode == .formatted || mode == .noSpeech || mode == .error - } - - private func restartAutoHideCountdown() { - guard isTerminalMode, !isPointerHovering else { - cancelAutoHide() - return - } - cancelAutoHide() - autoHideDeadline = nowProvider() + OverlayState.autoHideDelaySeconds - scheduleAutoHideWake(after: OverlayState.autoHideDelaySeconds) - } - - private func scheduleAutoHideWake(after delay: TimeInterval) { - let nanoseconds = UInt64(max(0, delay) * 1_000_000_000) - autoHideTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: nanoseconds) - guard !Task.isCancelled else { return } - self?.evaluateAutoHideDeadline(rescheduleIfEarly: true) - } - } - - private func evaluateAutoHideDeadline(rescheduleIfEarly: Bool) { - autoHideTask = nil - guard isTerminalMode, !isPointerHovering, let deadline = autoHideDeadline else { return } - let remaining = deadline - nowProvider() - if remaining > 0 { - if rescheduleIfEarly { scheduleAutoHideWake(after: remaining) } - return - } - autoHideDeadline = nil - if agentSessionArmed, agentFinalTranscriptAppeared { - if !agentAutoSendCancelled { - deliverAgentTranscript() - } - return - } - onClose?() - } - - /// Deterministic XCTest seam: tests inject a monotonic clock, advance it, - /// and evaluate the same deadline logic without wall-clock sleeps. - func fireAutoHideNowForTests() { - autoHideTask?.cancel() - autoHideTask = nil - evaluateAutoHideDeadline(rescheduleIfEarly: false) - } - - private func cancelAutoHide() { - autoHideTask?.cancel() - autoHideTask = nil - autoHideDeadline = nil - } - - private func deliverAgentTranscript() { - let text = activeText.trimmingCharacters(in: .whitespacesAndNewlines) - // No `agentSessionArmed` here: the explicit Send button is live for - // every terminal overlay (dictation and formatting included), and the - // controller falls back to the session trigger context when no - // assistive context was armed (review P0-03). Auto-send remains gated - // on the armed latch by its caller. - guard !agentDeliveryStarted, !text.isEmpty, let engine else { return } - agentDeliveryStarted = true - cancelAutoHide() - Task { @MainActor [weak self] in - guard let self else { return } - do { - if try await engine.sendAssistiveTranscript(text: text) { - onSendToAgent?(text) - onClose?() - } else { - agentDeliveryStarted = false - showToast("Agent delivery is no longer available") - } - } catch { - agentDeliveryStarted = false - showToast("Couldn't send to Agent") - } - } - } - - private func abortRecordingSession(resetTranscript shouldResetTranscript: Bool = false) { - let shouldNotifyStopped = - !finalized && (recording || warmingUp || transcribing || audioReady || vadActive) - cancelWarmupWatchdog() - cancelAutoHide() - recording = false - warmingUp = false - transcribing = false - audioReady = false - vadActive = false - isFinalPass = false - freezeCaptureClock() - levelMeter.reset() - hasMeasuredAudioLevel = false - if shouldResetTranscript { - resetTranscript() - } - if shouldNotifyStopped { - finalized = true - onRecordingStopped?() - } - } - - func handleError(message: String) { - // Mid-session Apple STT glitch with an existing draft must NOT kill the - // take (that was the "recording stopped before a transcript" dupa). Soft - // toast + keep listening; Whisper emergency recovery / next VAD segment - // can still fill. Empty take with hard fail stays terminal. - let draft = liveText.trimmingCharacters(in: .whitespacesAndNewlines) - let midSession = recording || warmingUp || transcribing - let appleish = message.contains("Apple STT") || message.contains("transcribe_live") - || message.contains("live path failed") - if midSession && !draft.isEmpty && appleish { - showToast("Apple lag — keeping draft") - return - } - presentTerminalError(message: message, toast: message) - } - - /// User-facing rewrite for Speech Recognition TCC failures. The engine - /// reports raw bridge markers (`speech_auth_not_determined` / `_denied` / - /// `_restricted`); surfacing those verbatim reads as a crash, when the fix - /// is one System Settings toggle. Returns nil for every other error. - static func speechAuthNotice(from message: String) -> String? { - guard message.contains("speech_auth_") else { return nil } - if message.contains("speech_auth_not_determined") { - return "Apple dictation needs Speech Recognition access — " - + "grant it in Settings › Dictation or System Settings › " - + "Privacy & Security › Speech Recognition" - } - if message.contains("speech_auth_denied") || message.contains("speech_auth_restricted") { - return "Speech Recognition is off for Codescribe — enable it in " - + "System Settings › Privacy & Security › Speech Recognition" - } - return "Speech Recognition access is unavailable — check System " - + "Settings › Privacy & Security › Speech Recognition" - } - - private func presentTerminalError(message: String, toast: String) { - let speechNotice = OverlayState.speechAuthNotice(from: message) - let message = speechNotice ?? message - let toast = speechNotice ?? toast - abortRecordingSession() - preview = "" - committedSegments = [] - committedUtterances = [] - authoritativeFinalText = nil - pendingNoSpeechMessage = nil - noSpeechNotice = OverlayState.defaultNoSpeechNotice - formattedText = "" - isFormatting = false - isFinalPass = false - errorMessage = message - mode = .error - finalized = true - showToast(toast) - restartAutoHideCountdown() - } - - // MARK: Listener-driven mutations (called on the main actor by DictationListener) - - /// `Preview` is utterance-LOCAL cumulative: each event carries the full - /// interim for the current (not-yet-finalised) utterance, and the bridge - /// clears it on every `UtteranceFinal`. So we simply mirror it — no prefix - /// matching, no commit-on-mismatch. - func applyPreview(_ text: String) { - guard !finalized else { return } - let next = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !next.isEmpty else { return } - markTranscriptActivity() - preview = next - refreshFormattedTranscriptIfNeeded() - } - - /// `Correction` targets the current utterance. Scope it to the live preview; - /// if the preview was already finalised, patch only the most-recent committed - /// segment (and only when `previousText` matches it). Never a free normalized - /// search across all committed slots. - func applyCorrection(_ text: String, previousText: String) { - guard !finalized else { return } - let corrected = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !corrected.isEmpty else { return } - markTranscriptActivity() - - if !preview.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - preview = corrected - refreshFormattedTranscriptIfNeeded() - return - } - - let previous = previousText.trimmingCharacters(in: .whitespacesAndNewlines) - if let lastIndex = committedSegments.indices.last, - previous.isEmpty || normalized(committedSegments[lastIndex].text) == normalized(previous) - { - committedSegments[lastIndex].text = corrected - committedSegments[lastIndex].annotations = [] - syncCommittedUtterances() - return - } - - // No live preview and nothing to patch: surface it as the current interim. - preview = corrected - refreshFormattedTranscriptIfNeeded() - } - - /// `UtteranceFinal` is one completed VAD-bounded utterance, delivered in FIFO - /// order with a stable `utteranceId`. Key segments by that id and append in id - /// order — the authoritative ordering the bridge already provides. No lossy - /// normalized matching, no text-dedup (a legitimately repeated token must not - /// be dropped). - func applyFinal( - utteranceId: UInt64, - _ text: String, - avgLogprob: Float? = nil, - speechPct: Float? = nil, - confidenceFlags: [String] = [] - ) { - noteUtteranceConfidence(avgLogprob: avgLogprob, speechPct: speechPct, flags: confidenceFlags) - guard !finalized else { return } - markTranscriptActivity() - // A1 contract sensor (debug-only): Rust trims at source and computes - // ReplaceRange/InsertAnnotation offsets over that exact string. A Swift-side - // trim here would silently shift those offsets, so we store the text - // byte-for-byte and only assert the guarantee. - assert( - text == text.trimmingCharacters(in: .whitespacesAndNewlines), - "UtteranceFinal text not trimmed at source (A1 contract) — ReplaceRange offsets would misalign" - ) - if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - upsertFinalSegment(utteranceId: utteranceId, text: text) - } - preview = "" - refreshFormattedTranscriptIfNeeded() - } - - func applyReplaceRange(utteranceId: UInt64, start: UInt64, end: UInt64, text: String) { - guard !finalized else { return } - guard let index = committedSegments.lastIndex(where: { $0.utteranceId == utteranceId }) else { - showToast("Skipped unbound transcript patch") - return - } - // Snapshot the live-text geometry BEFORE the patch. `contextMarkers` - // hold absolute offsets into `rawLiveText` captured at selection time, - // so a patch that changes an earlier span's length slides every marker - // behind it out of alignment. Rebasing has to happen in the same - // transaction — a `{selection_N}` fence that drifts into the middle of - // an unrelated word is worse for the agent lane than no fence at all. - let origin = liveTextOffset(ofSegmentAt: index) - let before = committedSegments[index] - let spanStart = origin + before.renderedOffset(forTextOffset: Int(exactly: start) ?? .max) - let spanEnd = origin + before.renderedOffset(forTextOffset: Int(exactly: end) ?? .max) - let renderedLengthBefore = before.renderedText.count - - guard committedSegments[index].replaceRange(start: start, end: end, replacement: text) else { - showToast("Skipped out-of-range transcript patch") - return - } - rebaseContextMarkers( - spanStart: spanStart, - spanEnd: spanEnd, - delta: committedSegments[index].renderedText.count - renderedLengthBefore - ) - syncCommittedUtterances() - } - - /// Offset at which `committedSegments[index]` starts inside `rawLiveText`. - /// Mirrors that property's own assembly (blank segments dropped, one space - /// between the survivors) — the two must not drift apart. - private func liveTextOffset(ofSegmentAt index: Int) -> Int { - var offset = 0 - for segment in committedSegments[..= spanEnd { - contextMarkers[index].position = max(0, position + delta) - } else { - // The characters this marker anchored to no longer exist. - // Collapse to the patch boundary: never dropped (lost intent), - // never left past the replacement (drifted intent). - contextMarkers[index].position = spanStart - } - } - } - - func applyInsertAnnotation(utteranceId: UInt64, position: UInt64, text: String) { - guard !finalized else { return } - let annotation = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !annotation.isEmpty else { return } - guard let index = committedSegments.lastIndex(where: { $0.utteranceId == utteranceId }) else { - showToast("Skipped unbound transcript annotation") - return - } - guard committedSegments[index].insertAnnotation(position: position, text: annotation) else { - showToast("Skipped out-of-range transcript annotation") - return - } - syncCommittedUtterances() - } - - func applyContextMarker(position: UInt64, marker: String) { - guard !finalized, let offset = Int(exactly: position) else { return } - let clean = marker.trimmingCharacters(in: .whitespacesAndNewlines) - guard !clean.isEmpty else { return } - contextMarkers.append( - OverlayContextMarker(position: offset, marker: clean, order: contextMarkers.count) + // Candle-live still surfaces via startRecording / later final-pass. + // Apple-live continues; bridge already degrades quietly when it can. + NSLog("codescribe: optional Whisper warm skipped: \(error)") + } + } + try await engine.startRecording(language: language) + } catch { + await handleStartFailure(error, language: language) + } + } + + /// A start failure caused by an undetermined Speech Recognition grant is + /// recoverable in place: fire the TCC dialog from the main app process (so + /// the grant lands on the app's identity, which the bridge child inherits) + /// and retry the start once when authorized. Every other failure — and a + /// declined dialog — funnels into the terminal error path, where + /// `speechAuthNotice` rewrites raw `speech_auth_*` markers. + private func handleStartFailure(_ error: Error, language: CsLanguage?) async { + let described = "\(error)" + if described.contains("speech_auth_not_determined"), !speechAuthRequestAttempted { + speechAuthRequestAttempted = true + abortRecordingSession() + let state = await withCheckedContinuation { continuation in + SpeechRecognitionPermission.request { continuation.resume(returning: $0) } + } + if state == .granted { + await runStart(language: language) + return + } + } + presentTerminalError( + message: "Couldn't start recording: \(described)", + toast: "Couldn't start recording" + ) + } + + func formatTranscript(level: FormattingPolicyOption) { + guard let engine, + canFormat, + OverlayActionPresentation.manualFormatLevels.contains(level) + else { return } + let source = formattedText + let sourceLevel = qualityFormattingLevel + isFormatting = true + // Format deliberately suspends passive dismissal. Its result stays until + // another user activity explicitly starts a fresh countdown. + cancelAutoHide() + Task { @MainActor in + defer { self.isFormatting = false } + do { + let formatted = try await engine.formatText( + text: source, + language: nil, + level: level ) - if mode == .formatted { - let base = usableAuthoritativeFinalText ?? rawLiveText - let rendered = insertingContextMarkers(into: base) - if formattedText != rendered { formattedText = rendered } - } - } - - func applySessionFinalised() { - guard !finalized else { return } - markTranscriptActivity() - // Enter final pass phase (the post-stop AI formatting / authoritative - // assembly). Status shows "final pass", transcript assembly remains - // visible; the controller finish will surface the resolved .formatted. - isFinalPass = true - transcribing = false - // Do not call finalizeTranscript here — that is driven by - // finishControllerRecording (or equivalent terminal) so the phase - // is observable to the user. - } - - /// `on_no_speech` — the engine adjudicated the session with no usable speech. - /// Fires BEFORE the terminal `on_recording_stopped`, so we only record the - /// user-facing reason here; `finalizeTranscript` treats it as the engine's - /// no-usable-speech adjudication (unless an authoritative final arrives) and - /// flips into the dedicated `.noSpeech` outcome. If the reason arrives AFTER - /// an already-empty finalize (late), upgrade the FINAL in place. - func applyNoSpeech(reason: String) { - let message: String - switch reason { - case "all_speech_rejected_by_quality_gate": - message = "Speech too quiet or short — adjust the mic and try again" - default: - message = OverlayState.defaultNoSpeechNotice - } - pendingNoSpeechMessage = message - if finalized, mode == .formatted, - formattedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - noSpeechNotice = message - mode = .noSpeech - restartAutoHideCountdown() - } else if mode == .noSpeech { - noSpeechNotice = message - } - } - - /// The Rust controller's authoritative post-stop transcript (LocalFinalPass) — - /// the SAME text that is delivered/pasted and shown by tray "Copy". Stored so - /// the single `finalizeTranscript()` uses it instead of the raw streaming - /// assembly. Emitted inside the awaited stop pipeline, so it normally arrives - /// before the stop/finalise events; if it arrives AFTER (mode already - /// `.formatted`), replace the FINAL immediately. Live PREVIEW is untouched — - /// it stays raw-streaming on purpose ("live preview · raw"). - func applyFinalTranscript(_ text: String) { - let clean = text.trimmingCharacters(in: .whitespacesAndNewlines) - // Dedupe: this event fires once per stop, but a redundant re-emit must not - // reassign `@Published` state (each write re-invalidates the TextEditor). - guard !clean.isEmpty, clean != authoritativeFinalText else { return } - authoritativeFinalText = clean - formatFailureStatus = nil - let rendered = insertingContextMarkers(into: clean) - if mode == .formatted, formattedText != rendered { - formattedText = rendered - } else if mode == .noSpeech { - // Real text arrived after we finalised to no-speech (empty at the - // time): recover it as the normal FINAL rather than losing it. - formattedText = rendered - mode = .formatted - restartAutoHideCountdown() - } - if deliveredText.isEmpty, !clean.isEmpty { - deliveredText = rendered - } - if agentSessionArmed { - agentFinalTranscriptAppeared = true - } - if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !clean.isEmpty { - sttRawText = clean - } - } - - /// Single authoritative finalize. `runStop`, `finishControllerRecording`, and - /// `applySessionFinalised` all funnel here so `formattedText` is produced from - /// ONE source rather than three paths each rewriting it from a different buffer. - /// Preference: the controller's authoritative LocalFinalPass text (matches - /// delivery/Copy); fall back to the id-ordered committed assembly only if that - /// event has not arrived. - private func finalizeTranscript() { - let wasFinalized = finalized - cancelWarmupWatchdog() - warmingUp = false - transcribing = false - vadActive = false - audioReady = false - levelMeter.reset() - hasMeasuredAudioLevel = false - let shouldShowNoSpeechOutcome = - pendingNoSpeechMessage != nil && usableAuthoritativeFinalText == nil - if shouldShowNoSpeechOutcome { - preview = "" + let isUsableChange = + !formatted + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty && formatted != source + if isUsableChange { + self.preFormatText = source + self.preFormatLevel = sourceLevel + self.formattedText = formatted + self.qualityFormattingLevel = level + } + self.formatFailureStatus = nil + self.mode = .formatted + self.cancelAutoHide() // User acted (Format); do not auto-hide the result. + } catch { + self.formattedText = source + self.formatFailureStatus = "raw — formatting failed" + self.mode = .formatted + self.cancelAutoHide() + self.errorMessage = "Couldn't format transcript: \(error)" + self.showToast("Couldn't format transcript") + } + } + } + + /// Arm the one-step revert slot after an AUTO-formatted FINAL, so Revert + /// restores the raw first version — the same undo manual Format already has. + /// Operator agreement (2026-08-13, re-raised 2026-08-14): an auto-formatted + /// transcript must never be a one-way door; before this, `preFormatText` was + /// only set by the manual path, so auto results showed no Revert at all. + /// Only arms when auto formatting is actually on, the shown text came from + /// the controller's authoritative final, a different non-empty raw assembly + /// exists, and no manual slot is already held. + private func armAutoFormatRevertSlot(shown: String) { + guard autoFormatLevel != .off, + usableAuthoritativeFinalText != nil, + preFormatText == nil + else { return } + let rawSource = insertingContextMarkers(into: rawLiveText) + guard !rawSource.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + rawSource != shown + else { return } + preFormatText = rawSource + preFormatLevel = .off + } + + /// Restore the exact source of the most recent successful changed format. + /// The slot is consumed once and this explicit user activity starts a fresh + /// terminal lifetime from the injected monotonic clock. + func revertFormat() { + guard !isFormatting, let source = preFormatText else { return } + let sourceLevel = preFormatLevel ?? .off + preFormatText = nil + preFormatLevel = nil + formattedText = source + qualityFormattingLevel = sourceLevel + formatFailureStatus = nil + mode = .formatted + restartAutoHideCountdown() + } + + private func runStop() async { + guard let engine else { return } + // Enter the explicit "transcribing" phase for the whole awaited stop: the + // waveform stops pulsing like capture and the status reads "transcribing" + // instead of leaving the recording UI up while the final pass runs. + transcribing = true + warmingUp = false + freezeCaptureClock() + levelMeter.reset() + do { + // The controller bridge returns "" here; the authoritative transcript + // is the id-ordered assembly of `UtteranceFinal` events (see liveText). + _ = try await engine.stopRecording() + recording = false + isFinalPass = false + finalizeTranscript() // clears `transcribing` as it flips to `.formatted` + } catch { + presentTerminalError( + message: "Couldn't finalize transcript: \(error)", + toast: "Couldn't finalize transcript" + ) + } + } + + // MARK: Action row + + func copyToPasteboard() { + // P0-D: capture user correction on FINAL for quality loop + lexicon learning. + captureQualityIfEdited(action: "copy") + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(activeText, forType: .string) + restartAutoHideCountdown() + } + + func sendToAgent() { + // P0-D: capture user correction on FINAL for quality loop + lexicon learning. + captureQualityIfEdited(action: "send") + deliverAgentTranscript() + } + + /// Caret-truth probe for the Insert self-paste guard. The overlay is a + /// non-activating panel that can become key WITHOUT the app being + /// frontmost (Spotlight-style), so a synthetic Cmd+V follows OUR key + /// window whenever a Codescribe text view holds the caret — the frontmost + /// app check on the Rust side cannot see that. Injectable so tests can + /// simulate both worlds. + var insertCaretInCodescribeProbe: () -> Bool = { + guard let keyWindow = NSApp.keyWindow else { return false } + return keyWindow.firstResponder is NSTextView + } + + func pasteToPreviousApp() { + captureQualityIfEdited(action: "paste") + // Do not let the previous deadline fire while the async delivery is in + // flight. A successful or failed attempt gets a fresh full countdown. + cancelAutoHide() + let text = activeText + Task { @MainActor in + defer { self.restartAutoHideCountdown() } + do { + let result: CsPasteResult? + if self.insertCaretInCodescribeProbe() { + // The caret sits inside Codescribe (e.g. the overlay's own + // editable FINAL) — a synthetic Cmd+V would paste the + // transcript right back into the overlay. Arm the in-memory + // Paste Here slot without touching the user's clipboard. + result = try await engine?.deferText(text: text) } else { - commitPreviewIfNeeded() - } - let resolvedBase = shouldShowNoSpeechOutcome ? "" : (usableAuthoritativeFinalText ?? rawLiveText) - let resolved = insertingContextMarkers(into: resolvedBase) - if resolvedBase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - // Nothing usable was captured — VAD silence, or all speech rejected by - // the quality gate. Surface a dedicated no-speech outcome instead of a - // blank editable FINAL (Copy/Format/Send acting on an empty string). - // When `on_no_speech` did not fire (empty final without an explicit - // event) we treat the empty finalize as no-speech — an honest - // approximation, since the user has nothing to act on either way. - if formattedText != "" { formattedText = "" } - noSpeechNotice = pendingNoSpeechMessage ?? OverlayState.defaultNoSpeechNotice - mode = .noSpeech + result = try await engine?.pasteText(text: text) + } + switch result?.outcome { + case .deferredInsertArmed: + let target = result?.targetAppName ?? "the target app" + let shortcut = result?.deferredInsertShortcut ?? "⌘⌥V" + self.showToast( + "Couldn't reach \(target) — put your cursor where you want the text " + + "and press \(shortcut). Your clipboard is untouched." + ) + case .copiedToClipboard: + self.showToast( + self.copiedInsertFallbackToast( + frontmost: result?.frontmostAppName, + target: result?.targetAppName, + failure: result?.deferredInsertFailure + )) + case .accessibilityPermissionNeeded: + self.showToast( + self.copiedInsertFallbackToast( + frontmost: result?.frontmostAppName, + target: result?.targetAppName, + failure: result?.deferredInsertFailure + )) + case .pasted, .noop, nil: + break + } + } catch { + self.errorMessage = "Couldn't paste transcript: \(error)" + self.showToast("Couldn't paste transcript") + } + } + } + + private func copiedInsertFallbackToast( + frontmost: String?, + target: String?, + failure: String? + ) -> String { + if let failure { + return "\(failure) — copied with tags instead. " + + "Clipboard replaced; press Cmd+V where you want it." + } + if let frontmost, let target { + return "Copied — your cursor is in \(frontmost), not \(target). " + + "Clipboard replaced; press Cmd+V where you want it." + } + if let target { + return "Copied — focus couldn't be confirmed for \(target). " + + "Clipboard replaced; press Cmd+V where you want it." + } + return "Copied — the target app was lost. " + + "Clipboard replaced; press Cmd+V where you want it." + } + + /// Persist through C02's single config seam, then immediately replace local + /// state with a fresh disk-backed snapshot. A rejected write therefore snaps + /// back to durable truth instead of leaving an optimistic switch behind. + func setAutoPasteEnabled(_ enabled: Bool) { + guard autoPasteControlAvailable, let engine else { return } + engine.setAutoPasteEnabled(enabled) + refreshOverlayPolicyTruth() + restartAutoHideCountdown() + } + + func setAutoPasteControlAvailable(_ available: Bool) { + autoPasteControlAvailable = available + } + + func close() { + // P0-D: capture user correction on FINAL for quality loop + lexicon learning. + captureQualityIfEdited(action: "close") + cancelWarmupWatchdog() + cancelAutoHide() + mockRevealTask?.cancel() + toastTask?.cancel() + pasteTargetRefreshTask?.cancel() + if recording, let engine { + recording = false + Task { @MainActor in _ = try? await engine.stopRecording() } + } + vadActive = false + audioReady = false + warmingUp = false + transcribing = false + isFinalPass = false + onClose?() + } + + private func refreshPasteTargetAppName(reset: Bool) { + pasteTargetRefreshTask?.cancel() + if reset { + pasteTargetAppName = nil + } + guard let engine else { return } + pasteTargetRefreshTask = Task { @MainActor [weak self] in + let target = await engine.pasteTargetAppName() + guard !Task.isCancelled, let self else { return } + self.pasteTargetAppName = + OverlayInsertActionPresentation( + targetAppName: target + ).targetAppName + } + } + + private func refreshOverlayPolicyTruth() { + guard let truth = engine?.currentOverlayPolicy() else { return } + autoPasteEnabled = truth.autoPasteEnabled + autoFormatLevel = truth.autoFormatLevel + } + + /// TextEditor writes through this seam so only actual user edits — never a + /// programmatic format/final update — re-anchor the terminal lifetime. + func userEditedTranscript(_ text: String) { + if agentSessionArmed, agentFinalTranscriptAppeared, text != formattedText { + agentAutoSendCancelled = true + } + formattedText = text + restartAutoHideCountdown() + } + + /// Consume the canonical Rust indicator mode. Agent arm is a one-shot + /// session latch; the accepted orange processing phase must not disarm it. + func applyIndicatorMode(_ mode: CsIndicatorMode) { + indicatorMode = mode + if mode == .assistive { + agentSessionArmed = true + autoPasteControlAvailable = false + } + } + + /// AppKit reports window motion separately from SwiftUI content events. + func userDraggedOverlay() { + restartAutoHideCountdown() + } + + /// A live edge-drag resize is activity and therefore receives a fresh window. + func userResizedOverlay() { + restartAutoHideCountdown() + } + + /// Hover pauses dismissal entirely; leaving starts a new full five seconds. + func setPointerHovering(_ hovering: Bool) { + guard hovering != isPointerHovering else { return } + isPointerHovering = hovering + guard isTerminalMode else { return } + if hovering { + cancelAutoHide() + } else { + restartAutoHideCountdown() + } + } + + // MARK: P0-D quality loop (user edits on FINAL → record + lexicon candidate) + + private func captureQualityIfEdited(action: String) { + guard mode == .formatted else { return } + // `commitOverlayQualityRecord` is a free FFI function, not a call on the + // injected `engine` — so a mocked engine does NOT stop it, and the XCTest + // suite was appending two synthetic corrections ("original delivered + // transcript here with user fix") to the OPERATOR'S live + // ~/.codescribe/quality/corrections.jsonl on every run. 276 of 501 rows + // in the real store came from test runs, and they surfaced in Settings › + // Dictionary as if the user had made them (operator screenshot + // 2026-08-09 14:21, three seconds after a suite finished). The keychain + // test-host gate landed earlier did not cover this path. + guard !QualityCaptureHost.isRunningTests else { return } + let delivered = deliveredText.trimmingCharacters(in: .whitespacesAndNewlines) + let edited = formattedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !edited.isEmpty else { return } + let isEdited = delivered != edited + // Unedited transcripts used to never reach the review queue — but "not + // corrected on the overlay" means "no time right now", not "perfect" + // (operator, 2026-08-09). Capture them once per session, on close, so + // Settings › Dictionary can serve as the deferred correction desk. The + // identical delivered/edited pair teaches the lexicon nothing (word-pair + // extraction over a zero delta yields zero rules), so this fills the + // queue without poisoning learning. + guard isEdited || action == "close" else { return } + let recordedAction = isEdited ? action : "close-unreviewed" + // Bridge FFI (generated by uniffi) appends the quality JSONL and feeds safe + // candidates to lexicon.custom.jsonl. That is blocking disk I/O, so it runs + // off the main actor — Copy/Send/Close must never wait on the disk. + // Raw is best-effort for MVP. + // D-05 over-correct: use sttRawText (wired from applyFinalTranscript / STT finals) + // as raw_text when available so quality records carry the real pre-formatting + // STT text for lexicon v2 consumers. Falls back to delivered (still better than ""). + let rawForRecord = + !sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? sttRawText + : delivered + let formattingLevel = qualityFormattingLevel.rawValue + let avgLogprob = sessionAvgLogprob + let speechPct = sessionSpeechPct + let confidenceFlags = sessionConfidenceFlags + Task.detached(priority: .utility) { [weak self] in + // Pass action through to meta (over-correct P2-03). try? because FFI throws on err but + // quality write is best-effort; never block UI action. + let result = try? commitOverlayQualityRecord( + rawText: rawForRecord, + deliveredText: delivered, + editedText: edited, + action: recordedAction, + formattingLevel: formattingLevel, + avgLogprob: avgLogprob, + speechPct: speechPct, + confidenceFlags: confidenceFlags + ) + if let acknowledgement = result?.acknowledgement, !acknowledgement.isEmpty { + await MainActor.run { + self?.showToast(acknowledgement) + } + } + } + } + + /// Pure helpers for XCTest — keep thresholds aligned with `core/transcript_tagging.rs`. + enum OverlayConfidence { + /// Same as `HIGH_CONFIDENCE_AVG_LOGPROB_MIN` / `LOW_CONFIDENCE_AVG_LOGPROB_MAX`. + static let highMin: Float = -0.45 + static let lowMax: Float = -1.20 + /// `POSSIBLE_HALLUCINATION_LOGPROB` in contracts.rs — badge gate. + static let hallucinationThreshold: Float = -1.0 + + static func confidenceLabel(avgLogprob: Float?) -> String { + guard let value = avgLogprob else { return "unknown" } + if value >= highMin { return "high" } + if value <= lowMax { return "low" } + return "medium" + } + + static func showsLowConfidenceBadge(avgLogprob: Float?, flags: [String]) -> Bool { + if flags.contains("possible_hallucination_logprob") { + return true + } + if let avg = avgLogprob, avg <= hallucinationThreshold { + return true + } + return confidenceLabel(avgLogprob: avgLogprob) == "low" + } + } + + /// Keep the most concerning utterance signal for the open session. + private func noteUtteranceConfidence( + avgLogprob: Float?, + speechPct: Float?, + flags: [String] + ) { + if let next = avgLogprob { + if let current = sessionAvgLogprob { + sessionAvgLogprob = min(current, next) + } else { + sessionAvgLogprob = next + } + } + if let speechPct { + sessionSpeechPct = speechPct + } + for flag in flags where !sessionConfidenceFlags.contains(flag) { + sessionConfidenceFlags.append(flag) + } + } + + func prepareForExternalStart() { + handleRecordingPreparing() + } + + func handleRecordingPreparing() { + agentSessionArmed = indicatorMode == .assistive + autoPasteControlAvailable = !agentSessionArmed + finalized = false + isFinalPass = false + mode = .listening + warmingUp = true + audioReady = false + hasMeasuredAudioLevel = false + levelMeter.reset() + if !recording { + resetTranscript() + formattedText = "" + isFormatting = false + errorMessage = nil + beginCaptureClock() + } + recording = true + refreshOverlayPolicyTruth() + refreshPasteTargetAppName(reset: true) + onRecordingPreparing?() + armWarmupWatchdog() + } + + func handleRecordingStarted() { + cancelWarmupWatchdog() + finalized = false + isFinalPass = false + mode = .listening + warmingUp = false + audioReady = true + if !recording { + hasMeasuredAudioLevel = false + levelMeter.reset() + if liveText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + resetTranscript() + } + formattedText = "" + isFormatting = false + formatFailureStatus = nil + errorMessage = nil + beginCaptureClock() + } + if captureStartedAtUptime == nil { + beginCaptureClock() + } + recording = true + refreshOverlayPolicyTruth() + refreshPasteTargetAppName(reset: false) + onRecordingStarted?() + } + + func finishControllerRecording() { + cancelWarmupWatchdog() + recording = false + isFinalPass = false + freezeCaptureClock() + finalizeTranscript() + } + + /// Native hold-release / toggle stop: the controller entered `Busy` (final + /// transcription pass) but no Swift-side `runStop` ran, so nothing had flipped + /// us out of the live-capture UI. Enter the same "transcribing" phase the + /// Finish button uses (waveform stops pulsing like capture, status reads + /// "transcribing"). The terminal `on_recording_stopped` (→ `finalizeTranscript`) + /// clears it, as do error / close / reset. Cancels the warmup watchdog because + /// reaching finalisation proves the session progressed. Idempotent: a repeated + /// `Busy` broadcast (or one arriving after finalize) is a no-op. + func handleRecordingFinalising() { + guard recording, !finalized, !transcribing else { return } + cancelWarmupWatchdog() + warmingUp = false + transcribing = true + freezeCaptureClock() + levelMeter.reset() + hasMeasuredAudioLevel = false + } + + // MARK: Warmup watchdog (orphaned "starting" overlay recovery) + + /// Arm (or re-arm) the warmup watchdog. Called every time an optimistic + /// "preparing" overlay is shown; a re-arm cancels any prior pending fire so + /// rapid repeated preparing events collapse to a single 4s window. + private func armWarmupWatchdog() { + warmupWatchdogTask?.cancel() + warmupWatchdogTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: OverlayState.warmupWatchdogNanos) + guard !Task.isCancelled else { return } + self?.fireWarmupWatchdog() + } + } + + /// Cancel the pending watchdog. Called from every path that proves the session + /// progressed (started / streaming activity / vad) or terminated (stop / + /// finalize / close), so a genuine session never trips the fallback dismiss. + private func cancelWarmupWatchdog() { + warmupWatchdogTask?.cancel() + warmupWatchdogTask = nil + } + + /// Fallback dismiss for a stuck optimistic overlay. Only fires if we are STILL + /// in the "starting" state (`warmingUp`, not finalized) — if any real event + /// already progressed us, `warmingUp` is false and this is a no-op. + private func fireWarmupWatchdog() { + warmupWatchdogTask = nil + guard warmingUp, !finalized else { return } + abortRecordingSession(resetTranscript: true) + mode = .listening + onClose?() + } + + private var isTerminalMode: Bool { + mode == .formatted || mode == .noSpeech || mode == .error + } + + private func restartAutoHideCountdown() { + guard isTerminalMode, !isPointerHovering else { + cancelAutoHide() + return + } + cancelAutoHide() + autoHideDeadline = nowProvider() + OverlayState.autoHideDelaySeconds + scheduleAutoHideWake(after: OverlayState.autoHideDelaySeconds) + } + + private func scheduleAutoHideWake(after delay: TimeInterval) { + let nanoseconds = UInt64(max(0, delay) * 1_000_000_000) + autoHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: nanoseconds) + guard !Task.isCancelled else { return } + self?.evaluateAutoHideDeadline(rescheduleIfEarly: true) + } + } + + private func evaluateAutoHideDeadline(rescheduleIfEarly: Bool) { + autoHideTask = nil + guard isTerminalMode, !isPointerHovering, let deadline = autoHideDeadline else { return } + let remaining = deadline - nowProvider() + if remaining > 0 { + if rescheduleIfEarly { scheduleAutoHideWake(after: remaining) } + return + } + autoHideDeadline = nil + if agentSessionArmed, agentFinalTranscriptAppeared { + if !agentAutoSendCancelled { + deliverAgentTranscript() + } + return + } + onClose?() + } + + /// Deterministic XCTest seam: tests inject a monotonic clock, advance it, + /// and evaluate the same deadline logic without wall-clock sleeps. + func fireAutoHideNowForTests() { + autoHideTask?.cancel() + autoHideTask = nil + evaluateAutoHideDeadline(rescheduleIfEarly: false) + } + + private func cancelAutoHide() { + autoHideTask?.cancel() + autoHideTask = nil + autoHideDeadline = nil + } + + private func deliverAgentTranscript() { + let text = activeText.trimmingCharacters(in: .whitespacesAndNewlines) + // No `agentSessionArmed` here: the explicit Send button is live for + // every terminal overlay (dictation and formatting included), and the + // controller falls back to the session trigger context when no + // assistive context was armed (review P0-03). Auto-send remains gated + // on the armed latch by its caller. + guard !agentDeliveryStarted, !text.isEmpty, let engine else { return } + agentDeliveryStarted = true + cancelAutoHide() + Task { @MainActor [weak self] in + guard let self else { return } + do { + if try await engine.sendAssistiveTranscript(text: text) { + onSendToAgent?(text) + onClose?() } else { - if formattedText != resolved { formattedText = resolved } - if deliveredText.isEmpty { deliveredText = resolved } - qualityFormattingLevel = autoFormatLevel - if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - // Best effort: if no STT raw from per-utterance finals yet, fall back to the - // resolved assembly (still the raw-streaming path, not AI formatted). - sttRawText = resolvedBase - } - mode = .formatted - if agentSessionArmed { - agentFinalTranscriptAppeared = true - } - } - // FREEZE: from here, late streaming events are dropped (see the apply guards) - // so nothing keeps mutating @Published state and re-rendering in Idle. - finalized = true - isFinalPass = false - // Notify the recording-lifecycle sink that the session ended. This is the - // stop-side counterpart to `handleRecordingStarted` firing `onRecordingStarted?()`: - // the tray otherwise only clears its "Recording" pill via the popover's one-shot - // onAppear poll, so a hotkey stop left it stuck. Gate on the finalize transition - // so redundant re-finalizes (finishControllerRecording + applySessionFinalised) - // don't re-fire and churn @Published tray state. - if !wasFinalized { - if !resolvedBase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - onSuccessfulDictation?() - } - onRecordingStopped?() + agentDeliveryStarted = false + showToast("Agent delivery is no longer available") } - - // Every terminal outcome gets the same activity-anchored lifetime. - restartAutoHideCountdown() - } - - private var usableAuthoritativeFinalText: String? { - guard let text = authoritativeFinalText else { return nil } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func resetTranscript() { - preview = "" - committedSegments = [] - committedUtterances = [] - contextMarkers = [] - authoritativeFinalText = nil - deliveredText = "" - sttRawText = "" - qualityFormattingLevel = .off - sessionAvgLogprob = nil - sessionSpeechPct = nil - sessionConfidenceFlags = [] - preFormatText = nil - preFormatLevel = nil - formatFailureStatus = nil - pendingNoSpeechMessage = nil - noSpeechNotice = OverlayState.defaultNoSpeechNotice - finalized = false - agentFinalTranscriptAppeared = false - agentAutoSendCancelled = false + } catch { agentDeliveryStarted = false - transcribing = false - isFinalPass = false - // A hidden panel may not emit a pointer-exit event. Never carry a paused - // hover latch into the next recording session. - isPointerHovering = false - cancelAutoHide() - } - - private func markTranscriptActivity() { - cancelWarmupWatchdog() - warmingUp = false - audioReady = true - if recording { - mode = .listening - } - } - - private func commitPreviewIfNeeded() { - let active = preview.trimmingCharacters(in: .whitespacesAndNewlines) - guard !active.isEmpty else { return } - appendCommittedSegment(active) - preview = "" - refreshFormattedTranscriptIfNeeded() - } - - /// Append a committed segment, keyed by `utteranceId`. Re-finals for an id we - /// already hold replace that slot in place (no duplicate, no drop); new ids - /// append in arrival order = id order, the bridge's FIFO ordering. - /// Contract: Rust already sends trimmed text and is the sole owner of offsets; - /// Swift stores it byte-for-byte because ReplaceRange/InsertAnnotation offsets - /// are computed by the emitter against this same string. - private func upsertFinalSegment(utteranceId: UInt64, text: String) { - guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - if let index = committedSegments.firstIndex(where: { $0.utteranceId == utteranceId }) { - guard committedSegments[index].text != text else { return } - committedSegments[index].text = text - committedSegments[index].annotations = [] + showToast("Couldn't send to Agent") + } + } + } + + private func abortRecordingSession(resetTranscript shouldResetTranscript: Bool = false) { + let shouldNotifyStopped = + !finalized && (recording || warmingUp || transcribing || audioReady || vadActive) + cancelWarmupWatchdog() + cancelAutoHide() + recording = false + warmingUp = false + transcribing = false + audioReady = false + vadActive = false + isFinalPass = false + freezeCaptureClock() + levelMeter.reset() + hasMeasuredAudioLevel = false + if shouldResetTranscript { + resetTranscript() + } + if shouldNotifyStopped { + finalized = true + onRecordingStopped?() + } + } + + func handleError(message: String) { + // Since the bridge-side warning split (`warning_is_user_terminal`), quality + // receipts (`tail_patch_under_commit`, `layer1_lane_degraded`, + // `apple_final_window_overlap_normalized`, ...) never reach `on_error` — + // they are log-only in both bridges. What lands here is a user-terminal + // failure (`transcription_failed`, start failures): the session is over. + // + // The content rule survives from the 2026-08-12 incident (a mislabelled + // warning ran `presentTerminalError` and discarded 282 already-committed + // characters): whatever the failure, a non-empty draft is sacred. But + // "sacred" no longer means pretending the take is alive behind an + // "Engine warning" toast while the engine is gone — that left the overlay + // in a zombie live-capture UI with no stop parity. A failure with a draft + // now ENDS the session exactly like a stop: engine released best-effort + // (the same orphan-mic guard as `ComposerDictation.handleEngineError`), + // state finalized through the single authoritative `finalizeTranscript` + // path, transcript kept on screen with the normal Copy/Format/Send surface. + // + // `liveText` is `committedUtterances + preview`, so a non-empty draft covers + // both the in-flight utterance and everything already sealed. An empty take + // stays terminal — with nothing to lose, the user must still learn that the + // session died. + let draft = liveText.trimmingCharacters(in: .whitespacesAndNewlines) + if !draft.isEmpty { + if let engine { + Task { @MainActor in _ = try? await engine.stopRecording() } + } + finishControllerRecording() + showToast("Dictation failed — transcript kept") + return + } + presentTerminalError(message: message, toast: message) + } + + /// User-facing rewrite for Speech Recognition TCC failures. The engine + /// reports raw bridge markers (`speech_auth_not_determined` / `_denied` / + /// `_restricted`); surfacing those verbatim reads as a crash, when the fix + /// is one System Settings toggle. Returns nil for every other error. + static func speechAuthNotice(from message: String) -> String? { + guard message.contains("speech_auth_") else { return nil } + if message.contains("speech_auth_not_determined") { + return "Apple dictation needs Speech Recognition access — " + + "grant it in Settings › Dictation or System Settings › " + + "Privacy & Security › Speech Recognition" + } + if message.contains("speech_auth_denied") || message.contains("speech_auth_restricted") { + return "Speech Recognition is off for Codescribe — enable it in " + + "System Settings › Privacy & Security › Speech Recognition" + } + return "Speech Recognition access is unavailable — check System " + + "Settings › Privacy & Security › Speech Recognition" + } + + private func presentTerminalError(message: String, toast: String) { + let speechNotice = OverlayState.speechAuthNotice(from: message) + let message = speechNotice ?? message + let toast = speechNotice ?? toast + abortRecordingSession() + preview = "" + committedSegments = [] + committedUtterances = [] + highlights = [] + selectedHighlightId = nil + speechWasActive = false + authoritativeFinalText = nil + pendingNoSpeechMessage = nil + noSpeechNotice = OverlayState.defaultNoSpeechNotice + formattedText = "" + isFormatting = false + isFinalPass = false + errorMessage = message + mode = .error + finalized = true + showToast(toast) + restartAutoHideCountdown() + } + + // MARK: Listener-driven mutations (called on the main actor by DictationListener) + + /// `Preview` is utterance-LOCAL cumulative: each event carries the full + /// interim for the current (not-yet-finalised) utterance, and the bridge + /// clears it on every `UtteranceFinal`. So we simply mirror it — no prefix + /// matching, no commit-on-mismatch. + func applyPreview(_ text: String) { + guard !finalized else { return } + let next = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !next.isEmpty else { return } + markTranscriptActivity() + preview = next + refreshFormattedTranscriptIfNeeded() + } + + /// `Correction` targets the current utterance. Scope it to the live preview; + /// if the preview was already finalised, patch only the most-recent committed + /// segment (and only when `previousText` matches it). Never a free normalized + /// search across all committed slots. + func applyCorrection(_ text: String, previousText: String) { + guard !finalized else { return } + let corrected = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !corrected.isEmpty else { return } + markTranscriptActivity() + + if !preview.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + preview = corrected + refreshFormattedTranscriptIfNeeded() + return + } + + let previous = previousText.trimmingCharacters(in: .whitespacesAndNewlines) + if let lastIndex = committedSegments.indices.last, + previous.isEmpty || normalized(committedSegments[lastIndex].text) == normalized(previous) + { + committedSegments[lastIndex].text = corrected + committedSegments[lastIndex].annotations = [] + syncCommittedUtterances() + return + } + + // No live preview and nothing to patch: surface it as the current interim. + preview = corrected + refreshFormattedTranscriptIfNeeded() + } + + /// `UtteranceFinal` is one completed VAD-bounded utterance, delivered in FIFO + /// order with a stable `utteranceId`. Key segments by that id and append in id + /// order — the authoritative ordering the bridge already provides. No lossy + /// normalized matching, no text-dedup (a legitimately repeated token must not + /// be dropped). + func applyFinal( + utteranceId: UInt64, + _ text: String, + avgLogprob: Float? = nil, + speechPct: Float? = nil, + confidenceFlags: [String] = [] + ) { + noteUtteranceConfidence(avgLogprob: avgLogprob, speechPct: speechPct, flags: confidenceFlags) + guard !finalized else { return } + markTranscriptActivity() + // A1 contract sensor (debug-only): Rust trims at source and computes + // ReplaceRange/InsertAnnotation offsets over that exact string. A Swift-side + // trim here would silently shift those offsets, so we store the text + // byte-for-byte and only assert the guarantee. + assert( + text == text.trimmingCharacters(in: .whitespacesAndNewlines), + "UtteranceFinal text not trimmed at source (A1 contract) — ReplaceRange offsets would misalign" + ) + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + upsertFinalSegment(utteranceId: utteranceId, text: text) + } else if highlightsEnabled { + noteSpeechGap( + utteranceId: utteranceId, + speechPct: speechPct + ) + } + preview = "" + refreshFormattedTranscriptIfNeeded() + } + + func applyReplaceRange( + utteranceId: UInt64, + start: UInt64, + end: UInt64, + text: String, + source: CsLayerSource = .tailPatch + ) { + guard !finalized else { return } + guard let index = committedSegments.lastIndex(where: { $0.utteranceId == utteranceId }) else { + showToast("Skipped unbound transcript patch") + return + } + // Snapshot the live-text geometry BEFORE the patch. `contextMarkers` + // hold absolute offsets into `rawLiveText` captured at selection time, + // so a patch that changes an earlier span's length slides every marker + // behind it out of alignment. Rebasing has to happen in the same + // transaction — a `{selection_N}` fence that drifts into the middle of + // an unrelated word is worse for the agent lane than no fence at all. + let origin = liveTextOffset(ofSegmentAt: index) + let before = committedSegments[index] + let spanStart = origin + before.renderedOffset(forTextOffset: Int(exactly: start) ?? .max) + let spanEnd = origin + before.renderedOffset(forTextOffset: Int(exactly: end) ?? .max) + let renderedLengthBefore = before.renderedText.count + + let replaced = sliceUtteranceText(before.text, start: start, end: end) + guard committedSegments[index].replaceRange(start: start, end: end, replacement: text) else { + showToast("Skipped out-of-range transcript patch") + return + } + rebaseContextMarkers( + spanStart: spanStart, + spanEnd: spanEnd, + delta: committedSegments[index].renderedText.count - renderedLengthBefore + ) + rebaseHighlights( + utteranceId: utteranceId, + start: start, + end: end, + replacementCount: UInt64(text.count) + ) + if highlightsEnabled, source == .lexicon, let highlight = OverlayCanvas.lexiconHighlight( + utteranceId: utteranceId, + start: start, + replacement: text, + before: replaced + ) { + highlights.append(highlight) + } + syncCommittedUtterances() + } + + /// Offset at which `committedSegments[index]` starts inside `rawLiveText`. + /// Mirrors that property's own assembly (blank segments dropped, one space + /// between the survivors) — the two must not drift apart. + private func liveTextOffset(ofSegmentAt index: Int) -> Int { + var offset = 0 + for segment in committedSegments[..= spanEnd { + contextMarkers[index].position = max(0, position + delta) + } else { + // The characters this marker anchored to no longer exist. + // Collapse to the patch boundary: never dropped (lost intent), + // never left past the replacement (drifted intent). + contextMarkers[index].position = spanStart + } + } + } + + func applyInsertAnnotation(utteranceId: UInt64, position: UInt64, text: String) { + guard !finalized else { return } + let annotation = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !annotation.isEmpty else { return } + guard let index = committedSegments.lastIndex(where: { $0.utteranceId == utteranceId }) else { + showToast("Skipped unbound transcript annotation") + return + } + guard committedSegments[index].insertAnnotation(position: position, text: annotation) else { + showToast("Skipped out-of-range transcript annotation") + return + } + syncCommittedUtterances() + } + + func applyContextMarker(position: UInt64, marker: String) { + guard !finalized, let offset = Int(exactly: position) else { return } + let clean = marker.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clean.isEmpty else { return } + contextMarkers.append( + OverlayContextMarker(position: offset, marker: clean, order: contextMarkers.count) + ) + if mode == .formatted { + let base = usableAuthoritativeFinalText ?? rawLiveText + let rendered = insertingContextMarkers(into: base) + if formattedText != rendered { formattedText = rendered } + } + } + + func applySessionFinalised() { + guard !finalized else { return } + markTranscriptActivity() + // Enter final pass phase (the post-stop AI formatting / authoritative + // assembly). Status shows "final pass", transcript assembly remains + // visible; the controller finish will surface the resolved .formatted. + isFinalPass = true + transcribing = false + // Do not call finalizeTranscript here — that is driven by + // finishControllerRecording (or equivalent terminal) so the phase + // is observable to the user. + } + + /// `on_no_speech` — the engine adjudicated the session with no usable speech. + /// Fires BEFORE the terminal `on_recording_stopped`, so we only record the + /// user-facing reason here; `finalizeTranscript` treats it as the engine's + /// no-usable-speech adjudication (unless an authoritative final arrives) and + /// flips into the dedicated `.noSpeech` outcome. If the reason arrives AFTER + /// an already-empty finalize (late), upgrade the FINAL in place. + func applyNoSpeech(reason: String) { + let message: String + switch reason { + case "all_speech_rejected_by_quality_gate": + message = "Speech too quiet or short — adjust the mic and try again" + default: + message = OverlayState.defaultNoSpeechNotice + } + pendingNoSpeechMessage = message + if finalized, mode == .formatted, + formattedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + noSpeechNotice = message + mode = .noSpeech + restartAutoHideCountdown() + } else if mode == .noSpeech { + noSpeechNotice = message + } + } + + /// The Rust controller's authoritative post-stop transcript (LocalFinalPass) — + /// the SAME text that is delivered/pasted and shown by tray "Copy". Stored so + /// the single `finalizeTranscript()` uses it instead of the raw streaming + /// assembly. Emitted inside the awaited stop pipeline, so it normally arrives + /// before the stop/finalise events; if it arrives AFTER (mode already + /// `.formatted`), replace the FINAL immediately. Live PREVIEW is untouched — + /// it stays raw-streaming on purpose ("live preview · raw"). + func applyFinalTranscript(_ text: String) { + let clean = text.trimmingCharacters(in: .whitespacesAndNewlines) + // Dedupe: this event fires once per stop, but a redundant re-emit must not + // reassign `@Published` state (each write re-invalidates the TextEditor). + guard !clean.isEmpty, clean != authoritativeFinalText else { return } + authoritativeFinalText = clean + formatFailureStatus = nil + let rendered = insertingContextMarkers(into: clean) + if mode == .formatted, formattedText != rendered { + formattedText = rendered + armAutoFormatRevertSlot(shown: rendered) + } else if mode == .noSpeech { + // Real text arrived after we finalised to no-speech (empty at the + // time): recover it as the normal FINAL rather than losing it. + formattedText = rendered + mode = .formatted + restartAutoHideCountdown() + } + if deliveredText.isEmpty, !clean.isEmpty { + deliveredText = rendered + } + if agentSessionArmed { + agentFinalTranscriptAppeared = true + } + if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !clean.isEmpty { + sttRawText = clean + } + } + + /// Single authoritative finalize. `runStop`, `finishControllerRecording`, and + /// `applySessionFinalised` all funnel here so `formattedText` is produced from + /// ONE source rather than three paths each rewriting it from a different buffer. + /// Preference: the controller's authoritative LocalFinalPass text (matches + /// delivery/Copy); fall back to the id-ordered committed assembly only if that + /// event has not arrived. + private func finalizeTranscript() { + let wasFinalized = finalized + cancelWarmupWatchdog() + warmingUp = false + transcribing = false + vadActive = false + audioReady = false + levelMeter.reset() + hasMeasuredAudioLevel = false + let shouldShowNoSpeechOutcome = + pendingNoSpeechMessage != nil && usableAuthoritativeFinalText == nil + if shouldShowNoSpeechOutcome { + preview = "" + } else { + commitPreviewIfNeeded() + } + let resolvedBase = + shouldShowNoSpeechOutcome ? "" : (usableAuthoritativeFinalText ?? rawLiveText) + let resolved = insertingContextMarkers(into: resolvedBase) + if resolvedBase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // Nothing usable was captured — VAD silence, or all speech rejected by + // the quality gate. Surface a dedicated no-speech outcome instead of a + // blank editable FINAL (Copy/Format/Send acting on an empty string). + // When `on_no_speech` did not fire (empty final without an explicit + // event) we treat the empty finalize as no-speech — an honest + // approximation, since the user has nothing to act on either way. + if formattedText != "" { formattedText = "" } + noSpeechNotice = pendingNoSpeechMessage ?? OverlayState.defaultNoSpeechNotice + mode = .noSpeech + } else { + if formattedText != resolved { formattedText = resolved } + if deliveredText.isEmpty { deliveredText = resolved } + qualityFormattingLevel = autoFormatLevel + armAutoFormatRevertSlot(shown: resolved) + if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // Best effort: if no STT raw from per-utterance finals yet, fall back to the + // resolved assembly (still the raw-streaming path, not AI formatted). + sttRawText = resolvedBase + } + mode = .formatted + if agentSessionArmed { + agentFinalTranscriptAppeared = true + } + } + // FREEZE: from here, late streaming events are dropped (see the apply guards) + // so nothing keeps mutating @Published state and re-rendering in Idle. + finalized = true + isFinalPass = false + // Notify the recording-lifecycle sink that the session ended. This is the + // stop-side counterpart to `handleRecordingStarted` firing `onRecordingStarted?()`: + // the tray otherwise only clears its "Recording" pill via the popover's one-shot + // onAppear poll, so a hotkey stop left it stuck. Gate on the finalize transition + // so redundant re-finalizes (finishControllerRecording + applySessionFinalised) + // don't re-fire and churn @Published tray state. + if !wasFinalized { + if !resolvedBase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + onSuccessfulDictation?() + } + onRecordingStopped?() + } + + // Every terminal outcome gets the same activity-anchored lifetime. + restartAutoHideCountdown() + } + + private var usableAuthoritativeFinalText: String? { + guard let text = authoritativeFinalText else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func resetTranscript() { + preview = "" + committedSegments = [] + committedUtterances = [] + contextMarkers = [] + highlights = [] + selectedHighlightId = nil + lastTeachAcknowledgement = nil + speechWasActive = false + authoritativeFinalText = nil + deliveredText = "" + sttRawText = "" + qualityFormattingLevel = .off + sessionAvgLogprob = nil + sessionSpeechPct = nil + sessionConfidenceFlags = [] + preFormatText = nil + preFormatLevel = nil + formatFailureStatus = nil + pendingNoSpeechMessage = nil + noSpeechNotice = OverlayState.defaultNoSpeechNotice + finalized = false + agentFinalTranscriptAppeared = false + agentAutoSendCancelled = false + agentDeliveryStarted = false + transcribing = false + isFinalPass = false + // A hidden panel may not emit a pointer-exit event. Never carry a paused + // hover latch into the next recording session. + isPointerHovering = false + cancelAutoHide() + } + + private func markTranscriptActivity() { + cancelWarmupWatchdog() + warmingUp = false + audioReady = true + if recording { + mode = .listening + } + } + + private func commitPreviewIfNeeded() { + let active = preview.trimmingCharacters(in: .whitespacesAndNewlines) + guard !active.isEmpty else { return } + appendCommittedSegment(active) + preview = "" + refreshFormattedTranscriptIfNeeded() + } + + /// Append a committed segment, keyed by `utteranceId`. Re-finals for an id we + /// already hold replace that slot in place (no duplicate, no drop); new ids + /// append in arrival order = id order, the bridge's FIFO ordering. + /// Contract: Rust already sends trimmed text and is the sole owner of offsets; + /// Swift stores it byte-for-byte because ReplaceRange/InsertAnnotation offsets + /// are computed by the emitter against this same string. + private func upsertFinalSegment(utteranceId: UInt64, text: String) { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + if let index = committedSegments.firstIndex(where: { $0.utteranceId == utteranceId }) { + guard committedSegments[index].text != text else { return } + committedSegments[index].text = text + committedSegments[index].annotations = [] + } else { + committedSegments.append(OverlayTranscriptSegment(utteranceId: utteranceId, text: text)) + } + syncCommittedUtterances() + } + + /// Append an un-keyed committed segment (trailing preview at finalize time — + /// speech that never received its own `UtteranceFinal`). + private func appendCommittedSegment(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + committedSegments.append(OverlayTranscriptSegment(utteranceId: nil, text: trimmed)) + syncCommittedUtterances() + } + + private func syncCommittedUtterances() { + committedUtterances = committedSegments.map(\.renderedText) + refreshFormattedTranscriptIfNeeded() + } + + private func refreshFormattedTranscriptIfNeeded() { + if mode == .formatted { + // Once the controller's authoritative final transcript is in, it wins: + // late streaming `UtteranceFinal` events must not clobber the FINAL with + // the raw streaming assembly. Without it, fall back to the live assembly. + // Dedupe the write — an identical reassignment still re-invalidates the + // bound TextEditor and feeds the Idle render churn. + let resolved = + usableAuthoritativeFinalText + .map { insertingContextMarkers(into: $0) } + ?? liveText + if formattedText != resolved { formattedText = resolved } + } + } + + private func insertingContextMarkers(into text: String) -> String { + guard !contextMarkers.isEmpty else { return text } + var rendered = text + let ordered = contextMarkers.sorted { + if $0.position == $1.position { return $0.order > $1.order } + return $0.position > $1.position + } + for item in ordered { + let offset = min(max(item.position, 0), rendered.count) + let index = rendered.index(rendered.startIndex, offsetBy: offset) + let previous: Character? = + index > rendered.startIndex ? rendered[rendered.index(before: index)] : nil + let next: Character? = index < rendered.endIndex ? rendered[index] : nil + // A marker landing INSIDE a word ("mn|ie") stays unpadded, so the + // split is lossless downstream: title derivation strips the bare + // marker and the word reads whole again ("mnie"). Space padding is + // only for word-boundary insertions. + let splitsWord = + (previous?.isLetter == true || previous?.isNumber == true) + && (next?.isLetter == true || next?.isNumber == true) + let needsLeadingSpace = !splitsWord && previous != nil && previous?.isWhitespace != true + let needsTrailingSpace = !splitsWord && next != nil && next?.isWhitespace != true + let insertion = + (needsLeadingSpace ? " " : "") + + item.marker + + (needsTrailingSpace ? " " : "") + rendered.insert(contentsOf: insertion, at: index) + } + return rendered + } + + private func normalized(_ text: String) -> String { + text.lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + /// `on_audio_level` — capture RMS per audio block. Only feeds the meter + /// during live capture: once the session is transcribing/finalised the + /// waveform is frozen or gone, and a late block must not wiggle it. + func applyAudioLevel(_ rms: Float) { + guard recording, + warmingUp || audioReady || vadActive, + !finalized, + !transcribing, + !isFinalPass, + mode == .listening + else { return } + levelMeter.push(rms: rms) + if levelMeter.gain != nil { hasMeasuredAudioLevel = true } + } + + func applyVad(_ active: Bool) { + // Drop late VAD toggles after finalize: the waveform is gone in Idle and a + // stray `vadActive` flip is just another needless @Published invalidation. + guard !finalized else { return } + vadActive = active + if active { + speechWasActive = true + cancelWarmupWatchdog() + warmingUp = false + audioReady = true + } + } + + func selectHighlight(_ highlight: OverlayHighlight) { + selectedHighlightId = highlight.id + } + + /// One-click send-span-to-Teach. Production goes through `qualityTeachSpan` + /// behind `QualityCaptureHost`; tests inject `teachSpan`. + func sendHighlightToTeach(_ highlight: OverlayHighlight) { + guard highlightsEnabled else { return } + guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return } + if let teachSpan { + do { + let acknowledgement = try teachSpan(highlight) + highlights[index].taught = true + lastTeachAcknowledgement = acknowledgement + if !acknowledgement.isEmpty { showToast(acknowledgement) } + } catch { + showToast("Teach failed") + } + return + } + guard !QualityCaptureHost.isRunningTests else { return } + let variant = highlight.teachVariant + let canonical = highlight.teachCanonical + let kind = highlight.teachKind + Task.detached(priority: .utility) { [weak self] in + let result = try? qualityTeachSpan( + variant: variant, + canonical: canonical, + kind: kind + ) + await MainActor.run { + guard let self else { return } + if let acknowledgement = result?.acknowledgement { + if let idx = self.highlights.firstIndex(where: { $0.id == highlight.id }) { + self.highlights[idx].taught = true + } + self.lastTeachAcknowledgement = acknowledgement + if !acknowledgement.isEmpty { self.showToast(acknowledgement) } } else { - committedSegments.append(OverlayTranscriptSegment(utteranceId: utteranceId, text: text)) - } - syncCommittedUtterances() - } - - /// Append an un-keyed committed segment (trailing preview at finalize time — - /// speech that never received its own `UtteranceFinal`). - private func appendCommittedSegment(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - committedSegments.append(OverlayTranscriptSegment(utteranceId: nil, text: trimmed)) - syncCommittedUtterances() - } - - private func syncCommittedUtterances() { - committedUtterances = committedSegments.map(\.renderedText) - refreshFormattedTranscriptIfNeeded() - } - - private func refreshFormattedTranscriptIfNeeded() { - if mode == .formatted { - // Once the controller's authoritative final transcript is in, it wins: - // late streaming `UtteranceFinal` events must not clobber the FINAL with - // the raw streaming assembly. Without it, fall back to the live assembly. - // Dedupe the write — an identical reassignment still re-invalidates the - // bound TextEditor and feeds the Idle render churn. - let resolved = usableAuthoritativeFinalText - .map { insertingContextMarkers(into: $0) } - ?? liveText - if formattedText != resolved { formattedText = resolved } - } - } - - private func insertingContextMarkers(into text: String) -> String { - guard !contextMarkers.isEmpty else { return text } - var rendered = text - let ordered = contextMarkers.sorted { - if $0.position == $1.position { return $0.order > $1.order } - return $0.position > $1.position - } - for item in ordered { - let offset = min(max(item.position, 0), rendered.count) - let index = rendered.index(rendered.startIndex, offsetBy: offset) - let previous: Character? = - index > rendered.startIndex ? rendered[rendered.index(before: index)] : nil - let next: Character? = index < rendered.endIndex ? rendered[index] : nil - // A marker landing INSIDE a word ("mn|ie") stays unpadded, so the - // split is lossless downstream: title derivation strips the bare - // marker and the word reads whole again ("mnie"). Space padding is - // only for word-boundary insertions. - let splitsWord = (previous?.isLetter == true || previous?.isNumber == true) - && (next?.isLetter == true || next?.isNumber == true) - let needsLeadingSpace = !splitsWord && previous != nil && previous?.isWhitespace != true - let needsTrailingSpace = !splitsWord && next != nil && next?.isWhitespace != true - let insertion = (needsLeadingSpace ? " " : "") - + item.marker - + (needsTrailingSpace ? " " : "") - rendered.insert(contentsOf: insertion, at: index) - } - return rendered - } - - private func normalized(_ text: String) -> String { - text.lowercased() - .components(separatedBy: CharacterSet.alphanumerics.inverted) - .filter { !$0.isEmpty } - .joined(separator: " ") - } - - /// `on_audio_level` — capture RMS per audio block. Only feeds the meter - /// during live capture: once the session is transcribing/finalised the - /// waveform is frozen or gone, and a late block must not wiggle it. - func applyAudioLevel(_ rms: Float) { - guard recording, - (warmingUp || audioReady || vadActive), - !finalized, - !transcribing, - !isFinalPass, - mode == .listening else { return } - levelMeter.push(rms: rms) - if levelMeter.gain != nil { hasMeasuredAudioLevel = true } - } - - func applyVad(_ active: Bool) { - // Drop late VAD toggles after finalize: the waveform is gone in Idle and a - // stray `vadActive` flip is just another needless @Published invalidation. - guard !finalized else { return } - vadActive = active - if active { - cancelWarmupWatchdog() - warmingUp = false - audioReady = true - } - } - - func showToast(_ message: String) { - toast = message - toastTask?.cancel() - toastTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 2_600_000_000) - guard !Task.isCancelled else { return } - self?.toast = nil - } - } - - // MARK: Preview / mock helpers (no engine required) - - /// Seeded view model for #Preview in the listening state, with a typing reveal - /// that imitates `on_preview` arriving char-by-char (mock: 46ms). - static func previewListening() -> OverlayState { - let s = OverlayState() - s.mode = .listening - s.vadActive = true - s.beginMockReveal("add a rate limiter to the login route and write a test for it") - return s - } - - /// Seeded view model for #Preview in the post-capture transcribing phase. - static func previewTranscribing() -> OverlayState { - let s = OverlayState() - s.mode = .listening - s.transcribing = true - s.audioReady = true - s.committedUtterances = ["add a rate limiter to the login route and write a test for it"] - return s - } - - /// Seeded view model for #Preview in the no-speech outcome (session ended - /// without any usable text). - static func previewNoSpeech() -> OverlayState { - let s = OverlayState() - s.mode = .noSpeech - s.noSpeechNotice = OverlayState.defaultNoSpeechNotice - return s - } - - /// Seeded view model for #Preview in the finalized state. - static func previewFormatted() -> OverlayState { - let s = OverlayState() - s.mode = .formatted - s.formattedText = "Add a rate limiter to the login route and write a test that covers the throttle window. Keep the existing error shape." - return s - } - - func beginMockReveal(_ full: String, interval: Double = 0.046) { - mockRevealTask?.cancel() - resetTranscript() - mockRevealTask = Task { @MainActor [weak self] in - var acc = "" - for ch in full { - if Task.isCancelled { return } - acc.append(ch) - self?.preview = acc - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } + self.showToast("Teach failed") + } + } + } + } + + private func noteSpeechGap(utteranceId: UInt64, speechPct: Float?) { + let heard = speechWasActive || (speechPct ?? 0) > 0 + speechWasActive = false + guard heard else { return } + let gap = OverlayCanvas.speechGap(utteranceId: utteranceId) + if !highlights.contains(where: { $0.id == gap.id }) { + highlights.append(gap) + } + } + + private func sliceUtteranceText(_ text: String, start: UInt64, end: UInt64) -> String { + guard let startOffset = Int(exactly: start), + let endOffset = Int(exactly: end), + startOffset <= endOffset, + endOffset <= text.count + else { return "" } + let startIndex = text.index(text.startIndex, offsetBy: startOffset) + let endIndex = text.index(text.startIndex, offsetBy: endOffset) + return String(text[startIndex..= start ? end - start : 0 + let delta = Int64(replacementCount) - Int64(removed) + highlights = highlights.compactMap { highlight in + guard highlight.utteranceId == utteranceId, highlight.kind == .lexiconCorrected else { + return highlight + } + if highlight.charEnd <= start { return highlight } + if highlight.charStart >= end { + var shifted = highlight + let startShift = Int64(highlight.charStart) + delta + let endShift = Int64(highlight.charEnd) + delta + guard startShift >= 0, endShift >= startShift else { return nil } + shifted.charStart = UInt64(startShift) + shifted.charEnd = UInt64(endShift) + return shifted + } + return nil + } + } + + func showToast(_ message: String) { + toast = message + toastTask?.cancel() + toastTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 2_600_000_000) + guard !Task.isCancelled else { return } + self?.toast = nil + } + } + + // MARK: Preview / mock helpers (no engine required) + + /// Seeded view model for #Preview in the listening state, with a typing reveal + /// that imitates `on_preview` arriving char-by-char (mock: 46ms). + static func previewListening() -> OverlayState { + let s = OverlayState() + s.mode = .listening + s.vadActive = true + s.beginMockReveal("add a rate limiter to the login route and write a test for it") + return s + } + + /// Seeded view model for #Preview in the post-capture transcribing phase. + static func previewTranscribing() -> OverlayState { + let s = OverlayState() + s.mode = .listening + s.transcribing = true + s.audioReady = true + s.committedUtterances = ["add a rate limiter to the login route and write a test for it"] + return s + } + + /// Seeded view model for #Preview in the no-speech outcome (session ended + /// without any usable text). + static func previewNoSpeech() -> OverlayState { + let s = OverlayState() + s.mode = .noSpeech + s.noSpeechNotice = OverlayState.defaultNoSpeechNotice + return s + } + + /// Seeded view model for #Preview in the finalized state. + static func previewFormatted() -> OverlayState { + let s = OverlayState() + s.mode = .formatted + s.formattedText = + "Add a rate limiter to the login route and write a test that covers the throttle window. Keep the existing error shape." + return s + } + + func beginMockReveal(_ full: String, interval: Double = 0.046) { + mockRevealTask?.cancel() + resetTranscript() + mockRevealTask = Task { @MainActor [weak self] in + var acc = "" + for ch in full { + if Task.isCancelled { return } + acc.append(ch) + self?.preview = acc + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } } /// Adapter for the redesign hotkey/controller path. This is the product path: /// one `RecordingController`, one event stream, one Swift overlay surface. final class ControllerDictationEngine: DictationEngine { - private let hotkeys = CodescribeHotkeys() - private let config = CodescribeConfig() - - func setListener(_ listener: CsTranscriptionListener) { - hotkeys.setListener(listener: listener) - } - func startRecording(language: CsLanguage?) async throws { - try await hotkeys.startRecording() - } - func stopRecording() async throws -> String { - try await hotkeys.stopRecording() - return "" - } - func isRecording() async -> Bool { - await hotkeys.isRecording() - } - func initModel() async throws {} - func isModelLoaded() -> Bool { true } - func isFormattingAvailable() -> Bool { - hotkeys.isFormattingAvailable() - } - func currentOverlayPolicy() -> OverlayPolicySnapshot? { - let toggles = config.trayToggles() - guard let formatLevel = FormattingPolicyOption(rawValue: toggles.formattingLevel) else { - return nil - } - return OverlayPolicySnapshot( - autoPasteEnabled: toggles.autoPasteEnabled, - autoFormatLevel: formatLevel - ) - } - func setAutoPasteEnabled(_ enabled: Bool) { - _ = try? config.setAutoPasteEnabled(enabled: enabled) - } - func formatText( - text: String, - language: CsLanguage?, - level: FormattingPolicyOption - ) async throws -> String { - try await hotkeys.formatTextForLevel( - text: text, - language: language, - level: level.rawValue - ) - } - func pasteText(text: String) async throws -> CsPasteResult { - try await hotkeys.pasteText(text: text) - } - func deferText(text: String) async throws -> CsPasteResult { - try await hotkeys.deferText(text: text) - } - func copyTaggedTranscript(text: String) async throws { - try await hotkeys.copyTextTagged(text: text) - } - func pasteTargetAppName() async -> String? { - await hotkeys.pasteTargetAppName() - } - func sendAssistiveTranscript(text: String) async throws -> Bool { - try await hotkeys.sendAssistiveTranscript(text: text) - } - func transcribeFile(path: String) async throws -> CsTranscription { - throw NSError(domain: "CodescribeRedesign", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "File transcription is not available through the hotkey controller." - ]) - } + private let hotkeys = CodescribeHotkeys() + private let config = CodescribeConfig() + + func setListener(_ listener: CsTranscriptionListener) { + hotkeys.setListener(listener: listener) + } + func startRecording(language: CsLanguage?) async throws { + try await hotkeys.startRecording() + } + func stopRecording() async throws -> String { + try await hotkeys.stopRecording() + return "" + } + func isRecording() async -> Bool { + await hotkeys.isRecording() + } + func initModel() async throws {} + func isModelLoaded() -> Bool { true } + func isFormattingAvailable() -> Bool { + hotkeys.isFormattingAvailable() + } + func currentOverlayPolicy() -> OverlayPolicySnapshot? { + let toggles = config.trayToggles() + guard let formatLevel = FormattingPolicyOption(rawValue: toggles.formattingLevel) else { + return nil + } + return OverlayPolicySnapshot( + autoPasteEnabled: toggles.autoPasteEnabled, + autoFormatLevel: formatLevel + ) + } + func setAutoPasteEnabled(_ enabled: Bool) { + _ = try? config.setAutoPasteEnabled(enabled: enabled) + } + func formatText( + text: String, + language: CsLanguage?, + level: FormattingPolicyOption + ) async throws -> String { + try await hotkeys.formatTextForLevel( + text: text, + language: language, + level: level.rawValue + ) + } + func pasteText(text: String) async throws -> CsPasteResult { + try await hotkeys.pasteText(text: text) + } + func deferText(text: String) async throws -> CsPasteResult { + try await hotkeys.deferText(text: text) + } + func copyTaggedTranscript(text: String) async throws { + try await hotkeys.copyTextTagged(text: text) + } + func pasteTargetAppName() async -> String? { + await hotkeys.pasteTargetAppName() + } + func sendAssistiveTranscript(text: String) async throws -> Bool { + try await hotkeys.sendAssistiveTranscript(text: text) + } + func transcribeFile(path: String) async throws -> CsTranscription { + throw NSError( + domain: "CodescribeRedesign", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "File transcription is not available through the hotkey controller." + ]) + } } // MARK: - Listener bridge (Rust callbacks → main actor → OverlayState) @@ -1888,102 +2105,120 @@ final class ControllerDictationEngine: DictationEngine { /// transcription thread) onto the main actor, driving `OverlayState`. Mirrors the /// hop pattern used by `StreamListener` in RealChatEngine. final class DictationListener: CsTranscriptionListener, @unchecked Sendable { - private weak var state: OverlayState? - - init(state: OverlayState) { - self.state = state - } - - func onRecordingPreparing() { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.handleRecordingPreparing() } } - } - func onRecordingStarted() { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.handleRecordingStarted() } } - } - func onRecordingStopped() { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.finishControllerRecording() } } - } - func onRecordingFinalising() { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.handleRecordingFinalising() } } - } - func onPreview(text: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyPreview(text) } } - } - func onCorrection(text: String, previousText: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyCorrection(text, previousText: previousText) } } - } - func onFinal( - utteranceId: UInt64, - text: String, - avgLogprob: Float?, - speechPct: Float?, - confidenceFlags: [String] - ) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.state?.applyFinal( - utteranceId: utteranceId, - text, - avgLogprob: avgLogprob, - speechPct: speechPct, - confidenceFlags: confidenceFlags - ) - } - } - } - func onReplaceRange(utteranceId: UInt64, start: UInt64, end: UInt64, text: String, source: CsLayerSource) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.state?.applyReplaceRange(utteranceId: utteranceId, start: start, end: end, text: text) - } - } - } - func onInsertAnnotation(utteranceId: UInt64, position: UInt64, text: String, kind: CsAnnotationKind) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.state?.applyInsertAnnotation(utteranceId: utteranceId, position: position, text: text) - } - } - } - func onContextMarker(position: UInt64, marker: String) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.state?.applyContextMarker(position: position, marker: marker) - } - } - } - func onSessionFinalised(sessionId: String, layerSummary: CsLayerSummary) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applySessionFinalised() } } - } - func onFinalTranscriptReady(text: String) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyFinalTranscript(text) } } - } - func onVadActive(active: Bool) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyVad(active) } } - } - func onAudioLevel(rms: Float) { - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyAudioLevel(rms) } } - } - func onNoSpeech(reason: String) { - // Route the reason into the dedicated no-speech OUTCOME (a persistent - // body + Close), not a transient toast that fades and leaves an empty - // editable FINAL behind. `applyNoSpeech` maps the reason to a user-facing - // notice (genuine silence vs. quality-gate rejection). - DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyNoSpeech(reason: reason) } } - } - func onError(message: String) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.state?.handleError(message: message) - } - } - } + private weak var state: OverlayState? + + init(state: OverlayState) { + self.state = state + } + + func onRecordingPreparing() { + DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.handleRecordingPreparing() } } + } + func onRecordingStarted() { + DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.handleRecordingStarted() } } + } + func onRecordingStopped() { + DispatchQueue.main.async { + MainActor.assumeIsolated { self.state?.finishControllerRecording() } + } + } + func onRecordingFinalising() { + DispatchQueue.main.async { + MainActor.assumeIsolated { self.state?.handleRecordingFinalising() } + } + } + func onPreview(text: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyPreview(text) } } + } + func onCorrection(text: String, previousText: String) { + DispatchQueue.main.async { + MainActor.assumeIsolated { self.state?.applyCorrection(text, previousText: previousText) } + } + } + func onFinal( + utteranceId: UInt64, + text: String, + avgLogprob: Float?, + speechPct: Float?, + confidenceFlags: [String] + ) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.state?.applyFinal( + utteranceId: utteranceId, + text, + avgLogprob: avgLogprob, + speechPct: speechPct, + confidenceFlags: confidenceFlags + ) + } + } + } + func onReplaceRange( + utteranceId: UInt64, start: UInt64, end: UInt64, text: String, source: CsLayerSource + ) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.state?.applyReplaceRange( + utteranceId: utteranceId, + start: start, + end: end, + text: text, + source: source + ) + } + } + } + func onInsertAnnotation( + utteranceId: UInt64, position: UInt64, text: String, kind: CsAnnotationKind + ) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.state?.applyInsertAnnotation(utteranceId: utteranceId, position: position, text: text) + } + } + } + func onContextMarker(position: UInt64, marker: String) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.state?.applyContextMarker(position: position, marker: marker) + } + } + } + func onSessionFinalised(sessionId: String, layerSummary: CsLayerSummary) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applySessionFinalised() } } + } + func onFinalTranscriptReady(text: String) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyFinalTranscript(text) } } + } + func onVadActive(active: Bool) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyVad(active) } } + } + func onAudioLevel(rms: Float) { + DispatchQueue.main.async { MainActor.assumeIsolated { self.state?.applyAudioLevel(rms) } } + } + func onNoSpeech(reason: String) { + // Route the reason into the dedicated no-speech OUTCOME (a persistent + // body + Close), not a transient toast that fades and leaves an empty + // editable FINAL behind. `applyNoSpeech` maps the reason to a user-facing + // notice (genuine silence vs. quality-gate rejection). + DispatchQueue.main.async { + MainActor.assumeIsolated { self.state?.applyNoSpeech(reason: reason) } + } + } + func onError(message: String) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.state?.handleError(message: message) + } + } + } } // MARK: - Mock engine for #Preview #if DEBUG -final class MockDictationEngine: DictationEngine { + final class MockDictationEngine: DictationEngine { func setListener(_ listener: CsTranscriptionListener) {} func startRecording(language: CsLanguage?) async throws {} func stopRecording() async throws -> String { "" } @@ -1992,37 +2227,37 @@ final class MockDictationEngine: DictationEngine { func isModelLoaded() -> Bool { true } func isFormattingAvailable() -> Bool { false } func currentOverlayPolicy() -> OverlayPolicySnapshot? { - OverlayPolicySnapshot(autoPasteEnabled: true, autoFormatLevel: .correction) + OverlayPolicySnapshot(autoPasteEnabled: true, autoFormatLevel: .correction) } func setAutoPasteEnabled(_ enabled: Bool) {} func formatText( - text: String, - language: CsLanguage?, - level: FormattingPolicyOption + text: String, + language: CsLanguage?, + level: FormattingPolicyOption ) async throws -> String { text } func pasteText(text: String) async throws -> CsPasteResult { - CsPasteResult( - outcome: .pasted, - targetAppName: nil, - frontmostAppName: nil, - deferredInsertShortcut: nil, - deferredInsertFailure: nil - ) + CsPasteResult( + outcome: .pasted, + targetAppName: nil, + frontmostAppName: nil, + deferredInsertShortcut: nil, + deferredInsertFailure: nil + ) } func deferText(text: String) async throws -> CsPasteResult { - CsPasteResult( - outcome: .deferredInsertArmed, - targetAppName: nil, - frontmostAppName: "Codescribe", - deferredInsertShortcut: "⌘⌥V", - deferredInsertFailure: nil - ) + CsPasteResult( + outcome: .deferredInsertArmed, + targetAppName: nil, + frontmostAppName: "Codescribe", + deferredInsertShortcut: "⌘⌥V", + deferredInsertFailure: nil + ) } func copyTaggedTranscript(text: String) async throws {} func pasteTargetAppName() async -> String? { nil } func sendAssistiveTranscript(text: String) async throws -> Bool { true } func transcribeFile(path: String) async throws -> CsTranscription { - CsTranscription(text: "", language: "en") + CsTranscription(text: "", language: "en") } -} + } #endif diff --git a/macos/Codescribe/Screens/Overlay/WaveformView.swift b/macos/Codescribe/Screens/Overlay/WaveformView.swift index 75e08c1b..3bd5dee2 100644 --- a/macos/Codescribe/Screens/Overlay/WaveformView.swift +++ b/macos/Codescribe/Screens/Overlay/WaveformView.swift @@ -1,5 +1,5 @@ -import SwiftUI import Foundation +import SwiftUI // 34-bar listening waveform — Canvas + per-bar `eq` animation with staggered delays. // @@ -22,118 +22,119 @@ import Foundation /// Main-actor only: pushed from the hopped listener callback, read from body. @MainActor final class AudioLevelMeter { - /// Smoothed display gain in 0...1, or nil when no live signal has arrived — - /// callers fall back to the ambient animation. - private(set) var gain: Double? + /// Smoothed display gain in 0...1, or nil when no live signal has arrived — + /// callers fall back to the ambient animation. + private(set) var gain: Double? - /// Map one linear RMS block onto display gain: dB scale (speech at a normal - /// mic distance lives around −45…−25 dBFS), fast attack / slow release so - /// peaks land instantly and the decay reads naturally instead of flickering - /// per block. The window is deliberately tight and the response curve - /// perceptual (pow 0.7): ordinary speech must visibly move the bars, not - /// hover just above the rest scale. - func push(rms: Float) { - guard rms.isFinite, rms >= 0 else { return } - let db = 20 * log10(max(Double(rms), 1e-6)) - let linear = min(max((db + 55) / 30, 0), 1) - let target = pow(linear, 0.7) - let current = gain ?? 0 - let smoothing = target > current ? 0.6 : 0.15 - gain = current + (target - current) * smoothing - } + /// Map one linear RMS block onto display gain: dB scale (speech at a normal + /// mic distance lives around −45…−25 dBFS), fast attack / slow release so + /// peaks land instantly and the decay reads naturally instead of flickering + /// per block. The window is deliberately tight and the response curve + /// perceptual (pow 0.7): ordinary speech must visibly move the bars, not + /// hover just above the rest scale. + func push(rms: Float) { + guard rms.isFinite, rms >= 0 else { return } + let db = 20 * log10(max(Double(rms), 1e-6)) + let linear = min(max((db + 55) / 30, 0), 1) + let target = pow(linear, 0.7) + let current = gain ?? 0 + let smoothing = target > current ? 0.6 : 0.15 + gain = current + (target - current) * smoothing + } - func reset() { gain = nil } + func reset() { gain = nil } } struct WaveformView: View { - var barCount: Int = 34 - var active: Bool = true - /// Post-capture "transcribing" phase. Overrides `active`: instead of the - /// audio-suggestive per-bar `eq` stagger, the bars hold a FROZEN silhouette - /// that breathes together on one slow synchronous cycle at reduced opacity — - /// unmistakably "processing", not "listening", and not a hung freeze either. - var transcribing: Bool = false - var indicatorMode: CsIndicatorMode = .hold - /// Real capture level, when the engine streams it. nil → ambient animation. - var meter: AudioLevelMeter? = nil + var barCount: Int = 34 + var active: Bool = true + /// Post-capture "transcribing" phase. Overrides `active`: instead of the + /// audio-suggestive per-bar `eq` stagger, the bars hold a FROZEN silhouette + /// that breathes together on one slow synchronous cycle at reduced opacity — + /// unmistakably "processing", not "listening", and not a hung freeze either. + var transcribing: Bool = false + var indicatorMode: CsIndicatorMode = .hold + /// Real capture level, when the engine streams it. nil → ambient animation. + var meter: AudioLevelMeter? = nil - private let barWidth: CGFloat = 3 - private let gap: CGFloat = 4 - private let maxBarHeight: CGFloat = 26 - private let trackHeight: CGFloat = 34 - private let minScale: CGFloat = 0.35 + private let barWidth: CGFloat = 3 + private let gap: CGFloat = 4 + private let maxBarHeight: CGFloat = 26 + private let trackHeight: CGFloat = 34 + private let minScale: CGFloat = 0.35 - private var contentWidth: CGFloat { - CGFloat(barCount) * (barWidth + gap) - gap - } + private var contentWidth: CGFloat { + CGFloat(barCount) * (barWidth + gap) - gap + } - var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !(active || transcribing))) { timeline in - Canvas { ctx, size in - let now = timeline.date.timeIntervalSinceReferenceDate - for i in 0.. CGFloat { - if transcribing { return transcribingScale(index: i, now: now) } - guard active else { return minScale } - let duration = 0.7 + Double((i * 7) % 9) / 10.0 - let delay = Double((i * 13) % 11) / 14.0 - let phase = (now + delay) / duration - // raised cosine: 0.675 - 0.325*cos → .35 at phase 0/1, 1.0 at phase 0.5 - let mid = (1 + minScale) / 2 // 0.675 - let amp = (1 - minScale) / 2 // 0.325 - let ambient = mid - amp * CGFloat(cos(phase * 2 * .pi)) - // Real signal: the per-bar sweep becomes the SHAPE and the live level - // the AMPLITUDE — full voice reads like the original animation, silence - // settles the bars near the rest scale. No signal → pure ambient. - if let gain = meter?.gain { - return minScale + (ambient - minScale) * CGFloat(gain) - } - return ambient + private func barScale(index i: Int, now: TimeInterval) -> CGFloat { + if transcribing { return transcribingScale(index: i, now: now) } + guard active else { return minScale } + let duration = 0.7 + Double((i * 7) % 9) / 10.0 + let delay = Double((i * 13) % 11) / 14.0 + let phase = (now + delay) / duration + // raised cosine: 0.675 - 0.325*cos → .35 at phase 0/1, 1.0 at phase 0.5 + let mid = (1 + minScale) / 2 // 0.675 + let amp = (1 - minScale) / 2 // 0.325 + let ambient = mid - amp * CGFloat(cos(phase * 2 * .pi)) + // Real signal: the per-bar sweep becomes the SHAPE and the live level + // the AMPLITUDE — full voice reads like the original animation, silence + // settles the bars near the rest scale. No signal → pure ambient. + if let gain = meter?.gain { + return minScale + (ambient - minScale) * CGFloat(gain) } + return ambient + } - /// Frozen per-bar silhouette (deterministic, no audio input — the capture - /// waveform is itself synthetic) modulated by ONE slow synchronous breath, so - /// the whole shape rises and falls together instead of the per-bar sweep. The - /// breath is subtle (~0.86–1.0) and never reaches the capture amplitude. - private func transcribingScale(index i: Int, now: TimeInterval) -> CGFloat { - let silhouette = 0.30 + 0.34 * abs(sin(Double(i) * 0.9)) // fixed, in ~0.30–0.64 - let breathPeriod = 1.7 - let breath = 0.93 - 0.07 * cos(now * 2 * .pi / breathPeriod) // ~0.86–1.0 - return CGFloat(silhouette * breath) - } + /// Frozen per-bar silhouette (deterministic, no audio input — the capture + /// waveform is itself synthetic) modulated by ONE slow synchronous breath, so + /// the whole shape rises and falls together instead of the per-bar sweep. The + /// breath is subtle (~0.86–1.0) and never reaches the capture amplitude. + private func transcribingScale(index i: Int, now: TimeInterval) -> CGFloat { + let silhouette = 0.30 + 0.34 * abs(sin(Double(i) * 0.9)) // fixed, in ~0.30–0.64 + let breathPeriod = 1.7 + let breath = 0.93 - 0.07 * cos(now * 2 * .pi / breathPeriod) // ~0.86–1.0 + return CGFloat(silhouette * breath) + } - private func color(for i: Int) -> Color { - // Muted terracotta so the phase reads as our brand "at work", clearly - // dimmer than the live-capture bars. - if transcribing { return CSColor.modeProcessing.opacity(0.55) } - guard active else { return CSColor.hairline(0.16) } - if indicatorMode == .assistive { - return i % 5 == 0 ? CSColor.assistiveLight : CSColor.modeAgent - } - return i % 5 == 0 ? CSColor.terracottaTintBars : CSColor.modeRecording + private func color(for i: Int) -> Color { + // Muted terracotta so the phase reads as our brand "at work", clearly + // dimmer than the live-capture bars. + if transcribing { return CSColor.modeProcessing.opacity(0.55) } + guard active else { return CSColor.hairline(0.16) } + if indicatorMode == .assistive { + return i % 5 == 0 ? CSColor.assistiveLight : CSColor.modeAgent } + return i % 5 == 0 ? CSColor.terracottaTintBars : CSColor.modeRecording + } } #if DEBUG -#Preview("Waveform — active") { + #Preview("Waveform — active") { WaveformView(active: true) - .padding(40) - .background(CSColor.glassUnder) -} + .padding(40) + .background(CSColor.glassUnder) + } #endif diff --git a/macos/Codescribe/Screens/Settings/AgentPanel.swift b/macos/Codescribe/Screens/Settings/AgentPanel.swift index 5cdaad1a..7dea0c27 100644 --- a/macos/Codescribe/Screens/Settings/AgentPanel.swift +++ b/macos/Codescribe/Screens/Settings/AgentPanel.swift @@ -4,159 +4,160 @@ import SwiftUI // Storage keys and bridge calls remain unchanged; this view only gives the // existing controls a dedicated navigation destination. struct AgentPanel: View { - static let ownedCapabilities: Set = [ - .llmLanes, - .workspaceRoots, - .agentStatus, - .mcpServers, - .toolPermissions, - ] + static let ownedCapabilities: Set = [ + .llmLanes, + .workspaceRoots, + .agentStatus, + .mcpServers, + .toolPermissions, + ] - @ObservedObject var model: SettingsViewModel + @ObservedObject var model: SettingsViewModel - /// One page at a time, and the page IS the content. Five independent - /// subsystems (lanes, roots, capabilities, tool permissions, MCP servers — - /// the last two alone are ~800 lines) used to stack into a single scroll. - /// The first paginated cut kept the monolith's headline and the 7-row - /// resolved-LLM table as a preamble on EVERY page, so each page rendered - /// below a screenful of old world — pagination in the rail, monolith in - /// the pane. Now each page owns its headline and body; the resolved-truth - /// table lives only where it is the subject (LLM lanes), below the editors. - var body: some View { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · \(SettingsSection.agent.title) · \(current.title)") - Text(headline) - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) + /// One page at a time, and the page IS the content. Five independent + /// subsystems (lanes, roots, capabilities, tool permissions, MCP servers — + /// the last two alone are ~800 lines) used to stack into a single scroll. + /// The first paginated cut kept the monolith's headline and the 7-row + /// resolved-LLM table as a preamble on EVERY page, so each page rendered + /// below a screenful of old world — pagination in the rail, monolith in + /// the pane. Now each page owns its headline and body; the resolved-truth + /// table lives only where it is the subject (LLM lanes), below the editors. + var body: some View { + VStack(alignment: .leading, spacing: 0) { + EyebrowLabel(text: "Settings · \(SettingsSection.agent.title) · \(current.title)") + Text(headline) + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) - Text(blurb) - .font(CSFont.ui(12.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) + Text(blurb) + .font(CSFont.ui(12.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) - page - .padding(.top, 20) - } - .padding(.horizontal, 28) - .padding(.vertical, 24) + page + .padding(.top, 20) } + .padding(.horizontal, 28) + .padding(.vertical, 24) + } - /// The nil route (a deep link that named no page) lands on lanes, - /// mirroring the `page` switch below. Pages of other sections cannot - /// reach this panel, so they clamp to lanes too. - private var current: SettingsPage { - switch model.page { - case .agentWorkspace, .agentStatus, .agentTools, .agentMcp: - return model.page ?? .agentLanes - default: - return .agentLanes - } + /// The nil route (a deep link that named no page) lands on lanes, + /// mirroring the `page` switch below. Pages of other sections cannot + /// reach this panel, so they clamp to lanes too. + private var current: SettingsPage { + switch model.page { + case .agentWorkspace, .agentStatus, .agentTools, .agentMcp: + return model.page ?? .agentLanes + default: + return .agentLanes } + } - private var headline: String { - switch current { - case .agentWorkspace: return "Workspace roots." - case .agentStatus: return "Capabilities." - case .agentTools: return "Tool permissions." - case .agentMcp: return "MCP servers." - default: return "Request lanes." - } + private var headline: String { + switch current { + case .agentWorkspace: return "Workspace roots." + case .agentStatus: return "Capabilities." + case .agentTools: return "Tool permissions." + case .agentMcp: return "MCP servers." + default: return "Request lanes." } + } - private var blurb: String { - switch current { - case .agentWorkspace: - return "Directories the agent may read and write. Everything outside them is out of reach." - case .agentStatus: - return "What the local agent substrate can currently do, and why." - case .agentTools: - return "Allow, ask, or deny — per tool. Deny wins over everything." - case .agentMcp: - return "External MCP servers the agent can call, and their transports." - default: - return "Provider, endpoint, and model per request path. The resolved runtime truth is below the editors." - } + private var blurb: String { + switch current { + case .agentWorkspace: + return "Directories the agent may read and write. Everything outside them is out of reach." + case .agentStatus: + return "What the local agent substrate can currently do, and why." + case .agentTools: + return "Allow, ask, or deny — per tool. Deny wins over everything." + case .agentMcp: + return "External MCP servers the agent can call, and their transports." + default: + return + "Provider, endpoint, and model per request path. The resolved runtime truth is below the editors." } + } - @ViewBuilder - private var page: some View { - switch model.page { - case .agentWorkspace: - WorkspaceRootsSection(model: model) - case .agentStatus: - AgentStatusSection(model: model) - case .agentTools: - ToolPermissionsSection(model: model) - case .agentMcp: - MCPServersSection(model: model) - default: - // `.agentLanes` and the nil route (deep link that named no page). - VStack(alignment: .leading, spacing: 0) { - LLMLanesSection(model: model) + @ViewBuilder + private var page: some View { + switch model.page { + case .agentWorkspace: + WorkspaceRootsSection(model: model) + case .agentStatus: + AgentStatusSection(model: model) + case .agentTools: + ToolPermissionsSection(model: model) + case .agentMcp: + MCPServersSection(model: model) + default: + // `.agentLanes` and the nil route (deep link that named no page). + VStack(alignment: .leading, spacing: 0) { + LLMLanesSection(model: model) - // The answer to "which provider am I actually talking to", - // placed after the editors as read-only proof of what resolved. - SettingsSectionLabel("Resolved runtime truth") - .padding(.top, 24) - runtimeRows - .padding(.top, 11) - } - } + // The answer to "which provider am I actually talking to", + // placed after the editors as read-only proof of what resolved. + SettingsSectionLabel("Resolved runtime truth") + .padding(.top, 24) + runtimeRows + .padding(.top, 11) + } } + } - // MARK: - Resolved LLM truth (read-only) + // MARK: - Resolved LLM truth (read-only) - private var runtimeRows: some View { - VStack(spacing: 0) { - RuntimeRow( - key: "AI formatting", - value: model.formattingDescription, - tint: true, - trailing: .none - ) - divider - ForEach(LLMLane.allCases) { lane in - let laneModel = model.llmLane(lane) - RuntimeRow( - key: "\(lane.title) endpoint", - value: laneModel.resolvedEndpoint, - tint: false, - mono: true, - trailing: .none - ) - divider - RuntimeRow( - key: "\(lane.title) model", - value: laneModel.resolvedModel, - tint: true, - mono: true, - trailing: .none - ) - if lane != LLMLane.allCases.last { - divider - } - } - } - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + private var runtimeRows: some View { + VStack(spacing: 0) { + RuntimeRow( + key: "AI formatting", + value: model.formattingDescription, + tint: true, + trailing: .none + ) + divider + ForEach(LLMLane.allCases) { lane in + let laneModel = model.llmLane(lane) + RuntimeRow( + key: "\(lane.title) endpoint", + value: laneModel.resolvedEndpoint, + tint: false, + mono: true, + trailing: .none + ) + divider + RuntimeRow( + key: "\(lane.title) model", + value: laneModel.resolvedModel, + tint: true, + mono: true, + trailing: .none ) + if lane != LLMLane.allCases.last { + divider + } + } } + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } - private var divider: some View { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) - } + private var divider: some View { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + } } #if DEBUG -#Preview("Agent panel") { + #Preview("Agent panel") { ScrollView { AgentPanel(model: .preview(.agent)) } - .frame(width: 720, height: 900) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 900) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/AgentStatusEngine.swift b/macos/Codescribe/Screens/Settings/AgentStatusEngine.swift index 58429aee..af571a28 100644 --- a/macos/Codescribe/Screens/Settings/AgentStatusEngine.swift +++ b/macos/Codescribe/Screens/Settings/AgentStatusEngine.swift @@ -12,12 +12,12 @@ import Foundation /// Read-only agent-substrate status surface the Settings screen consumes. protocol AgentStatusEngine { - /// Agentic-lane readiness (Vibecrafted + AICX + Loctree + PRView). - func agenticReadiness() -> CsAgenticReadiness - /// Basic-lane MCP config + runtime status. Missing mcp.json → neutral row. - func mcpStatus() -> CsMcpStatusReport - /// Provider-neutral capability matrix (native / enhanced / unavailable). - func capabilityMatrix() -> [CsCapabilityRow] + /// Agentic-lane readiness (Vibecrafted + AICX + Loctree + PRView). + func agenticReadiness() -> CsAgenticReadiness + /// Basic-lane MCP config + runtime status. Missing mcp.json → neutral row. + func mcpStatus() -> CsMcpStatusReport + /// Provider-neutral capability matrix (native / enhanced / unavailable). + func capabilityMatrix() -> [CsCapabilityRow] } // MARK: - Real engine (UniFFI bridge adapter) @@ -26,86 +26,88 @@ protocol AgentStatusEngine { /// every call re-reads config truth so Swift always sees on-disk state. Injected /// by App.swift for the live app. final class RealAgentStatusEngine: AgentStatusEngine { - private let status = CodescribeAgentStatus() + private let status = CodescribeAgentStatus() - func agenticReadiness() -> CsAgenticReadiness { status.agenticReadiness() } - func mcpStatus() -> CsMcpStatusReport { status.mcpStatus() } - func capabilityMatrix() -> [CsCapabilityRow] { status.capabilityMatrix() } + func agenticReadiness() -> CsAgenticReadiness { status.agenticReadiness() } + func mcpStatus() -> CsMcpStatusReport { status.mcpStatus() } + func capabilityMatrix() -> [CsCapabilityRow] { status.capabilityMatrix() } } // MARK: - Mock engine (previews) /// In-memory stand-in for #Preview and standalone rendering. struct MockAgentStatusEngine: AgentStatusEngine { - var readiness: CsAgenticReadiness = .sample - var mcp: CsMcpStatusReport = .sample - var matrix: [CsCapabilityRow] = CsCapabilityRow.sampleMatrix + var readiness: CsAgenticReadiness = .sample + var mcp: CsMcpStatusReport = .sample + var matrix: [CsCapabilityRow] = CsCapabilityRow.sampleMatrix - func agenticReadiness() -> CsAgenticReadiness { readiness } - func mcpStatus() -> CsMcpStatusReport { mcp } - func capabilityMatrix() -> [CsCapabilityRow] { matrix } + func agenticReadiness() -> CsAgenticReadiness { readiness } + func mcpStatus() -> CsMcpStatusReport { mcp } + func capabilityMatrix() -> [CsCapabilityRow] { matrix } } // MARK: - Bridge value helpers (preview seeds) extension CsMcpStatusReport { - /// Sample MCP status with a mix of live / pending servers (preview seed). - static let sample = CsMcpStatusReport( - configPathDisplay: "~/.codescribe/mcp.json", - configured: true, - rows: [ - CsMcpStatusRow(label: "loctree-mcp:", value: "9 tool(s)", tone: .good), - CsMcpStatusRow(label: "aicx-mcp:", value: "configured (agent not started)", tone: .warn), - CsMcpStatusRow(label: "vibecrafted-mcp:", value: "failed: command not found", tone: .bad) - ] - ) + /// Sample MCP status with a mix of live / pending servers (preview seed). + static let sample = CsMcpStatusReport( + configPathDisplay: "~/.codescribe/mcp.json", + configured: true, + rows: [ + CsMcpStatusRow(label: "loctree-mcp:", value: "9 tool(s)", tone: .good), + CsMcpStatusRow(label: "aicx-mcp:", value: "configured (agent not started)", tone: .warn), + CsMcpStatusRow(label: "vibecrafted-mcp:", value: "failed: command not found", tone: .bad), + ] + ) } extension CsAgenticReadiness { - /// Sample readiness: the core capability gate passes (provider + key + native - /// tools), and the operator-tooling MCP rows are informational (preview seed). - static let sample = CsAgenticReadiness( - configPathDisplay: "~/.codescribe/mcp.json", - ready: true, - rows: [ - CsMcpStatusRow( - label: "Agentic readiness:", - value: "ready — OpenAI (Responses) configured, key set, 10 native tool(s)", - tone: .good - ), - CsMcpStatusRow(label: "Provider:", value: "OpenAI (Responses) — key set", tone: .good), - CsMcpStatusRow(label: "Native tools:", value: "10 tool(s) available", tone: .good), - CsMcpStatusRow(label: "Vibecrafted runtime:", value: "not configured (optional)", tone: .neutral), - CsMcpStatusRow(label: "AICX MCP:", value: "configured — agent not started yet", tone: .warn), - CsMcpStatusRow(label: "Loctree MCP:", value: "ready — 9 tool(s) live", tone: .good), - CsMcpStatusRow(label: "PRView integration:", value: "not configured (optional)", tone: .neutral) - ] - ) + /// Sample readiness: the core capability gate passes (provider + key + native + /// tools), and the operator-tooling MCP rows are informational (preview seed). + static let sample = CsAgenticReadiness( + configPathDisplay: "~/.codescribe/mcp.json", + ready: true, + rows: [ + CsMcpStatusRow( + label: "Agentic readiness:", + value: "ready — OpenAI (Responses) configured, key set, 10 native tool(s)", + tone: .good + ), + CsMcpStatusRow(label: "Provider:", value: "OpenAI (Responses) — key set", tone: .good), + CsMcpStatusRow(label: "Native tools:", value: "10 tool(s) available", tone: .good), + CsMcpStatusRow( + label: "Vibecrafted runtime:", value: "not configured (optional)", tone: .neutral), + CsMcpStatusRow(label: "AICX MCP:", value: "configured — agent not started yet", tone: .warn), + CsMcpStatusRow(label: "Loctree MCP:", value: "ready — 9 tool(s) live", tone: .good), + CsMcpStatusRow( + label: "PRView integration:", value: "not configured (optional)", tone: .neutral), + ] + ) } extension CsCapabilityRow { - /// Preview seed for Settings → Agent capability matrix. - static let sampleMatrix: [CsCapabilityRow] = [ - CsCapabilityRow( - op: "fs.list", - tier: "native", - provider: "native", - nativeTool: "list_directory", - reason: "Native workspace-sandboxed list" - ), - CsCapabilityRow( - op: "fs.search", - tier: "enhanced", - provider: "loctree", - nativeTool: "search_files", - reason: "Native search + Loctree enrichment preferred when healthy" - ), - CsCapabilityRow( - op: "code.symbols", - tier: "unavailable", - provider: "unavailable", - nativeTool: "", - reason: "No native implementation and no healthy MCP provider" - ), - ] + /// Preview seed for Settings → Agent capability matrix. + static let sampleMatrix: [CsCapabilityRow] = [ + CsCapabilityRow( + op: "fs.list", + tier: "native", + provider: "native", + nativeTool: "list_directory", + reason: "Native workspace-sandboxed list" + ), + CsCapabilityRow( + op: "fs.search", + tier: "enhanced", + provider: "loctree", + nativeTool: "search_files", + reason: "Native search + Loctree enrichment preferred when healthy" + ), + CsCapabilityRow( + op: "code.symbols", + tier: "unavailable", + provider: "unavailable", + nativeTool: "", + reason: "No native implementation and no healthy MCP provider" + ), + ] } diff --git a/macos/Codescribe/Screens/Settings/AgentStatusSection.swift b/macos/Codescribe/Screens/Settings/AgentStatusSection.swift index 47bb8f78..e9bd6f76 100644 --- a/macos/Codescribe/Screens/Settings/AgentStatusSection.swift +++ b/macos/Codescribe/Screens/Settings/AgentStatusSection.swift @@ -8,294 +8,294 @@ import SwiftUI // "MCP off" row, never an error. struct AgentStatusSection: View { - @ObservedObject var model: SettingsViewModel + @ObservedObject var model: SettingsViewModel - /// Collapsed by default: the per-server health probe is an informational - /// drill-down, not a readiness input, so it stays out of the way until asked. - @State private var probesExpanded = false + /// Collapsed by default: the per-server health probe is an informational + /// drill-down, not a readiness input, so it stays out of the way until asked. + @State private var probesExpanded = false - var body: some View { - VStack(alignment: .leading, spacing: 0) { - header + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header - // Agentic readiness verdict + per-prerequisite rows. - statusCard(rows: model.agentReadiness.rows) - .padding(.top, 11) + // Agentic readiness verdict + per-prerequisite rows. + statusCard(rows: model.agentReadiness.rows) + .padding(.top, 11) - SettingsSectionLabel("Capability matrix") - .padding(.top, 22) - Text("Native substrate vs enrichment providers (IntelliJ optional).") - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.textFaint) - .padding(.top, 4) - capabilityMatrixCard - .padding(.top, 8) + SettingsSectionLabel("Capability matrix") + .padding(.top, 22) + Text("Native substrate vs enrichment providers (IntelliJ optional).") + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textFaint) + .padding(.top, 4) + capabilityMatrixCard + .padding(.top, 8) - SettingsSectionLabel("MCP servers") - .padding(.top, 22) - Text(model.mcpStatus.configPathDisplay) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(1) - .truncationMode(.middle) - .padding(.top, 4) - statusCard(rows: model.mcpStatus.rows) - .padding(.top, 8) + SettingsSectionLabel("MCP servers") + .padding(.top, 22) + Text(model.mcpStatus.configPathDisplay) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(1) + .truncationMode(.middle) + .padding(.top, 4) + statusCard(rows: model.mcpStatus.rows) + .padding(.top, 8) - // Collapsible per-server health probe. Reflects the cached Test / - // handshake result from the management section below; purely - // informational and never flips the readiness verdict above. - if !model.mcpServers.isEmpty { - probeDisclosure - .padding(.top, 13) - } - } + // Collapsible per-server health probe. Reflects the cached Test / + // handshake result from the management section below; purely + // informational and never flips the readiness verdict above. + if !model.mcpServers.isEmpty { + probeDisclosure + .padding(.top, 13) + } } + } - // MARK: Per-server probe (collapsible) + // MARK: Per-server probe (collapsible) - /// One row per configured server: the cached probe status (ok / fail / - /// testing / not tested) mapped to a tone the shared status card renders. - private var probeRows: [CsMcpStatusRow] { - model.mcpServers.map { server in - if model.mcpTestPending.contains(server.name) { - return CsMcpStatusRow(label: server.name, value: "testing…", tone: .warn) - } - guard let result = model.mcpTestResults[server.name] else { - return CsMcpStatusRow(label: server.name, value: "not tested", tone: .neutral) - } - if result.ok { - var value = "ok — \(result.toolCount) tool(s)" - if !result.serverVersion.isEmpty { value += " · v\(result.serverVersion)" } - return CsMcpStatusRow(label: server.name, value: value, tone: .good) - } - return CsMcpStatusRow(label: server.name, value: "fail: \(result.error)", tone: .bad) - } + /// One row per configured server: the cached probe status (ok / fail / + /// testing / not tested) mapped to a tone the shared status card renders. + private var probeRows: [CsMcpStatusRow] { + model.mcpServers.map { server in + if model.mcpTestPending.contains(server.name) { + return CsMcpStatusRow(label: server.name, value: "testing…", tone: .warn) + } + guard let result = model.mcpTestResults[server.name] else { + return CsMcpStatusRow(label: server.name, value: "not tested", tone: .neutral) + } + if result.ok { + var value = "ok — \(result.toolCount) tool(s)" + if !result.serverVersion.isEmpty { value += " · v\(result.serverVersion)" } + return CsMcpStatusRow(label: server.name, value: value, tone: .good) + } + return CsMcpStatusRow(label: server.name, value: "fail: \(result.error)", tone: .bad) } + } - private var probeDisclosure: some View { - VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation(.easeOut(duration: 0.18)) { probesExpanded.toggle() } - } label: { - HStack(spacing: 7) { - CSIconView( - icon: probesExpanded ? .chevronDown : .chevronRight, - size: 10, weight: .semibold, color: CSColor.textMuted - ) - Text("Per-server probe") - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.textMutedAlt) - Spacer(minLength: 0) - Text("\(model.mcpServers.count) configured") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - } - } - .csFocusRing(cornerRadius: 8) - .help("Cached initialize + tools/list result per configured server") - - if probesExpanded { - statusCard(rows: probeRows) - .padding(.top, 8) - } + private var probeDisclosure: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeOut(duration: 0.18)) { probesExpanded.toggle() } + } label: { + HStack(spacing: 7) { + CSIconView( + icon: probesExpanded ? .chevronDown : .chevronRight, + size: 10, weight: .semibold, color: CSColor.textMuted + ) + Text("Per-server probe") + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.textMutedAlt) + Spacer(minLength: 0) + Text("\(model.mcpServers.count) configured") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) } + } + .csFocusRing(cornerRadius: 8) + .help("Cached initialize + tools/list result per configured server") + + if probesExpanded { + statusCard(rows: probeRows) + .padding(.top, 8) + } } + } - // MARK: Header + refresh + // MARK: Header + refresh - private var header: some View { - HStack(spacing: 10) { - SettingsSectionLabel("Agent readiness") - readinessPill - Spacer(minLength: 0) - Button { - model.refreshAgentStatus() - } label: { - HStack(spacing: 5) { - CSIconView(icon: .refresh, size: 11, weight: .semibold) - Text("Refresh").font(CSFont.mono(11, .semibold)) - } - .foregroundStyle(CSColor.textBodyAlt) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) + private var header: some View { + HStack(spacing: 10) { + SettingsSectionLabel("Agent readiness") + readinessPill + Spacer(minLength: 0) + Button { + model.refreshAgentStatus() + } label: { + HStack(spacing: 5) { + CSIconView(icon: .refresh, size: 11, weight: .semibold) + Text("Refresh").font(CSFont.mono(11, .semibold)) } + .foregroundStyle(CSColor.textBodyAlt) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + .csFocusRing(cornerRadius: 8) } + } - private var readinessPill: some View { - let ready = model.agentReadiness.ready - let accent = ready ? CSColor.olive : CSColor.terracotta - let accentLight = ready ? CSColor.oliveLight : CSColor.terracottaLight - return Text(ready ? "READY" : "NOT READY") - .font(CSFont.mono(9, .semibold)) - .tracking(0.4) - .foregroundStyle(accentLight) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(accent.opacity(0.12)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(accent.opacity(0.24), lineWidth: 1) - ) - } + private var readinessPill: some View { + let ready = model.agentReadiness.ready + let accent = ready ? CSColor.olive : CSColor.terracotta + let accentLight = ready ? CSColor.oliveLight : CSColor.terracottaLight + return Text(ready ? "READY" : "NOT READY") + .font(CSFont.mono(9, .semibold)) + .tracking(0.4) + .foregroundStyle(accentLight) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(accent.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(accent.opacity(0.24), lineWidth: 1) + ) + } - // MARK: Capability matrix + // MARK: Capability matrix - private var capabilityMatrixCard: some View { - VStack(spacing: 0) { - if model.capabilityMatrix.isEmpty { - AgentStatusRow( - row: CsMcpStatusRow( - label: "matrix", - value: "no capability rows (refresh or start agent substrate)", - tone: .neutral - ) - ) - } else { - ForEach(Array(model.capabilityMatrix.enumerated()), id: \.offset) { index, row in - if index > 0 { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) - } - CapabilityMatrixRow(row: row) - } - } - } - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + private var capabilityMatrixCard: some View { + VStack(spacing: 0) { + if model.capabilityMatrix.isEmpty { + AgentStatusRow( + row: CsMcpStatusRow( + label: "matrix", + value: "no capability rows (refresh or start agent substrate)", + tone: .neutral + ) ) + } else { + ForEach(Array(model.capabilityMatrix.enumerated()), id: \.offset) { index, row in + if index > 0 { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + } + CapabilityMatrixRow(row: row) + } + } } + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } - // MARK: Status card (shared by readiness + MCP) + // MARK: Status card (shared by readiness + MCP) - @ViewBuilder - private func statusCard(rows: [CsMcpStatusRow]) -> some View { - VStack(spacing: 0) { - ForEach(Array(rows.enumerated()), id: \.offset) { index, row in - if index > 0 { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) - } - AgentStatusRow(row: row) - } + @ViewBuilder + private func statusCard(rows: [CsMcpStatusRow]) -> some View { + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + if index > 0 { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) } - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) + AgentStatusRow(row: row) + } } + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } } // MARK: - One status row (label · value · tone dot) private struct AgentStatusRow: View { - let row: CsMcpStatusRow + let row: CsMcpStatusRow - var body: some View { - HStack(spacing: 12) { - Text(row.label) - .font(CSFont.mono(12, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .frame(width: 160, alignment: .leading) - Text(row.value) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - Circle().fill(row.tone.dotColor).frame(width: 7, height: 7) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) + var body: some View { + HStack(spacing: 12) { + Text(row.label) + .font(CSFont.mono(12, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .frame(width: 160, alignment: .leading) + Text(row.value) + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Circle().fill(row.tone.dotColor).frame(width: 7, height: 7) } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } } // MARK: - Capability matrix row (op · tier · reason) private struct CapabilityMatrixRow: View { - let row: CsCapabilityRow + let row: CsCapabilityRow - var body: some View { - HStack(alignment: .top, spacing: 12) { - Text(row.op) - .font(CSFont.mono(12, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .frame(width: 120, alignment: .leading) - Text(row.tier.uppercased()) - .font(CSFont.mono(10, .semibold)) - .tracking(0.3) - .foregroundStyle(tierColor) - .padding(.horizontal, 7) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(tierColor.opacity(0.12)) - ) - .overlay( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .strokeBorder(tierColor.opacity(0.28), lineWidth: 1) - ) - .frame(width: 96, alignment: .leading) - VStack(alignment: .leading, spacing: 2) { - Text(row.reason.isEmpty ? row.provider : row.reason) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - .lineLimit(2) - if !row.nativeTool.isEmpty { - Text("tool: \(row.nativeTool) · provider: \(row.provider)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(1) - } else if !row.provider.isEmpty { - Text("provider: \(row.provider)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - Circle().fill(tierColor).frame(width: 7, height: 7).padding(.top, 5) + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(row.op) + .font(CSFont.mono(12, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .frame(width: 120, alignment: .leading) + Text(row.tier.uppercased()) + .font(CSFont.mono(10, .semibold)) + .tracking(0.3) + .foregroundStyle(tierColor) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(tierColor.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .strokeBorder(tierColor.opacity(0.28), lineWidth: 1) + ) + .frame(width: 96, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(row.reason.isEmpty ? row.provider : row.reason) + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + .lineLimit(2) + if !row.nativeTool.isEmpty { + Text("tool: \(row.nativeTool) · provider: \(row.provider)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(1) + } else if !row.provider.isEmpty { + Text("provider: \(row.provider)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(1) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(row.op), \(row.tier)") - .accessibilityValue(row.reason) + } + .frame(maxWidth: .infinity, alignment: .leading) + Circle().fill(tierColor).frame(width: 7, height: 7).padding(.top, 5) } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.op), \(row.tier)") + .accessibilityValue(row.reason) + } - private var tierColor: Color { - switch row.tier.lowercased() { - case "native": return CSColor.oliveLight - case "enhanced": return CSColor.amber - case "unavailable": return CSColor.terracottaLight - default: return CSColor.textFaint - } + private var tierColor: Color { + switch row.tier.lowercased() { + case "native": return CSColor.oliveLight + case "enhanced": return CSColor.amber + case "unavailable": return CSColor.terracottaLight + default: return CSColor.textFaint } + } } // MARK: - Tone → color extension CsMcpRowTone { - /// Map the UI-agnostic core tone onto concrete brand tokens. - var dotColor: Color { - switch self { - case .good: return CSColor.oliveLight - case .warn: return CSColor.amber - case .bad: return CSColor.terracottaLight - case .neutral: return CSColor.textFaint - } + /// Map the UI-agnostic core tone onto concrete brand tokens. + var dotColor: Color { + switch self { + case .good: return CSColor.oliveLight + case .warn: return CSColor.amber + case .bad: return CSColor.terracottaLight + case .neutral: return CSColor.textFaint } + } } diff --git a/macos/Codescribe/Screens/Settings/AudioPanel.swift b/macos/Codescribe/Screens/Settings/AudioPanel.swift index 4f05793b..c4db094a 100644 --- a/macos/Codescribe/Screens/Settings/AudioPanel.swift +++ b/macos/Codescribe/Screens/Settings/AudioPanel.swift @@ -1,290 +1,291 @@ import SwiftUI enum AudioInputDisplayTone: Equatable { - case healthy - case fallback - case unavailable + case healthy + case fallback + case unavailable } struct AudioInputDisplayState: Equatable { - let tone: AudioInputDisplayTone - let title: String - let detail: String + let tone: AudioInputDisplayTone + let title: String + let detail: String } /// Pure UI projection for XCTest. The bridge snapshot already contains the /// live cpal resolution; this function never re-resolves a configured wish. func audioInputDisplayState(_ snapshot: CsAudioInputSnapshot) -> AudioInputDisplayState { - guard let runtimeDevice = snapshot.runtimeDevice, !runtimeDevice.isEmpty else { - return AudioInputDisplayState( - tone: .unavailable, - title: "No input device available", - detail: "Connect a microphone and refresh Audio settings." - ) - } - - if !snapshot.runtimeConfigurationMatches { - let saved = snapshot.configuredDevice ?? "System default" - return AudioInputDisplayState( - tone: .fallback, - title: "Currently using: \(runtimeDevice)", - detail: "Saved: \(saved). Restart Codescribe to apply it; an explicit AUDIO_INPUT_DEVICE launch override can keep a different runtime input active." - ) - } + guard let runtimeDevice = snapshot.runtimeDevice, !runtimeDevice.isEmpty else { + return AudioInputDisplayState( + tone: .unavailable, + title: "No input device available", + detail: "Connect a microphone and refresh Audio settings." + ) + } - if snapshot.fallbackToDefault { - let missing = snapshot.configuredDevice ?? "The configured input" - return AudioInputDisplayState( - tone: .fallback, - title: "Using system fallback: \(runtimeDevice)", - detail: "\(missing) is unavailable. Recording continues on the live default input." - ) - } + if !snapshot.runtimeConfigurationMatches { + let saved = snapshot.configuredDevice ?? "System default" + return AudioInputDisplayState( + tone: .fallback, + title: "Currently using: \(runtimeDevice)", + detail: + "Saved: \(saved). Restart Codescribe to apply it; an explicit AUDIO_INPUT_DEVICE launch override can keep a different runtime input active." + ) + } - if snapshot.configuredDevice == nil { - return AudioInputDisplayState( - tone: .healthy, - title: "System default: \(runtimeDevice)", - detail: "The recorder resolves this device from Core Audio at runtime." - ) - } + if snapshot.fallbackToDefault { + let missing = snapshot.configuredDevice ?? "The configured input" + return AudioInputDisplayState( + tone: .fallback, + title: "Using system fallback: \(runtimeDevice)", + detail: "\(missing) is unavailable. Recording continues on the live default input." + ) + } + if snapshot.configuredDevice == nil { return AudioInputDisplayState( - tone: .healthy, - title: "Runtime input: \(runtimeDevice)", - detail: "The configured device is present and selected by the recorder." + tone: .healthy, + title: "System default: \(runtimeDevice)", + detail: "The recorder resolves this device from Core Audio at runtime." ) + } + + return AudioInputDisplayState( + tone: .healthy, + title: "Runtime input: \(runtimeDevice)", + detail: "The configured device is present and selected by the recorder." + ) } struct AudioPanel: View { - @ObservedObject var model: SettingsViewModel + @ObservedObject var model: SettingsViewModel - private static let systemDefaultChoice = "__codescribe_system_default__" + private static let systemDefaultChoice = "__codescribe_system_default__" - var body: some View { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · Audio") - Text("Hear the real input.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) - Text("Device choice and sound feedback use the live recorder config.") - .font(CSFont.ui(12.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) - } - Spacer(minLength: 0) - Button("Refresh") { - model.refreshAudioInput() - } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - .accessibilityLabel("Refresh audio input devices") - } + EyebrowLabel(text: "Settings · Audio") + Text("Hear the real input.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) + Text("Device choice and sound feedback use the live recorder config.") + .font(CSFont.ui(12.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) + } + Spacer(minLength: 0) + Button("Refresh") { + model.refreshAudioInput() + } + .csFocusRing(cornerRadius: 8) + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + .accessibilityLabel("Refresh audio input devices") + } - SettingsSectionLabel("Input device") - .padding(.top, 24) - inputDeviceSection - .padding(.top, 11) + SettingsSectionLabel("Input device") + .padding(.top, 24) + inputDeviceSection + .padding(.top, 11) - SettingsSectionLabel("Sound feedback") - .padding(.top, 24) - feedbackSection - .padding(.top, 11) - } - .padding(.horizontal, 28) - .padding(.vertical, 24) + SettingsSectionLabel("Sound feedback") + .padding(.top, 24) + feedbackSection + .padding(.top, 11) } + .padding(.horizontal, 28) + .padding(.vertical, 24) + } - private var inputDeviceSection: some View { - VStack(alignment: .leading, spacing: 14) { - SettingsControlRow( - title: "Microphone", - subtitle: "Saved in settings.json; runtime falls back safely if it disappears" - ) { - Picker("Input device", selection: inputDeviceBinding) { - Text("System default").tag(Self.systemDefaultChoice) - ForEach(deviceOptions, id: \.self) { device in - if device == model.audioInput.configuredDevice, - !model.audioInput.configuredDeviceAvailable - { - Text("\(device) — unavailable").tag(device) - } else { - Text(device).tag(device) - } - } - } - .labelsHidden() - .frame(width: 260) - .accessibilityLabel("Audio input device") - .accessibilityValue(inputDeviceAccessibilityValue) + private var inputDeviceSection: some View { + VStack(alignment: .leading, spacing: 14) { + SettingsControlRow( + title: "Microphone", + subtitle: "Saved in settings.json; runtime falls back safely if it disappears" + ) { + Picker("Input device", selection: inputDeviceBinding) { + Text("System default").tag(Self.systemDefaultChoice) + ForEach(deviceOptions, id: \.self) { device in + if device == model.audioInput.configuredDevice, + !model.audioInput.configuredDeviceAvailable + { + Text("\(device) — unavailable").tag(device) + } else { + Text(device).tag(device) } + } + } + .labelsHidden() + .frame(width: 260) + .accessibilityLabel("Audio input device") + .accessibilityValue(inputDeviceAccessibilityValue) + } - runtimeInputStatus + runtimeInputStatus - HStack { - Text("Reset removes the preference; it never writes an empty device name.") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - Spacer(minLength: 12) - Button("Use system default") { - model.resetAudioInputDevice() - } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - .disabled(model.settings.audioInputDevice == nil) - .accessibilityLabel("Reset audio input to system default") - } + HStack { + Text("Reset removes the preference; it never writes an empty device name.") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + Spacer(minLength: 12) + Button("Use system default") { + model.resetAudioInputDevice() } - .padding(15) - .background(card) - .overlay(cardBorder) + .csFocusRing(cornerRadius: 8) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + .disabled(model.settings.audioInputDevice == nil) + .accessibilityLabel("Reset audio input to system default") + } } + .padding(15) + .background(card) + .overlay(cardBorder) + } - @ViewBuilder - private var runtimeInputStatus: some View { - if let error = model.audioInputReadError { - statusRow( - color: CSColor.terracottaLight, - title: "Audio hardware unavailable", - detail: error - ) - } else { - let state = audioInputDisplayState(model.audioInput) - statusRow( - color: statusColor(state.tone), - title: state.title, - detail: state.detail - ) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Runtime audio input") - .accessibilityValue("\(state.title). \(state.detail)") - } + @ViewBuilder + private var runtimeInputStatus: some View { + if let error = model.audioInputReadError { + statusRow( + color: CSColor.terracottaLight, + title: "Audio hardware unavailable", + detail: error + ) + } else { + let state = audioInputDisplayState(model.audioInput) + statusRow( + color: statusColor(state.tone), + title: state.title, + detail: state.detail + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Runtime audio input") + .accessibilityValue("\(state.title). \(state.detail)") } + } - private var feedbackSection: some View { - VStack(alignment: .leading, spacing: 14) { - SettingsControlRow( - title: "Start sound", - subtitle: "Play the recorder's live start confirmation" - ) { - Toggle("", isOn: soundFeedbackBinding) - .toggleStyle(.switch) - .labelsHidden() - .tint(CSColor.chromeAccent) - .accessibilityLabel("Recording start sound") - .accessibilityValue(model.settings.beepOnStart ? "On" : "Off") - } + private var feedbackSection: some View { + VStack(alignment: .leading, spacing: 14) { + SettingsControlRow( + title: "Start sound", + subtitle: "Play the recorder's live start confirmation" + ) { + Toggle("", isOn: soundFeedbackBinding) + .toggleStyle(.switch) + .labelsHidden() + .tint(CSColor.chromeAccent) + .accessibilityLabel("Recording start sound") + .accessibilityValue(model.settings.beepOnStart ? "On" : "Off") + } - VStack(alignment: .leading, spacing: 7) { - HStack { - Text("Volume") - .font(CSFont.ui(12.5, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - Spacer(minLength: 0) - Text("\(Int((model.settings.soundVolume * 100).rounded()))%") - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.textBody) - } - Slider(value: soundVolumeBinding, in: 0 ... 1, step: 0.05) - .tint(CSColor.chromeAccent) - .disabled(!model.settings.beepOnStart) - .accessibilityLabel("Recording start sound volume") - .accessibilityValue("\(Int((model.settings.soundVolume * 100).rounded())) percent") - } + VStack(alignment: .leading, spacing: 7) { + HStack { + Text("Volume") + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + Spacer(minLength: 0) + Text("\(Int((model.settings.soundVolume * 100).rounded()))%") + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.textBody) } - .padding(15) - .background(card) - .overlay(cardBorder) + Slider(value: soundVolumeBinding, in: 0...1, step: 0.05) + .tint(CSColor.chromeAccent) + .disabled(!model.settings.beepOnStart) + .accessibilityLabel("Recording start sound volume") + .accessibilityValue("\(Int((model.settings.soundVolume * 100).rounded())) percent") + } } + .padding(15) + .background(card) + .overlay(cardBorder) + } - private var deviceOptions: [String] { - var devices = model.audioInput.devices - if let configured = model.audioInput.configuredDevice, - !devices.contains(configured) - { - devices.insert(configured, at: 0) - } - return devices + private var deviceOptions: [String] { + var devices = model.audioInput.devices + if let configured = model.audioInput.configuredDevice, + !devices.contains(configured) + { + devices.insert(configured, at: 0) } + return devices + } - private var inputDeviceBinding: Binding { - Binding( - get: { model.settings.audioInputDevice ?? Self.systemDefaultChoice }, - set: { choice in - if choice == Self.systemDefaultChoice { - model.resetAudioInputDevice() - } else { - model.setAudioInputDevice(choice) - } - } - ) - } + private var inputDeviceBinding: Binding { + Binding( + get: { model.settings.audioInputDevice ?? Self.systemDefaultChoice }, + set: { choice in + if choice == Self.systemDefaultChoice { + model.resetAudioInputDevice() + } else { + model.setAudioInputDevice(choice) + } + } + ) + } - private var soundFeedbackBinding: Binding { - Binding( - get: { model.settings.beepOnStart }, - set: { model.setSoundFeedbackEnabled($0) } - ) - } + private var soundFeedbackBinding: Binding { + Binding( + get: { model.settings.beepOnStart }, + set: { model.setSoundFeedbackEnabled($0) } + ) + } - private var soundVolumeBinding: Binding { - Binding( - get: { Double(model.settings.soundVolume) }, - set: { model.setSoundVolume(Float($0)) } - ) - } + private var soundVolumeBinding: Binding { + Binding( + get: { Double(model.settings.soundVolume) }, + set: { model.setSoundVolume(Float($0)) } + ) + } - private var inputDeviceAccessibilityValue: String { - model.settings.audioInputDevice ?? "System default" - } + private var inputDeviceAccessibilityValue: String { + model.settings.audioInputDevice ?? "System default" + } - private func statusRow(color: Color, title: String, detail: String) -> some View { - HStack(alignment: .top, spacing: 9) { - Circle().fill(color).frame(width: 7, height: 7).padding(.top, 4) - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text(detail) - .font(CSFont.ui(11.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - } - Spacer(minLength: 0) - } - .padding(12) - .background(CSColor.surfaceRaised(0.03)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + private func statusRow(color: Color, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 9) { + Circle().fill(color).frame(width: 7, height: 7).padding(.top, 4) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text(detail) + .font(CSFont.ui(11.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + } + Spacer(minLength: 0) } + .padding(12) + .background(CSColor.surfaceRaised(0.03)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + } - private func statusColor(_ tone: AudioInputDisplayTone) -> Color { - switch tone { - case .healthy: return CSColor.oliveLight - case .fallback: return CSColor.amber - case .unavailable: return CSColor.terracottaLight - } + private func statusColor(_ tone: AudioInputDisplayTone) -> Color { + switch tone { + case .healthy: return CSColor.oliveLight + case .fallback: return CSColor.amber + case .unavailable: return CSColor.terracottaLight } + } - private var card: some ShapeStyle { - CSColor.surfaceRaised(0.025) - } + private var card: some ShapeStyle { + CSColor.surfaceRaised(0.025) + } - private var cardBorder: some View { - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - } + private var cardBorder: some View { + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + } } #if DEBUG -#Preview("Settings — Audio") { + #Preview("Settings — Audio") { SettingsView(model: SettingsViewModel.preview(.audio)) - .frame(width: 960, height: 720) -} + .frame(width: 960, height: 720) + } #endif diff --git a/macos/Codescribe/Screens/Settings/CloudPrivacyCopy.swift b/macos/Codescribe/Screens/Settings/CloudPrivacyCopy.swift new file mode 100644 index 00000000..3597a84f --- /dev/null +++ b/macos/Codescribe/Screens/Settings/CloudPrivacyCopy.swift @@ -0,0 +1,49 @@ +import Foundation + +// Single source of truth for the cloud-transcription privacy copy (C2). +// The Rust core enforces the contract these strings describe +// (core/config/cloud_asr.rs + core/asr_session/consent.rs): cloud requires an +// explicit audio-egress consent record, every refusal resolves to Apple + +// dictionary, and no consent fallback may load local weights. Tests pin the +// copy to that contract so UI text cannot drift from the enforced behavior. +enum CloudPrivacyCopy { + /// Section title in the Dictation settings panel. + static let title = "Cloud & privacy" + + /// Where audio lives by default, and the one condition under which it moves. + static let intro = + "Codescribe transcribes on this Mac. Audio leaves this Mac only in Cloud mode, " + + "and only after you explicitly allow it." + + /// The safe floor every failure resolves to. + static let modeAppleOnly = + "Apple only — on-device Apple Speech plus your dictionary. " + + "The default, and the mode every failure falls back to." + + /// The consent-gated cloud lane. Provider-neutral by contract: the app + /// talks to the Libraxis gateway and never stores a vendor key. + static let modeCloud = + "Cloud — live refinement through the Libraxis gateway. " + + "Requires your explicit consent to send audio off this Mac; " + + "the app stores no vendor keys." + + /// The power-user local lane: separate killable process, opt-in weights. + static let modeLocalPower = + "Local power — optional on-device model weights in a separate helper process, " + + "downloaded only when you choose them." + + /// What happens when consent is missing or declined. + static let consentFallback = + "Without your consent, Cloud never arms: dictation continues with Apple " + + "plus your dictionary, and no local model is loaded in its place." + + /// The telemetry bound: counters and identifiers, never content. + static let telemetry = + "Cloud diagnostics are counters only — latency, bytes, error and model identifiers. " + + "Never audio, never transcript text." + + /// Render order for the settings section. + static let lines: [String] = [ + intro, modeAppleOnly, modeCloud, modeLocalPower, consentFallback, telemetry, + ] +} diff --git a/macos/Codescribe/Screens/Settings/CreatorPanel.swift b/macos/Codescribe/Screens/Settings/CreatorPanel.swift index 3fffcf1e..5180a026 100644 --- a/macos/Codescribe/Screens/Settings/CreatorPanel.swift +++ b/macos/Codescribe/Screens/Settings/CreatorPanel.swift @@ -6,326 +6,333 @@ import SwiftUI // Permission rows reflect LIVE AVAuthorization / AX / IOHID / CG status. struct CreatorPanel: View { - @ObservedObject var model: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · Creator") - Text("Get set up.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) - - SettingsSectionLabel("Permission checklist") - .padding(.top, 22) - VStack(spacing: 8) { - ForEach([ - PermissionKind.microphone, - .accessibility, - .inputMonitoring, - .screenRecording, - .speechRecognition, - ]) { kind in - PermissionChecklistRow( - kind: kind, - state: model.permissions.state(kind), - onStateChanged: { model.refresh() } - ) - } - } - .padding(.top, 11) - - SettingsSectionLabel("Voice & formatting") - .padding(.top, 24) - VStack(spacing: 8) { - LanguageIdentityRow(selection: languageBinding) - SettingsControlRow(title: "AI formatting", - subtitle: "Compatibility gate; Off below always bypasses the LLM") { - Toggle("", isOn: formattingEnabledBinding) - .toggleStyle(.switch) - .labelsHidden() - .tint(CSColor.chromeAccent) - } - SettingsControlRow(title: "Auto Format", - subtitle: "Correction only, balanced editing, or maximum polish") { - Picker("", selection: formattingLevelBinding) { - ForEach(FormattingPolicyOption.allCases) { policy in - Text(policy.visibleName).tag(policy.rawValue) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 330) - .disabled(!model.settings.aiFormattingEnabled) - } - } - .padding(.top, 11) - - SettingsSectionLabel("Quick start") - .padding(.top, 24) - HStack(spacing: 10) { - QuickStartCard( - icon: .mic, - title: "Test mic", - subtitle: "Check levels & engine", - accessibilityId: "settings-quickstart-test-mic" - ) { model.performQuickStart(.testMic) } - QuickStartCard( - icon: .overlay, - title: "Open overlay", - subtitle: "Start a dictation session", - accessibilityId: "settings-quickstart-open-overlay" - ) { model.performQuickStart(.openOverlay) } - QuickStartCard( - icon: .shortcuts, - title: "Tune shortcuts", - subtitle: "Hotkeys & cadence", - accessibilityId: "settings-quickstart-tune-shortcuts" - ) { model.performQuickStart(.tuneShortcuts) } + @ObservedObject var model: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + EyebrowLabel(text: "Settings · Creator") + Text("Get set up.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) + + SettingsSectionLabel("Permission checklist") + .padding(.top, 22) + VStack(spacing: 8) { + ForEach([ + PermissionKind.microphone, + .accessibility, + .inputMonitoring, + .screenRecording, + .speechRecognition, + ]) { kind in + PermissionChecklistRow( + kind: kind, + state: model.permissions.state(kind), + onStateChanged: { model.refresh() } + ) + } + } + .padding(.top, 11) + + SettingsSectionLabel("Voice & formatting") + .padding(.top, 24) + VStack(spacing: 8) { + LanguageIdentityRow(selection: languageBinding) + SettingsControlRow( + title: "AI formatting", + subtitle: "Compatibility gate; Off below always bypasses the LLM" + ) { + Toggle("", isOn: formattingEnabledBinding) + .toggleStyle(.switch) + .labelsHidden() + .tint(CSColor.chromeAccent) + } + SettingsControlRow( + title: "Auto Format", + subtitle: "Correction only, balanced editing, or maximum polish" + ) { + Picker("", selection: formattingLevelBinding) { + ForEach(FormattingPolicyOption.allCases) { policy in + Text(policy.visibleName).tag(policy.rawValue) } - .padding(.top, 11) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 330) + .disabled(!model.settings.aiFormattingEnabled) } - .padding(.horizontal, 28) - .padding(.vertical, 24) - } - - // MARK: - Bindings (read VM state, write through the router) - - private var languageBinding: Binding { - Binding(get: { model.settings.whisperLanguage }, - set: { model.setLanguage($0) }) - } - - private var formattingEnabledBinding: Binding { - Binding(get: { model.settings.aiFormattingEnabled }, - set: { model.setFormattingEnabled($0) }) - } - - private var formattingLevelBinding: Binding { - Binding(get: { - FormattingPolicyOption(storedValue: model.settings.formattingLevel)?.rawValue - ?? FormattingPolicyOption.correction.rawValue - }, - set: { model.setFormattingLevel($0) }) + } + .padding(.top, 11) + + SettingsSectionLabel("Quick start") + .padding(.top, 24) + HStack(spacing: 10) { + QuickStartCard( + icon: .mic, + title: "Test mic", + subtitle: "Check levels & engine", + accessibilityId: "settings-quickstart-test-mic" + ) { model.performQuickStart(.testMic) } + QuickStartCard( + icon: .overlay, + title: "Open overlay", + subtitle: "Start a dictation session", + accessibilityId: "settings-quickstart-open-overlay" + ) { model.performQuickStart(.openOverlay) } + QuickStartCard( + icon: .shortcuts, + title: "Tune shortcuts", + subtitle: "Hotkeys & cadence", + accessibilityId: "settings-quickstart-tune-shortcuts" + ) { model.performQuickStart(.tuneShortcuts) } + } + .padding(.top, 11) } + .padding(.horizontal, 28) + .padding(.vertical, 24) + } + + // MARK: - Bindings (read VM state, write through the router) + + private var languageBinding: Binding { + Binding( + get: { model.settings.whisperLanguage }, + set: { model.setLanguage($0) }) + } + + private var formattingEnabledBinding: Binding { + Binding( + get: { model.settings.aiFormattingEnabled }, + set: { model.setFormattingEnabled($0) }) + } + + private var formattingLevelBinding: Binding { + Binding( + get: { + FormattingPolicyOption(storedValue: model.settings.formattingLevel)?.rawValue + ?? FormattingPolicyOption.correction.rawValue + }, + set: { model.setFormattingLevel($0) }) + } } // MARK: - Language identity struct LanguageIdentityPresentation: Identifiable, Equatable { - let language: CsLanguage - let title: String - let isFineTuned: Bool + let language: CsLanguage + let title: String + let isFineTuned: Bool - var id: String { language.shortCode } + var id: String { language.shortCode } - var accessibilityLabel: String { - isFineTuned ? "\(title), Fine-tuned" : title - } + var accessibilityLabel: String { + isFineTuned ? "\(title), Fine-tuned" : title + } - func accessibilityValue(isSelected: Bool) -> String { - isSelected ? "Selected" : "Not selected" - } + func accessibilityValue(isSelected: Bool) -> String { + isSelected ? "Selected" : "Not selected" + } - static let supportingCopy = - "Programming vocabulary and your \(SettingsSection.voiceLab.title) entries enrich the selected language." + static let supportingCopy = + "Programming vocabulary and your \(SettingsSection.voiceLab.title) entries enrich the selected language." - static let choices: [LanguageIdentityPresentation] = [ - .init(language: .auto, title: "Multilingual", isFineTuned: false), - .init(language: .polish, title: "Polish", isFineTuned: true), - .init(language: .english, title: "English", isFineTuned: true), - ] + static let choices: [LanguageIdentityPresentation] = [ + .init(language: .auto, title: "Multilingual", isFineTuned: false), + .init(language: .polish, title: "Polish", isFineTuned: true), + .init(language: .english, title: "English", isFineTuned: true), + ] } private struct LanguageIdentityRow: View { - @Binding var selection: CsLanguage - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text("Whisper language") - .font(CSFont.ui(13.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text("Choose automatic detection or a language-specialized path") - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.textMutedAlt) - } - - LanguageIdentityPicker(selection: $selection) - - Text(LanguageIdentityPresentation.supportingCopy) - .font(CSFont.ui(10.5)) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 15) - .padding(.vertical, 12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.surfaceRaised(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) + @Binding var selection: CsLanguage + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("Whisper language") + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text("Choose automatic detection or a language-specialized path") + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) + } + + LanguageIdentityPicker(selection: $selection) + + Text(LanguageIdentityPresentation.supportingCopy) + .font(CSFont.ui(10.5)) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) } + .padding(.horizontal, 15) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.surfaceRaised(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } } private struct LanguageIdentityPicker: View { - @Binding var selection: CsLanguage - - var body: some View { - HStack(spacing: 5) { - ForEach(LanguageIdentityPresentation.choices) { choice in - let isSelected = selection == choice.language - Button { - selection = choice.language - } label: { - VStack(spacing: 3) { - Text(choice.title) - .font(CSFont.ui(11.5, .semibold)) - .lineLimit(1) - if choice.isFineTuned { - Text("Fine-tuned") - .font(CSFont.ui(8.5, .semibold)) - .padding(.horizontal, 5) - .padding(.vertical, 1.5) - .background( - Capsule().fill(CSColor.chromeAccent.opacity(0.16)) - ) - } else { - Text("Automatic detection") - .font(CSFont.ui(8.5, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - } - } - .foregroundStyle(isSelected ? CSColor.textHigh : CSColor.textBody) - .frame(maxWidth: .infinity, minHeight: 43) - .padding(.horizontal, 5) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isSelected ? CSColor.chromeAccent.opacity(0.12) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder( - isSelected ? CSColor.chromeAccent.opacity(0.5) : CSColor.hairline(0.07), - lineWidth: 1 - ) - ) - } - .csFocusRing(cornerRadius: 8) - .accessibilityLabel(choice.accessibilityLabel) - .accessibilityValue(choice.accessibilityValue(isSelected: isSelected)) - .accessibilityAddTraits(isSelected ? [.isSelected] : []) + @Binding var selection: CsLanguage + + var body: some View { + HStack(spacing: 5) { + ForEach(LanguageIdentityPresentation.choices) { choice in + let isSelected = selection == choice.language + Button { + selection = choice.language + } label: { + VStack(spacing: 3) { + Text(choice.title) + .font(CSFont.ui(11.5, .semibold)) + .lineLimit(1) + if choice.isFineTuned { + Text("Fine-tuned") + .font(CSFont.ui(8.5, .semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1.5) + .background( + Capsule().fill(CSColor.chromeAccent.opacity(0.16)) + ) + } else { + Text("Automatic detection") + .font(CSFont.ui(8.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) } + } + .foregroundStyle(isSelected ? CSColor.textHigh : CSColor.textBody) + .frame(maxWidth: .infinity, minHeight: 43) + .padding(.horizontal, 5) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isSelected ? CSColor.chromeAccent.opacity(0.12) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder( + isSelected ? CSColor.chromeAccent.opacity(0.5) : CSColor.hairline(0.07), + lineWidth: 1 + ) + ) } - .frame(maxWidth: 460) - .accessibilityElement(children: .contain) - .accessibilityLabel("Whisper language") + .csFocusRing(cornerRadius: 8) + .accessibilityLabel(choice.accessibilityLabel) + .accessibilityValue(choice.accessibilityValue(isSelected: isSelected)) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } } + .frame(maxWidth: 460) + .accessibilityElement(children: .contain) + .accessibilityLabel("Whisper language") + } } // MARK: - Labeled control row (shared shape for the editable settings rows) struct SettingsControlRow: View { - let title: String - let subtitle: String - @ViewBuilder var control: () -> Control - - var body: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(CSFont.ui(13.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text(subtitle) - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.textMutedAlt) - } - .frame(maxWidth: .infinity, alignment: .leading) - control() - } - .padding(.horizontal, 15) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.surfaceRaised(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) + let title: String + let subtitle: String + @ViewBuilder var control: () -> Control + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text(subtitle) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) + } + .frame(maxWidth: .infinity, alignment: .leading) + control() } + .padding(.horizontal, 15) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.surfaceRaised(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } } // MARK: - Permission checklist row private struct PermissionChecklistRow: View { - let kind: PermissionKind - let state: PermissionState - /// Re-probe after an in-app request (Speech Recognition dialog). - var onStateChanged: (() -> Void)? = nil - - private var granted: Bool { state.isGranted } - - var body: some View { - HStack(spacing: 12) { - statusBadge - Text(kind.rawValue) - .font(CSFont.ui(13.5, .medium)) - .foregroundStyle(CSColor.textBody) - .frame(maxWidth: .infinity, alignment: .leading) - if granted { - Text("granted") - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.oliveLight) - } else { - Button { - if state == .notDetermined, kind.supportsInAppPermissionRequest { - kind.requestInApp { _ in onStateChanged?() } - } else { - kind.openSystemSettings() - } - } label: { - Text( - state == .notDetermined && kind.supportsInAppPermissionRequest - ? "allow \(kind.rawValue)" - : "open System Settings" - ) - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.terracottaLight) - } - .csFocusRing(cornerRadius: 8) - } + let kind: PermissionKind + let state: PermissionState + /// Re-probe after an in-app request (Speech Recognition dialog). + var onStateChanged: (() -> Void)? = nil + + private var granted: Bool { state.isGranted } + + var body: some View { + HStack(spacing: 12) { + statusBadge + Text(kind.rawValue) + .font(CSFont.ui(13.5, .medium)) + .foregroundStyle(CSColor.textBody) + .frame(maxWidth: .infinity, alignment: .leading) + if granted { + Text("granted") + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.oliveLight) + } else { + Button { + if state == .notDetermined, kind.supportsInAppPermissionRequest { + kind.requestInApp { _ in onStateChanged?() } + } else { + kind.openSystemSettings() + } + } label: { + Text( + state == .notDetermined && kind.supportsInAppPermissionRequest + ? "allow \(kind.rawValue)" + : "open System Settings" + ) + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.terracottaLight) } - .padding(.horizontal, 15) - .padding(.vertical, 13) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill((granted ? CSColor.olive : CSColor.terracotta).opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder((granted ? CSColor.olive : CSColor.terracotta).opacity(0.22), lineWidth: 1) - ) + .csFocusRing(cornerRadius: 8) + } } - - @ViewBuilder - private var statusBadge: some View { - ZStack { - Circle().fill((granted ? CSColor.olive : CSColor.terracotta).opacity(0.2)) - CSIconView( - icon: granted ? .success : .warning, - size: 11, - weight: .semibold, - color: granted ? CSColor.oliveLight : CSColor.terracottaLight - ) - } - .frame(width: 20, height: 20) + .padding(.horizontal, 15) + .padding(.vertical, 13) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill((granted ? CSColor.olive : CSColor.terracotta).opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder((granted ? CSColor.olive : CSColor.terracotta).opacity(0.22), lineWidth: 1) + ) + } + + @ViewBuilder + private var statusBadge: some View { + ZStack { + Circle().fill((granted ? CSColor.olive : CSColor.terracotta).opacity(0.2)) + CSIconView( + icon: granted ? .success : .warning, + size: 11, + weight: .semibold, + color: granted ? CSColor.oliveLight : CSColor.terracottaLight + ) } + .frame(width: 20, height: 20) + } } // MARK: - Quick start card @@ -335,55 +342,55 @@ private struct PermissionChecklistRow: View { /// UI_DIVERGENCE_AUDIT pkt 4 called the previous inert tiles out as fake UX, /// and the duplicate "Launchpads" decoration row below them was removed with it. private struct QuickStartCard: View { - let icon: CSIcon - let title: String - let subtitle: String - let accessibilityId: String - let action: () -> Void - - @State private var hovered = false - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 0) { - CSIconView(icon: icon, size: 16, color: CSColor.textHigh) - Text(title) - .font(CSFont.ui(13, .semibold)) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 9) - Text(subtitle) - .font(CSFont.ui(11.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 3) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .fill(CSColor.surfaceRaised(hovered ? 0.05 : 0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(hovered ? 0.14 : 0.07), lineWidth: 1) - ) - .contentShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) - } - .csFocusRing(cornerRadius: CSRadius.card) - .onHover { hovered = $0 } - .accessibilityLabel(title) - .accessibilityHint(subtitle) - .accessibilityIdentifier(accessibilityId) + let icon: CSIcon + let title: String + let subtitle: String + let accessibilityId: String + let action: () -> Void + + @State private var hovered = false + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 0) { + CSIconView(icon: icon, size: 16, color: CSColor.textHigh) + Text(title) + .font(CSFont.ui(13, .semibold)) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 9) + Text(subtitle) + .font(CSFont.ui(11.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 3) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .fill(CSColor.surfaceRaised(hovered ? 0.05 : 0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(hovered ? 0.14 : 0.07), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous)) } + .csFocusRing(cornerRadius: CSRadius.card) + .onHover { hovered = $0 } + .accessibilityLabel(title) + .accessibilityHint(subtitle) + .accessibilityIdentifier(accessibilityId) + } } #if DEBUG -#Preview("Creator panel") { + #Preview("Creator panel") { ScrollView { CreatorPanel(model: .preview) } - .frame(width: 720, height: 620) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 620) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/EnginePanel.swift b/macos/Codescribe/Screens/Settings/EnginePanel.swift index b99f814d..552160bc 100644 --- a/macos/Codescribe/Screens/Settings/EnginePanel.swift +++ b/macos/Codescribe/Screens/Settings/EnginePanel.swift @@ -7,550 +7,592 @@ import SwiftUI // window — all persisted through the promoted-key config router. struct EnginePanel: View { - @ObservedObject var model: SettingsViewModel - @State private var advancedTimingExpanded = false - /// Secondary blocks start collapsed — cold open shows runtime + engine only. - @State private var whisperModelExpanded = false - @State private var previewTimingExpanded = false - @State private var silenceExpanded = false - @State private var permissionsExpanded = false - - private let matrixOrder: [PermissionKind] = [ - .microphone, .accessibility, .inputMonitoring, .screenRecording, - .speechRecognition - ] - private let columns = [ - GridItem(.flexible(), spacing: 8), - GridItem(.flexible(), spacing: 8) - ] - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { - EyebrowLabel(text: "Settings · \(SettingsSection.engine.title)") - Text("RUNTIME TRUTH · READ-ONLY ROWS") - .font(CSFont.mono(9, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - - Text("What's actually running.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) - - runtimeRows - .padding(.top, 20) - - SettingsSectionLabel("Engine controls") - .padding(.top, 22) - engineControls - .padding(.top, 11) - - collapsibleSection( - title: "Local Whisper model", - isExpanded: $whisperModelExpanded - ) { - whisperDownloadSection - } - - collapsibleSection( - title: "Preview timing", - isExpanded: $previewTimingExpanded - ) { - previewTimingSection - } - - collapsibleSection( - title: "Hands-free silence", - isExpanded: $silenceExpanded - ) { - silenceSection - } - - collapsibleSection( - title: "Permission matrix", - isExpanded: $permissionsExpanded - ) { - LazyVGrid(columns: columns, spacing: 8) { - ForEach(matrixOrder) { kind in - PermissionMatrixCell( - kind: kind, - state: model.permissions.state(kind), - onStateChanged: { model.refresh() } - ) - } - } - } - - HStack(spacing: 8) { - Text("●").font(CSFont.mono(11, .medium)).foregroundStyle(CSColor.olive) - Text("runtime rows reflect the live engine — changes apply on the next recording session") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - } - .padding(.top, 16) - } - .padding(.horizontal, 28) - .padding(.vertical, 24) - } - - /// Section chrome: label is the disclosure chevron host; body mounts only when open. - private func collapsibleSection( - title: String, - isExpanded: Binding, - @ViewBuilder content: @escaping () -> Content - ) -> some View { - DisclosureGroup(isExpanded: isExpanded) { - content() - .padding(.top, 11) - } label: { - SettingsSectionLabel(title) - } - .tint(CSColor.chromeAccent) + @ObservedObject var model: SettingsViewModel + @State private var advancedTimingExpanded = false + /// Secondary blocks start collapsed — cold open shows runtime + engine only. + @State private var whisperModelExpanded = false + @State private var previewTimingExpanded = false + @State private var silenceExpanded = false + @State private var permissionsExpanded = false + @State private var cloudPrivacyExpanded = false + + private let matrixOrder: [PermissionKind] = [ + .microphone, .accessibility, .inputMonitoring, .screenRecording, + .speechRecognition, + ] + private let columns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + EyebrowLabel(text: "Settings · \(SettingsSection.engine.title)") + Text("RUNTIME TRUTH · READ-ONLY ROWS") + .font(CSFont.mono(9, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + + Text("What's actually running.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) + + runtimeRows + .padding(.top, 20) + + SettingsSectionLabel("Engine controls") .padding(.top, 22) - } - - // MARK: Runtime key/value rows (STT truth only — LLM truth lives in Providers) - - private var runtimeRows: some View { - VStack(spacing: 0) { - RuntimeRow(key: "Active STT", value: model.activeSTT, - tint: true, trailing: .dot(model.sttHealthy ? CSColor.oliveLight : CSColor.amber)) - divider - RuntimeRow(key: "STT model", value: model.sttModelDescription, - tint: false, mono: true, trailing: .none) - divider - RuntimeRow(key: "Whisper language", value: model.whisperLanguageCode, - tint: true, mono: true, trailing: .none) + engineControls + .padding(.top, 11) + + collapsibleSection( + title: "Local Whisper model", + isExpanded: $whisperModelExpanded + ) { + whisperDownloadSection + } + + collapsibleSection( + title: "Preview timing", + isExpanded: $previewTimingExpanded + ) { + previewTimingSection + } + + collapsibleSection( + title: "Hands-free silence", + isExpanded: $silenceExpanded + ) { + silenceSection + } + + collapsibleSection( + title: CloudPrivacyCopy.title, + isExpanded: $cloudPrivacyExpanded + ) { + cloudPrivacySection + } + + collapsibleSection( + title: "Permission matrix", + isExpanded: $permissionsExpanded + ) { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(matrixOrder) { kind in + PermissionMatrixCell( + kind: kind, + state: model.permissions.state(kind), + onStateChanged: { model.refresh() } + ) + } } - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) + } + + HStack(spacing: 8) { + Text("●").font(CSFont.mono(11, .medium)).foregroundStyle(CSColor.olive) + Text("runtime rows reflect the live engine — changes apply on the next recording session") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + } + .padding(.top, 16) } - - private var divider: some View { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + .padding(.horizontal, 28) + .padding(.vertical, 24) + } + + /// Section chrome: label is the disclosure chevron host; body mounts only when open. + private func collapsibleSection( + title: String, + isExpanded: Binding, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + DisclosureGroup(isExpanded: isExpanded) { + content() + .padding(.top, 11) + } label: { + SettingsSectionLabel(title) } - - // MARK: Engine controls (editable — F1 layered transcription) - - /// Selectable engines. "onnx" is deliberately NOT exposed (experimental, - /// frozen); "auto" defers to the core policy (Apple live when available). - private static let sttEngineOptions: [(id: String, label: String)] = [ - ("auto", "Auto"), - ("apple", "Apple (live)"), - ("whisper", "Whisper (Candle)"), - ] - - private static let finalPassModeOptions: [(id: String, label: String)] = [ - ("always", "Always"), - ("smart", "Smart"), - ("off", "Off"), - ] - - private var layeredBinding: Binding { - Binding(get: { model.layeredTranscriptionEnabled }, - set: { model.setLayeredTranscription($0) }) + .tint(CSColor.chromeAccent) + .padding(.top, 22) + } + + // MARK: Runtime key/value rows (STT truth only — LLM truth lives in Providers) + + private var runtimeRows: some View { + VStack(spacing: 0) { + RuntimeRow( + key: "Active STT", value: model.activeSTT, + tint: true, trailing: .dot(model.sttHealthy ? CSColor.oliveLight : CSColor.amber)) + divider + RuntimeRow( + key: "STT model", value: model.sttModelDescription, + tint: false, mono: true, trailing: .none) + divider + RuntimeRow( + key: "Whisper language", value: model.whisperLanguageCode, + tint: true, mono: true, trailing: .none) } - - private var engineControls: some View { - VStack(spacing: 8) { - // Preferred live engine is Apple (must-have). Whisper is final-pass / - // recovery / offline — not the silent dual-brain lottery. - if let note = model.sttEngineTruthNote { - Text(note) - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.amber) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 2) - } - SettingsControlRow(title: "STT engine", - subtitle: "Apple = live speech (product default). Whisper = final pass / offline.") { - Menu { - ForEach(Self.sttEngineOptions, id: \.id) { option in - Button { - model.setSttEngine(option.id) - } label: { - if option.id == model.sttEngineId { - Label(option.label, systemImage: "checkmark") - } else { - Text(option.label) - } - } - } - } label: { - SettingsMenuLabel(text: model.sttEngineLabel) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - } - SettingsControlRow(title: "Final pass", - subtitle: "Always = full re-pass; Smart = skip full re-pass when complete (pair with Layered for live tail-patch); Off = off. Dictionary always applies.") { - Menu { - ForEach(Self.finalPassModeOptions, id: \.id) { option in - Button { - model.setFinalPassMode(option.id) - } label: { - if option.id == model.finalPassModeId { - Label(option.label, systemImage: "checkmark") - } else { - Text(option.label) - } - } - } - } label: { - SettingsMenuLabel(text: model.finalPassModeLabel) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - } - SettingsControlRow(title: "Layered transcription", - subtitle: "Experimental: Apple live layer + Whisper tail patches") { - Toggle("", isOn: layeredBinding) - .toggleStyle(.switch) - .labelsHidden() - .tint(CSColor.chromeAccent) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } + + private var divider: some View { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + } + + // MARK: Engine controls (editable — F1 layered transcription) + + /// Selectable engines. "onnx" is deliberately NOT exposed (experimental, + /// frozen); "auto" defers to the core policy (Apple live when available). + private static let sttEngineOptions: [(id: String, label: String)] = [ + ("auto", "Auto"), + ("apple", "Apple (live)"), + ("whisper", "Whisper (Candle)"), + ] + + private static let finalPassModeOptions: [(id: String, label: String)] = [ + ("always", "Always"), + ("smart", "Smart"), + ("off", "Off"), + ] + + private var layeredBinding: Binding { + Binding( + get: { model.layeredTranscriptionEnabled }, + set: { model.setLayeredTranscription($0) }) + } + + private var engineControls: some View { + VStack(spacing: 8) { + // Preferred live engine is Apple (must-have). Whisper is final-pass / + // recovery / offline — not the silent dual-brain lottery. + if let note = model.sttEngineTruthNote { + Text(note) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.amber) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 2) + } + SettingsControlRow( + title: "STT engine", + subtitle: "Apple = live speech (product default). Whisper = final pass / offline." + ) { + Menu { + ForEach(Self.sttEngineOptions, id: \.id) { option in + Button { + model.setSttEngine(option.id) + } label: { + if option.id == model.sttEngineId { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) + } } + } + } label: { + SettingsMenuLabel(text: model.sttEngineLabel) } - } - - // MARK: Local Whisper download (public DMG is slim — model is opt-in) - - private var whisperDownloadSection: some View { - let status = model.localWhisperStatus - return VStack(alignment: .leading, spacing: 10) { - SettingsControlRow( - title: "Install state", - subtitle: whisperInstallSubtitle(status) - ) { - Text(whisperInstallLabel(status)) - .font(CSFont.mono(11, .medium)) - .foregroundStyle(status.available ? CSColor.oliveLight : CSColor.amber) - } - - if model.whisperDownloadInFlight { - VStack(alignment: .leading, spacing: 6) { - if let fraction = model.whisperDownloadFraction { - ProgressView(value: fraction) - .progressViewStyle(.linear) - .tint(CSColor.chromeAccent) - } else { - ProgressView() - .controlSize(.small) - } - Text(model.whisperDownloadDetail ?? "Downloading…") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(2) - } - } else if !status.available { - SettingsControlRow( - title: "Download Whisper", - subtitle: "Optional local Candle model (\(status.sizeHint)). Apple STT works without it." - ) { - Button("Download") { - model.startWhisperDownload() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .tint(CSColor.chromeAccent) - } - } else if !status.embedded { - // On-disk / cache — offer re-check, not re-download spam. - SettingsControlRow( - title: "Local path", - subtitle: status.path ?? status.modelId - ) { - Button("Recheck") { - model.refreshWhisperModelStatus() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } else { - Text("This build embeds Whisper (fat SKU). Runtime download is not required.") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaint) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + SettingsControlRow( + title: "Final pass", + subtitle: + "Always = full re-pass; Smart = skip full re-pass when complete (pair with Layered for live tail-patch); Off = off. Dictionary always applies." + ) { + Menu { + ForEach(Self.finalPassModeOptions, id: \.id) { option in + Button { + model.setFinalPassMode(option.id) + } label: { + if option.id == model.finalPassModeId { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) + } } + } + } label: { + SettingsMenuLabel(text: model.finalPassModeLabel) } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + SettingsControlRow( + title: "Layered transcription", + subtitle: "Apple live canvas + Whisper tail patches (on by default)" + ) { + Toggle("", isOn: layeredBinding) + .toggleStyle(.switch) + .labelsHidden() + .tint(CSColor.chromeAccent) + } } - - private func whisperInstallLabel(_ status: CsWhisperModelStatus) -> String { - if status.embedded { return "Embedded" } - if status.available { return "Installed" } - return "Not installed" - } - - private func whisperInstallSubtitle(_ status: CsWhisperModelStatus) -> String { - if status.embedded { - return "Baked into this fat build · \(status.modelId)" + } + + // MARK: Local Whisper download (public DMG is slim — model is opt-in) + + private var whisperDownloadSection: some View { + let status = model.localWhisperStatus + return VStack(alignment: .leading, spacing: 10) { + SettingsControlRow( + title: "Install state", + subtitle: whisperInstallSubtitle(status) + ) { + Text(whisperInstallLabel(status)) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(status.available ? CSColor.oliveLight : CSColor.amber) + } + + if model.whisperDownloadInFlight { + VStack(alignment: .leading, spacing: 6) { + if let fraction = model.whisperDownloadFraction { + ProgressView(value: fraction) + .progressViewStyle(.linear) + .tint(CSColor.chromeAccent) + } else { + ProgressView() + .controlSize(.small) + } + Text(model.whisperDownloadDetail ?? "Downloading…") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(2) } - if status.available { - return "Ready for Whisper engine · \(status.modelId)" + } else if !status.available { + SettingsControlRow( + title: "Download Whisper", + subtitle: "Optional local Candle model (\(status.sizeHint)). Apple STT works without it." + ) { + Button("Download") { + model.startWhisperDownload() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(CSColor.chromeAccent) } - return "Needed only when STT engine is Whisper or layered tail patches" - } - - // MARK: Preview timing (overlay pacing — writes the existing promoted keys) - - private var previewTimingSection: some View { - VStack(alignment: .leading, spacing: 10) { - Picker("Preview timing preset", selection: presetBinding) { - ForEach(PreviewTimingPreset.allCases) { preset in - Text(preset.rawValue).tag(preset) - } - } - .pickerStyle(.segmented) - .labelsHidden() - - Text(previewSummary) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaint) - - DisclosureGroup(isExpanded: $advancedTimingExpanded) { - VStack(spacing: 8) { - timingSlider( - title: "Buffer delay", - value: bufferDelayBinding, - range: 0 ... 1500, - step: 1, - valueLabel: "\(model.previewTimingConfiguration.values.bufferDelayMs) ms" - ) - timingSlider( - title: "Typing speed", - value: typingCpsBinding, - range: 5 ... 180, - step: 0.1, - valueLabel: String( - format: "%.1f cps", - model.previewTimingConfiguration.values.typingCps - ) - ) - timingSlider( - title: "Words per tick", - value: emitWordsBinding, - range: 1 ... 10, - step: 1, - valueLabel: "\(model.previewTimingConfiguration.values.emitWordsMax)" - ) - timingSlider( - title: "Interim cadence", - value: interimBinding, - range: 1 ... 30, - step: 0.1, - valueLabel: String( - format: "%.1f s", - model.previewTimingConfiguration.values.interimSeconds - ) - ) - } - .padding(.top, 10) - } label: { - Text("Advanced") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - } - .tint(CSColor.chromeAccent) + } else if !status.embedded { + // On-disk / cache — offer re-check, not re-download spam. + SettingsControlRow( + title: "Local path", + subtitle: status.path ?? status.modelId + ) { + Button("Recheck") { + model.refreshWhisperModelStatus() + } + .buttonStyle(.bordered) + .controlSize(.small) } - .padding(15) - .background(card) - .overlay(cardBorder) - } - - private var presetBinding: Binding { - Binding( - get: { model.previewTimingPreset }, - set: { preset in - if preset == .custom { - advancedTimingExpanded = true - } else { - advancedTimingExpanded = false - model.applyPreviewTimingPreset(preset) - } - } - ) + } else { + Text("This build embeds Whisper (fat SKU). Runtime download is not required.") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) + } } + } - private var bufferDelayBinding: Binding { - Binding( - get: { Double(model.previewTimingConfiguration.values.bufferDelayMs) }, - set: { model.setPreviewBufferDelayMs(UInt64($0.rounded())) } - ) - } + private func whisperInstallLabel(_ status: CsWhisperModelStatus) -> String { + if status.embedded { return "Embedded" } + if status.available { return "Installed" } + return "Not installed" + } - private var typingCpsBinding: Binding { - Binding( - get: { Double(model.previewTimingConfiguration.values.typingCps) }, - set: { model.setPreviewTypingCps(Float($0)) } - ) + private func whisperInstallSubtitle(_ status: CsWhisperModelStatus) -> String { + if status.embedded { + return "Baked into this fat build · \(status.modelId)" } - - private var emitWordsBinding: Binding { - Binding( - get: { Double(model.previewTimingConfiguration.values.emitWordsMax) }, - set: { model.setPreviewEmitWordsMax(UInt64($0.rounded())) } - ) + if status.available { + return "Ready for Whisper engine · \(status.modelId)" } + return "Needed only when STT engine is Whisper or layered tail patches" + } - private var interimBinding: Binding { - Binding( - get: { Double(model.previewTimingConfiguration.values.interimSeconds) }, - set: { model.setPreviewInterimSeconds(Float($0)) } - ) - } + // MARK: Preview timing (overlay pacing — writes the existing promoted keys) - private var previewSummary: String { - guard model.previewTimingConfiguration.overlayEnabled else { - return "Preview off · committed transcripts are unchanged" + private var previewTimingSection: some View { + VStack(alignment: .leading, spacing: 10) { + Picker("Preview timing preset", selection: presetBinding) { + ForEach(PreviewTimingPreset.allCases) { preset in + Text(preset.rawValue).tag(preset) } - let values = model.previewTimingConfiguration.values - return String( - format: "%llu ms · %.1f cps · %llu words · %.1f s interim", - values.bufferDelayMs, - values.typingCps, - values.emitWordsMax, - values.interimSeconds - ) - } + } + .pickerStyle(.segmented) + .labelsHidden() - private func timingSlider( - title: String, - value: Binding, - range: ClosedRange, - step: Double, - valueLabel: String - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(title) - .font(CSFont.ui(12, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - Spacer(minLength: 0) - Text(valueLabel) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.textBody) - } - Slider(value: value, in: range, step: step) - .tint(CSColor.chromeAccent) + Text(previewSummary) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) + + DisclosureGroup(isExpanded: $advancedTimingExpanded) { + VStack(spacing: 8) { + timingSlider( + title: "Buffer delay", + value: bufferDelayBinding, + range: 0...1500, + step: 1, + valueLabel: "\(model.previewTimingConfiguration.values.bufferDelayMs) ms" + ) + timingSlider( + title: "Typing speed", + value: typingCpsBinding, + range: 5...180, + step: 0.1, + valueLabel: String( + format: "%.1f cps", + model.previewTimingConfiguration.values.typingCps + ) + ) + timingSlider( + title: "Words per tick", + value: emitWordsBinding, + range: 1...10, + step: 1, + valueLabel: "\(model.previewTimingConfiguration.values.emitWordsMax)" + ) + timingSlider( + title: "Interim cadence", + value: interimBinding, + range: 1...30, + step: 0.1, + valueLabel: String( + format: "%.1f s", + model.previewTimingConfiguration.values.interimSeconds + ) + ) } + .padding(.top, 10) + } label: { + Text("Advanced") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + } + .tint(CSColor.chromeAccent) } - - // MARK: Hands-free silence (toggle-mode VAD window — TOGGLE_SILENCE_SEC) - - private var silenceSection: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Hands-free silence") - .font(CSFont.ui(13, .semibold)) - .foregroundStyle(CSColor.textBody) - Text("End a toggle-mode utterance after this much live VAD silence") - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.textMutedAlt) - } - Spacer(minLength: 12) - Text(String(format: "%.1f s", model.settings.toggleSilenceSec)) - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.textBody) - } - Slider(value: silenceBinding, in: 0.5 ... 30, step: 0.5) - .tint(CSColor.chromeAccent) - .accessibilityLabel("Hands-free silence duration") - .accessibilityValue(String(format: "%.1f seconds", model.settings.toggleSilenceSec)) + .padding(15) + .background(card) + .overlay(cardBorder) + } + + private var presetBinding: Binding { + Binding( + get: { model.previewTimingPreset }, + set: { preset in + if preset == .custom { + advancedTimingExpanded = true + } else { + advancedTimingExpanded = false + model.applyPreviewTimingPreset(preset) } - .padding(15) - .background(card) - .overlay(cardBorder) + } + ) + } + + private var bufferDelayBinding: Binding { + Binding( + get: { Double(model.previewTimingConfiguration.values.bufferDelayMs) }, + set: { model.setPreviewBufferDelayMs(UInt64($0.rounded())) } + ) + } + + private var typingCpsBinding: Binding { + Binding( + get: { Double(model.previewTimingConfiguration.values.typingCps) }, + set: { model.setPreviewTypingCps(Float($0)) } + ) + } + + private var emitWordsBinding: Binding { + Binding( + get: { Double(model.previewTimingConfiguration.values.emitWordsMax) }, + set: { model.setPreviewEmitWordsMax(UInt64($0.rounded())) } + ) + } + + private var interimBinding: Binding { + Binding( + get: { Double(model.previewTimingConfiguration.values.interimSeconds) }, + set: { model.setPreviewInterimSeconds(Float($0)) } + ) + } + + private var previewSummary: String { + guard model.previewTimingConfiguration.overlayEnabled else { + return "Preview off · committed transcripts are unchanged" } - - private var silenceBinding: Binding { - Binding( - get: { Double(model.settings.toggleSilenceSec) }, - set: { model.setToggleSilenceSeconds(Float($0)) } - ) + let values = model.previewTimingConfiguration.values + return String( + format: "%llu ms · %.1f cps · %llu words · %.1f s interim", + values.bufferDelayMs, + values.typingCps, + values.emitWordsMax, + values.interimSeconds + ) + } + + private func timingSlider( + title: String, + value: Binding, + range: ClosedRange, + step: Double, + valueLabel: String + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + .font(CSFont.ui(12, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + Spacer(minLength: 0) + Text(valueLabel) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.textBody) + } + Slider(value: value, in: range, step: step) + .tint(CSColor.chromeAccent) } - - private var card: some ShapeStyle { - CSColor.surfaceRaised(0.025) + } + + // MARK: Cloud & privacy (C2 — copy pinned by CloudPrivacyCopyTests; the + // mode picker itself arrives with the recorder integration that consumes + // the resolved mode. Contract enforced in core: cloud requires explicit + // audio-egress consent, refusal = Apple + dictionary, never local weights.) + + private var cloudPrivacySection: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(CloudPrivacyCopy.lines, id: \.self) { line in + Text(line) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } } - - private var cardBorder: some View { - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + .padding(15) + .background(card) + .overlay(cardBorder) + } + + // MARK: Hands-free silence (Apple engine epoch — TOGGLE_SILENCE_SEC) + + private var silenceSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Hands-free silence") + .font(CSFont.ui(13, .semibold)) + .foregroundStyle(CSColor.textBody) + Text( + "Rest the Apple engine after this much silence; the next speech edge wakes a fresh epoch so Whisper can patch the sealed span" + ) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) + } + Spacer(minLength: 12) + Text(String(format: "%.1f s", model.settings.toggleSilenceSec)) + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.textBody) + } + Slider(value: silenceBinding, in: 0.5...30, step: 0.5) + .tint(CSColor.chromeAccent) + .accessibilityLabel("Hands-free silence duration") + .accessibilityValue(String(format: "%.1f seconds", model.settings.toggleSilenceSec)) } + .padding(15) + .background(card) + .overlay(cardBorder) + } + + private var silenceBinding: Binding { + Binding( + get: { Double(model.settings.toggleSilenceSec) }, + set: { model.setToggleSilenceSeconds(Float($0)) } + ) + } + + private var card: some ShapeStyle { + CSColor.surfaceRaised(0.025) + } + + private var cardBorder: some View { + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + } } // MARK: - Permission matrix cell private struct PermissionMatrixCell: View { - let kind: PermissionKind - let state: PermissionState - /// Re-probe hook fired after an in-app permission request resolves. - var onStateChanged: (() -> Void)? = nil - - private var granted: Bool { state.isGranted } - private var accent: Color { granted ? CSColor.olive : CSColor.terracotta } - private var accentLight: Color { granted ? CSColor.oliveLight : CSColor.terracottaLight } - - var body: some View { - HStack(spacing: 10) { - CSIconView(icon: granted ? .success : .warning, size: 11, weight: .semibold, color: accentLight) - Text(kind.rawValue) - .font(CSFont.ui(12.5, .medium)) - .foregroundStyle(CSColor.textBodyAlt) - .frame(maxWidth: .infinity, alignment: .leading) - Text(granted ? "granted" : state.label) - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(accentLight) - } - .padding(.horizontal, 14) - .padding(.vertical, 11) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(accent.opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(accent.opacity(0.2), lineWidth: 1) - ) - .contentShape(Rectangle()) - .onTapGesture { - guard !granted else { return } - // Speech Recognition can be requested straight from the app while - // undetermined — the system dialog grants the app's own TCC - // identity, which the bridge child inherits. Once determined, - // macOS never re-prompts, so fall through to the deep link. - // Same grant path as onboarding: in-app dialog while undetermined, - // System Settings deep-link once macOS will no longer re-prompt. - if state == .notDetermined, kind.supportsInAppPermissionRequest { - kind.requestInApp { _ in onStateChanged?() } - } else { - kind.openSystemSettings() - } - } + let kind: PermissionKind + let state: PermissionState + /// Re-probe hook fired after an in-app permission request resolves. + var onStateChanged: (() -> Void)? = nil + + private var granted: Bool { state.isGranted } + private var accent: Color { granted ? CSColor.olive : CSColor.terracotta } + private var accentLight: Color { granted ? CSColor.oliveLight : CSColor.terracottaLight } + + var body: some View { + HStack(spacing: 10) { + CSIconView( + icon: granted ? .success : .warning, size: 11, weight: .semibold, color: accentLight) + Text(kind.rawValue) + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textBodyAlt) + .frame(maxWidth: .infinity, alignment: .leading) + Text(granted ? "granted" : state.label) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(accentLight) } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(accent.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(accent.opacity(0.2), lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + guard !granted else { return } + // Speech Recognition can be requested straight from the app while + // undetermined — the system dialog grants the app's own TCC + // identity, which the bridge child inherits. Once determined, + // macOS never re-prompts, so fall through to the deep link. + // Same grant path as onboarding: in-app dialog while undetermined, + // System Settings deep-link once macOS will no longer re-prompt. + if state == .notDetermined, kind.supportsInAppPermissionRequest { + kind.requestInApp { _ in onStateChanged?() } + } else { + kind.openSystemSettings() + } + } + } } #if DEBUG -#Preview("Dictation panel") { + #Preview("Dictation panel") { ScrollView { EnginePanel(model: .preview(.engine)) } - .frame(width: 720, height: 620) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 620) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/HotkeysEngine.swift b/macos/Codescribe/Screens/Settings/HotkeysEngine.swift index d6079020..d9ca40d4 100644 --- a/macos/Codescribe/Screens/Settings/HotkeysEngine.swift +++ b/macos/Codescribe/Screens/Settings/HotkeysEngine.swift @@ -14,19 +14,19 @@ import Foundation /// Read/write mode-binding surface the Shortcuts panel consumes. protocol HotkeysEngine { - /// Current per-mode bindings (Dictation / Formatting / Assistive), normalized. - func modeBindings() -> [CsModeBinding] - /// The closed set of selectable gestures (with labels) for the picker. - func availableBindings() -> [CsBindingOption] - /// Persist one mode's binding and live-reload the detector. - func setModeBinding(mode: CsWorkMode, binding: CsShortcutBinding) throws - /// Clear all custom bindings back to the built-in defaults. - func resetToDefaults() throws - /// Validate a candidate set WITHOUT persisting; returns detected conflicts. - func validate(candidate: [CsModeBinding]) -> [CsHotkeyConflict] - /// Re-arm the global CGEventTap after a first-run permission grant so hotkeys - /// go live without an app restart. Idempotent — safe on every Refresh. - func rearmAfterPermissionGrant() + /// Current per-mode bindings (Dictation / Formatting / Assistive), normalized. + func modeBindings() -> [CsModeBinding] + /// The closed set of selectable gestures (with labels) for the picker. + func availableBindings() -> [CsBindingOption] + /// Persist one mode's binding and live-reload the detector. + func setModeBinding(mode: CsWorkMode, binding: CsShortcutBinding) throws + /// Clear all custom bindings back to the built-in defaults. + func resetToDefaults() throws + /// Validate a candidate set WITHOUT persisting; returns detected conflicts. + func validate(candidate: [CsModeBinding]) -> [CsHotkeyConflict] + /// Re-arm the global CGEventTap after a first-run permission grant so hotkeys + /// go live without an app restart. Idempotent — safe on every Refresh. + func rearmAfterPermissionGrant() } // MARK: - Real engine (UniFFI bridge adapter) @@ -36,24 +36,24 @@ protocol HotkeysEngine { /// listener state lives in process-global statics, and binding reads/writes go /// through settings.json. final class RealHotkeysEngine: HotkeysEngine { - private let hotkeys = CodescribeHotkeys() + private let hotkeys = CodescribeHotkeys() - func modeBindings() -> [CsModeBinding] { hotkeys.getModeBindings() } - func availableBindings() -> [CsBindingOption] { hotkeys.availableBindings() } - func setModeBinding(mode: CsWorkMode, binding: CsShortcutBinding) throws { - try hotkeys.setModeBinding(mode: mode, binding: binding) - } - func resetToDefaults() throws { try hotkeys.resetBindingsToDefaults() } - func validate(candidate: [CsModeBinding]) -> [CsHotkeyConflict] { - hotkeys.validateBindings(candidate: candidate) - } + func modeBindings() -> [CsModeBinding] { hotkeys.getModeBindings() } + func availableBindings() -> [CsBindingOption] { hotkeys.availableBindings() } + func setModeBinding(mode: CsWorkMode, binding: CsShortcutBinding) throws { + try hotkeys.setModeBinding(mode: mode, binding: binding) + } + func resetToDefaults() throws { try hotkeys.resetBindingsToDefaults() } + func validate(candidate: [CsModeBinding]) -> [CsHotkeyConflict] { + hotkeys.validateBindings(candidate: candidate) + } - func rearmAfterPermissionGrant() { - // Bridge call is idempotent and returns whether hotkeys are live; the UI - // reflects live status through the native permission probe, so the result - // is intentionally discarded here. - _ = hotkeys.rearmAfterPermissionGrant() - } + func rearmAfterPermissionGrant() { + // Bridge call is idempotent and returns whether hotkeys are live; the UI + // reflects live status through the native permission probe, so the result + // is intentionally discarded here. + _ = hotkeys.rearmAfterPermissionGrant() + } } // MARK: - Mock engine (previews) @@ -61,70 +61,70 @@ final class RealHotkeysEngine: HotkeysEngine { /// In-memory stand-in for #Preview. Writes are no-ops; the view-model updates its /// own draft optimistically so the picker still feels live in previews. struct MockHotkeysEngine: HotkeysEngine { - var bindings: [CsModeBinding] = CsModeBinding.sampleBindings + var bindings: [CsModeBinding] = CsModeBinding.sampleBindings - func modeBindings() -> [CsModeBinding] { bindings } - func availableBindings() -> [CsBindingOption] { CsBindingOption.sampleOptions } - func setModeBinding(mode: CsWorkMode, binding: CsShortcutBinding) throws {} - func resetToDefaults() throws {} - func rearmAfterPermissionGrant() {} - func validate(candidate: [CsModeBinding]) -> [CsHotkeyConflict] { - // Surface a representative blocking conflict when dictation double-taps Ctrl - // while a toggle mode is also active — matches the core reachability rule. - let dictation = candidate.first { $0.mode == .dictation }?.binding - let formatting = candidate.first { $0.mode == .formatting }?.binding - guard dictation == .doubleCtrl, formatting == .doubleLeftOption else { return [] } - return [ - CsHotkeyConflict( - gestureLabel: "Double-tap Left Option", - message: "Dictation is set to Double Ctrl, so Left Option toggle is disabled.", - blocking: true - ) - ] - } + func modeBindings() -> [CsModeBinding] { bindings } + func availableBindings() -> [CsBindingOption] { CsBindingOption.sampleOptions } + func setModeBinding(mode: CsWorkMode, binding: CsShortcutBinding) throws {} + func resetToDefaults() throws {} + func rearmAfterPermissionGrant() {} + func validate(candidate: [CsModeBinding]) -> [CsHotkeyConflict] { + // Surface a representative blocking conflict when dictation double-taps Ctrl + // while a toggle mode is also active — matches the core reachability rule. + let dictation = candidate.first { $0.mode == .dictation }?.binding + let formatting = candidate.first { $0.mode == .formatting }?.binding + guard dictation == .doubleCtrl, formatting == .doubleLeftOption else { return [] } + return [ + CsHotkeyConflict( + gestureLabel: "Double-tap Left Option", + message: "Dictation is set to Double Ctrl, so Left Option toggle is disabled.", + blocking: true + ) + ] + } } // MARK: - Bridge value helpers (preview seeds) extension CsModeBinding { - /// Default binding set (Dictation=Hold Fn, Formatting=Double Left Option, - /// Assistive=Double Right Option) — preview seed matching the core defaults. - static let sampleBindings: [CsModeBinding] = [ - CsModeBinding( - mode: .dictation, - modeLabel: "Dictation", - modeDescription: "Transcribes your voice and pastes the text.", - binding: .holdFn, - bindingLabel: "Hold Fn/Globe" - ), - CsModeBinding( - mode: .formatting, - modeLabel: "Formatting", - modeDescription: "Records dictation, then formats it before pasting.", - binding: .doubleLeftOption, - bindingLabel: "Double-tap Left Option" - ), - CsModeBinding( - mode: .assistive, - modeLabel: "Assistive", - modeDescription: "Sends your voice to the agent instead of pasting.", - binding: .doubleRightOption, - bindingLabel: "Double-tap Right Option" - ) - ] + /// Default binding set (Dictation=Hold Fn, Formatting=Double Left Option, + /// Assistive=Double Right Option) — preview seed matching the core defaults. + static let sampleBindings: [CsModeBinding] = [ + CsModeBinding( + mode: .dictation, + modeLabel: "Dictation", + modeDescription: "Transcribes your voice and pastes the text.", + binding: .holdFn, + bindingLabel: "Hold Fn/Globe" + ), + CsModeBinding( + mode: .formatting, + modeLabel: "Formatting", + modeDescription: "Records dictation, then formats it before pasting.", + binding: .doubleLeftOption, + bindingLabel: "Double-tap Left Option" + ), + CsModeBinding( + mode: .assistive, + modeLabel: "Assistive", + modeDescription: "Sends your voice to the agent instead of pasting.", + binding: .doubleRightOption, + bindingLabel: "Double-tap Right Option" + ), + ] } extension CsBindingOption { - /// The closed gesture set (mirrors `ShortcutBinding`), preview seed. - static let sampleOptions: [CsBindingOption] = [ - CsBindingOption(binding: .disabled, label: "Disabled"), - CsBindingOption(binding: .holdFn, label: "Hold Fn/Globe"), - CsBindingOption(binding: .holdCtrl, label: "Hold Ctrl"), - CsBindingOption(binding: .holdCtrlAlt, label: "Hold Ctrl+Option"), - CsBindingOption(binding: .holdCtrlShift, label: "Hold Ctrl+Shift"), - CsBindingOption(binding: .holdCtrlCmd, label: "Hold Ctrl+Command"), - CsBindingOption(binding: .doubleCtrl, label: "Double-tap Ctrl"), - CsBindingOption(binding: .doubleLeftOption, label: "Double-tap Left Option"), - CsBindingOption(binding: .doubleRightOption, label: "Double-tap Right Option") - ] + /// The closed gesture set (mirrors `ShortcutBinding`), preview seed. + static let sampleOptions: [CsBindingOption] = [ + CsBindingOption(binding: .disabled, label: "Disabled"), + CsBindingOption(binding: .holdFn, label: "Hold Fn/Globe"), + CsBindingOption(binding: .holdCtrl, label: "Hold Ctrl"), + CsBindingOption(binding: .holdCtrlAlt, label: "Hold Ctrl+Option"), + CsBindingOption(binding: .holdCtrlShift, label: "Hold Ctrl+Shift"), + CsBindingOption(binding: .holdCtrlCmd, label: "Hold Ctrl+Command"), + CsBindingOption(binding: .doubleCtrl, label: "Double-tap Ctrl"), + CsBindingOption(binding: .doubleLeftOption, label: "Double-tap Left Option"), + CsBindingOption(binding: .doubleRightOption, label: "Double-tap Right Option"), + ] } diff --git a/macos/Codescribe/Screens/Settings/KeysPanel.swift b/macos/Codescribe/Screens/Settings/KeysPanel.swift index 4ee2f632..51a53b59 100644 --- a/macos/Codescribe/Screens/Settings/KeysPanel.swift +++ b/macos/Codescribe/Screens/Settings/KeysPanel.swift @@ -6,64 +6,74 @@ import SwiftUI // `CsKeyStatus` booleans. Agent configuration lives in `AgentPanel`. struct KeysPanel: View { - static let ownedCapabilities: Set = [.apiKeys] - - @ObservedObject var model: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · \(SettingsSection.keys.title)") - Text("API keys.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) - - Text("Stored in the macOS Keychain. Keys are write-only here — codescribe never displays a stored secret.") - .font(CSFont.ui(12.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) - - SettingsSectionLabel("API keys") - .padding(.top, 22) - VStack(spacing: 8) { - ForEach(model.keyAccounts, id: \.self) { account in - let provider = model.providerForKeyAccount(account) - KeyRow( - account: account, - label: SettingsViewModel.keyLabel(for: account), - isSet: model.keyStatus.isSet(account: account), - probeResult: model.keyProbeResults[account], - probePending: model.keyProbePending.contains(account), - accountProvider: provider, - accountLoginPending: provider.map { - model.accountLoginPending.contains($0.id) - } ?? false, - accountLoginNotice: provider.flatMap { model.accountLoginNotices[$0.id] }, - onSave: { model.saveKey(account: account, secret: $0) }, - onClear: { model.clearKey(account: account) }, - onTest: { model.testKey(account: account) }, - onStartAccountLogin: { model.startAccountLogin(providerId: $0) }, - onSignOutAccount: { model.signOutAccount(providerId: $0) }, - onSaveOauthClientId: { model.saveOauthClientId(providerId: $0, value: $1) } - ) - } - } - .padding(.top, 11) - - HStack(spacing: 8) { - Text("●").font(CSFont.mono(11, .medium)).foregroundStyle(CSColor.olive) - Text("secrets live only in the Keychain — presence shown, value hidden") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - } - .padding(.top, 16) - + static let ownedCapabilities: Set = [.apiKeys] + + @ObservedObject var model: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + EyebrowLabel(text: "Settings · \(SettingsSection.keys.title)") + Text("API keys.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) + + Text( + "Stored in the macOS Keychain. Keys are write-only here — codescribe never displays a stored secret." + ) + .font(CSFont.ui(12.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) + + SettingsSectionLabel("API keys") + .padding(.top, 22) + VStack(spacing: 8) { + ForEach(model.keyAccounts, id: \.self) { account in + let provider = model.providerForKeyAccount(account) + KeyRow( + account: account, + label: SettingsViewModel.keyLabel(for: account), + isSet: model.keyStatus.isSet(account: account), + probeResult: model.keyProbeResults[account], + probePending: model.keyProbePending.contains(account), + accountProvider: provider, + accountLoginPending: provider.map { + model.accountLoginPending.contains($0.id) + } ?? false, + accountLoginNotice: provider.flatMap { model.accountLoginNotices[$0.id] }, + onSave: { model.saveKey(account: account, secret: $0) }, + onClear: { model.clearKey(account: account) }, + onTest: { model.testKey(account: account) }, + onStartAccountLogin: { model.startAccountLogin(providerId: $0) }, + onSignOutAccount: { model.signOutAccount(providerId: $0) }, + onSaveOauthClientId: { model.saveOauthClientId(providerId: $0, value: $1) } + ) } - .padding(.horizontal, 28) - .padding(.vertical, 24) + } + .padding(.top, 11) + + SettingsSectionLabel("Cloud STT endpoint") + .padding(.top, 22) + SttEndpointRow( + current: model.sttEndpoint, + onSave: { model.setSttEndpoint($0) } + ) + .padding(.top, 11) + + HStack(spacing: 8) { + Text("●").font(CSFont.mono(11, .medium)).foregroundStyle(CSColor.olive) + Text("secrets live only in the Keychain — presence shown, value hidden") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + } + .padding(.top, 16) + } + .padding(.horizontal, 28) + .padding(.vertical, 24) + } } @@ -73,248 +83,252 @@ struct KeysPanel: View { /// promoted config keys. AgentPanel's runtime rows remain the effective /// read-only truth. struct LLMLanesSection: View { - @ObservedObject var model: SettingsViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("Set provider, endpoint, and model per request path. Leave an override empty to use the resolved fallback.") - .font(CSFont.ui(11.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - - ForEach(LLMLane.allCases) { lane in - LLMLaneEditor(model: model, lane: lane) - if lane != LLMLane.allCases.last { - Rectangle() - .fill(CSColor.hairline(0.05)) - .frame(height: 1) - } - } + @ObservedObject var model: SettingsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text( + "Set provider, endpoint, and model per request path. Leave an override empty to use the resolved fallback." + ) + .font(CSFont.ui(11.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + + ForEach(LLMLane.allCases) { lane in + LLMLaneEditor(model: model, lane: lane) + if lane != LLMLane.allCases.last { + Rectangle() + .fill(CSColor.hairline(0.05)) + .frame(height: 1) } + } } + } } private struct LLMLaneEditor: View { - @ObservedObject var model: SettingsViewModel - let lane: LLMLane - - @State private var endpointDraft = "" - @State private var modelDraft = "" - - private var laneModel: LLMLaneModel { model.llmLane(lane) } - - private var providerLabel: String { - laneModel.provider?.displayName ?? laneModel.providerId + @ObservedObject var model: SettingsViewModel + let lane: LLMLane + + @State private var endpointDraft = "" + @State private var modelDraft = "" + + private var laneModel: LLMLaneModel { model.llmLane(lane) } + + private var providerLabel: String { + laneModel.provider?.displayName ?? laneModel.providerId + } + + private var currentModelLabel: String { + laneModel.modelOptions.first { $0.id == laneModel.resolvedModel }?.displayName + ?? laneModel.resolvedModel + } + + private var discoveryDotColor: Color { + if laneModel.manualModelReason != nil { return CSColor.textFaint } + switch laneModel.discovery.status { + case "fresh": return CSColor.olive + case "cached": return CSColor.amber + case "no_key", "loading": return CSColor.textFaint + default: return CSColor.terracotta } - - private var currentModelLabel: String { - laneModel.modelOptions.first { $0.id == laneModel.resolvedModel }?.displayName - ?? laneModel.resolvedModel - } - - private var discoveryDotColor: Color { - if laneModel.manualModelReason != nil { return CSColor.textFaint } - switch laneModel.discovery.status { - case "fresh": return CSColor.olive - case "cached": return CSColor.amber - case "no_key", "loading": return CSColor.textFaint - default: return CSColor.terracotta - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - VStack(alignment: .leading, spacing: 2) { - Text(lane.title) - .font(CSFont.ui(14.5, .bold)) - .foregroundStyle(CSColor.textHigh) - Text(lane.subtitle) - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.textMutedAlt) - } - - if lane == .assistive { - SettingsControlRow(title: "Provider", subtitle: "Assistive requests only") { - Menu { - ForEach(model.providers, id: \.id) { provider in - Button { - model.setAssistiveProvider(provider.id) - } label: { - if provider.id == laneModel.providerId { - Label(provider.displayName, systemImage: "checkmark") - } else { - Text(provider.displayName) - } - } - } - } label: { - SettingsMenuLabel(text: providerLabel) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .accessibilityLabel("Assistive provider") - .accessibilityValue(providerLabel) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(lane.title) + .font(CSFont.ui(14.5, .bold)) + .foregroundStyle(CSColor.textHigh) + Text(lane.subtitle) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) + } + + if lane == .assistive { + SettingsControlRow(title: "Provider", subtitle: "Assistive requests only") { + Menu { + ForEach(model.providers, id: \.id) { provider in + Button { + model.setAssistiveProvider(provider.id) + } label: { + if provider.id == laneModel.providerId { + Label(provider.displayName, systemImage: "checkmark") + } else { + Text(provider.displayName) } + } } - - SettingsControlRow(title: "Endpoint", subtitle: lane.endpointKey) { - HStack(spacing: 8) { - overrideTextField( - placeholder: laneModel.resolvedEndpoint, - text: $endpointDraft, - accessibilityLabel: "\(lane.title) LLM endpoint", - onSubmit: saveEndpoint - ) - - saveOverrideButton( - draft: endpointDraft, - accessibilityLabel: "Save \(lane.title) endpoint", - action: saveEndpoint - ) - - resetOverrideButton( - help: "Clear this endpoint override", - accessibilityLabel: "Reset \(lane.title) endpoint" - ) { - endpointDraft = "" - model.setLLMEndpoint("", for: lane) - } - } - .frame(width: 380) + } label: { + SettingsMenuLabel(text: providerLabel) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("Assistive provider") + .accessibilityValue(providerLabel) + } + } + + SettingsControlRow(title: "Endpoint", subtitle: lane.endpointKey) { + HStack(spacing: 8) { + overrideTextField( + placeholder: laneModel.resolvedEndpoint, + text: $endpointDraft, + accessibilityLabel: "\(lane.title) LLM endpoint", + onSubmit: saveEndpoint + ) + + saveOverrideButton( + draft: endpointDraft, + accessibilityLabel: "Save \(lane.title) endpoint", + action: saveEndpoint + ) + + resetOverrideButton( + help: "Clear this endpoint override", + accessibilityLabel: "Reset \(lane.title) endpoint" + ) { + endpointDraft = "" + model.setLLMEndpoint("", for: lane) + } + } + .frame(width: 380) + } + + SettingsControlRow(title: "Model", subtitle: lane.modelKey) { + HStack(spacing: 8) { + if laneModel.discovery.status == "loading" + && laneModel.manualModelReason == nil + { + HStack(spacing: 7) { + ProgressView() + .controlSize(.small) + Text("Discovering models…") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) } - - SettingsControlRow(title: "Model", subtitle: lane.modelKey) { - HStack(spacing: 8) { - if laneModel.discovery.status == "loading" - && laneModel.manualModelReason == nil - { - HStack(spacing: 7) { - ProgressView() - .controlSize(.small) - Text("Discovering models…") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaint) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .accessibilityLabel("Discovering \(lane.title) models") - } else if laneModel.usesDiscoveredPicker { - Menu { - ForEach(laneModel.modelOptions, id: \.id) { option in - Button { - model.setLLMModel(option.id, for: lane) - } label: { - if option.id == laneModel.resolvedModel { - Label(option.displayName, systemImage: "checkmark") - } else { - Text(option.displayName) - } - } - } - } label: { SettingsMenuLabel(text: currentModelLabel) } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .frame(maxWidth: .infinity, alignment: .trailing) - .accessibilityLabel("\(lane.title) model") - .accessibilityValue(currentModelLabel) - } else { - overrideTextField( - placeholder: laneModel.resolvedModel, - text: $modelDraft, - accessibilityLabel: "\(lane.title) model ID", - onSubmit: saveModel - ) - - saveOverrideButton( - draft: modelDraft, - accessibilityLabel: "Save \(lane.title) model", - action: saveModel - ) - } - - resetOverrideButton( - help: "Clear this model override", - accessibilityLabel: "Reset \(lane.title) model" - ) { - modelDraft = "" - model.setLLMModel("", for: lane) - } + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityLabel("Discovering \(lane.title) models") + } else if laneModel.usesDiscoveredPicker { + Menu { + ForEach(laneModel.modelOptions, id: \.id) { option in + Button { + model.setLLMModel(option.id, for: lane) + } label: { + if option.id == laneModel.resolvedModel { + Label(option.displayName, systemImage: "checkmark") + } else { + Text(option.displayName) + } } - .frame(width: 380) - } - - HStack(spacing: 8) { - Circle() - .fill(discoveryDotColor.opacity(0.85)) - .frame(width: 7, height: 7) - Text(laneModel.discoveryDescription) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(2) + } + } label: { + SettingsMenuLabel(text: currentModelLabel) } - .padding(.leading, 2) - } - } - - private func saveEndpoint() { - model.setLLMEndpoint(endpointDraft, for: lane) - endpointDraft = "" - } - - private func saveModel() { - model.setLLMModel(modelDraft, for: lane) - modelDraft = "" - } - - private func overrideTextField( - placeholder: String, - text: Binding, - accessibilityLabel: String, - onSubmit: @escaping () -> Void - ) -> some View { - TextField(placeholder, text: text) - .textFieldStyle(.plain) - .font(CSFont.mono(11.5, .regular)) - .foregroundStyle(CSColor.textBody) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityLabel("\(lane.title) model") + .accessibilityValue(currentModelLabel) + } else { + overrideTextField( + placeholder: laneModel.resolvedModel, + text: $modelDraft, + accessibilityLabel: "\(lane.title) model ID", + onSubmit: saveModel ) - .onSubmit(onSubmit) - .accessibilityLabel(accessibilityLabel) - } - - private func saveOverrideButton( - draft: String, - accessibilityLabel: String, - action: @escaping () -> Void - ) -> some View { - Button("Save", action: action) - .font(CSFont.ui(11.5, .semibold)) - .foregroundStyle(draft.isEmpty ? CSColor.textFaint : CSColor.chromeAccent) - .csFocusRing(cornerRadius: 8) - .disabled(draft.isEmpty) - .accessibilityLabel(accessibilityLabel) - } - private func resetOverrideButton( - help: String, - accessibilityLabel: String, - action: @escaping () -> Void - ) -> some View { - Button("Reset", action: action) - .font(CSFont.ui(11.5, .semibold)) - .foregroundStyle(CSColor.textMutedAlt) - .csFocusRing(cornerRadius: 8) - .help(help) - .accessibilityLabel(accessibilityLabel) + saveOverrideButton( + draft: modelDraft, + accessibilityLabel: "Save \(lane.title) model", + action: saveModel + ) + } + + resetOverrideButton( + help: "Clear this model override", + accessibilityLabel: "Reset \(lane.title) model" + ) { + modelDraft = "" + model.setLLMModel("", for: lane) + } + } + .frame(width: 380) + } + + HStack(spacing: 8) { + Circle() + .fill(discoveryDotColor.opacity(0.85)) + .frame(width: 7, height: 7) + Text(laneModel.discoveryDescription) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(2) + } + .padding(.leading, 2) } + } + + private func saveEndpoint() { + model.setLLMEndpoint(endpointDraft, for: lane) + endpointDraft = "" + } + + private func saveModel() { + model.setLLMModel(modelDraft, for: lane) + modelDraft = "" + } + + private func overrideTextField( + placeholder: String, + text: Binding, + accessibilityLabel: String, + onSubmit: @escaping () -> Void + ) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(CSFont.mono(11.5, .regular)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .onSubmit(onSubmit) + .accessibilityLabel(accessibilityLabel) + } + + private func saveOverrideButton( + draft: String, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> some View { + Button("Save", action: action) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(draft.isEmpty ? CSColor.textFaint : CSColor.chromeAccent) + .csFocusRing(cornerRadius: 8) + .disabled(draft.isEmpty) + .accessibilityLabel(accessibilityLabel) + } + + private func resetOverrideButton( + help: String, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> some View { + Button("Reset", action: action) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(CSColor.textMutedAlt) + .csFocusRing(cornerRadius: 8) + .help(help) + .accessibilityLabel(accessibilityLabel) + } } // MARK: - Agent workspace roots editor @@ -324,556 +338,662 @@ private struct LLMLaneEditor: View { /// through `SettingsViewModel.setAgentWorkspaceRoots` (colon-joined -> /// `AGENT_WORKSPACE_ROOTS`). Each row shows a live "directory exists" indicator. struct WorkspaceRootsSection: View { - @ObservedObject var model: SettingsViewModel - - @State private var rows: [String] = [] - @State private var loaded = false - - private var isDirty: Bool { - cleaned(rows) != cleaned(model.agentWorkspaceRoots) - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - SettingsSectionLabel("Agent workspace roots") - - Text("Directories the assistant scans for git checkouts to resolve a project name to a path (list_projects). Recursive, a few levels deep; build and hidden folders are skipped.") - .font(CSFont.ui(11.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) - - VStack(spacing: 8) { - ForEach(rows.indices, id: \.self) { index in - rootRow(index: index) - } - } - .padding(.top, 12) - - HStack(spacing: 10) { - Button { - rows.append("") - } label: { - Label("Add root", systemImage: "plus") - .font(CSFont.ui(12, .semibold)) - } - .csFocusRing(cornerRadius: 8) - .foregroundStyle(CSColor.textBody) - - Spacer() - - Button { - model.setAgentWorkspaceRoots(rows) - syncFromModel() - } label: { - Text("Save roots") - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(isDirty ? CSColor.textHigh : CSColor.textFaint) - } - .csFocusRing(cornerRadius: 8) - .disabled(!isDirty) - } - .padding(.top, 12) + @ObservedObject var model: SettingsViewModel + + @State private var rows: [String] = [] + @State private var loaded = false + + private var isDirty: Bool { + cleaned(rows) != cleaned(model.agentWorkspaceRoots) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Agent workspace roots") + + Text( + "Directories the assistant scans for git checkouts to resolve a project name to a path (list_projects). Recursive, a few levels deep; build and hidden folders are skipped." + ) + .font(CSFont.ui(11.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) + + VStack(spacing: 8) { + ForEach(rows.indices, id: \.self) { index in + rootRow(index: index) } - .onAppear { - guard !loaded else { return } - loaded = true - syncFromModel() + } + .padding(.top, 12) + + HStack(spacing: 10) { + Button { + rows.append("") + } label: { + Label("Add root", systemImage: "plus") + .font(CSFont.ui(12, .semibold)) } - } + .csFocusRing(cornerRadius: 8) + .foregroundStyle(CSColor.textBody) - private func rootRow(index: Int) -> some View { - HStack(spacing: 10) { - existsDot(for: rows[index]) - TextField("/path/to/checkouts", text: Binding( - get: { index < rows.count ? rows[index] : "" }, - set: { if index < rows.count { rows[index] = $0 } } - )) - .textFieldStyle(.plain) - .font(CSFont.mono(12, .regular)) - .foregroundStyle(CSColor.textBody) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - rows.remove(at: index) - } label: { - CSIconView(icon: .remove, size: 13, weight: .semibold, color: CSColor.textFaint) - } - .csFocusRing(cornerRadius: 8) + Spacer() + + Button { + model.setAgentWorkspaceRoots(rows) + syncFromModel() + } label: { + Text("Save roots") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(isDirty ? CSColor.textHigh : CSColor.textFaint) } - .padding(.horizontal, 11) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + .csFocusRing(cornerRadius: 8) + .disabled(!isDirty) + } + .padding(.top, 12) + } + .onAppear { + guard !loaded else { return } + loaded = true + syncFromModel() + } + } + + private func rootRow(index: Int) -> some View { + HStack(spacing: 10) { + existsDot(for: rows[index]) + TextField( + "/path/to/checkouts", + text: Binding( + get: { index < rows.count ? rows[index] : "" }, + set: { if index < rows.count { rows[index] = $0 } } ) + ) + .textFieldStyle(.plain) + .font(CSFont.mono(12, .regular)) + .foregroundStyle(CSColor.textBody) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + rows.remove(at: index) + } label: { + CSIconView(icon: .remove, size: 13, weight: .semibold, color: CSColor.textFaint) + } + .csFocusRing(cornerRadius: 8) } + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + + /// Green when the (tilde-expanded) path is an existing directory, amber + /// otherwise — the tool will silently skip a root that does not resolve. + private func existsDot(for path: String) -> some View { + let trimmed = path.trimmingCharacters(in: .whitespaces) + let valid = Self.directoryExists(trimmed) + return Circle() + .fill(valid ? CSColor.oliveLight : CSColor.amber) + .frame(width: 7, height: 7) + } + + private func syncFromModel() { + rows = model.agentWorkspaceRoots + // Mirror of the runtime default (DEFAULT_AGENT_WORKSPACE_ROOT): with no + // configured roots the tool really scans the app's own data dir. + if rows.isEmpty { rows = ["~/.codescribe"] } + } + + private func cleaned(_ input: [String]) -> [String] { + input + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + private static func directoryExists(_ path: String) -> Bool { + guard !path.isEmpty else { return false } + let expanded = (path as NSString).expandingTildeInPath + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir) + return exists && isDir.boolValue + } +} - /// Green when the (tilde-expanded) path is an existing directory, amber - /// otherwise — the tool will silently skip a root that does not resolve. - private func existsDot(for path: String) -> some View { - let trimmed = path.trimmingCharacters(in: .whitespaces) - let valid = Self.directoryExists(trimmed) - return Circle() - .fill(valid ? CSColor.oliveLight : CSColor.amber) - .frame(width: 7, height: 7) - } +// MARK: - Key row - private func syncFromModel() { - rows = model.agentWorkspaceRoots - // Mirror of the runtime default (DEFAULT_AGENT_WORKSPACE_ROOT): with no - // configured roots the tool really scans the app's own data dir. - if rows.isEmpty { rows = ["~/.codescribe"] } +/// Editor for `STT_ENDPOINT` — the cloud speech-to-text base URL that pairs +/// with the `STT_API_KEY` Keychain entry above. Until this row existed the +/// endpoint was reachable only by hand-editing `~/.codescribe/.env`, so the +/// key had a Settings surface while its companion endpoint did not. +private struct SttEndpointRow: View { + let current: String + let onSave: (String) -> Void + + @State private var draft: String = "" + @State private var loadedInitial = false + + private var isSet: Bool { !current.isEmpty } + private var accent: Color { isSet ? CSColor.olive : CSColor.textFaint } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Circle().fill(accent.opacity(0.85)).frame(width: 7, height: 7) + Text("Cloud transcription endpoint") + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text("STT_ENDPOINT") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + Spacer(minLength: 0) + Text(isSet ? "set" : "provider default") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(isSet ? CSColor.oliveLight : CSColor.textFaint) + } + + HStack(spacing: 8) { + TextField("https://…", text: $draft) + .textFieldStyle(.plain) + .font(CSFont.mono(12, .regular)) + .foregroundStyle(CSColor.textBody) + .autocorrectionDisabled() + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .onSubmit { onSave(draft) } + + Button(action: { onSave(draft) }) { + Text("Save") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(draft == current ? CSColor.textFaint : CSColor.chromeAccent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.chromeAccent.opacity(draft == current ? 0.06 : 0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder( + CSColor.chromeAccent.opacity(draft == current ? 0.1 : 0.28), lineWidth: 1) + ) + } + .csFocusRing(cornerRadius: 8) + .disabled(draft == current) + } + + Text( + "Used by cloud reference lanes (quality reports, cloud STT). Not a secret — the API key above stays in the Keychain. Clearing the field restores the provider default; takes effect after restart." + ) + .font(CSFont.ui(11.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) } - - private func cleaned(_ input: [String]) -> [String] { - input - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } + .padding(.horizontal, 14) + .padding(.vertical, 13) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(accent.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(accent.opacity(0.18), lineWidth: 1) + ) + .onAppear { + if !loadedInitial { + draft = current + loadedInitial = true + } } - - private static func directoryExists(_ path: String) -> Bool { - guard !path.isEmpty else { return false } - let expanded = (path as NSString).expandingTildeInPath - var isDir: ObjCBool = false - let exists = FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir) - return exists && isDir.boolValue + .onChange(of: current) { _, newValue in + draft = newValue } + } } -// MARK: - Key row - private struct KeyRow: View { - let account: String - let label: String - let isSet: Bool - let probeResult: CsApiKeyProbeResult? - let probePending: Bool - let accountProvider: CsProviderOption? - let accountLoginPending: Bool - let accountLoginNotice: String? - let onSave: (String) -> Void - let onClear: () -> Void - let onTest: () -> Void - let onStartAccountLogin: (String) -> Void - let onSignOutAccount: (String) -> Void - let onSaveOauthClientId: (String, String) -> Void - - @State private var draft: String = "" - - private var accent: Color { isSet ? CSColor.olive : CSColor.terracotta } - private var accentLight: Color { isSet ? CSColor.oliveLight : CSColor.terracottaLight } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Circle().fill(accent.opacity(0.85)).frame(width: 7, height: 7) - Text(label) - .font(CSFont.ui(13.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text(account) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - Spacer(minLength: 0) - if let probeResult { - KeyProbeChip(result: probeResult) - } - Text(isSet ? "set" : "not set") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(accentLight) - } - - HStack(spacing: 8) { - SecureField(isSet ? "Replace key…" : "Paste key…", text: $draft) - .textFieldStyle(.plain) - .font(CSFont.mono(12, .regular)) - .foregroundStyle(CSColor.textBody) - .padding(.horizontal, 11) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - .onSubmit(save) - - Button(action: save) { - Text("Save") - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(draft.isEmpty ? CSColor.textFaint : CSColor.chromeAccent) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.chromeAccent.opacity(draft.isEmpty ? 0.06 : 0.14)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.chromeAccent.opacity(draft.isEmpty ? 0.1 : 0.28), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(draft.isEmpty) - - Button(action: onTest) { - Group { - if probePending { - ProgressView() - .controlSize(.small) - .scaleEffect(0.62) - .frame(width: 20, height: 14) - } else { - Text("Test") - .font(CSFont.ui(12, .semibold)) - } - } - .frame(width: 48, height: 32) - .foregroundStyle(isSet ? CSColor.textMutedAlt : CSColor.textFaint) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(probePending || !isSet) - .help(isSet ? "Test this key" : "Save a key first to test it") - - Button(action: onClear) { - CSIconView( - icon: .delete, - size: 12, - weight: .semibold, - color: isSet ? CSColor.terracottaLight : CSColor.textFaint - ) - .frame(width: 32, height: 32) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(!isSet) - .help("Remove this key from the Keychain") - } + let account: String + let label: String + let isSet: Bool + let probeResult: CsApiKeyProbeResult? + let probePending: Bool + let accountProvider: CsProviderOption? + let accountLoginPending: Bool + let accountLoginNotice: String? + let onSave: (String) -> Void + let onClear: () -> Void + let onTest: () -> Void + let onStartAccountLogin: (String) -> Void + let onSignOutAccount: (String) -> Void + let onSaveOauthClientId: (String, String) -> Void + + @State private var draft: String = "" + + private var accent: Color { isSet ? CSColor.olive : CSColor.terracotta } + private var accentLight: Color { isSet ? CSColor.oliveLight : CSColor.terracottaLight } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Circle().fill(accent.opacity(0.85)).frame(width: 7, height: 7) + Text(label) + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text(account) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + Spacer(minLength: 0) + if let probeResult { + KeyProbeChip(result: probeResult) + } + Text(isSet ? "set" : "not set") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(accentLight) + } + + HStack(spacing: 8) { + SecureField(isSet ? "Replace key…" : "Paste key…", text: $draft) + .textFieldStyle(.plain) + .font(CSFont.mono(12, .regular)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .onSubmit(save) - if let accountProvider, - accountProvider.accountLoginEnabled || accountProvider.accountSignedIn { - AccountLoginRow( - provider: accountProvider, - loginPending: accountLoginPending, - loginNotice: accountLoginNotice, - onStart: { onStartAccountLogin(accountProvider.id) }, - onSignOut: { onSignOutAccount(accountProvider.id) }, - onSaveClientId: { onSaveOauthClientId(accountProvider.id, $0) } - ) + Button(action: save) { + Text("Save") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(draft.isEmpty ? CSColor.textFaint : CSColor.chromeAccent) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.chromeAccent.opacity(draft.isEmpty ? 0.06 : 0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder( + CSColor.chromeAccent.opacity(draft.isEmpty ? 0.1 : 0.28), lineWidth: 1) + ) + } + .csFocusRing(cornerRadius: 8) + .disabled(draft.isEmpty) + + Button(action: onTest) { + Group { + if probePending { + ProgressView() + .controlSize(.small) + .scaleEffect(0.62) + .frame(width: 20, height: 14) + } else { + Text("Test") + .font(CSFont.ui(12, .semibold)) } + } + .frame(width: 48, height: 32) + .foregroundStyle(isSet ? CSColor.textMutedAlt : CSColor.textFaint) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) } - .padding(.horizontal, 15) - .padding(.vertical, 13) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(accent.opacity(0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(accent.opacity(0.18), lineWidth: 1) + .csFocusRing(cornerRadius: 8) + .disabled(probePending || !isSet) + .help(isSet ? "Test this key" : "Save a key first to test it") + + Button(action: onClear) { + CSIconView( + icon: .delete, + size: 12, + weight: .semibold, + color: isSet ? CSColor.terracottaLight : CSColor.textFaint + ) + .frame(width: 32, height: 32) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + .csFocusRing(cornerRadius: 8) + .disabled(!isSet) + .help("Remove this key from the Keychain") + } + + if let accountProvider, + accountProvider.accountLoginEnabled || accountProvider.accountSignedIn + { + AccountLoginRow( + provider: accountProvider, + loginPending: accountLoginPending, + loginNotice: accountLoginNotice, + onStart: { onStartAccountLogin(accountProvider.id) }, + onSignOut: { onSignOutAccount(accountProvider.id) }, + onSaveClientId: { onSaveOauthClientId(accountProvider.id, $0) } ) + } } - - private func save() { - guard !draft.isEmpty else { return } - onSave(draft) - draft = "" - } + .padding(.horizontal, 15) + .padding(.vertical, 13) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(accent.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(accent.opacity(0.18), lineWidth: 1) + ) + } + + private func save() { + guard !draft.isEmpty else { return } + onSave(draft) + draft = "" + } } private struct KeyProbeChip: View { - let result: CsApiKeyProbeResult - - private var label: String { - let verdict: String - switch result.status { - case .ok: verdict = "Key OK" - case .invalid: verdict = "Invalid key" - case .noQuota: verdict = "No credits (check billing)" - case .network: verdict = "Network error" - case .missing: verdict = "Not set" - case .unsupported: verdict = "Unsupported" - } - guard let endpoint = result.probedEndpoint, - let host = URL(string: endpoint)?.host, - !host.isEmpty - else { return verdict } - return "\(verdict) @ \(host)" + let result: CsApiKeyProbeResult + + private var label: String { + let verdict: String + switch result.status { + case .ok: verdict = "Key OK" + case .invalid: verdict = "Invalid key" + case .noQuota: verdict = "No credits (check billing)" + case .network: verdict = "Network error" + case .missing: verdict = "Not set" + // "Unsupported" read as "bad key" — it only means this provider ships no + // cheap liveness probe. The key itself is stored and used normally. + case .unsupported: verdict = "Saved — no test for this key" } - - private var tint: Color { - switch result.status { - case .ok: return CSColor.oliveLight - case .invalid, .noQuota: return CSColor.terracottaLight - case .network: return CSColor.amber - case .missing, .unsupported: return CSColor.textFaint - } - } - - var body: some View { - Text(label) - .font(CSFont.mono(10, .semibold)) - .lineLimit(1) - .foregroundStyle(tint) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(tint.opacity(0.11)) - ) - .overlay( - Capsule() - .strokeBorder(tint.opacity(0.24), lineWidth: 1) - ) - .help( - result.probedEndpoint.map { - "\(result.message)\nEndpoint: \($0)" - } ?? result.message - ) + guard let endpoint = result.probedEndpoint, + let host = URL(string: endpoint)?.host, + !host.isEmpty + else { return verdict } + return "\(verdict) @ \(host)" + } + + private var tint: Color { + switch result.status { + case .ok: return CSColor.oliveLight + case .invalid, .noQuota: return CSColor.terracottaLight + case .network: return CSColor.amber + case .missing, .unsupported: return CSColor.textFaint } + } + + var body: some View { + Text(label) + .font(CSFont.mono(10, .semibold)) + .lineLimit(1) + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(tint.opacity(0.11)) + ) + .overlay( + Capsule() + .strokeBorder(tint.opacity(0.24), lineWidth: 1) + ) + .help( + result.probedEndpoint.map { + "\(result.message)\nEndpoint: \($0)" + } ?? result.message + ) + } } private struct AccountLoginRow: View { - let provider: CsProviderOption - let loginPending: Bool - let loginNotice: String? - let onStart: () -> Void - let onSignOut: () -> Void - let onSaveClientId: (String) -> Void - - @State private var clientIdDraft: String = "" - @State private var showAdvancedClientId = false - - private var signedIn: Bool { provider.accountSignedIn } - private var accent: Color { signedIn ? CSColor.olive : CSColor.textFaint } - - /// Short brand for the account row — OpenCode-style, not a client-id dump. - private var accountBrand: String { - switch provider.id { - case "openai-responses": return "ChatGPT" - case "xai-responses": return "xAI" - case "anthropic-messages": return "Claude" - default: return provider.displayName - } + let provider: CsProviderOption + let loginPending: Bool + let loginNotice: String? + let onStart: () -> Void + let onSignOut: () -> Void + let onSaveClientId: (String) -> Void + + @State private var clientIdDraft: String = "" + @State private var showAdvancedClientId = false + + private var signedIn: Bool { provider.accountSignedIn } + private var accent: Color { signedIn ? CSColor.olive : CSColor.textFaint } + + /// Short brand for the account row — OpenCode-style, not a client-id dump. + private var accountBrand: String { + switch provider.id { + case "openai-responses": return "ChatGPT" + case "xai-responses": return "xAI" + case "anthropic-messages": return "Claude" + default: return provider.displayName } - - /// Honest routing: signed-in OAuth account wins over a stored API key - /// (matches `assistive_snapshot` preference). Shown so the operator never - /// has to guess which credential the assistive path will send. - private var credentialSourceLabel: String? { - if signedIn && provider.apiKeySet { - return "using \(accountBrand) account (API key held as fallback)" + } + + /// Honest routing: signed-in OAuth account wins over a stored API key + /// (matches `assistive_snapshot` preference). Shown so the operator never + /// has to guess which credential the assistive path will send. + private var credentialSourceLabel: String? { + if signedIn && provider.apiKeySet { + return "using \(accountBrand) account (API key held as fallback)" + } + if signedIn { + return "using \(accountBrand) account" + } + if provider.apiKeySet { + return "using API key" + } + return nil + } + + private var credentialSourceTint: Color { + if signedIn { return CSColor.oliveLight } + if provider.apiKeySet { return CSColor.amber } + return CSColor.textFaint + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Circle() + .fill(accent.opacity(0.85)) + .frame(width: 7, height: 7) + Text("\(accountBrand) account") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + // Carries "signed in as " / "not signed in" / + // "awaiting app registration" straight from the core. + Text(provider.accountStatusMessage) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(accent) + .lineLimit(1) + if let loginNotice, !loginNotice.isEmpty { + Text(loginNotice) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.terracottaLight) + .lineLimit(1) + .help(loginNotice) } + Spacer(minLength: 0) if signedIn { - return "using \(accountBrand) account" - } - if provider.apiKeySet { - return "using API key" + AccountActionButton( + title: "Sign out", + tint: CSColor.terracottaLight, + enabled: !loginPending, + action: onSignOut + ) + .help("Remove the stored \(accountBrand) account tokens") } - return nil - } - - private var credentialSourceTint: Color { - if signedIn { return CSColor.oliveLight } - if provider.apiKeySet { return CSColor.amber } - return CSColor.textFaint - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Circle() - .fill(accent.opacity(0.85)) - .frame(width: 7, height: 7) - Text("\(accountBrand) account") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - // Carries "signed in as " / "not signed in" / - // "awaiting app registration" straight from the core. - Text(provider.accountStatusMessage) - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(accent) - .lineLimit(1) - if let loginNotice, !loginNotice.isEmpty { - Text(loginNotice) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.terracottaLight) - .lineLimit(1) - .help(loginNotice) - } - Spacer(minLength: 0) - if signedIn { - AccountActionButton( - title: "Sign out", - tint: CSColor.terracottaLight, - enabled: !loginPending, - action: onSignOut - ) - .help("Remove the stored \(accountBrand) account tokens") - } - Button(action: onStart) { - HStack(spacing: 6) { - if loginPending { - ProgressView() - .controlSize(.small) - .scaleEffect(0.62) - .frame(width: 14, height: 12) - } else { - CSIconView(icon: .accountVerified, size: 12, weight: .semibold) - } - Text( - loginPending - ? (provider.id == "xai-responses" - ? "Approve in browser…" - : "Waiting for browser…") - : "Sign in with \(accountBrand)" - ) - .font(CSFont.ui(12, .semibold)) - } - .foregroundStyle( - provider.accountLoginEnabled && !loginPending - ? CSColor.oliveLight : CSColor.textFaint - ) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(!provider.accountLoginEnabled || loginPending) - .help(provider.accountStatusMessage) - } - - if let credentialSourceLabel { - Text(credentialSourceLabel) - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(credentialSourceTint) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(credentialSourceTint.opacity(0.11)) - ) - .overlay( - Capsule() - .strokeBorder(credentialSourceTint.opacity(0.24), lineWidth: 1) - ) - .help( - "Assistive traffic for this provider uses the account when signed in; " - + "the API key remains a fallback if you sign out." - ) - } - - // Client id is a non-secret public app identity. OpenAI + xAI ship - // defaults (NOTICE); operators almost never need to paste one. Keep - // the override under Advanced so the Keys panel matches OpenCode's - // "pick a login method" calm instead of a scary UUID field. - DisclosureGroup(isExpanded: $showAdvancedClientId) { - HStack(spacing: 8) { - Text("client id") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - TextField( - provider.oauthClientId == nil - ? "Override OAuth client id…" - : provider.oauthClientId ?? "", - text: $clientIdDraft - ) - .textFieldStyle(.plain) - .font(CSFont.mono(11, .regular)) - .foregroundStyle(CSColor.textBody) - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - .onSubmit { onSaveClientId(clientIdDraft) } - AccountActionButton( - title: "Save", - tint: CSColor.oliveLight, - enabled: clientIdDraft != (provider.oauthClientId ?? ""), - action: { onSaveClientId(clientIdDraft) } - ) - .help("Optional override (settings.json) — empty restores the shipped default") - } - .padding(.top, 4) - } label: { - Text("Advanced · OAuth client id") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) + Button(action: onStart) { + HStack(spacing: 6) { + if loginPending { + ProgressView() + .controlSize(.small) + .scaleEffect(0.62) + .frame(width: 14, height: 12) + } else { + CSIconView(icon: .accountVerified, size: 12, weight: .semibold) } + Text( + loginPending + ? (provider.id == "xai-responses" + ? "Approve in browser…" + : "Waiting for browser…") + : "Sign in with \(accountBrand)" + ) + .font(CSFont.ui(12, .semibold)) + } + .foregroundStyle( + provider.accountLoginEnabled && !loginPending + ? CSColor.oliveLight : CSColor.textFaint + ) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) } - .onAppear { clientIdDraft = provider.oauthClientId ?? "" } - .onChange(of: provider.oauthClientId) { _, updated in - clientIdDraft = updated ?? "" + .csFocusRing(cornerRadius: 8) + .disabled(!provider.accountLoginEnabled || loginPending) + .help(provider.accountStatusMessage) + } + + if let credentialSourceLabel { + Text(credentialSourceLabel) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(credentialSourceTint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(credentialSourceTint.opacity(0.11)) + ) + .overlay( + Capsule() + .strokeBorder(credentialSourceTint.opacity(0.24), lineWidth: 1) + ) + .help( + "Assistive traffic for this provider uses the account when signed in; " + + "the API key remains a fallback if you sign out." + ) + } + + // Client id is a non-secret public app identity. OpenAI + xAI ship + // defaults (NOTICE); operators almost never need to paste one. Keep + // the override under Advanced so the Keys panel matches OpenCode's + // "pick a login method" calm instead of a scary UUID field. + DisclosureGroup(isExpanded: $showAdvancedClientId) { + HStack(spacing: 8) { + Text("client id") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + TextField( + provider.oauthClientId == nil + ? "Override OAuth client id…" + : provider.oauthClientId ?? "", + text: $clientIdDraft + ) + .textFieldStyle(.plain) + .font(CSFont.mono(11, .regular)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .onSubmit { onSaveClientId(clientIdDraft) } + AccountActionButton( + title: "Save", + tint: CSColor.oliveLight, + enabled: clientIdDraft != (provider.oauthClientId ?? ""), + action: { onSaveClientId(clientIdDraft) } + ) + .help("Optional override (settings.json) — empty restores the shipped default") } + .padding(.top, 4) + } label: { + Text("Advanced · OAuth client id") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + } + } + .onAppear { clientIdDraft = provider.oauthClientId ?? "" } + .onChange(of: provider.oauthClientId) { _, updated in + clientIdDraft = updated ?? "" } + } } private struct AccountActionButton: View { - let title: String - let tint: Color - let enabled: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(CSFont.ui(11.5, .semibold)) - .foregroundStyle(enabled ? tint : CSColor.textFaint) - .padding(.horizontal, 11) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(!enabled) + let title: String + let tint: Color + let enabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(enabled ? tint : CSColor.textFaint) + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) } + .csFocusRing(cornerRadius: 8) + .disabled(!enabled) + } } #if DEBUG -#Preview("Providers panel") { + #Preview("Providers panel") { ScrollView { KeysPanel(model: .preview(.keys)) } - .frame(width: 720, height: 620) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 620) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/LicensePanel.swift b/macos/Codescribe/Screens/Settings/LicensePanel.swift index 23323d77..b1bf377d 100644 --- a/macos/Codescribe/Screens/Settings/LicensePanel.swift +++ b/macos/Codescribe/Screens/Settings/LicensePanel.swift @@ -2,123 +2,133 @@ import AppKit import SwiftUI struct LicensePanel: View { - @ObservedObject var model: SettingsViewModel - @State private var key = "" + @ObservedObject var model: SettingsViewModel + @State private var key = "" - var body: some View { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · License") - Text("Basic stays free.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) - Text("A signed CSK1 key unlocks the Agentic lane. Validation is local and the key stays in the macOS Keychain.") - .font(CSFont.ui(12.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) + var body: some View { + VStack(alignment: .leading, spacing: 0) { + EyebrowLabel(text: "Settings · License") + Text("Basic stays free.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) + Text( + "A signed CSK1 key unlocks the Agentic lane. Validation is local and the key stays in the macOS Keychain." + ) + .font(CSFont.ui(12.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) - SettingsSectionLabel("License status") - .padding(.top, 24) - VStack(spacing: 0) { - RuntimeRow(key: "State", value: stateLabel, tint: model.licenseStatus.agenticEntitled, trailing: .none) - divider - RuntimeRow(key: "SKU", value: model.licenseStatus.sku ?? "Basic", tint: false, mono: true, trailing: .none) - divider - RuntimeRow(key: "Updates through", value: model.licenseStatus.updatesUntil ?? "—", tint: false, mono: true, trailing: .none) - } - .padding(.top, 11) - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) + SettingsSectionLabel("License status") + .padding(.top, 24) + VStack(spacing: 0) { + RuntimeRow( + key: "State", value: stateLabel, tint: model.licenseStatus.agenticEntitled, + trailing: .none) + divider + RuntimeRow( + key: "SKU", value: model.licenseStatus.sku ?? "Basic", tint: false, mono: true, + trailing: .none) + divider + RuntimeRow( + key: "Updates through", value: model.licenseStatus.updatesUntil ?? "—", tint: false, + mono: true, trailing: .none) + } + .padding(.top, 11) + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) - SettingsSectionLabel("Enter or restore key") - .padding(.top, 24) - SecureField("CSK1.…", text: $key) - .font(CSFont.mono(11.5, .regular)) - .textFieldStyle(.plain) - .padding(12) - .background(CSColor.surfaceRaised(0.04)) - .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) - ) - .padding(.top, 11) - .accessibilityLabel("Codescribe license key") + SettingsSectionLabel("Enter or restore key") + .padding(.top, 24) + SecureField("CSK1.…", text: $key) + .font(CSFont.mono(11.5, .regular)) + .textFieldStyle(.plain) + .padding(12) + .background(CSColor.surfaceRaised(0.04)) + .clipShape(RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) + ) + .padding(.top, 11) + .accessibilityLabel("Codescribe license key") - HStack(spacing: 12) { - Button("Activate / Restore") { - if model.activateLicense(key) { key = "" } - } - .buttonStyle(.borderedProminent) - .tint(CSColor.chromeAccent) - .disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + HStack(spacing: 12) { + Button("Activate / Restore") { + if model.activateLicense(key) { key = "" } + } + .buttonStyle(.borderedProminent) + .tint(CSColor.chromeAccent) + .disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - // Self-service issuance: codescribe.vetcoders.io/license/ mints a - // signed key for an email on the spot (open beta). Without this - // button the panel demanded a key and never said where one comes - // from (operator, 2026-08-09). - Button("Get license") { - if let url = URL(string: "https://codescribe.vetcoders.io/license/") { - NSWorkspace.shared.open(url) - } - } - .buttonStyle(.bordered) - .help("Open codescribe.vetcoders.io/license — enter your email, paste the key back here") - .accessibilityIdentifier("settings-license-get") + // Self-service issuance: codescribe.vetcoders.io/license/ mints a + // signed key for an email on the spot (open beta). Without this + // button the panel demanded a key and never said where one comes + // from (operator, 2026-08-09). + Button("Get license") { + if let url = URL(string: "https://codescribe.vetcoders.io/license/") { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.bordered) + .help("Open codescribe.vetcoders.io/license — enter your email, paste the key back here") + .accessibilityIdentifier("settings-license-get") - if model.licenseStatus.state != .unlicensed { - Button("Remove license", role: .destructive) { - model.removeLicense() - } - .csFocusRing(cornerRadius: 8) - .foregroundStyle(CSColor.dangerLight) - } - } - .padding(.top, 12) + if model.licenseStatus.state != .unlicensed { + Button("Remove license", role: .destructive) { + model.removeLicense() + } + .csFocusRing(cornerRadius: 8) + .foregroundStyle(CSColor.dangerLight) + } + } + .padding(.top, 12) - if let error = model.licenseError { - Text(error) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.dangerLight) - .padding(.top, 10) - .textSelection(.enabled) - } + if let error = model.licenseError { + Text(error) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.dangerLight) + .padding(.top, 10) + .textSelection(.enabled) + } - Text("Codescribe does not phone home while you work. A future fulfillment service may refresh the validation timestamp explicitly; offline grace is 30 days.") - .font(CSFont.ui(11.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textFaintAlt) - .padding(.top, 18) - } - .padding(.horizontal, 28) - .padding(.vertical, 24) + Text( + "Codescribe does not phone home while you work. A future fulfillment service may refresh the validation timestamp explicitly; offline grace is 30 days." + ) + .font(CSFont.ui(11.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textFaintAlt) + .padding(.top, 18) } + .padding(.horizontal, 28) + .padding(.vertical, 24) + } - private var stateLabel: String { - switch model.licenseStatus.state { - case .unlicensed: return "Unlicensed · Basic" - case .active: return "Active · Agentic unlocked" - case .graceOffline: - return "Offline grace · \(model.licenseStatus.daysLeft ?? 0) days left" - case .expiredUpdates: return "Updates expired · installed app remains active" - } + private var stateLabel: String { + switch model.licenseStatus.state { + case .unlicensed: return "Unlicensed · Basic" + case .active: return "Active · Agentic unlocked" + case .graceOffline: + return "Offline grace · \(model.licenseStatus.daysLeft ?? 0) days left" + case .expiredUpdates: return "Updates expired · installed app remains active" } + } - private var divider: some View { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) - } + private var divider: some View { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + } } #if DEBUG -#Preview("License panel") { + #Preview("License panel") { ScrollView { LicensePanel(model: .preview(.license)) } - .frame(width: 720, height: 760) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 760) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/MCPAdminEngine.swift b/macos/Codescribe/Screens/Settings/MCPAdminEngine.swift index 032392e2..4175e3db 100644 --- a/macos/Codescribe/Screens/Settings/MCPAdminEngine.swift +++ b/macos/Codescribe/Screens/Settings/MCPAdminEngine.swift @@ -10,132 +10,143 @@ import Foundation // can take up to ~10s, so it runs off the main actor to keep Settings responsive. protocol MCPAdminEngine { - func listServers() throws -> [CsMcpServer] - func addServer(_ input: CsMcpServerInput) throws - func updateServer(name: String, input: CsMcpServerInput) throws - func removeServer(name: String) throws - func testServer(_ name: String) async -> CsMcpTestResult - /// Persisted "always allow" tool grants (`server:tool` keys). - func listToolGrants() throws -> [CsToolGrant] - /// Revoke one grant so that tool asks for approval again. - func revokeToolGrant(key: String) throws - /// Durable agent.permissions snapshot. - func getPermissionPolicy() -> CsPermissionPolicy - func setPermissionDefaults(defaultLevel: String, readOnlyDefault: String, sideEffectDefault: String) throws - func setToolPermission(identity: String, level: String) throws - func setServerPermission(server: String, level: String) throws - /// Live capabilities from the same registry the dispatcher uses. - func listToolCapabilities() -> [CsToolCapability] + func listServers() throws -> [CsMcpServer] + func addServer(_ input: CsMcpServerInput) throws + func updateServer(name: String, input: CsMcpServerInput) throws + func removeServer(name: String) throws + func testServer(_ name: String) async -> CsMcpTestResult + /// Persisted "always allow" tool grants (`server:tool` keys). + func listToolGrants() throws -> [CsToolGrant] + /// Revoke one grant so that tool asks for approval again. + func revokeToolGrant(key: String) throws + /// Durable agent.permissions snapshot. + func getPermissionPolicy() -> CsPermissionPolicy + func setPermissionDefaults( + defaultLevel: String, readOnlyDefault: String, sideEffectDefault: String) throws + func setToolPermission(identity: String, level: String) throws + func setServerPermission(server: String, level: String) throws + /// Live capabilities from the same registry the dispatcher uses. + func listToolCapabilities() -> [CsToolCapability] } extension MCPAdminEngine { - // Default no-op surface so mocks and previews predating the grants panel - // keep compiling; the live engine below overrides both. - func listToolGrants() throws -> [CsToolGrant] { [] } - func revokeToolGrant(key: String) throws {} - func getPermissionPolicy() -> CsPermissionPolicy { - CsPermissionPolicy( - defaultLevel: "ask", - readOnlyDefault: "allow", - sideEffectDefault: "ask", - tools: [], - servers: [] - ) - } - func setPermissionDefaults(defaultLevel: String, readOnlyDefault: String, sideEffectDefault: String) throws {} - func setToolPermission(identity: String, level: String) throws {} - func setServerPermission(server: String, level: String) throws {} - func listToolCapabilities() -> [CsToolCapability] { [] } + // Default no-op surface so mocks and previews predating the grants panel + // keep compiling; the live engine below overrides both. + func listToolGrants() throws -> [CsToolGrant] { [] } + func revokeToolGrant(key: String) throws {} + func getPermissionPolicy() -> CsPermissionPolicy { + CsPermissionPolicy( + defaultLevel: "ask", + readOnlyDefault: "allow", + sideEffectDefault: "ask", + tools: [], + servers: [] + ) + } + func setPermissionDefaults( + defaultLevel: String, readOnlyDefault: String, sideEffectDefault: String + ) throws {} + func setToolPermission(identity: String, level: String) throws {} + func setServerPermission(server: String, level: String) throws {} + func listToolCapabilities() -> [CsToolCapability] { [] } } // MARK: - Real engine (UniFFI bridge adapter) final class RealMCPAdminEngine: MCPAdminEngine { - private let admin = CodescribeMcpAdmin() - - func listServers() throws -> [CsMcpServer] { try admin.listServers() } - func addServer(_ input: CsMcpServerInput) throws { try admin.addServer(server: input) } - func updateServer(name: String, input: CsMcpServerInput) throws { - try admin.updateServer(name: name, server: input) - } - func removeServer(name: String) throws { try admin.removeServer(name: name) } - func listToolGrants() throws -> [CsToolGrant] { try admin.listToolGrants() } - func revokeToolGrant(key: String) throws { try admin.revokeToolGrant(key: key) } - func getPermissionPolicy() -> CsPermissionPolicy { admin.getPermissionPolicy() } - func setPermissionDefaults(defaultLevel: String, readOnlyDefault: String, sideEffectDefault: String) throws { - try admin.setPermissionDefaults( - defaultLevel: defaultLevel, - readOnlyDefault: readOnlyDefault, - sideEffectDefault: sideEffectDefault - ) - } - func setToolPermission(identity: String, level: String) throws { - try admin.setToolPermission(identity: identity, level: level) - } - func setServerPermission(server: String, level: String) throws { - try admin.setServerPermission(server: server, level: level) - } - func listToolCapabilities() -> [CsToolCapability] { admin.listToolCapabilities() } - - // Spawning + handshaking a server can take up to ~10s; run it off the main - // actor so the Settings window never freezes. A fresh stateless handle is - // created INSIDE the detached task to avoid sending a non-Sendable object - // across executors — only the `String` name crosses the boundary. - func testServer(_ name: String) async -> CsMcpTestResult { - await Task.detached { CodescribeMcpAdmin().testServer(name: name) }.value - } + private let admin = CodescribeMcpAdmin() + + func listServers() throws -> [CsMcpServer] { try admin.listServers() } + func addServer(_ input: CsMcpServerInput) throws { try admin.addServer(server: input) } + func updateServer(name: String, input: CsMcpServerInput) throws { + try admin.updateServer(name: name, server: input) + } + func removeServer(name: String) throws { try admin.removeServer(name: name) } + func listToolGrants() throws -> [CsToolGrant] { try admin.listToolGrants() } + func revokeToolGrant(key: String) throws { try admin.revokeToolGrant(key: key) } + func getPermissionPolicy() -> CsPermissionPolicy { admin.getPermissionPolicy() } + func setPermissionDefaults( + defaultLevel: String, readOnlyDefault: String, sideEffectDefault: String + ) throws { + try admin.setPermissionDefaults( + defaultLevel: defaultLevel, + readOnlyDefault: readOnlyDefault, + sideEffectDefault: sideEffectDefault + ) + } + func setToolPermission(identity: String, level: String) throws { + try admin.setToolPermission(identity: identity, level: level) + } + func setServerPermission(server: String, level: String) throws { + try admin.setServerPermission(server: server, level: level) + } + func listToolCapabilities() -> [CsToolCapability] { admin.listToolCapabilities() } + + // Spawning + handshaking a server can take up to ~10s; run it off the main + // actor so the Settings window never freezes. A fresh stateless handle is + // created INSIDE the detached task to avoid sending a non-Sendable object + // across executors — only the `String` name crosses the boundary. + func testServer(_ name: String) async -> CsMcpTestResult { + await Task.detached { CodescribeMcpAdmin().testServer(name: name) }.value + } } // MARK: - Mock engine (previews / standalone) final class MockMCPAdminEngine: MCPAdminEngine { - private var servers: [CsMcpServer] - - init(servers: [CsMcpServer] = CsMcpServer.samples) { self.servers = servers } - - func listServers() throws -> [CsMcpServer] { servers } - - func addServer(_ input: CsMcpServerInput) throws { - servers.append( - CsMcpServer( - name: input.name, command: input.command, args: input.args, - envKeys: [], enabled: input.enabled, - transport: input.endpoint.isEmpty ? "stdio" : "remote", - endpoint: input.endpoint, authRef: input.authRef - ) - ) - } - - func updateServer(name: String, input: CsMcpServerInput) throws { - guard let index = servers.firstIndex(where: { $0.name == name }) else { return } - servers[index] = CsMcpServer( - name: input.name, command: input.command, args: input.args, - envKeys: servers[index].envKeys, enabled: input.enabled, - transport: input.endpoint.isEmpty ? "stdio" : "remote", - endpoint: input.endpoint, authRef: input.authRef - ) - } - - func removeServer(name: String) throws { servers.removeAll { $0.name == name } } - - func testServer(_ name: String) async -> CsMcpTestResult { - CsMcpTestResult( - ok: true, - toolCount: 7, - serverName: "\(name).mcp.v1", - serverVersion: "0.4.0", - protocolVersion: "2025-06-18", - error: "" - ) - } + private var servers: [CsMcpServer] + + init(servers: [CsMcpServer] = CsMcpServer.samples) { self.servers = servers } + + func listServers() throws -> [CsMcpServer] { servers } + + func addServer(_ input: CsMcpServerInput) throws { + servers.append( + CsMcpServer( + name: input.name, command: input.command, args: input.args, + envKeys: [], enabled: input.enabled, + transport: input.endpoint.isEmpty ? "stdio" : "remote", + endpoint: input.endpoint, authRef: input.authRef + ) + ) + } + + func updateServer(name: String, input: CsMcpServerInput) throws { + guard let index = servers.firstIndex(where: { $0.name == name }) else { return } + servers[index] = CsMcpServer( + name: input.name, command: input.command, args: input.args, + envKeys: servers[index].envKeys, enabled: input.enabled, + transport: input.endpoint.isEmpty ? "stdio" : "remote", + endpoint: input.endpoint, authRef: input.authRef + ) + } + + func removeServer(name: String) throws { servers.removeAll { $0.name == name } } + + func testServer(_ name: String) async -> CsMcpTestResult { + CsMcpTestResult( + ok: true, + toolCount: 7, + serverName: "\(name).mcp.v1", + serverVersion: "0.4.0", + protocolVersion: "2025-06-18", + error: "" + ) + } } // MARK: - Bridge value helpers (preview seeds) extension CsMcpServer { - static let samples: [CsMcpServer] = [ - CsMcpServer(name: "loctree-mcp", command: "loctree-mcp", args: ["mcp"], envKeys: [], enabled: true, transport: "stdio", endpoint: "", authRef: ""), - CsMcpServer(name: "aicx-mcp", command: "aicx", args: ["mcp"], envKeys: ["AICX_TOKEN"], enabled: true, transport: "stdio", endpoint: "", authRef: ""), - CsMcpServer(name: "slack", command: "", args: [], envKeys: [], enabled: true, transport: "remote", endpoint: "https://connector.example/mcp", authRef: "MCP_CONNECTOR_SLACK_TOKEN") - ] + static let samples: [CsMcpServer] = [ + CsMcpServer( + name: "loctree-mcp", command: "loctree-mcp", args: ["mcp"], envKeys: [], enabled: true, + transport: "stdio", endpoint: "", authRef: ""), + CsMcpServer( + name: "aicx-mcp", command: "aicx", args: ["mcp"], envKeys: ["AICX_TOKEN"], enabled: true, + transport: "stdio", endpoint: "", authRef: ""), + CsMcpServer( + name: "slack", command: "", args: [], envKeys: [], enabled: true, transport: "remote", + endpoint: "https://connector.example/mcp", authRef: "MCP_CONNECTOR_SLACK_TOKEN"), + ] } diff --git a/macos/Codescribe/Screens/Settings/MCPServersSection.swift b/macos/Codescribe/Screens/Settings/MCPServersSection.swift index 794ee7c6..85ee4e52 100644 --- a/macos/Codescribe/Screens/Settings/MCPServersSection.swift +++ b/macos/Codescribe/Screens/Settings/MCPServersSection.swift @@ -8,399 +8,408 @@ import SwiftUI // form, which creates the file on first add. struct MCPServersSection: View { - @ObservedObject var model: SettingsViewModel - @State private var confirmingClear = false - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - SettingsSectionLabel("Manage MCP servers") - - Text("Edited on disk in mcp.json. Hand edits (env, custom fields) are preserved.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - .padding(.top, 4) - - if model.mcpServers.isEmpty { - emptyState.padding(.top, 11) - } else { - VStack(spacing: 8) { - ForEach(model.mcpServers, id: \.name) { server in - MCPServerRow( - server: server, - pending: model.mcpTestPending.contains(server.name), - result: model.mcpTestResults[server.name], - onToggle: { model.toggleMcpServer(server) }, - onTest: { model.testMcpServer(server.name) }, - onRemove: { model.removeMcpServer(server.name) } - ) - } - } - .padding(.top, 11) - } - - MCPAddServerForm { name, command, args, endpoint, token in - model.addMcpServer( - name: name, command: command, args: args, - endpoint: endpoint, token: token - ) - } - .padding(.top, 12) - - Button(role: .destructive) { - confirmingClear = true - } label: { - Text("Clear MCP configuration…") - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.dangerLight) - } - .csFocusRing(cornerRadius: 8) - .padding(.top, 13) - .accessibilityHint("Moves only mcp.json to Trash after confirmation.") - } - .alert("Clear MCP configuration?", isPresented: $confirmingClear) { - Button("Cancel", role: .cancel) {} - Button("Move mcp.json to Trash", role: .destructive) { - model.clearMcpConfiguration() - } - } message: { - Text("Moves only ~/.codescribe/mcp.json to Trash. Recordings, transcripts, " - + "threads, preferences, and API keys stay untouched.") + @ObservedObject var model: SettingsViewModel + @State private var confirmingClear = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Manage MCP servers") + + Text("Edited on disk in mcp.json. Hand edits (env, custom fields) are preserved.") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + .padding(.top, 4) + + if model.mcpServers.isEmpty { + emptyState.padding(.top, 11) + } else { + VStack(spacing: 8) { + ForEach(model.mcpServers, id: \.name) { server in + MCPServerRow( + server: server, + pending: model.mcpTestPending.contains(server.name), + result: model.mcpTestResults[server.name], + onToggle: { model.toggleMcpServer(server) }, + onTest: { model.testMcpServer(server.name) }, + onRemove: { model.removeMcpServer(server.name) } + ) + } } - } + .padding(.top, 11) + } - private var emptyState: some View { - VStack(alignment: .leading, spacing: 6) { - Text("No MCP servers yet — this is optional.") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text("MCP servers extend the agent with extra tools like code search, " - + "PR review, or web search. Add your first server below, or skip it " - + "and wire one any time.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.surfaceRaised(0.02)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + MCPAddServerForm { name, command, args, endpoint, token in + model.addMcpServer( + name: name, command: command, args: args, + endpoint: endpoint, token: token ) + } + .padding(.top, 12) + + Button(role: .destructive) { + confirmingClear = true + } label: { + Text("Clear MCP configuration…") + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.dangerLight) + } + .csFocusRing(cornerRadius: 8) + .padding(.top, 13) + .accessibilityHint("Moves only mcp.json to Trash after confirmation.") + } + .alert("Clear MCP configuration?", isPresented: $confirmingClear) { + Button("Cancel", role: .cancel) {} + Button("Move mcp.json to Trash", role: .destructive) { + model.clearMcpConfiguration() + } + } message: { + Text( + "Moves only ~/.codescribe/mcp.json to Trash. Recordings, transcripts, " + + "threads, preferences, and API keys stay untouched.") } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 6) { + Text("No MCP servers yet — this is optional.") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text( + "MCP servers extend the agent with extra tools like code search, " + + "PR review, or web search. Add your first server below, or skip it " + + "and wire one any time." + ) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.surfaceRaised(0.02)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } } // MARK: - One server row (identity · command · test result · actions) private struct MCPServerRow: View { - let server: CsMcpServer - let pending: Bool - let result: CsMcpTestResult? - let onToggle: () -> Void - let onTest: () -> Void - let onRemove: () -> Void - - private var accent: Color { - guard server.enabled else { return CSColor.textFaint } - if pending { return CSColor.amber } - if let result { return result.ok ? CSColor.olive : CSColor.terracottaLight } - return CSColor.textFaint - } - - private var commandLine: String { - if server.transport == "remote" { return server.endpoint } - return server.args.isEmpty - ? server.command - : "\(server.command) \(server.args.joined(separator: " "))" - } + let server: CsMcpServer + let pending: Bool + let result: CsMcpTestResult? + let onToggle: () -> Void + let onTest: () -> Void + let onRemove: () -> Void + + private var accent: Color { + guard server.enabled else { return CSColor.textFaint } + if pending { return CSColor.amber } + if let result { return result.ok ? CSColor.olive : CSColor.terracottaLight } + return CSColor.textFaint + } + + private var commandLine: String { + if server.transport == "remote" { return server.endpoint } + return server.args.isEmpty + ? server.command + : "\(server.command) \(server.args.joined(separator: " "))" + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 10) { + Circle().fill(accent.opacity(0.85)).frame(width: 7, height: 7) + Text(server.name) + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Spacer(minLength: 0) + enabledButton + testButton + removeButton + } + + Text(commandLine) + .font(CSFont.mono(11.5, .regular)) + .foregroundStyle(CSColor.textMutedAlt) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) - var body: some View { - VStack(alignment: .leading, spacing: 9) { - HStack(spacing: 10) { - Circle().fill(accent.opacity(0.85)).frame(width: 7, height: 7) - Text(server.name) - .font(CSFont.ui(13.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Spacer(minLength: 0) - enabledButton - testButton - removeButton - } - - Text(commandLine) - .font(CSFont.mono(11.5, .regular)) - .foregroundStyle(CSColor.textMutedAlt) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) - - if !server.envKeys.isEmpty { - Text("env: \(server.envKeys.joined(separator: ", "))") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(1) - .truncationMode(.middle) - } - - if server.transport == "remote" { - Text(server.authRef.isEmpty - ? "remote · no authentication · policy: ask" - : "remote · token in Keychain · policy: ask") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.oliveLight) - } - - if server.name == "desktop-commander" { - // No hardcoded per-level counts here: the Permissions panel - // renders them from the live registry. A frozen literal drifts - // from the policy it claims to describe (review P2-12). - Text("Terminal and process tools always require Allow once. Commands and paths remain constrained to Agent workspace roots.") - .font(CSFont.ui(11, .regular)) - .foregroundStyle(CSColor.amber) - .fixedSize(horizontal: false, vertical: true) - } - - if pending { - resultLine(text: "connecting…", color: CSColor.amber) - } else if let result { - if result.ok { - resultLine( - text: "connected — \(result.toolCount) tool(s)", - color: CSColor.oliveLight - ) - if let identity = Self.handshakeIdentity(result) { - Text(identity) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(1) - .truncationMode(.middle) - } - } else { - resultLine( - text: "degraded — \(result.error)", - color: CSColor.terracottaLight - ) - } - } else { - resultLine( - text: server.enabled ? "disconnected — not tested" : "disconnected — disabled", - color: CSColor.textFaint - ) - } + if !server.envKeys.isEmpty { + Text("env: \(server.envKeys.joined(separator: ", "))") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(1) + .truncationMode(.middle) + } + + if server.transport == "remote" { + Text( + server.authRef.isEmpty + ? "remote · no authentication · policy: ask" + : "remote · token in Keychain · policy: ask" + ) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.oliveLight) + } + + if server.name == "desktop-commander" { + // No hardcoded per-level counts here: the Permissions panel + // renders them from the live registry. A frozen literal drifts + // from the policy it claims to describe (review P2-12). + Text( + "Terminal and process tools always require Allow once. Commands and paths remain constrained to Agent workspace roots." + ) + .font(CSFont.ui(11, .regular)) + .foregroundStyle(CSColor.amber) + .fixedSize(horizontal: false, vertical: true) + } + + if pending { + resultLine(text: "connecting…", color: CSColor.amber) + } else if let result { + if result.ok { + resultLine( + text: "connected — \(result.toolCount) tool(s)", + color: CSColor.oliveLight + ) + if let identity = Self.handshakeIdentity(result) { + Text(identity) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(1) + .truncationMode(.middle) + } + } else { + resultLine( + text: "degraded — \(result.error)", + color: CSColor.terracottaLight + ) } - .padding(.horizontal, 15) - .padding(.vertical, 12) + } else { + resultLine( + text: server.enabled ? "disconnected — not tested" : "disconnected — disabled", + color: CSColor.textFaint + ) + } + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(accent.opacity(0.05)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(accent.opacity(0.16), lineWidth: 1) + ) + } + + /// Compact identity advertised by the server in the `initialize` handshake: + /// name · version · protocol. Nil when the server exposed none of them. + static func handshakeIdentity(_ result: CsMcpTestResult) -> String? { + var parts: [String] = [] + if !result.serverName.isEmpty { parts.append(result.serverName) } + if !result.serverVersion.isEmpty { parts.append("v\(result.serverVersion)") } + if !result.protocolVersion.isEmpty { parts.append("proto \(result.protocolVersion)") } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private func resultLine(text: String, color: Color) -> some View { + Text(text) + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(color) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var enabledButton: some View { + Button(action: onToggle) { + Text(server.enabled ? "enabled" : "disabled") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(server.enabled ? CSColor.oliveLight : CSColor.textFaint) + .padding(.horizontal, 9) + .padding(.vertical, 5) .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(accent.opacity(0.05)) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(accent.opacity(0.10)) ) .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(accent.opacity(0.16), lineWidth: 1) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(accent.opacity(0.22), lineWidth: 1) ) } - - /// Compact identity advertised by the server in the `initialize` handshake: - /// name · version · protocol. Nil when the server exposed none of them. - static func handshakeIdentity(_ result: CsMcpTestResult) -> String? { - var parts: [String] = [] - if !result.serverName.isEmpty { parts.append(result.serverName) } - if !result.serverVersion.isEmpty { parts.append("v\(result.serverVersion)") } - if !result.protocolVersion.isEmpty { parts.append("proto \(result.protocolVersion)") } - return parts.isEmpty ? nil : parts.joined(separator: " · ") - } - - private func resultLine(text: String, color: Color) -> some View { - Text(text) - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(color) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var enabledButton: some View { - Button(action: onToggle) { - Text(server.enabled ? "enabled" : "disabled") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(server.enabled ? CSColor.oliveLight : CSColor.textFaint) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(accent.opacity(0.10)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(accent.opacity(0.22), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .help(server.enabled ? "Disable this server" : "Enable this server") - } - - private var testButton: some View { - Button(action: onTest) { - Text("Test") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(pending ? CSColor.textFaint : CSColor.textBodyAlt) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(pending) - .help("Spawn the server and list its tools") + .csFocusRing(cornerRadius: 8) + .help(server.enabled ? "Disable this server" : "Enable this server") + } + + private var testButton: some View { + Button(action: onTest) { + Text("Test") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(pending ? CSColor.textFaint : CSColor.textBodyAlt) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) } - - private var removeButton: some View { - Button(action: onRemove) { - CSIconView(icon: .delete, size: 11, weight: .semibold, color: CSColor.terracottaLight) - .frame(width: 28, height: 26) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .help("Remove this server from mcp.json") + .csFocusRing(cornerRadius: 8) + .disabled(pending) + .help("Spawn the server and list its tools") + } + + private var removeButton: some View { + Button(action: onRemove) { + CSIconView(icon: .delete, size: 11, weight: .semibold, color: CSColor.terracottaLight) + .frame(width: 28, height: 26) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) } + .csFocusRing(cornerRadius: 8) + .help("Remove this server from mcp.json") + } } // MARK: - Add-server form private struct MCPAddServerForm: View { - let onAdd: ( - _ name: String, _ command: String, _ args: [String], - _ endpoint: String, _ token: String + let onAdd: + ( + _ name: String, _ command: String, _ args: [String], + _ endpoint: String, _ token: String ) -> Void - @State private var remote = false - @State private var name: String = "" - @State private var command: String = "" - @State private var argsText: String = "" - @State private var endpoint: String = "" - @State private var token: String = "" - - private var canAdd: Bool { - !name.trimmingCharacters(in: .whitespaces).isEmpty - && (remote - ? endpoint.trimmingCharacters(in: .whitespaces).hasPrefix("http") - : !command.trimmingCharacters(in: .whitespaces).isEmpty) - } - - var body: some View { - VStack(alignment: .leading, spacing: 9) { - Text("ADD SERVER") - .font(CSFont.mono(10, .semibold)) - .tracking(0.5) - .foregroundStyle(CSColor.textMuted) - - Picker("Transport", selection: $remote) { - Text("Local process").tag(false) - Text("Remote HTTP").tag(true) - } - .pickerStyle(.segmented) - - field(placeholder: "name (e.g. prview)", text: $name, mono: true) - if remote { - field(placeholder: "endpoint (https://…/mcp)", text: $endpoint, mono: true) - SecureField("bearer token (optional, saved in Keychain)", text: $token) - .textFieldStyle(.plain) - .font(CSFont.mono(12, .regular)) - .foregroundStyle(CSColor.textBody) - .padding(.horizontal, 11) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - } else { - field(placeholder: "command (e.g. prview)", text: $command, mono: true) - field(placeholder: "args, space-separated (e.g. mcp)", text: $argsText, mono: true) - } - - HStack { - Spacer(minLength: 0) - Button(action: submit) { - Text("Add") - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(canAdd ? CSColor.oliveLight : CSColor.textFaint) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.olive.opacity(canAdd ? 0.14 : 0.05)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.olive.opacity(canAdd ? 0.28 : 0.10), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(!canAdd) - } - } - .padding(.horizontal, 15) - .padding(.vertical, 13) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - } - - private func field(placeholder: String, text: Binding, mono: Bool) -> some View { - TextField(placeholder, text: text) - .textFieldStyle(.plain) - .font(mono ? CSFont.mono(12, .regular) : CSFont.ui(12, .regular)) - .foregroundStyle(CSColor.textBody) - .padding(.horizontal, 11) + @State private var remote = false + @State private var name: String = "" + @State private var command: String = "" + @State private var argsText: String = "" + @State private var endpoint: String = "" + @State private var token: String = "" + + private var canAdd: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty + && (remote + ? endpoint.trimmingCharacters(in: .whitespaces).hasPrefix("http") + : !command.trimmingCharacters(in: .whitespaces).isEmpty) + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Text("ADD SERVER") + .font(CSFont.mono(10, .semibold)) + .tracking(0.5) + .foregroundStyle(CSColor.textMuted) + + Picker("Transport", selection: $remote) { + Text("Local process").tag(false) + Text("Remote HTTP").tag(true) + } + .pickerStyle(.segmented) + + field(placeholder: "name (e.g. prview)", text: $name, mono: true) + if remote { + field(placeholder: "endpoint (https://…/mcp)", text: $endpoint, mono: true) + SecureField("bearer token (optional, saved in Keychain)", text: $token) + .textFieldStyle(.plain) + .font(CSFont.mono(12, .regular)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + } else { + field(placeholder: "command (e.g. prview)", text: $command, mono: true) + field(placeholder: "args, space-separated (e.g. mcp)", text: $argsText, mono: true) + } + + HStack { + Spacer(minLength: 0) + Button(action: submit) { + Text("Add") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(canAdd ? CSColor.oliveLight : CSColor.textFaint) + .padding(.horizontal, 16) .padding(.vertical, 8) .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.olive.opacity(canAdd ? 0.14 : 0.05)) ) .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.olive.opacity(canAdd ? 0.28 : 0.10), lineWidth: 1) ) - .onSubmit(submit) - } - - private func submit() { - guard canAdd else { return } - let args = argsText - .split(whereSeparator: { $0 == " " || $0 == "\t" }) - .map(String.init) - onAdd( - name.trimmingCharacters(in: .whitespaces), - remote ? "" : command.trimmingCharacters(in: .whitespaces), - remote ? [] : args, - remote ? endpoint.trimmingCharacters(in: .whitespaces) : "", - remote ? token : "" - ) - name = "" - command = "" - argsText = "" - endpoint = "" - token = "" + } + .csFocusRing(cornerRadius: 8) + .disabled(!canAdd) + } } + .padding(.horizontal, 15) + .padding(.vertical, 13) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + + private func field(placeholder: String, text: Binding, mono: Bool) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(mono ? CSFont.mono(12, .regular) : CSFont.ui(12, .regular)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .onSubmit(submit) + } + + private func submit() { + guard canAdd else { return } + let args = + argsText + .split(whereSeparator: { $0 == " " || $0 == "\t" }) + .map(String.init) + onAdd( + name.trimmingCharacters(in: .whitespaces), + remote ? "" : command.trimmingCharacters(in: .whitespaces), + remote ? [] : args, + remote ? endpoint.trimmingCharacters(in: .whitespaces) : "", + remote ? token : "" + ) + name = "" + command = "" + argsText = "" + endpoint = "" + token = "" + } } diff --git a/macos/Codescribe/Screens/Settings/PermissionStatus.swift b/macos/Codescribe/Screens/Settings/PermissionStatus.swift index dce69582..0a87337f 100644 --- a/macos/Codescribe/Screens/Settings/PermissionStatus.swift +++ b/macos/Codescribe/Screens/Settings/PermissionStatus.swift @@ -1,9 +1,9 @@ -import Foundation import AVFoundation +import AppKit import ApplicationServices import CoreGraphics +import Foundation import IOKit.hid -import AppKit import Speech // Native macOS permission probes for the Settings screen. @@ -18,20 +18,20 @@ import Speech /// Tri-state permission result. `.notDetermined` is rendered as actionable /// ("open System Settings") rather than as a hard failure. enum PermissionState: Equatable { - case granted - case denied - case notDetermined - - var isGranted: Bool { self == .granted } - - /// Short mono label shown on the right of a permission row. - var label: String { - switch self { - case .granted: return "granted" - case .denied: return "denied" - case .notDetermined: return "not determined" - } + case granted + case denied + case notDetermined + + var isGranted: Bool { self == .granted } + + /// Short mono label shown on the right of a permission row. + var label: String { + switch self { + case .granted: return "granted" + case .denied: return "denied" + case .notDetermined: return "not determined" } + } } /// The privacy scopes codescribe touches. The first five gate live dictation / @@ -42,110 +42,110 @@ enum PermissionState: Equatable { /// dictation (`SFSpeechRecognizer`) — the bridge child process inherits the /// app's grant, so the main app must own request + display. enum PermissionKind: String, CaseIterable, Identifiable { - case microphone = "Microphone" - case accessibility = "Accessibility" - case inputMonitoring = "Input Monitoring" - case screenRecording = "Screen Recording" - case speechRecognition = "Speech Recognition" - case fullDiskAccess = "Full Disk Access" - - var id: String { rawValue } - - /// Deep-link into the matching System Settings privacy pane. - var settingsURL: URL? { - let base = "x-apple.systempreferences:com.apple.preference.security?" - switch self { - case .microphone: return URL(string: base + "Privacy_Microphone") - case .accessibility: return URL(string: base + "Privacy_Accessibility") - case .inputMonitoring: return URL(string: base + "Privacy_ListenEvent") - case .screenRecording: return URL(string: base + "Privacy_ScreenCapture") - case .speechRecognition: return URL(string: base + "Privacy_SpeechRecognition") - case .fullDiskAccess: return URL(string: base + "Privacy_AllFiles") - } + case microphone = "Microphone" + case accessibility = "Accessibility" + case inputMonitoring = "Input Monitoring" + case screenRecording = "Screen Recording" + case speechRecognition = "Speech Recognition" + case fullDiskAccess = "Full Disk Access" + + var id: String { rawValue } + + /// Deep-link into the matching System Settings privacy pane. + var settingsURL: URL? { + let base = "x-apple.systempreferences:com.apple.preference.security?" + switch self { + case .microphone: return URL(string: base + "Privacy_Microphone") + case .accessibility: return URL(string: base + "Privacy_Accessibility") + case .inputMonitoring: return URL(string: base + "Privacy_ListenEvent") + case .screenRecording: return URL(string: base + "Privacy_ScreenCapture") + case .speechRecognition: return URL(string: base + "Privacy_SpeechRecognition") + case .fullDiskAccess: return URL(string: base + "Privacy_AllFiles") } - - func openSystemSettings() { - guard let url = settingsURL else { return } - NSWorkspace.shared.open(url) - } - - /// Scopes that can fire a first-run system dialog from our process. Once - /// the user has decided (granted/denied), macOS never re-prompts — callers - /// must deep-link to System Settings instead. - var supportsInAppPermissionRequest: Bool { - switch self { - case .microphone, .speechRecognition, .screenRecording, .inputMonitoring: - return true - case .accessibility, .fullDiskAccess: - // Accessibility and Full Disk Access are only toggled in System Settings. - return false - } + } + + func openSystemSettings() { + guard let url = settingsURL else { return } + NSWorkspace.shared.open(url) + } + + /// Scopes that can fire a first-run system dialog from our process. Once + /// the user has decided (granted/denied), macOS never re-prompts — callers + /// must deep-link to System Settings instead. + var supportsInAppPermissionRequest: Bool { + switch self { + case .microphone, .speechRecognition, .screenRecording, .inputMonitoring: + return true + case .accessibility, .fullDiskAccess: + // Accessibility and Full Disk Access are only toggled in System Settings. + return false } - - /// Fire the in-app TCC dialog when still undetermined. Always calls - /// `completion` on the main queue with the post-request state (or the - /// preflight state when the scope cannot request in-app). - func requestInApp(completion: @escaping (PermissionState) -> Void) { - switch self { - case .speechRecognition: - SpeechRecognitionPermission.request(completion: completion) - case .microphone: - AVCaptureDevice.requestAccess(for: .audio) { granted in - DispatchQueue.main.async { - completion(granted ? .granted : NativePermissionProbe().snapshot().microphone) - } - } - case .screenRecording: - let ok = CGRequestScreenCaptureAccess() - DispatchQueue.main.async { - completion(ok ? .granted : NativePermissionProbe().snapshot().screenRecording) - } - case .inputMonitoring: - let ok = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) - DispatchQueue.main.async { - completion(ok ? .granted : NativePermissionProbe().snapshot().inputMonitoring) - } - case .accessibility, .fullDiskAccess: - DispatchQueue.main.async { - completion(NativePermissionProbe().snapshot().state(self)) - } + } + + /// Fire the in-app TCC dialog when still undetermined. Always calls + /// `completion` on the main queue with the post-request state (or the + /// preflight state when the scope cannot request in-app). + func requestInApp(completion: @escaping (PermissionState) -> Void) { + switch self { + case .speechRecognition: + SpeechRecognitionPermission.request(completion: completion) + case .microphone: + AVCaptureDevice.requestAccess(for: .audio) { granted in + DispatchQueue.main.async { + completion(granted ? .granted : NativePermissionProbe().snapshot().microphone) } + } + case .screenRecording: + let ok = CGRequestScreenCaptureAccess() + DispatchQueue.main.async { + completion(ok ? .granted : NativePermissionProbe().snapshot().screenRecording) + } + case .inputMonitoring: + let ok = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) + DispatchQueue.main.async { + completion(ok ? .granted : NativePermissionProbe().snapshot().inputMonitoring) + } + case .accessibility, .fullDiskAccess: + DispatchQueue.main.async { + completion(NativePermissionProbe().snapshot().state(self)) + } } + } } /// Snapshot of all four scopes captured at one moment. struct PermissionSnapshot: Equatable { - var microphone: PermissionState - var accessibility: PermissionState - var inputMonitoring: PermissionState - var screenRecording: PermissionState - /// Speech Recognition TCC scope (Apple live dictation). Defaulted so the - /// existing call sites and tests that build snapshots keep compiling. - var speechRecognition: PermissionState = .notDetermined - /// Optional scope, probed only for the onboarding wizard. Settings never - /// reads it (its matrix lists the dictation scopes explicitly). - var fullDiskAccess: PermissionState = .notDetermined - - func state(_ kind: PermissionKind) -> PermissionState { - switch kind { - case .microphone: return microphone - case .accessibility: return accessibility - case .inputMonitoring: return inputMonitoring - case .screenRecording: return screenRecording - case .speechRecognition: return speechRecognition - case .fullDiskAccess: return fullDiskAccess - } + var microphone: PermissionState + var accessibility: PermissionState + var inputMonitoring: PermissionState + var screenRecording: PermissionState + /// Speech Recognition TCC scope (Apple live dictation). Defaulted so the + /// existing call sites and tests that build snapshots keep compiling. + var speechRecognition: PermissionState = .notDetermined + /// Optional scope, probed only for the onboarding wizard. Settings never + /// reads it (its matrix lists the dictation scopes explicitly). + var fullDiskAccess: PermissionState = .notDetermined + + func state(_ kind: PermissionKind) -> PermissionState { + switch kind { + case .microphone: return microphone + case .accessibility: return accessibility + case .inputMonitoring: return inputMonitoring + case .screenRecording: return screenRecording + case .speechRecognition: return speechRecognition + case .fullDiskAccess: return fullDiskAccess } - - /// Mock value used by #Preview and the seeded view-model. - static let allGranted = PermissionSnapshot( - microphone: .granted, - accessibility: .granted, - inputMonitoring: .granted, - screenRecording: .granted, - speechRecognition: .granted, - fullDiskAccess: .granted - ) + } + + /// Mock value used by #Preview and the seeded view-model. + static let allGranted = PermissionSnapshot( + microphone: .granted, + accessibility: .granted, + inputMonitoring: .granted, + screenRecording: .granted, + speechRecognition: .granted, + fullDiskAccess: .granted + ) } // MARK: - Probing @@ -153,79 +153,80 @@ struct PermissionSnapshot: Equatable { /// Abstraction so #Preview / tests can inject deterministic states without /// touching the real system privacy database. protocol PermissionProbing { - func snapshot() -> PermissionSnapshot + func snapshot() -> PermissionSnapshot } /// Live system probe. Reads — never prompts. struct NativePermissionProbe: PermissionProbing { - func snapshot() -> PermissionSnapshot { - PermissionSnapshot( - microphone: microphoneState(), - accessibility: AXIsProcessTrusted() ? .granted : .denied, - inputMonitoring: inputMonitoringState(), - screenRecording: CGPreflightScreenCaptureAccess() ? .granted : .denied, - speechRecognition: speechRecognitionState(), - fullDiskAccess: fullDiskAccessState() - ) + func snapshot() -> PermissionSnapshot { + PermissionSnapshot( + microphone: microphoneState(), + accessibility: AXIsProcessTrusted() ? .granted : .denied, + inputMonitoring: inputMonitoringState(), + screenRecording: CGPreflightScreenCaptureAccess() ? .granted : .denied, + speechRecognition: speechRecognitionState(), + fullDiskAccess: fullDiskAccessState() + ) + } + + private func speechRecognitionState() -> PermissionState { + switch SFSpeechRecognizer.authorizationStatus() { + case .authorized: return .granted + case .notDetermined: return .notDetermined + case .denied, .restricted: return .denied + @unknown default: return .denied } - - private func speechRecognitionState() -> PermissionState { - switch SFSpeechRecognizer.authorizationStatus() { - case .authorized: return .granted - case .notDetermined: return .notDetermined - case .denied, .restricted: return .denied - @unknown default: return .denied + } + + /// Heuristic Full Disk Access probe mirroring the core's + /// `full_disk_access_status` (app/os/permissions.rs): try to list a handful + /// of TCC-protected roots. A successful read means granted; an explicit + /// permission error means denied; otherwise the paths are simply absent on + /// this machine and the scope is treated as not-yet-determined. Never prompts. + private func fullDiskAccessState() -> PermissionState { + let home = FileManager.default.homeDirectoryForCurrentUser + let protectedRoots = ["Library/Mail", "Library/Messages", "Library/Safari"] + var sawPermissionDenied = false + for relative in protectedRoots { + let path = home.appendingPathComponent(relative).path + do { + _ = try FileManager.default.contentsOfDirectory(atPath: path) + return .granted + } catch let error as NSError { + if error.domain == NSCocoaErrorDomain, + error.code == NSFileReadNoPermissionError + { + sawPermissionDenied = true } + // Absent path (NSFileReadNoSuchFileError) → keep probing. + } } - - /// Heuristic Full Disk Access probe mirroring the core's - /// `full_disk_access_status` (app/os/permissions.rs): try to list a handful - /// of TCC-protected roots. A successful read means granted; an explicit - /// permission error means denied; otherwise the paths are simply absent on - /// this machine and the scope is treated as not-yet-determined. Never prompts. - private func fullDiskAccessState() -> PermissionState { - let home = FileManager.default.homeDirectoryForCurrentUser - let protectedRoots = ["Library/Mail", "Library/Messages", "Library/Safari"] - var sawPermissionDenied = false - for relative in protectedRoots { - let path = home.appendingPathComponent(relative).path - do { - _ = try FileManager.default.contentsOfDirectory(atPath: path) - return .granted - } catch let error as NSError { - if error.domain == NSCocoaErrorDomain, - error.code == NSFileReadNoPermissionError { - sawPermissionDenied = true - } - // Absent path (NSFileReadNoSuchFileError) → keep probing. - } - } - return sawPermissionDenied ? .denied : .notDetermined + return sawPermissionDenied ? .denied : .notDetermined + } + + private func microphoneState() -> PermissionState { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: return .granted + case .notDetermined: return .notDetermined + case .denied, .restricted: return .denied + @unknown default: return .denied } + } - private func microphoneState() -> PermissionState { - switch AVCaptureDevice.authorizationStatus(for: .audio) { - case .authorized: return .granted - case .notDetermined: return .notDetermined - case .denied, .restricted: return .denied - @unknown default: return .denied - } - } - - private func inputMonitoringState() -> PermissionState { - switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) { - case kIOHIDAccessTypeGranted: return .granted - case kIOHIDAccessTypeUnknown: return .notDetermined - default: return .denied - } + private func inputMonitoringState() -> PermissionState { + switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) { + case kIOHIDAccessTypeGranted: return .granted + case kIOHIDAccessTypeUnknown: return .notDetermined + default: return .denied } + } } /// Mock probe for previews. struct MockPermissionProbe: PermissionProbing { - let value: PermissionSnapshot - init(_ value: PermissionSnapshot = .allGranted) { self.value = value } - func snapshot() -> PermissionSnapshot { value } + let value: PermissionSnapshot + init(_ value: PermissionSnapshot = .allGranted) { self.value = value } + func snapshot() -> PermissionSnapshot { value } } // MARK: - Speech Recognition request (main-process TCC dialog) @@ -237,16 +238,16 @@ struct MockPermissionProbe: PermissionProbing { /// this helper closes. Only fires the system dialog while `.notDetermined`; /// afterwards macOS never re-prompts and the caller should deep-link instead. enum SpeechRecognitionPermission { - static func request(completion: @escaping (PermissionState) -> Void) { - SFSpeechRecognizer.requestAuthorization { status in - let state: PermissionState - switch status { - case .authorized: state = .granted - case .notDetermined: state = .notDetermined - case .denied, .restricted: state = .denied - @unknown default: state = .denied - } - DispatchQueue.main.async { completion(state) } - } + static func request(completion: @escaping (PermissionState) -> Void) { + SFSpeechRecognizer.requestAuthorization { status in + let state: PermissionState + switch status { + case .authorized: state = .granted + case .notDetermined: state = .notDetermined + case .denied, .restricted: state = .denied + @unknown default: state = .denied + } + DispatchQueue.main.async { completion(state) } } + } } diff --git a/macos/Codescribe/Screens/Settings/PromptPanel.swift b/macos/Codescribe/Screens/Settings/PromptPanel.swift index 6cba8204..ec86992a 100644 --- a/macos/Codescribe/Screens/Settings/PromptPanel.swift +++ b/macos/Codescribe/Screens/Settings/PromptPanel.swift @@ -9,327 +9,341 @@ import SwiftUI // at runtime (not shown here). struct PromptPanel: View { - @ObservedObject var model: SettingsViewModel + @ObservedObject var model: SettingsViewModel - @State private var formatting: String = "" - @State private var formattingSmart: String = "" - @State private var formattingMax: String = "" - @State private var assistive: String = "" - @State private var formattingSnapshot: CsPromptSnapshot? - @State private var formattingSmartSnapshot: CsPromptSnapshot? - @State private var formattingMaxSnapshot: CsPromptSnapshot? - @State private var assistiveSnapshot: CsPromptSnapshot? + @State private var formatting: String = "" + @State private var formattingSmart: String = "" + @State private var formattingMax: String = "" + @State private var assistive: String = "" + @State private var formattingSnapshot: CsPromptSnapshot? + @State private var formattingSmartSnapshot: CsPromptSnapshot? + @State private var formattingMaxSnapshot: CsPromptSnapshot? + @State private var assistiveSnapshot: CsPromptSnapshot? - /// One prompt per page, mirroring AgentPanel. Four stacked TextEditors in - /// a single scroll meant every visit wheeled past prompts you did not come - /// for; the rail tree addresses each file directly. - var body: some View { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · Prompts · \(current.title)") - Text(headline) - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) + /// One prompt per page, mirroring AgentPanel. Four stacked TextEditors in + /// a single scroll meant every visit wheeled past prompts you did not come + /// for; the rail tree addresses each file directly. + var body: some View { + VStack(alignment: .leading, spacing: 0) { + EyebrowLabel(text: "Settings · Prompts · \(current.title)") + Text(headline) + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) - Text("Edits the BASE prompt file. The core still appends its tuning prompt at runtime.") - .font(CSFont.ui(12.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) + Text("Edits the BASE prompt file. The core still appends its tuning prompt at runtime.") + .font(CSFont.ui(12.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) - editor - .padding(.top, 22) - } - .padding(.horizontal, 28) - .padding(.vertical, 24) - .onAppear { - guard formattingSnapshot == nil, formattingSmartSnapshot == nil, - formattingMaxSnapshot == nil, assistiveSnapshot == nil else { return } - loadAllSnapshots() - } + editor + .padding(.top, 22) } + .padding(.horizontal, 28) + .padding(.vertical, 24) + .onAppear { + guard formattingSnapshot == nil, formattingSmartSnapshot == nil, + formattingMaxSnapshot == nil, assistiveSnapshot == nil + else { return } + loadAllSnapshots() + } + } - /// The nil route (section clicked, no page) lands on the correction - /// prompt — the file most edits target. - private var current: SettingsPage { - switch model.page { - case .promptSmart, .promptMax, .promptAssistive: - return model.page ?? .promptCorrection - default: - return .promptCorrection - } + /// The nil route (section clicked, no page) lands on the correction + /// prompt — the file most edits target. + private var current: SettingsPage { + switch model.page { + case .promptSmart, .promptMax, .promptAssistive: + return model.page ?? .promptCorrection + default: + return .promptCorrection } + } - private var headline: String { - switch current { - case .promptSmart: return "Smart prompt." - case .promptMax: return "Max prompt." - case .promptAssistive: return "Assistive prompt." - default: return "Correction prompt." - } + private var headline: String { + switch current { + case .promptSmart: return "Smart prompt." + case .promptMax: return "Max prompt." + case .promptAssistive: return "Assistive prompt." + default: return "Correction prompt." } + } - @ViewBuilder - private var editor: some View { - switch current { - case .promptSmart: - PromptEditor( - title: "Smart prompt", - subtitle: "Balanced transcript editing (formatting-smart.txt)", - text: $formattingSmart, - snapshot: formattingSmartSnapshot, - onSave: { - guard let updated = model.saveFormattingPrompt(.smart, content: formattingSmart) else { return false } - formattingSmart = updated.content - formattingSmartSnapshot = updated - return true - }, - onRestore: { - guard let updated = model.restoreFormattingPromptToDefault(.smart) else { return false } - formattingSmart = updated.content - formattingSmartSnapshot = updated - return true - } - ) - case .promptMax: - PromptEditor( - title: "Max prompt", - subtitle: "Maximum supported prose polish (formatting-max.txt)", - text: $formattingMax, - snapshot: formattingMaxSnapshot, - onSave: { - guard let updated = model.saveFormattingPrompt(.max, content: formattingMax) else { return false } - formattingMax = updated.content - formattingMaxSnapshot = updated - return true - }, - onRestore: { - guard let updated = model.restoreFormattingPromptToDefault(.max) else { return false } - formattingMax = updated.content - formattingMaxSnapshot = updated - return true - } - ) - case .promptAssistive: - PromptEditor( - title: "Assistive prompt", - subtitle: "Base system prompt for the voice assistant (assistive.txt)", - text: $assistive, - snapshot: assistiveSnapshot, - onSave: { - guard let updated = model.saveAssistivePrompt(assistive) else { return false } - assistive = updated.content - assistiveSnapshot = updated - return true - }, - onRestore: { - guard let updated = model.restoreAssistivePromptToDefault() else { return false } - assistive = updated.content - assistiveSnapshot = updated - return true - } - ) - default: - PromptEditor( - title: "Correction prompt", - subtitle: "Correction only AI formatting (formatting.txt)", - text: $formatting, - snapshot: formattingSnapshot, - onSave: { - guard let updated = model.saveFormattingPrompt(.correction, content: formatting) else { return false } - formatting = updated.content - formattingSnapshot = updated - return true - }, - onRestore: { - guard let updated = model.restoreFormattingPromptToDefault(.correction) else { return false } - formatting = updated.content - formattingSnapshot = updated - return true - } - ) + @ViewBuilder + private var editor: some View { + switch current { + case .promptSmart: + PromptEditor( + title: "Smart prompt", + subtitle: "Balanced transcript editing (formatting-smart.txt)", + text: $formattingSmart, + snapshot: formattingSmartSnapshot, + onSave: { + guard let updated = model.saveFormattingPrompt(.smart, content: formattingSmart) else { + return false + } + formattingSmart = updated.content + formattingSmartSnapshot = updated + return true + }, + onRestore: { + guard let updated = model.restoreFormattingPromptToDefault(.smart) else { return false } + formattingSmart = updated.content + formattingSmartSnapshot = updated + return true } + ) + case .promptMax: + PromptEditor( + title: "Max prompt", + subtitle: "Maximum supported prose polish (formatting-max.txt)", + text: $formattingMax, + snapshot: formattingMaxSnapshot, + onSave: { + guard let updated = model.saveFormattingPrompt(.max, content: formattingMax) else { + return false + } + formattingMax = updated.content + formattingMaxSnapshot = updated + return true + }, + onRestore: { + guard let updated = model.restoreFormattingPromptToDefault(.max) else { return false } + formattingMax = updated.content + formattingMaxSnapshot = updated + return true + } + ) + case .promptAssistive: + PromptEditor( + title: "Assistive prompt", + subtitle: "Base system prompt for the voice assistant (assistive.txt)", + text: $assistive, + snapshot: assistiveSnapshot, + onSave: { + guard let updated = model.saveAssistivePrompt(assistive) else { return false } + assistive = updated.content + assistiveSnapshot = updated + return true + }, + onRestore: { + guard let updated = model.restoreAssistivePromptToDefault() else { return false } + assistive = updated.content + assistiveSnapshot = updated + return true + } + ) + default: + PromptEditor( + title: "Correction prompt", + subtitle: "Correction only AI formatting (formatting.txt)", + text: $formatting, + snapshot: formattingSnapshot, + onSave: { + guard let updated = model.saveFormattingPrompt(.correction, content: formatting) else { + return false + } + formatting = updated.content + formattingSnapshot = updated + return true + }, + onRestore: { + guard let updated = model.restoreFormattingPromptToDefault(.correction) else { + return false + } + formatting = updated.content + formattingSnapshot = updated + return true + } + ) } + } - private func loadAllSnapshots() { - let formattingLoaded = model.formattingPromptSnapshot(level: .correction) - ?? model.formattingPromptSnapshot() - let smartLoaded = model.formattingPromptSnapshot(level: .smart) - let maxLoaded = model.formattingPromptSnapshot(level: .max) - let assistiveLoaded = model.assistivePromptSnapshot() - formatting = formattingLoaded.content - formattingSmart = smartLoaded?.content ?? "" - formattingMax = maxLoaded?.content ?? "" - assistive = assistiveLoaded.content - formattingSnapshot = formattingLoaded - formattingSmartSnapshot = smartLoaded - formattingMaxSnapshot = maxLoaded - assistiveSnapshot = assistiveLoaded - } + private func loadAllSnapshots() { + let formattingLoaded = + model.formattingPromptSnapshot(level: .correction) + ?? model.formattingPromptSnapshot() + let smartLoaded = model.formattingPromptSnapshot(level: .smart) + let maxLoaded = model.formattingPromptSnapshot(level: .max) + let assistiveLoaded = model.assistivePromptSnapshot() + formatting = formattingLoaded.content + formattingSmart = smartLoaded?.content ?? "" + formattingMax = maxLoaded?.content ?? "" + assistive = assistiveLoaded.content + formattingSnapshot = formattingLoaded + formattingSmartSnapshot = smartLoaded + formattingMaxSnapshot = maxLoaded + assistiveSnapshot = assistiveLoaded + } } // MARK: - Single prompt editor block private struct PromptEditor: View { - let title: String - let subtitle: String - @Binding var text: String - let snapshot: CsPromptSnapshot? - let onSave: () -> Bool - let onRestore: () -> Bool - - /// VIEW (rendered markdown) by default; EDIT (raw editor) on demand. Saving - /// returns to VIEW so the persisted prompt is shown rendered. - @State private var editing = false - @State private var confirmingRestore = false + let title: String + let subtitle: String + @Binding var text: String + let snapshot: CsPromptSnapshot? + let onSave: () -> Bool + let onRestore: () -> Bool - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(CSFont.ui(14, .semibold)) - .foregroundStyle(CSColor.textHigh) - Text(subtitle) - .font(CSFont.ui(11.5)) - .foregroundStyle(CSColor.textMutedAlt) - } - Spacer(minLength: 0) - HStack(spacing: 8) { - restoreButton - toggleButton - } - } + /// VIEW (rendered markdown) by default; EDIT (raw editor) on demand. Saving + /// returns to VIEW so the persisted prompt is shown rendered. + @State private var editing = false + @State private var confirmingRestore = false - sourceTruth - .padding(.top, 7) - - content - .padding(.top, 11) + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(CSFont.ui(14, .semibold)) + .foregroundStyle(CSColor.textHigh) + Text(subtitle) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) } - .alert("Restore \(title) to the built-in default?", isPresented: $confirmingRestore) { - Button("Cancel", role: .cancel) {} - Button("Restore this prompt", role: .destructive) { - if onRestore() { - editing = false - } - } - } message: { - Text("Only this base prompt file will change. The previous version remains recoverable in the prompt backups folder.") + Spacer(minLength: 0) + HStack(spacing: 8) { + restoreButton + toggleButton } - } + } - /// Edit ⇄ Save toggle. In EDIT it persists and flips back to VIEW; in VIEW it - /// enters EDIT. - private var toggleButton: some View { - Button(action: { - if editing { - if onSave() { - editing = false - } - } else { - editing = true - } - }) { - Text(editing ? "Save" : "Edit") - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.chromeAccent.opacity(0.14)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.chromeAccent.opacity(0.28), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .help(editing ? "Save the prompt" : "Edit the raw markdown") - } + sourceTruth + .padding(.top, 7) - private var restoreButton: some View { - Button("Restore…") { - confirmingRestore = true + content + .padding(.top, 11) + } + .alert("Restore \(title) to the built-in default?", isPresented: $confirmingRestore) { + Button("Cancel", role: .cancel) {} + Button("Restore this prompt", role: .destructive) { + if onRestore() { + editing = false } - .csFocusRing(cornerRadius: 8) - .font(CSFont.ui(11.5, .semibold)) - .foregroundStyle(CSColor.textMutedAlt) - .help("Restore only \(title.lowercased())") - .accessibilityHint("Requires confirmation and keeps a recoverable backup.") + } + } message: { + Text( + "Only this base prompt file will change. The previous version remains recoverable in the prompt backups folder." + ) } + } - private var sourceTruth: some View { - VStack(alignment: .leading, spacing: 3) { - Text(promptSourceLabel(snapshot?.source)) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(snapshot?.source == "read_error" ? CSColor.dangerLight : CSColor.textMutedAlt) - Text(snapshot?.path ?? "Path unavailable") - .font(CSFont.mono(10.5, .regular)) - .foregroundStyle(CSColor.textMuted) - .textSelection(.enabled) - if let error = snapshot?.readError, !error.isEmpty { - Text(error) - .font(CSFont.mono(10.5, .regular)) - .foregroundStyle(CSColor.dangerLight) - } + /// Edit ⇄ Save toggle. In EDIT it persists and flips back to VIEW; in VIEW it + /// enters EDIT. + private var toggleButton: some View { + Button(action: { + if editing { + if onSave() { + editing = false } - .accessibilityElement(children: .combine) - .accessibilityLabel("Prompt source") - .accessibilityValue("\(promptSourceLabel(snapshot?.source)), \(snapshot?.path ?? "path unavailable")") + } else { + editing = true + } + }) { + Text(editing ? "Save" : "Edit") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.chromeAccent.opacity(0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.chromeAccent.opacity(0.28), lineWidth: 1) + ) } + .csFocusRing(cornerRadius: 8) + .help(editing ? "Save the prompt" : "Edit the raw markdown") + } - @ViewBuilder - private var content: some View { - if editing { - TextEditor(text: $text) - .font(CSFont.mono(12.5, .regular)) - .foregroundStyle(CSColor.textBody) - .scrollContentBackground(.hidden) - .padding(10) - .frame(minHeight: 132) - .background(card) - .overlay(cardBorder) - } else { - // Reuse the chat markdown renderer (MarkdownText, ChatComponents.swift): - // it is dependency-free (DesignSystem tokens only) and carries headings, - // bold/italic, lists, inline code, and fenced code blocks. - MarkdownText(raw: text.isEmpty ? "_No prompt set._" : text, size: 13) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .frame(minHeight: 132, alignment: .topLeading) - .background(card) - .overlay(cardBorder) - } + private var restoreButton: some View { + Button("Restore…") { + confirmingRestore = true } + .csFocusRing(cornerRadius: 8) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(CSColor.textMutedAlt) + .help("Restore only \(title.lowercased())") + .accessibilityHint("Requires confirmation and keeps a recoverable backup.") + } - private var card: some View { - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .fill(CSColor.surfaceRaised(0.025)) + private var sourceTruth: some View { + VStack(alignment: .leading, spacing: 3) { + Text(promptSourceLabel(snapshot?.source)) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle( + snapshot?.source == "read_error" ? CSColor.dangerLight : CSColor.textMutedAlt) + Text(snapshot?.path ?? "Path unavailable") + .font(CSFont.mono(10.5, .regular)) + .foregroundStyle(CSColor.textMuted) + .textSelection(.enabled) + if let error = snapshot?.readError, !error.isEmpty { + Text(error) + .font(CSFont.mono(10.5, .regular)) + .foregroundStyle(CSColor.dangerLight) + } } + .accessibilityElement(children: .combine) + .accessibilityLabel("Prompt source") + .accessibilityValue( + "\(promptSourceLabel(snapshot?.source)), \(snapshot?.path ?? "path unavailable")") + } - private var cardBorder: some View { - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + @ViewBuilder + private var content: some View { + if editing { + TextEditor(text: $text) + .font(CSFont.mono(12.5, .regular)) + .foregroundStyle(CSColor.textBody) + .scrollContentBackground(.hidden) + .padding(10) + .frame(minHeight: 132) + .background(card) + .overlay(cardBorder) + } else { + // Reuse the chat markdown renderer (MarkdownText, ChatComponents.swift): + // it is dependency-free (DesignSystem tokens only) and carries headings, + // bold/italic, lists, inline code, and fenced code blocks. + MarkdownText(raw: text.isEmpty ? "_No prompt set._" : text, size: 13) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .frame(minHeight: 132, alignment: .topLeading) + .background(card) + .overlay(cardBorder) } + } + + private var card: some View { + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .fill(CSColor.surfaceRaised(0.025)) + } + + private var cardBorder: some View { + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + } } func promptSourceLabel(_ source: String?) -> String { - switch source { - case "custom_file": return "Custom file" - case "built_in_fallback": return "Built-in fallback" - case "read_error": return "Read error" - default: return "Source unavailable" - } + switch source { + case "custom_file": return "Custom file" + case "built_in_fallback": return "Built-in fallback" + case "read_error": return "Read error" + default: return "Source unavailable" + } } #if DEBUG -#Preview("Prompt panel") { + #Preview("Prompt panel") { ScrollView { PromptPanel(model: .preview(.prompts)) } - .frame(width: 720, height: 620) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 620) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/SettingsEngine.swift b/macos/Codescribe/Screens/Settings/SettingsEngine.swift index 1e37f461..36735aec 100644 --- a/macos/Codescribe/Screens/Settings/SettingsEngine.swift +++ b/macos/Codescribe/Screens/Settings/SettingsEngine.swift @@ -20,67 +20,67 @@ import Foundation /// Subset of the codescribe config surface the Settings screen consumes. protocol SettingsEngine { - // Snapshot / location - func loadSettings() -> CsSettings - func configDir() -> String - func shouldShowOnboarding() -> Bool - func onboardingMode() -> String? - func setOnboardingMode(mode: String) throws - - /// Delegates to core lane_truth normalization (eliminates suffix-list dupe in Swift). - func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String - - // Config writes (auto-tiered by the core router) - func updateConfig(key: String, value: String) throws - func updateConfigMany(entries: [CsConfigEntry]) throws - - // Live audio hardware truth + explicit unset for the preferred device. - func loadAudioInputSnapshot() throws -> CsAudioInputSnapshot - func resetAudioInputDevice() throws - - // Voice Lab quality truth (JSONL stays behind the Rust bridge) - func loadQualityRecentRecords(limit: UInt64) throws -> [CsQualityRecord] - func loadLexiconCustomEntries() throws -> [CsLexiconEntry] - func finalizeVoiceLabCorrection(id: String, canonical: String) throws -> CsVoiceLabSaveResult - func teachDictionaryFromStore() throws -> CsDictionaryTeachResult - - // Keychain-backed API keys — presence booleans only, secrets never read back - func keyStatus() -> CsKeyStatus - func keyAccounts() -> [String] - func setApiKey(account: String, secret: String) throws - func clearApiKey(account: String) throws - func testApiKey(account: String) throws -> CsApiKeyProbeResult - - // Assistive/agent-lane providers and live model discovery - func availableProviders() -> [CsProviderOption] - func discoverModels(providerId: String) -> CsModelDiscovery - func startAccountLogin(providerId: String) throws -> CsAccountLoginResult - // Blocks until the in-flight login completes/fails/times out — call from a - // background queue only. Timeout shuts the local callback server down. - func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult - func cancelAccountLogin() - func signOutAccount(providerId: String) throws - - // Editable BASE prompts - func getFormattingPrompt() -> String - func getAssistivePrompt() -> String - func formattingPromptSnapshot() -> CsPromptSnapshot - func formattingPromptSnapshot(level: String) throws -> CsPromptSnapshot - func assistivePromptSnapshot() -> CsPromptSnapshot - func defaultFormattingPrompt() -> String - func defaultAssistivePrompt() -> String - func setFormattingPrompt(content: String) throws - func setFormattingPrompt(level: String, content: String) throws - func setAssistivePrompt(content: String) throws - func restoreFormattingPromptToDefault() throws - func restoreFormattingPromptToDefault(level: String) throws - func restoreAssistivePromptToDefault() throws - - // Recoverable reset: preview live impact, move local data to Trash, and - // optionally remove Keychain keys. MCP-only clear is a separate concern. - func resetPreview() -> CsResetPreview - func resetAppData(includeKeys: Bool, includePrompts: Bool) throws - func clearMcpConfiguration() throws + // Snapshot / location + func loadSettings() -> CsSettings + func configDir() -> String + func shouldShowOnboarding() -> Bool + func onboardingMode() -> String? + func setOnboardingMode(mode: String) throws + + /// Delegates to core lane_truth normalization (eliminates suffix-list dupe in Swift). + func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String + + // Config writes (auto-tiered by the core router) + func updateConfig(key: String, value: String) throws + func updateConfigMany(entries: [CsConfigEntry]) throws + + // Live audio hardware truth + explicit unset for the preferred device. + func loadAudioInputSnapshot() throws -> CsAudioInputSnapshot + func resetAudioInputDevice() throws + + // Voice Lab quality truth (JSONL stays behind the Rust bridge) + func loadQualityRecentRecords(limit: UInt64) throws -> [CsQualityRecord] + func loadLexiconCustomEntries() throws -> [CsLexiconEntry] + func finalizeVoiceLabCorrection(id: String, canonical: String) throws -> CsVoiceLabSaveResult + func teachDictionaryFromStore() throws -> CsDictionaryTeachResult + + // Keychain-backed API keys — presence booleans only, secrets never read back + func keyStatus() -> CsKeyStatus + func keyAccounts() -> [String] + func setApiKey(account: String, secret: String) throws + func clearApiKey(account: String) throws + func testApiKey(account: String) throws -> CsApiKeyProbeResult + + // Assistive/agent-lane providers and live model discovery + func availableProviders() -> [CsProviderOption] + func discoverModels(providerId: String) -> CsModelDiscovery + func startAccountLogin(providerId: String) throws -> CsAccountLoginResult + // Blocks until the in-flight login completes/fails/times out — call from a + // background queue only. Timeout shuts the local callback server down. + func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult + func cancelAccountLogin() + func signOutAccount(providerId: String) throws + + // Editable BASE prompts + func getFormattingPrompt() -> String + func getAssistivePrompt() -> String + func formattingPromptSnapshot() -> CsPromptSnapshot + func formattingPromptSnapshot(level: String) throws -> CsPromptSnapshot + func assistivePromptSnapshot() -> CsPromptSnapshot + func defaultFormattingPrompt() -> String + func defaultAssistivePrompt() -> String + func setFormattingPrompt(content: String) throws + func setFormattingPrompt(level: String, content: String) throws + func setAssistivePrompt(content: String) throws + func restoreFormattingPromptToDefault() throws + func restoreFormattingPromptToDefault(level: String) throws + func restoreAssistivePromptToDefault() throws + + // Recoverable reset: preview live impact, move local data to Trash, and + // optionally remove Keychain keys. MCP-only clear is a separate concern. + func resetPreview() -> CsResetPreview + func resetAppData(includeKeys: Bool, includePrompts: Bool) throws + func clearMcpConfiguration() throws } // MARK: - Real engine (UniFFI bridge adapter) @@ -89,101 +89,102 @@ protocol SettingsEngine { /// call reloads or writes through the live core, so Swift always sees on-disk /// truth. Injected by App.swift for the live app. final class RealSettingsEngine: SettingsEngine { - private let config = CodescribeConfig() - - func loadSettings() -> CsSettings { config.loadSettings() } - func configDir() -> String { config.configDir() } - func shouldShowOnboarding() -> Bool { config.shouldShowOnboarding() } - func onboardingMode() -> String? { config.onboardingMode() } - func setOnboardingMode(mode: String) throws { try config.setOnboardingMode(mode: mode) } - - func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String { - config.normalizeOpenaiResponsesEndpoint(endpoint: endpoint) - } - - func updateConfig(key: String, value: String) throws { - try config.updateConfig(key: key, value: value) - } - func updateConfigMany(entries: [CsConfigEntry]) throws { - try config.updateConfigMany(entries: entries) - } - func loadAudioInputSnapshot() throws -> CsAudioInputSnapshot { - try audioInputSnapshot() - } - func resetAudioInputDevice() throws { - try config.resetAudioInputDevice() - } - func loadQualityRecentRecords(limit: UInt64) throws -> [CsQualityRecord] { - try qualityRecentRecords(limit: limit) - } - func loadLexiconCustomEntries() throws -> [CsLexiconEntry] { - try lexiconCustomEntries() - } - func finalizeVoiceLabCorrection(id: String, canonical: String) throws -> CsVoiceLabSaveResult { - try qualityFinalizeCorrection(correctionId: id, canonical: canonical) - } - func teachDictionaryFromStore() throws -> CsDictionaryTeachResult { - try qualityTeachDictionaryFromStore() - } - - func keyStatus() -> CsKeyStatus { config.keyStatus() } - func keyAccounts() -> [String] { config.keyAccounts() } - func setApiKey(account: String, secret: String) throws { - try config.setApiKey(account: account, secret: secret) - } - func clearApiKey(account: String) throws { try config.clearApiKey(account: account) } - func testApiKey(account: String) throws -> CsApiKeyProbeResult { - try config.testApiKey(account: account) - } - - func availableProviders() -> [CsProviderOption] { config.availableProviders() } - func discoverModels(providerId: String) -> CsModelDiscovery { - config.discoverModels(providerId: providerId) - } - func startAccountLogin(providerId: String) throws -> CsAccountLoginResult { - try config.startAccountLogin(providerId: providerId) - } - func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult { - try config.awaitAccountLogin(providerId: providerId, timeoutSeconds: timeoutSeconds) - } - func cancelAccountLogin() { config.cancelAccountLogin() } - func signOutAccount(providerId: String) throws { - try config.signOutAccount(providerId: providerId) - } - - func getFormattingPrompt() -> String { config.getFormattingPrompt() } - func getAssistivePrompt() -> String { config.getAssistivePrompt() } - func formattingPromptSnapshot() -> CsPromptSnapshot { config.formattingPromptSnapshot() } - func formattingPromptSnapshot(level: String) throws -> CsPromptSnapshot { - try config.formattingPromptSnapshotForLevel(level: level) - } - func assistivePromptSnapshot() -> CsPromptSnapshot { config.assistivePromptSnapshot() } - func defaultFormattingPrompt() -> String { config.defaultFormattingPrompt() } - func defaultAssistivePrompt() -> String { config.defaultAssistivePrompt() } - func setFormattingPrompt(content: String) throws { - try config.setFormattingPrompt(content: content) - } - func setFormattingPrompt(level: String, content: String) throws { - try config.setFormattingPromptForLevel(level: level, content: content) - } - func setAssistivePrompt(content: String) throws { - try config.setAssistivePrompt(content: content) - } - func restoreFormattingPromptToDefault() throws { - try config.restoreFormattingPromptToDefault() - } - func restoreFormattingPromptToDefault(level: String) throws { - try config.restoreFormattingPromptForLevelToDefault(level: level) - } - func restoreAssistivePromptToDefault() throws { - try config.restoreAssistivePromptToDefault() - } - - func resetPreview() -> CsResetPreview { config.resetPreview() } - func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { - try config.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) - } - func clearMcpConfiguration() throws { try config.clearMcpConfiguration() } + private let config = CodescribeConfig() + + func loadSettings() -> CsSettings { config.loadSettings() } + func configDir() -> String { config.configDir() } + func shouldShowOnboarding() -> Bool { config.shouldShowOnboarding() } + func onboardingMode() -> String? { config.onboardingMode() } + func setOnboardingMode(mode: String) throws { try config.setOnboardingMode(mode: mode) } + + func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String { + config.normalizeOpenaiResponsesEndpoint(endpoint: endpoint) + } + + func updateConfig(key: String, value: String) throws { + try config.updateConfig(key: key, value: value) + } + func updateConfigMany(entries: [CsConfigEntry]) throws { + try config.updateConfigMany(entries: entries) + } + func loadAudioInputSnapshot() throws -> CsAudioInputSnapshot { + try audioInputSnapshot() + } + func resetAudioInputDevice() throws { + try config.resetAudioInputDevice() + } + func loadQualityRecentRecords(limit: UInt64) throws -> [CsQualityRecord] { + try qualityRecentRecords(limit: limit) + } + func loadLexiconCustomEntries() throws -> [CsLexiconEntry] { + try lexiconCustomEntries() + } + func finalizeVoiceLabCorrection(id: String, canonical: String) throws -> CsVoiceLabSaveResult { + try qualityFinalizeCorrection(correctionId: id, canonical: canonical) + } + func teachDictionaryFromStore() throws -> CsDictionaryTeachResult { + try qualityTeachDictionaryFromStore() + } + + func keyStatus() -> CsKeyStatus { config.keyStatus() } + func keyAccounts() -> [String] { config.keyAccounts() } + func setApiKey(account: String, secret: String) throws { + try config.setApiKey(account: account, secret: secret) + } + func clearApiKey(account: String) throws { try config.clearApiKey(account: account) } + func testApiKey(account: String) throws -> CsApiKeyProbeResult { + try config.testApiKey(account: account) + } + + func availableProviders() -> [CsProviderOption] { config.availableProviders() } + func discoverModels(providerId: String) -> CsModelDiscovery { + config.discoverModels(providerId: providerId) + } + func startAccountLogin(providerId: String) throws -> CsAccountLoginResult { + try config.startAccountLogin(providerId: providerId) + } + func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult + { + try config.awaitAccountLogin(providerId: providerId, timeoutSeconds: timeoutSeconds) + } + func cancelAccountLogin() { config.cancelAccountLogin() } + func signOutAccount(providerId: String) throws { + try config.signOutAccount(providerId: providerId) + } + + func getFormattingPrompt() -> String { config.getFormattingPrompt() } + func getAssistivePrompt() -> String { config.getAssistivePrompt() } + func formattingPromptSnapshot() -> CsPromptSnapshot { config.formattingPromptSnapshot() } + func formattingPromptSnapshot(level: String) throws -> CsPromptSnapshot { + try config.formattingPromptSnapshotForLevel(level: level) + } + func assistivePromptSnapshot() -> CsPromptSnapshot { config.assistivePromptSnapshot() } + func defaultFormattingPrompt() -> String { config.defaultFormattingPrompt() } + func defaultAssistivePrompt() -> String { config.defaultAssistivePrompt() } + func setFormattingPrompt(content: String) throws { + try config.setFormattingPrompt(content: content) + } + func setFormattingPrompt(level: String, content: String) throws { + try config.setFormattingPromptForLevel(level: level, content: content) + } + func setAssistivePrompt(content: String) throws { + try config.setAssistivePrompt(content: content) + } + func restoreFormattingPromptToDefault() throws { + try config.restoreFormattingPromptToDefault() + } + func restoreFormattingPromptToDefault(level: String) throws { + try config.restoreFormattingPromptForLevelToDefault(level: level) + } + func restoreAssistivePromptToDefault() throws { + try config.restoreAssistivePromptToDefault() + } + + func resetPreview() -> CsResetPreview { config.resetPreview() } + func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { + try config.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) + } + func clearMcpConfiguration() throws { try config.clearMcpConfiguration() } } // MARK: - Mock engine (previews) @@ -192,421 +193,424 @@ final class RealSettingsEngine: SettingsEngine { /// the view-model also updates its own snapshot optimistically so the controls /// still feel live in previews. struct MockSettingsEngine: SettingsEngine { - var settings: CsSettings = .sample - var status: CsKeyStatus = .sampleAllSet - var dir: String = "~/.codescribe" - var onboarding: Bool = false - var mode: String? = "agentic" - var qualityRecords: [CsQualityRecord] = [] - var lexiconEntries: [CsLexiconEntry] = [] - var qualityRecordsLoader: (() throws -> [CsQualityRecord])? - var lexiconEntriesLoader: (() throws -> [CsLexiconEntry])? - var audioSnapshot: CsAudioInputSnapshot = .sample - var resetPreviewValue: CsResetPreview = .sample - var formattingSnapshot: CsPromptSnapshot = .sampleFormatting - var assistiveSnapshot: CsPromptSnapshot = .sampleAssistive - var promptSaveObserver: ((String, String) throws -> Void)? - var promptRestoreObserver: ((String) throws -> Void)? - var resetAppDataObserver: ((Bool, Bool) throws -> Void)? - var clearMcpConfigurationObserver: (() throws -> Void)? - var settingsLoader: (() -> CsSettings)? - var updateConfigManyObserver: (([CsConfigEntry]) throws -> Void)? - var resetAudioInputDeviceObserver: (() throws -> Void)? - var voiceLabEditObserver: ((String, String) throws -> CsVoiceLabSaveResult)? - // Keep the long-standing config observer last so existing trailing-closure - // call sites continue to bind to config writes, not Voice Lab edits. - var updateConfigObserver: ((String, String) throws -> Void)? - - func loadSettings() -> CsSettings { settingsLoader?() ?? settings } - func configDir() -> String { dir } - func shouldShowOnboarding() -> Bool { onboarding } - func onboardingMode() -> String? { mode } - func setOnboardingMode(mode: String) throws {} - - func updateConfig(key: String, value: String) throws { - try updateConfigObserver?(key, value) - } - func updateConfigMany(entries: [CsConfigEntry]) throws { - try updateConfigManyObserver?(entries) - } - func loadAudioInputSnapshot() throws -> CsAudioInputSnapshot { audioSnapshot } - func resetAudioInputDevice() throws { - try resetAudioInputDeviceObserver?() - } - func loadQualityRecentRecords(limit: UInt64) throws -> [CsQualityRecord] { - let records = try qualityRecordsLoader?() ?? qualityRecords - return Array(records.prefix(Int(clamping: limit))) - } - func loadLexiconCustomEntries() throws -> [CsLexiconEntry] { - try lexiconEntriesLoader?() ?? lexiconEntries - } - func finalizeVoiceLabCorrection(id: String, canonical: String) throws -> CsVoiceLabSaveResult { - if let voiceLabEditObserver { - return try voiceLabEditObserver(id, canonical) - } - guard let record = qualityRecords.first(where: { $0.id == id }) else { - throw NSError(domain: "VoiceLab", code: 404) - } - return CsVoiceLabSaveResult( - record: CsQualityRecord( - id: record.id, - revision: record.revision + 1, - rawText: record.rawText, - variant: record.variant, - editedText: canonical, - action: "edit", - timestampMs: record.timestampMs, - avgLogprob: nil, - speechPct: nil, - confidenceFlags: [] - ), - pairsLearned: 0, - lexiconError: nil - ) - } - func teachDictionaryFromStore() throws -> CsDictionaryTeachResult { - // Preview / mock: treat current lexicon as already taught. - let total = UInt32(lexiconEntries.count) - let fromCorrection = UInt32(lexiconEntries.filter { $0.source == "correction" }.count) - return CsDictionaryTeachResult( - fromCorrections: 0, - fromProposed: 0, - totalRules: total, - rulesFromCorrectionSource: fromCorrection - ) - } - - func keyStatus() -> CsKeyStatus { status } - func keyAccounts() -> [String] { - [ - "LLM_API_KEY", "STT_API_KEY", "LLM_FORMATTING_API_KEY", - "LLM_ASSISTIVE_API_KEY", "LLM_ANTHROPIC_API_KEY", "LLM_XAI_API_KEY", - "GITHUB_TOKEN", - ] - } - func setApiKey(account: String, secret: String) throws {} - func clearApiKey(account: String) throws {} - func testApiKey(account: String) throws -> CsApiKeyProbeResult { - CsApiKeyProbeResult.sample(account: account) - } - - func availableProviders() -> [CsProviderOption] { CsProviderOption.sampleProviders } - func discoverModels(providerId: String) -> CsModelDiscovery { - CsModelDiscovery.sample(for: providerId) - } - func startAccountLogin(providerId: String) throws -> CsAccountLoginResult { - CsAccountLoginResult( - providerId: providerId, - status: "blocked", - message: "awaiting app registration", - authUrl: nil, - signedIn: false, - clientIdConfigured: false - ) - } - - func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String { - // Mock: pass-through or minimal normalize for preview stability. - var base = endpoint.trimmingCharacters(in: .whitespacesAndNewlines.union(.init(charactersIn: "/"))) - for s in ["/v1/responses", "/v1/chat/completions", "/v1/completions"] where base.hasSuffix(s) { - base.removeLast(s.count) - return base + "/v1/responses" - } - if base.hasSuffix("/v1") { base.removeLast(3) } - return base + "/v1/responses" - } - func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult { - CsAccountLoginResult( - providerId: providerId, - status: "idle", - message: "no sign-in in progress", - authUrl: nil, - signedIn: false, - clientIdConfigured: false - ) - } - func cancelAccountLogin() {} - func signOutAccount(providerId: String) throws {} - - func getFormattingPrompt() -> String { CsSettings.samplePrompt } - func getAssistivePrompt() -> String { CsSettings.sampleAssistivePrompt } - func formattingPromptSnapshot() -> CsPromptSnapshot { formattingSnapshot } - func formattingPromptSnapshot(level: String) throws -> CsPromptSnapshot { - switch level { - case "correction": return formattingSnapshot - case "smart": return .sampleFormattingSmart - case "max": return .sampleFormattingMax - default: throw NSError(domain: "FormattingPolicy", code: 1) - } - } - func assistivePromptSnapshot() -> CsPromptSnapshot { assistiveSnapshot } - func defaultFormattingPrompt() -> String { CsSettings.samplePrompt } - func defaultAssistivePrompt() -> String { CsSettings.sampleAssistivePrompt } - func setFormattingPrompt(content: String) throws { - try promptSaveObserver?("formatting", content) - } - func setFormattingPrompt(level: String, content: String) throws { - try promptSaveObserver?(level, content) - } - func setAssistivePrompt(content: String) throws { - try promptSaveObserver?("assistive", content) - } - func restoreFormattingPromptToDefault() throws { - try promptRestoreObserver?("formatting") - } - func restoreFormattingPromptToDefault(level: String) throws { - try promptRestoreObserver?(level) - } - func restoreAssistivePromptToDefault() throws { - try promptRestoreObserver?("assistive") - } - func resetPreview() -> CsResetPreview { resetPreviewValue } - func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { - try resetAppDataObserver?(includeKeys, includePrompts) - } - func clearMcpConfiguration() throws { - try clearMcpConfigurationObserver?() - } + var settings: CsSettings = .sample + var status: CsKeyStatus = .sampleAllSet + var dir: String = "~/.codescribe" + var onboarding: Bool = false + var mode: String? = "agentic" + var qualityRecords: [CsQualityRecord] = [] + var lexiconEntries: [CsLexiconEntry] = [] + var qualityRecordsLoader: (() throws -> [CsQualityRecord])? + var lexiconEntriesLoader: (() throws -> [CsLexiconEntry])? + var audioSnapshot: CsAudioInputSnapshot = .sample + var resetPreviewValue: CsResetPreview = .sample + var formattingSnapshot: CsPromptSnapshot = .sampleFormatting + var assistiveSnapshot: CsPromptSnapshot = .sampleAssistive + var promptSaveObserver: ((String, String) throws -> Void)? + var promptRestoreObserver: ((String) throws -> Void)? + var resetAppDataObserver: ((Bool, Bool) throws -> Void)? + var clearMcpConfigurationObserver: (() throws -> Void)? + var settingsLoader: (() -> CsSettings)? + var updateConfigManyObserver: (([CsConfigEntry]) throws -> Void)? + var resetAudioInputDeviceObserver: (() throws -> Void)? + var voiceLabEditObserver: ((String, String) throws -> CsVoiceLabSaveResult)? + // Keep the long-standing config observer last so existing trailing-closure + // call sites continue to bind to config writes, not Voice Lab edits. + var updateConfigObserver: ((String, String) throws -> Void)? + + func loadSettings() -> CsSettings { settingsLoader?() ?? settings } + func configDir() -> String { dir } + func shouldShowOnboarding() -> Bool { onboarding } + func onboardingMode() -> String? { mode } + func setOnboardingMode(mode: String) throws {} + + func updateConfig(key: String, value: String) throws { + try updateConfigObserver?(key, value) + } + func updateConfigMany(entries: [CsConfigEntry]) throws { + try updateConfigManyObserver?(entries) + } + func loadAudioInputSnapshot() throws -> CsAudioInputSnapshot { audioSnapshot } + func resetAudioInputDevice() throws { + try resetAudioInputDeviceObserver?() + } + func loadQualityRecentRecords(limit: UInt64) throws -> [CsQualityRecord] { + let records = try qualityRecordsLoader?() ?? qualityRecords + return Array(records.prefix(Int(clamping: limit))) + } + func loadLexiconCustomEntries() throws -> [CsLexiconEntry] { + try lexiconEntriesLoader?() ?? lexiconEntries + } + func finalizeVoiceLabCorrection(id: String, canonical: String) throws -> CsVoiceLabSaveResult { + if let voiceLabEditObserver { + return try voiceLabEditObserver(id, canonical) + } + guard let record = qualityRecords.first(where: { $0.id == id }) else { + throw NSError(domain: "VoiceLab", code: 404) + } + return CsVoiceLabSaveResult( + record: CsQualityRecord( + id: record.id, + revision: record.revision + 1, + rawText: record.rawText, + variant: record.variant, + editedText: canonical, + action: "edit", + timestampMs: record.timestampMs, + avgLogprob: nil, + speechPct: nil, + confidenceFlags: [] + ), + pairsLearned: 0, + lexiconError: nil + ) + } + func teachDictionaryFromStore() throws -> CsDictionaryTeachResult { + // Preview / mock: treat current lexicon as already taught. + let total = UInt32(lexiconEntries.count) + let fromCorrection = UInt32(lexiconEntries.filter { $0.source == "correction" }.count) + return CsDictionaryTeachResult( + fromCorrections: 0, + fromProposed: 0, + totalRules: total, + rulesFromCorrectionSource: fromCorrection + ) + } + + func keyStatus() -> CsKeyStatus { status } + func keyAccounts() -> [String] { + [ + "LLM_API_KEY", "STT_API_KEY", "LLM_FORMATTING_API_KEY", + "LLM_ASSISTIVE_API_KEY", "LLM_ANTHROPIC_API_KEY", "LLM_XAI_API_KEY", + "GITHUB_TOKEN", + ] + } + func setApiKey(account: String, secret: String) throws {} + func clearApiKey(account: String) throws {} + func testApiKey(account: String) throws -> CsApiKeyProbeResult { + CsApiKeyProbeResult.sample(account: account) + } + + func availableProviders() -> [CsProviderOption] { CsProviderOption.sampleProviders } + func discoverModels(providerId: String) -> CsModelDiscovery { + CsModelDiscovery.sample(for: providerId) + } + func startAccountLogin(providerId: String) throws -> CsAccountLoginResult { + CsAccountLoginResult( + providerId: providerId, + status: "blocked", + message: "awaiting app registration", + authUrl: nil, + signedIn: false, + clientIdConfigured: false + ) + } + + func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String { + // Mock: pass-through or minimal normalize for preview stability. + var base = endpoint.trimmingCharacters( + in: .whitespacesAndNewlines.union(.init(charactersIn: "/"))) + for s in ["/v1/responses", "/v1/chat/completions", "/v1/completions"] where base.hasSuffix(s) { + base.removeLast(s.count) + return base + "/v1/responses" + } + if base.hasSuffix("/v1") { base.removeLast(3) } + return base + "/v1/responses" + } + func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult + { + CsAccountLoginResult( + providerId: providerId, + status: "idle", + message: "no sign-in in progress", + authUrl: nil, + signedIn: false, + clientIdConfigured: false + ) + } + func cancelAccountLogin() {} + func signOutAccount(providerId: String) throws {} + + func getFormattingPrompt() -> String { CsSettings.samplePrompt } + func getAssistivePrompt() -> String { CsSettings.sampleAssistivePrompt } + func formattingPromptSnapshot() -> CsPromptSnapshot { formattingSnapshot } + func formattingPromptSnapshot(level: String) throws -> CsPromptSnapshot { + switch level { + case "correction": return formattingSnapshot + case "smart": return .sampleFormattingSmart + case "max": return .sampleFormattingMax + default: throw NSError(domain: "FormattingPolicy", code: 1) + } + } + func assistivePromptSnapshot() -> CsPromptSnapshot { assistiveSnapshot } + func defaultFormattingPrompt() -> String { CsSettings.samplePrompt } + func defaultAssistivePrompt() -> String { CsSettings.sampleAssistivePrompt } + func setFormattingPrompt(content: String) throws { + try promptSaveObserver?("formatting", content) + } + func setFormattingPrompt(level: String, content: String) throws { + try promptSaveObserver?(level, content) + } + func setAssistivePrompt(content: String) throws { + try promptSaveObserver?("assistive", content) + } + func restoreFormattingPromptToDefault() throws { + try promptRestoreObserver?("formatting") + } + func restoreFormattingPromptToDefault(level: String) throws { + try promptRestoreObserver?(level) + } + func restoreAssistivePromptToDefault() throws { + try promptRestoreObserver?("assistive") + } + func resetPreview() -> CsResetPreview { resetPreviewValue } + func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { + try resetAppDataObserver?(includeKeys, includePrompts) + } + func clearMcpConfiguration() throws { + try clearMcpConfigurationObserver?() + } } // MARK: - Bridge value helpers extension CsAudioInputSnapshot { - static let sample = CsAudioInputSnapshot( - devices: ["MacBook Pro Microphone", "USB Studio Mic"], - configuredDevice: nil, - runtimeDevice: "MacBook Pro Microphone", - configuredDeviceAvailable: true, - fallbackToDefault: false, - runtimeConfigurationMatches: true - ) + static let sample = CsAudioInputSnapshot( + devices: ["MacBook Pro Microphone", "USB Studio Mic"], + configuredDevice: nil, + runtimeDevice: "MacBook Pro Microphone", + configuredDeviceAvailable: true, + fallbackToDefault: false, + runtimeConfigurationMatches: true + ) } extension CsResetPreview { - static let sample = CsResetPreview( - audioFiles: 98, - transcriptDays: 6, - threads: 12, - totalBytes: 31_981_568 - ) + static let sample = CsResetPreview( + audioFiles: 98, + transcriptDays: 6, + threads: 12, + totalBytes: 31_981_568 + ) } extension CsPromptSnapshot { - static let sampleFormatting = CsPromptSnapshot( - content: CsSettings.samplePrompt, - path: "~/.codescribe/prompts/formatting.txt", - source: "custom_file", - readError: nil - ) - - static let sampleFormattingSmart = CsPromptSnapshot( - content: "Smart formatting preview prompt.", - path: "~/.codescribe/prompts/formatting-smart.txt", - source: "built_in_fallback", - readError: nil - ) - - static let sampleFormattingMax = CsPromptSnapshot( - content: "Max formatting preview prompt.", - path: "~/.codescribe/prompts/formatting-max.txt", - source: "built_in_fallback", - readError: nil - ) - - static let sampleAssistive = CsPromptSnapshot( - content: CsSettings.sampleAssistivePrompt, - path: "~/.codescribe/prompts/assistive.txt", - source: "custom_file", - readError: nil - ) + static let sampleFormatting = CsPromptSnapshot( + content: CsSettings.samplePrompt, + path: "~/.codescribe/prompts/formatting.txt", + source: "custom_file", + readError: nil + ) + + static let sampleFormattingSmart = CsPromptSnapshot( + content: "Smart formatting preview prompt.", + path: "~/.codescribe/prompts/formatting-smart.txt", + source: "built_in_fallback", + readError: nil + ) + + static let sampleFormattingMax = CsPromptSnapshot( + content: "Max formatting preview prompt.", + path: "~/.codescribe/prompts/formatting-max.txt", + source: "built_in_fallback", + readError: nil + ) + + static let sampleAssistive = CsPromptSnapshot( + content: CsSettings.sampleAssistivePrompt, + path: "~/.codescribe/prompts/assistive.txt", + source: "custom_file", + readError: nil + ) } extension CsLanguage { - /// Two-letter code shown in the UI and written to `WHISPER_LANGUAGE`. - var shortCode: String { - switch self { - case .auto: return "auto" - case .polish: return "pl" - case .english: return "en" - } - } - - /// Human-readable label for the language picker. - var displayName: String { - switch self { - case .auto: return "Auto" - case .polish: return "Polish" - case .english: return "English" - } - } + /// Two-letter code shown in the UI and written to `WHISPER_LANGUAGE`. + var shortCode: String { + switch self { + case .auto: return "auto" + case .polish: return "pl" + case .english: return "en" + } + } + + /// Human-readable label for the language picker. + var displayName: String { + switch self { + case .auto: return "Auto" + case .polish: return "Polish" + case .english: return "English" + } + } } extension CsSettings { - /// Sample config matching the mock (Polish whisper, local STT final-verdict). - static let sample = CsSettings( - holdExclusive: true, - holdArmModifier: "shift", - holdStartDelayMs: 250, - doubleTapIntervalMs: 320, - toggleSilenceSec: 1.5, - whisperLanguage: .polish, - aiFormattingEnabled: true, - transcriptSendMode: "end_of_utterance", - transcriptTaggingEnabled: false, - transcriptTagTemplate: "\n{text}\n", - aiMaxTokens: 1024, - aiAssistiveMaxTokens: 2048, - showTrayGlyph: true, - showDockIcon: false, - transcriptionOverlayEnabled: true, - holdIndicator: true, - holdBadgeSize: 12, - holdBadgeOffsetX: 0, - holdBadgeOffsetY: 0, - overlayPositionMode: "snapped_top_right", - overlayCustomX: nil, - overlayCustomY: nil, - beepOnStart: true, - soundName: "Tink", - soundVolume: 0.6, - audioInputDevice: nil, - historyEnabled: true, - quickNotesEnabled: true, - quickNotesSaveOnly: false, - useLocalStt: true, - localModel: "whisper-large-v3-turbo", - sttEndpoint: nil, - sttEngine: nil, - finalPassMode: nil, - llmEndpoint: "https://api.openai.com/v1/responses", - restoreClipboard: true, - restoreClipboardDelayMs: 200, - startAtLogin: false, - agentEnterSends: true, - dumpAudioLogs: false, - llmModel: "gpt-4o-mini", - llmFormattingEndpoint: "https://api.openai.com/v1/responses", - llmFormattingModel: "gpt-4o-mini", - llmAssistiveEndpoint: "https://api.openai.com/v1/responses", - llmAssistiveModel: "gpt-4o", - llmAssistiveProvider: "openai-responses", - formattingLevel: "correction", - whisperModel: "whisper-large-v3-turbo", - layeredTranscription: nil, - agentWorkspaceRoots: ["~/.codescribe"], - bufferDelayMs: nil, - typingCps: nil, - emitWordsMax: nil, - bufferedInterimSec: nil, - backendMaxUploadMb: nil - ) - - static let samplePrompt = - "Clean up the dictated text: fix punctuation and casing, drop filler words, keep the speaker's meaning intact." - static let sampleAssistivePrompt = - "You are a concise voice assistant. Answer the user's spoken request directly and act on it using the available tools." + /// Sample config matching the mock (Polish whisper, local STT final-verdict). + static let sample = CsSettings( + holdExclusive: true, + holdArmModifier: "shift", + holdStartDelayMs: 250, + doubleTapIntervalMs: 320, + toggleSilenceSec: 1.5, + deferredInsertShortcut: "disabled", + whisperLanguage: .polish, + aiFormattingEnabled: true, + transcriptSendMode: "end_of_utterance", + transcriptTaggingEnabled: false, + transcriptTagTemplate: "\n{text}\n", + aiMaxTokens: 1024, + aiAssistiveMaxTokens: 2048, + showTrayGlyph: true, + showDockIcon: false, + transcriptionOverlayEnabled: true, + holdIndicator: true, + holdBadgeSize: 12, + holdBadgeOffsetX: 0, + holdBadgeOffsetY: 0, + overlayPositionMode: "snapped_top_right", + overlayCustomX: nil, + overlayCustomY: nil, + beepOnStart: true, + soundName: "Tink", + soundVolume: 0.6, + audioInputDevice: nil, + historyEnabled: true, + quickNotesEnabled: true, + quickNotesSaveOnly: false, + useLocalStt: true, + localModel: "whisper-large-v3-turbo", + sttEndpoint: nil, + sttEngine: nil, + finalPassMode: nil, + llmEndpoint: "https://api.openai.com/v1/responses", + restoreClipboard: true, + restoreClipboardDelayMs: 200, + startAtLogin: false, + agentEnterSends: true, + dumpAudioLogs: false, + llmModel: "gpt-4o-mini", + llmFormattingEndpoint: "https://api.openai.com/v1/responses", + llmFormattingModel: "gpt-4o-mini", + llmAssistiveEndpoint: "https://api.openai.com/v1/responses", + llmAssistiveModel: "gpt-4o", + llmAssistiveProvider: "openai-responses", + formattingLevel: "correction", + whisperModel: "whisper-large-v3-turbo", + layeredTranscription: nil, + agentWorkspaceRoots: ["~/.codescribe"], + bufferDelayMs: nil, + typingCps: nil, + emitWordsMax: nil, + bufferedInterimSec: nil, + backendMaxUploadMb: nil + ) + + static let samplePrompt = + "Clean up the dictated text: fix punctuation and casing, drop filler words, keep the speaker's meaning intact." + static let sampleAssistivePrompt = + "You are a concise voice assistant. Answer the user's spoken request directly and act on it using the available tools." } extension CsKeyStatus { - /// All providers configured — used by the preview seed. - static let sampleAllSet = CsKeyStatus( - llmApiKeySet: true, - sttApiKeySet: true, - llmFormattingApiKeySet: true, - llmAssistiveApiKeySet: true, - llmAnthropicApiKeySet: false, - llmXaiApiKeySet: false, - githubTokenSet: false - ) - - /// Presence boolean for a canonical Keychain account name. - func isSet(account: String) -> Bool { - switch account { - case "LLM_API_KEY": return llmApiKeySet - case "STT_API_KEY": return sttApiKeySet - case "LLM_FORMATTING_API_KEY": return llmFormattingApiKeySet - case "LLM_ASSISTIVE_API_KEY": return llmAssistiveApiKeySet - case "LLM_ANTHROPIC_API_KEY": return llmAnthropicApiKeySet - case "LLM_XAI_API_KEY": return llmXaiApiKeySet - case "GITHUB_TOKEN": return githubTokenSet - default: return false - } - } + /// All providers configured — used by the preview seed. + static let sampleAllSet = CsKeyStatus( + llmApiKeySet: true, + sttApiKeySet: true, + llmFormattingApiKeySet: true, + llmAssistiveApiKeySet: true, + llmAnthropicApiKeySet: false, + llmXaiApiKeySet: false, + githubTokenSet: false + ) + + /// Presence boolean for a canonical Keychain account name. + func isSet(account: String) -> Bool { + switch account { + case "LLM_API_KEY": return llmApiKeySet + case "STT_API_KEY": return sttApiKeySet + case "LLM_FORMATTING_API_KEY": return llmFormattingApiKeySet + case "LLM_ASSISTIVE_API_KEY": return llmAssistiveApiKeySet + case "LLM_ANTHROPIC_API_KEY": return llmAnthropicApiKeySet + case "LLM_XAI_API_KEY": return llmXaiApiKeySet + case "GITHUB_TOKEN": return githubTokenSet + default: return false + } + } } extension CsApiKeyProbeResult { - static func sample(account: String) -> CsApiKeyProbeResult { - CsApiKeyProbeResult( - account: account, - status: account == "STT_API_KEY" ? .unsupported : .ok, - message: account == "STT_API_KEY" - ? "no cheap liveness probe is available for this STT key" - : "key accepted and quota available", - probedEndpoint: nil - ) - } + static func sample(account: String) -> CsApiKeyProbeResult { + CsApiKeyProbeResult( + account: account, + status: account == "STT_API_KEY" ? .unsupported : .ok, + message: account == "STT_API_KEY" + ? "no cheap liveness probe is available for this STT key" + : "key accepted and quota available", + probedEndpoint: nil + ) + } } extension CsProviderOption { - /// Preview seed mirroring the core provider identities (OpenAI, Anthropic, xAI). - static let sampleProviders: [CsProviderOption] = [ - // OpenAI + xAI ship public desktop client ids (NOTICE); Anthropic does not. - CsProviderOption( - id: "openai-responses", - displayName: "OpenAI (Responses)", - apiKeyAccount: "LLM_ASSISTIVE_API_KEY", - apiKeySet: true, - accountSignedIn: false, - accountLoginEnabled: true, - accountStatusMessage: "not signed in", - oauthClientId: nil, - models: [] - ), - CsProviderOption( - id: "anthropic-messages", - displayName: "Anthropic (Messages)", - apiKeyAccount: "LLM_ANTHROPIC_API_KEY", - apiKeySet: false, - accountSignedIn: false, - accountLoginEnabled: false, - accountStatusMessage: "provider account login unavailable", - oauthClientId: nil, - models: [] - ), - CsProviderOption( - id: "xai-responses", - displayName: "xAI (Grok)", - apiKeyAccount: "LLM_XAI_API_KEY", - apiKeySet: false, - accountSignedIn: false, - accountLoginEnabled: true, - accountStatusMessage: "not signed in", - oauthClientId: nil, - models: [] - ), - ] + /// Preview seed mirroring the core provider identities (OpenAI, Anthropic, xAI). + static let sampleProviders: [CsProviderOption] = [ + // OpenAI + xAI ship public desktop client ids (NOTICE); Anthropic does not. + CsProviderOption( + id: "openai-responses", + displayName: "OpenAI (Responses)", + apiKeyAccount: "LLM_ASSISTIVE_API_KEY", + apiKeySet: true, + accountSignedIn: false, + accountLoginEnabled: true, + accountStatusMessage: "not signed in", + oauthClientId: nil, + models: [] + ), + CsProviderOption( + id: "anthropic-messages", + displayName: "Anthropic (Messages)", + apiKeyAccount: "LLM_ANTHROPIC_API_KEY", + apiKeySet: false, + accountSignedIn: false, + accountLoginEnabled: false, + accountStatusMessage: "provider account login unavailable", + oauthClientId: nil, + models: [] + ), + CsProviderOption( + id: "xai-responses", + displayName: "xAI (Grok)", + apiKeyAccount: "LLM_XAI_API_KEY", + apiKeySet: false, + accountSignedIn: false, + accountLoginEnabled: true, + accountStatusMessage: "not signed in", + oauthClientId: nil, + models: [] + ), + ] } extension CsModelDiscovery { - static func sample(for providerId: String) -> CsModelDiscovery { - switch providerId { - case "anthropic-messages": - return CsModelDiscovery( - providerId: providerId, - status: "no_key", - message: "Add API key to discover models", - models: [] - ) - default: - let models = [CsSettings.sample.llmAssistiveModel, CsSettings.sample.llmFormattingModel] - .compactMap { $0 } - .map { CsModelOption(id: $0, displayName: $0) } - return CsModelDiscovery( - providerId: "openai-responses", - status: "fresh", - message: nil, - models: models - ) - } - } + static func sample(for providerId: String) -> CsModelDiscovery { + switch providerId { + case "anthropic-messages": + return CsModelDiscovery( + providerId: providerId, + status: "no_key", + message: "Add API key to discover models", + models: [] + ) + default: + let models = [CsSettings.sample.llmAssistiveModel, CsSettings.sample.llmFormattingModel] + .compactMap { $0 } + .map { CsModelOption(id: $0, displayName: $0) } + return CsModelDiscovery( + providerId: "openai-responses", + status: "fresh", + message: nil, + models: models + ) + } + } } diff --git a/macos/Codescribe/Screens/Settings/SettingsView.swift b/macos/Codescribe/Screens/Settings/SettingsView.swift index a577b94b..2d0723e8 100644 --- a/macos/Codescribe/Screens/Settings/SettingsView.swift +++ b/macos/Codescribe/Screens/Settings/SettingsView.swift @@ -18,191 +18,194 @@ import SwiftUI // in it, and it hosts the system sidebar toggle. That turns the reason for the // fork into the feature the operator asked for. struct SettingsView: View { - @StateObject private var model: SettingsViewModel - @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var search: String = "" - - init(model: SettingsViewModel? = nil) { - _model = StateObject(wrappedValue: model ?? SettingsViewModel()) - } - - var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - sidebar - .navigationSplitViewColumnWidth(min: 196, ideal: 216, max: 300) - .safeAreaInset(edge: .bottom, spacing: 0) { SettingsHealthFooter(model: model) } - } detail: { - detail - } - .navigationTitle("") - .toolbar { - ToolbarItem(placement: .navigation) { - HStack(spacing: 9) { - Wordmark(size: 14) - .fixedSize(horizontal: true, vertical: false) - Text("v\(model.appVersion)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - } - } - .csFocusPolicy() - .frame(minWidth: 880, maxWidth: .infinity, minHeight: 620, maxHeight: .infinity) - .background(SettingsWindowCapabilities()) - // The panels still paint hand-picked dark tokens, so the window stays - // pinned to dark until the palette itself is theme-aware. Removing this - // line before that work lands would render dark text on a light system - // background — the sidebar is native either way. - .preferredColorScheme(.dark) - .onAppear { - model.refresh() - consumePendingDeepLink() - } - .onReceive(NotificationCenter.default.publisher(for: SettingsDeepLink.pendingSectionDidChange)) { _ in - consumePendingDeepLink() - } + @StateObject private var model: SettingsViewModel + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var search: String = "" + + init(model: SettingsViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? SettingsViewModel()) + } + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + sidebar + .navigationSplitViewColumnWidth(min: 196, ideal: 216, max: 300) + .safeAreaInset(edge: .bottom, spacing: 0) { SettingsHealthFooter(model: model) } + } detail: { + detail } - - /// Native sidebar: grouped sections, SF Symbol rows, system selection, and a - /// search field that matches panel names AND what each panel does. - private var sidebar: some View { - List(selection: routeSelection) { - ForEach(SettingsSectionGroup.allCases) { group in - let items = matchedSections.filter { $0.group == group } - if !items.isEmpty { - Section(group.title) { - ForEach(items) { item in - sidebarRow(item) - } - } - } - } - } - .listStyle(.sidebar) - .searchable( - text: $search, - placement: .sidebar, - prompt: "Search settings" - ) - } - - /// A paginated section renders as an expandable parent whose children are - /// its pages; everything else stays a plain row. While a search is active - /// the tree is pre-expanded — a hit the user cannot see is not a hit. - @ViewBuilder - private func sidebarRow(_ item: SettingsSection) -> some View { - let pages = visiblePages(in: item) - if pages.isEmpty { - Label(item.title, systemImage: item.symbol) - .tag(SettingsRoute.section(item)) - .accessibilityIdentifier("settings-rail-\(item.rawValue)") - } else { - DisclosureGroup(isExpanded: expansion(for: item)) { - ForEach(pages) { page in - Label(page.title, systemImage: page.symbol) - .tag(SettingsRoute.page(page)) - .accessibilityIdentifier("settings-rail-page-\(page.rawValue)") - } - } label: { - Label(item.title, systemImage: item.symbol) - .tag(SettingsRoute.section(item)) - .accessibilityIdentifier("settings-rail-\(item.rawValue)") - } + .navigationTitle("") + .toolbar { + ToolbarItem(placement: .navigation) { + HStack(spacing: 9) { + Wordmark(size: 14) + .fixedSize(horizontal: true, vertical: false) + Text("v\(model.appVersion)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) } + } } - - /// Sections the current query reveals: a title/keyword hit on the section - /// itself, or on any of its pages (so "mcp" surfaces Agent). - private var matchedSections: [SettingsSection] { - let direct = Set(SettingsSection.matching(query: search)) - let viaPages = Set(SettingsPage.matching(query: search).map(\.section)) - let hits = search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? direct - : direct.union(viaPages) - return SettingsSection.allCases.filter { hits.contains($0) } + .csFocusPolicy() + .frame(minWidth: 880, maxWidth: .infinity, minHeight: 620, maxHeight: .infinity) + .background(SettingsWindowCapabilities()) + // The panels still paint hand-picked dark tokens, so the window stays + // pinned to dark until the palette itself is theme-aware. Removing this + // line before that work lands would render dark text on a light system + // background — the sidebar is native either way. + .preferredColorScheme(.dark) + .onAppear { + model.refresh() + consumePendingDeepLink() } - - /// Pages to show under a section: all of them normally, only the matches - /// while searching — unless the section itself matched, which means the user - /// asked for the section and deserves its full contents. - private func visiblePages(in section: SettingsSection) -> [SettingsPage] { - let all = SettingsPage.pages(in: section) - guard !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !SettingsSection.matching(query: search).contains(section) else { return all } - let matched = Set(SettingsPage.matching(query: search)) - return all.filter { matched.contains($0) } + .onReceive(NotificationCenter.default.publisher(for: SettingsDeepLink.pendingSectionDidChange)) + { _ in + consumePendingDeepLink() } - - private func expansion(for section: SettingsSection) -> Binding { - Binding( - get: { - !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || model.section == section - }, - set: { expanded in - // Expanding a collapsed parent is also a navigation intent. - if expanded, model.section != section { model.select(section) } + } + + /// Native sidebar: grouped sections, SF Symbol rows, system selection, and a + /// search field that matches panel names AND what each panel does. + private var sidebar: some View { + List(selection: routeSelection) { + ForEach(SettingsSectionGroup.allCases) { group in + let items = matchedSections.filter { $0.group == group } + if !items.isEmpty { + Section(group.title) { + ForEach(items) { item in + sidebarRow(item) } - ) - } - - /// `List` selection is optional by contract; a nil write (⌘-click clearing a - /// row) must not blank the detail pane, so it is dropped instead of applied. - private var routeSelection: Binding { - Binding( - get: { model.route }, - set: { if let value = $0 { model.select(value) } } - ) - } - - private func consumePendingDeepLink() { - guard let target = SettingsDeepLink.consume() else { return } - model.select(target) + } + } + } } - - @ViewBuilder - private var detail: some View { - ScrollView { - Group { - switch model.section.destination { - case .dictation: - EnginePanel(model: model) - case .shortcuts: - ShortcutsPanel(model: model) - case .providers: - KeysPanel(model: model) - case .agent: - AgentPanel(model: model) - case .prompts: - PromptPanel(model: model) - case .user: - UserPanel(model: model) - case .dictionary: - VoiceLabPanel(model: model) - case .audio: - AudioPanel(model: model) - case .license: - LicensePanel(model: model) - case .creator: - CreatorPanel(model: model) - } - } - .frame(maxWidth: .infinity, alignment: .leading) + .listStyle(.sidebar) + .searchable( + text: $search, + placement: .sidebar, + prompt: "Search settings" + ) + } + + /// A paginated section renders as an expandable parent whose children are + /// its pages; everything else stays a plain row. While a search is active + /// the tree is pre-expanded — a hit the user cannot see is not a hit. + @ViewBuilder + private func sidebarRow(_ item: SettingsSection) -> some View { + let pages = visiblePages(in: item) + if pages.isEmpty { + Label(item.title, systemImage: item.symbol) + .tag(SettingsRoute.section(item)) + .accessibilityIdentifier("settings-rail-\(item.rawValue)") + } else { + DisclosureGroup(isExpanded: expansion(for: item)) { + ForEach(pages) { page in + Label(page.title, systemImage: page.symbol) + .tag(SettingsRoute.page(page)) + .accessibilityIdentifier("settings-rail-page-\(page.rawValue)") } - .scrollContentBackground(.hidden) - .background(Self.windowGradient) + } label: { + Label(item.title, systemImage: item.symbol) + .tag(SettingsRoute.section(item)) + .accessibilityIdentifier("settings-rail-\(item.rawValue)") + } } - - /// linear-gradient(135deg,#15110e,#0b0c10 55%,#0d1012) from the mock. - static let windowGradient = LinearGradient( - stops: [ - .init(color: Color(hex: 0x15110E), location: 0.0), - .init(color: Color(hex: 0x0B0C10), location: 0.55), - .init(color: Color(hex: 0x0D1012), location: 1.0) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing + } + + /// Sections the current query reveals: a title/keyword hit on the section + /// itself, or on any of its pages (so "mcp" surfaces Agent). + private var matchedSections: [SettingsSection] { + let direct = Set(SettingsSection.matching(query: search)) + let viaPages = Set(SettingsPage.matching(query: search).map(\.section)) + let hits = + search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? direct + : direct.union(viaPages) + return SettingsSection.allCases.filter { hits.contains($0) } + } + + /// Pages to show under a section: all of them normally, only the matches + /// while searching — unless the section itself matched, which means the user + /// asked for the section and deserves its full contents. + private func visiblePages(in section: SettingsSection) -> [SettingsPage] { + let all = SettingsPage.pages(in: section) + guard !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !SettingsSection.matching(query: search).contains(section) + else { return all } + let matched = Set(SettingsPage.matching(query: search)) + return all.filter { matched.contains($0) } + } + + private func expansion(for section: SettingsSection) -> Binding { + Binding( + get: { + !search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || model.section == section + }, + set: { expanded in + // Expanding a collapsed parent is also a navigation intent. + if expanded, model.section != section { model.select(section) } + } + ) + } + + /// `List` selection is optional by contract; a nil write (⌘-click clearing a + /// row) must not blank the detail pane, so it is dropped instead of applied. + private var routeSelection: Binding { + Binding( + get: { model.route }, + set: { if let value = $0 { model.select(value) } } ) + } + + private func consumePendingDeepLink() { + guard let target = SettingsDeepLink.consume() else { return } + model.select(target) + } + + @ViewBuilder + private var detail: some View { + ScrollView { + Group { + switch model.section.destination { + case .dictation: + EnginePanel(model: model) + case .shortcuts: + ShortcutsPanel(model: model) + case .providers: + KeysPanel(model: model) + case .agent: + AgentPanel(model: model) + case .prompts: + PromptPanel(model: model) + case .user: + UserPanel(model: model) + case .dictionary: + VoiceLabPanel(model: model) + case .audio: + AudioPanel(model: model) + case .license: + LicensePanel(model: model) + case .creator: + CreatorPanel(model: model) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollContentBackground(.hidden) + .background(Self.windowGradient) + } + + /// linear-gradient(135deg,#15110e,#0b0c10 55%,#0d1012) from the mock. + static let windowGradient = LinearGradient( + stops: [ + .init(color: Color(hex: 0x15110E), location: 0.0), + .init(color: Color(hex: 0x0B0C10), location: 0.55), + .init(color: Color(hex: 0x0D1012), location: 1.0), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) } /// SwiftUI's Settings scene can silently keep the content-sized AppKit style @@ -210,36 +213,36 @@ struct SettingsView: View { /// older saved frame). Enforce normal macOS window capabilities on the actual /// host window so Settings can resize, zoom and enter native full screen. private struct SettingsWindowCapabilities: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - DispatchQueue.main.async { configure(view.window) } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { configure(nsView.window) } + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + DispatchQueue.main.async { configure(view.window) } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { configure(nsView.window) } + } + + private func configure(_ window: NSWindow?) { + guard let window else { return } + window.styleMask.formUnion([.resizable, .miniaturizable, .fullSizeContentView]) + window.collectionBehavior.insert(.fullScreenPrimary) + let minimum = NSSize(width: 880, height: 620) + window.minSize = minimum + window.level = .normal + window.standardWindowButton(.zoomButton)?.isEnabled = true + window.standardWindowButton(.miniaturizeButton)?.isEnabled = true + + var frame = window.frame + frame.size.width = max(frame.width, minimum.width) + frame.size.height = max(frame.height, minimum.height) + if let screen = window.screen ?? NSScreen.main { + frame = window.constrainFrameRect(frame, to: screen) } - - private func configure(_ window: NSWindow?) { - guard let window else { return } - window.styleMask.formUnion([.resizable, .miniaturizable, .fullSizeContentView]) - window.collectionBehavior.insert(.fullScreenPrimary) - let minimum = NSSize(width: 880, height: 620) - window.minSize = minimum - window.level = .normal - window.standardWindowButton(.zoomButton)?.isEnabled = true - window.standardWindowButton(.miniaturizeButton)?.isEnabled = true - - var frame = window.frame - frame.size.width = max(frame.width, minimum.width) - frame.size.height = max(frame.height, minimum.height) - if let screen = window.screen ?? NSScreen.main { - frame = window.constrainFrameRect(frame, to: screen) - } - if frame != window.frame { - window.setFrame(frame, display: false) - } + if frame != window.frame { + window.setFrame(frame, display: false) } + } } // MARK: - Rail @@ -248,165 +251,169 @@ private struct SettingsWindowCapabilities: NSViewRepresentable { /// hand-drawn rail that carried information rather than chrome: a live health /// line that deep-links to the section owning the problem. private struct SettingsHealthFooter: View { - @ObservedObject var model: SettingsViewModel - - var body: some View { - let health = model.settingsHealth - Group { - if let target = health.targetSection { - Button { model.select(target) } label: { content(health) } - .csFocusRing(cornerRadius: 8) - .help("Open \(target.title) settings") - } else { - content(health) - } + @ObservedObject var model: SettingsViewModel + + var body: some View { + let health = model.settingsHealth + Group { + if let target = health.targetSection { + Button { + model.select(target) + } label: { + content(health) } - .accessibilityIdentifier("settings-health-footer") + .csFocusRing(cornerRadius: 8) + .help("Open \(target.title) settings") + } else { + content(health) + } } - - private func content(_ health: SettingsHealthState) -> some View { - HStack(spacing: 8) { - Circle().fill(health.level.color).frame(width: 6, height: 6) - Text(health.message) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(health.level.color) - .lineLimit(2) - Spacer(minLength: 0) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .contentShape(Rectangle()) - .overlay(alignment: .top) { - Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) - } + .accessibilityIdentifier("settings-health-footer") + } + + private func content(_ health: SettingsHealthState) -> some View { + HStack(spacing: 8) { + Circle().fill(health.level.color).frame(width: 6, height: 6) + Text(health.message) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(health.level.color) + .lineLimit(2) + Spacer(minLength: 0) } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .overlay(alignment: .top) { + Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) + } + } } -private extension SettingsHealthLevel { - var color: Color { - switch self { - case .healthy: return CSColor.oliveLight - case .degraded: return CSColor.amber - case .offline: return CSColor.terracottaLight - case .unknown: return CSColor.textFaint - } +extension SettingsHealthLevel { + fileprivate var color: Color { + switch self { + case .healthy: return CSColor.oliveLight + case .degraded: return CSColor.amber + case .offline: return CSColor.terracottaLight + case .unknown: return CSColor.textFaint } + } } // MARK: - Shared Settings chrome (consumed by every panel) struct SettingsSectionLabel: View { - let text: String - init(_ text: String) { self.text = text } - var body: some View { - Text(text.uppercased()) - .font(CSFont.mono(12, .semibold)) - .tracking(0.5) - .foregroundStyle(CSColor.textMuted) - } + let text: String + init(_ text: String) { self.text = text } + var body: some View { + Text(text.uppercased()) + .font(CSFont.mono(12, .semibold)) + .tracking(0.5) + .foregroundStyle(CSColor.textMuted) + } } struct SettingsMenuLabel: View { - let text: String - var mono: Bool = false - var chrome: Bool = false - - var body: some View { - if chrome { - content - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - .contentShape(Rectangle()) - } else { - content - } + let text: String + var mono: Bool = false + var chrome: Bool = false + + var body: some View { + if chrome { + content + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .contentShape(Rectangle()) + } else { + content } - - private var content: some View { - HStack(spacing: 6) { - Text(text) - .font(mono ? CSFont.mono(12.5, .semibold) : CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - .lineLimit(1) - CSIconView(icon: .chevronUpDown, size: 9, weight: .semibold, color: CSColor.textFaint) - } + } + + private var content: some View { + HStack(spacing: 6) { + Text(text) + .font(mono ? CSFont.mono(12.5, .semibold) : CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + .lineLimit(1) + CSIconView(icon: .chevronUpDown, size: 9, weight: .semibold, color: CSColor.textFaint) } + } } /// Read-only key/value row for runtime-truth blocks (Dictation and Providers). struct RuntimeRow: View { - enum Trailing { - case none - case dot(Color) - case text(String, Color) + enum Trailing { + case none + case dot(Color) + case text(String, Color) + } + + let key: String + let value: String + var tint: Bool = false + var mono: Bool = false + var trailing: Trailing = .none + + var body: some View { + HStack(spacing: 12) { + Text(key) + .font(CSFont.mono(12, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .frame(width: 160, alignment: .leading) + Text(value) + .font(mono ? CSFont.mono(12.5, .semibold) : CSFont.ui(12.5, .semibold)) + .foregroundStyle(mono ? CSColor.textBodyAlt : CSColor.textHigh) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + trailingView } - - let key: String - let value: String - var tint: Bool = false - var mono: Bool = false - var trailing: Trailing = .none - - var body: some View { - HStack(spacing: 12) { - Text(key) - .font(CSFont.mono(12, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .frame(width: 160, alignment: .leading) - Text(value) - .font(mono ? CSFont.mono(12.5, .semibold) : CSFont.ui(12.5, .semibold)) - .foregroundStyle(mono ? CSColor.textBodyAlt : CSColor.textHigh) - .lineLimit(1) - .truncationMode(.middle) - .frame(maxWidth: .infinity, alignment: .leading) - trailingView - } - .padding(.horizontal, 16) - .padding(.vertical, 13) - .background(tint ? CSColor.surfaceRaised(0.02) : Color.clear) - } - - @ViewBuilder - private var trailingView: some View { - switch trailing { - case .none: - EmptyView() - case .dot(let color): - Circle().fill(color).frame(width: 7, height: 7) - case .text(let label, let color): - Text(label) - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(color) - } + .padding(.horizontal, 16) + .padding(.vertical, 13) + .background(tint ? CSColor.surfaceRaised(0.02) : Color.clear) + } + + @ViewBuilder + private var trailingView: some View { + switch trailing { + case .none: + EmptyView() + case .dot(let color): + Circle().fill(color).frame(width: 7, height: 7) + case .text(let label, let color): + Text(label) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(color) } + } } #if DEBUG -#Preview("Settings — Creator") { + #Preview("Settings — Creator") { SettingsView(model: SettingsViewModel.preview(.creator)) - .frame(width: 960, height: 620) -} + .frame(width: 960, height: 620) + } -#Preview("Settings — Dictation") { + #Preview("Settings — Dictation") { SettingsView(model: SettingsViewModel.preview(.engine)) - .frame(width: 960, height: 620) -} + .frame(width: 960, height: 620) + } -#Preview("Settings — Providers") { + #Preview("Settings — Providers") { SettingsView(model: SettingsViewModel.preview(.keys)) - .frame(width: 960, height: 620) -} + .frame(width: 960, height: 620) + } -#Preview("Settings — Prompts") { + #Preview("Settings — Prompts") { SettingsView(model: SettingsViewModel.preview(.prompts)) - .frame(width: 960, height: 620) -} + .frame(width: 960, height: 620) + } #endif diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 500319c4..2b2471cd 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -1,176 +1,213 @@ import AppKit import SwiftUI -let defaultTranscriptTagTemplate = "\n{text}\n" +let defaultTranscriptTagTemplate = + "\n{text}\n" let transcriptTagTemplatePlaceholders = ["{mode}", "{lang}", "{text}", "{conf}", "{flags}"] func transcriptTagTemplatePreview( - _ template: String, - mode: String = "dictation", - lang: String = "pl", - text: String = "…", - conf: String = "medium", - flags: String = "possible_hallucination_logprob" + _ template: String, + mode: String = "dictation", + lang: String = "pl", + text: String = "…", + conf: String = "medium", + flags: String = "possible_hallucination_logprob" ) -> String { - var rendered = template - .replacingOccurrences(of: "{mode}", with: mode) - .replacingOccurrences(of: "{lang}", with: lang) - .replacingOccurrences(of: "{conf}", with: conf) - .replacingOccurrences(of: "{flags}", with: flags) - if rendered.contains("{text}") { - return rendered.replacingOccurrences(of: "{text}", with: text) - } - if !rendered.isEmpty, !rendered.hasSuffix("\n") { - rendered.append("\n") - } - rendered.append(text) - return rendered + var rendered = + template + .replacingOccurrences(of: "{mode}", with: mode) + .replacingOccurrences(of: "{lang}", with: lang) + .replacingOccurrences(of: "{conf}", with: conf) + .replacingOccurrences(of: "{flags}", with: flags) + if rendered.contains("{text}") { + return rendered.replacingOccurrences(of: "{text}", with: text) + } + if !rendered.isEmpty, !rendered.hasSuffix("\n") { + rendered.append("\n") + } + rendered.append(text) + return rendered } func transcriptTagTemplateAppendWarning(_ template: String) -> String? { - template.contains("{text}") - ? nil - : "Missing {text}; delivered transcript will be appended after the template." + template.contains("{text}") + ? nil + : "Missing {text}; delivered transcript will be appended after the template." } /// Runtime serving truth for the Active STT row (not configured preference). struct LastServingVerdict: Equatable { - let engine: String - let routingMode: String - let disposition: String? - let fallbackUsed: Bool + let engine: String + let routingMode: String + let disposition: String? + let fallbackUsed: Bool } /// Format Active STT from the last serving verdict. Config projection is forbidden. func formatActiveSTT(lastServing: LastServingVerdict?) -> String { - guard let verdict = lastServing else { - return "Not yet served" - } - let engine: String - switch verdict.engine { - case "local_apple": - engine = "Apple on-device" - case "local_whisper": - engine = verdict.fallbackUsed ? "Whisper (fallback)" : "Whisper" - case "streaming_whisper": - engine = "Streaming Whisper" - case "cloud_stt": - engine = "Cloud" - default: - engine = verdict.engine.isEmpty ? "Unknown" : verdict.engine - } - let mode: String - switch verdict.routingMode.lowercased() { - case "always": - mode = "Always final pass" - case "off": - mode = "Off final pass" - default: - mode = "Smart final pass" - } - if let disposition = verdict.disposition, !disposition.isEmpty { - return "\(engine) · \(mode) · \(disposition)" - } - return "\(engine) · \(mode)" + guard let verdict = lastServing else { + return "Not yet served" + } + let engine: String + switch verdict.engine { + case "local_apple": + engine = "Apple on-device" + case "local_whisper": + engine = verdict.fallbackUsed ? "Whisper (fallback)" : "Whisper" + case "streaming_whisper": + engine = "Streaming Whisper" + case "cloud_stt": + engine = "Cloud" + default: + engine = verdict.engine.isEmpty ? "Unknown" : verdict.engine + } + let mode: String + switch verdict.routingMode.lowercased() { + case "always": + mode = "Always final pass" + case "off": + mode = "Off final pass" + default: + mode = "Smart final pass" + } + if let disposition = verdict.disposition, !disposition.isEmpty { + return "\(engine) · \(mode) · \(disposition)" + } + return "\(engine) · \(mode)" } enum SettingsSectionAvailability: Equatable { - case available - case hidden + case available + case hidden } enum FormattingPolicyOption: String, CaseIterable, Identifiable { - case off - case correction - case smart - case max - - var id: String { rawValue } - var visibleName: String { rawValue.capitalized } - - init?(storedValue: String?) { - switch storedValue { - case "off", "raw": self = .off - case "correction", "medium", nil: self = .correction - case "smart": self = .smart - case "max", "creative": self = .max - default: return nil - } - } - - static let editablePrompts: [Self] = [.correction, .smart, .max] - - /// Next level in the tray's cycling control: Off → Correction → Smart → Max → Off. - var next: Self { - let all = Self.allCases - let index = all.firstIndex(of: self) ?? all.startIndex - return all[(index + 1) % all.count] - } + case off + case correction + case smart + case max + + var id: String { rawValue } + var visibleName: String { rawValue.capitalized } + + init?(storedValue: String?) { + switch storedValue { + case "off", "raw": self = .off + case "correction", "medium", nil: self = .correction + case "smart": self = .smart + case "max", "creative": self = .max + default: return nil + } + } + + static let editablePrompts: [Self] = [.correction, .smart, .max] + + /// Next level in the tray's cycling control: Off → Correction → Smart → Max → Off. + var next: Self { + let all = Self.allCases + let index = all.firstIndex(of: self) ?? all.startIndex + return all[(index + 1) % all.count] + } } enum HoldBadgeOption: CaseIterable, Identifiable, Equatable { - case off - case four - case eight - case twelve - - var id: String { visibleName } - var visibleName: String { - guard let size else { return "Off" } - return "\(size)px" - } - var size: UInt32? { - switch self { - case .off: return nil - case .four: return 4 - case .eight: return 8 - case .twelve: return 12 - } - } - - init(indicatorEnabled: Bool, size: UInt32) { - guard indicatorEnabled else { - self = .off - return - } - switch size { - case 4: self = .four - case 8: self = .eight - default: self = .twelve - } - } + case off + case four + case eight + case twelve + + var id: String { visibleName } + var visibleName: String { + guard let size else { return "Off" } + return "\(size)px" + } + var size: UInt32? { + switch self { + case .off: return nil + case .four: return 4 + case .eight: return 8 + case .twelve: return 12 + } + } + + init(indicatorEnabled: Bool, size: UInt32) { + guard indicatorEnabled else { + self = .off + return + } + switch size { + case 4: self = .four + case 8: self = .eight + default: self = .twelve + } + } + + var next: Self { + let all = Self.allCases + let index = all.firstIndex(of: self) ?? all.startIndex + return all[(index + 1) % all.count] + } +} - var next: Self { - let all = Self.allCases - let index = all.firstIndex(of: self) ?? all.startIndex - return all[(index + 1) % all.count] - } +/// Deferred-insert delivery chord (`CODESCRIBE_DEFERRED_INSERT_SHORTCUT`). +/// Mirrors core `DeferredInsertShortcut` (core/config/types.rs) — the same +/// closed set, the same wire ids, the same modifier-glyph labels. Disabled is +/// the product default: the CGEventTap is listen-only, so a host app bound to +/// the same chord would see BOTH actions fire (types.rs, review P1-04). +enum DeferredInsertShortcutOption: String, CaseIterable, Identifiable, Equatable { + case disabled = "disabled" + case commandOptionV = "command_option_v" + case commandShiftV = "command_shift_v" + case commandControlV = "command_control_v" + + var id: String { rawValue } + + /// Canonical wire identifier persisted through `update_config` — must stay + /// lockstep with `DeferredInsertShortcut::wire_id()`. + var wireId: String { rawValue } + + /// Reads the canonical bridge value defensively. The core always emits one + /// of these four ids, while an unknown legacy/corrupt value renders as the + /// safe opt-in default instead of causing a passive write. + init(wireId: String) { + self = Self(rawValue: wireId) ?? .disabled + } + + /// Chord rendered with macOS modifier glyphs (matches the Rust `label()`). + var visibleName: String { + switch self { + case .disabled: return "Disabled" + case .commandOptionV: return "⌘⌥V" + case .commandShiftV: return "⌘⇧V" + case .commandControlV: return "⌘⌃V" + } + } } /// Panel a rail section routes to. `SettingsView`'s detail switch consumes this /// map exhaustively, so routing stays testable without rendering. enum SettingsPanelDestination: Equatable { - case creator - case shortcuts - case providers - case agent - case prompts - case dictation - case audio - case dictionary - case license - case user + case creator + case shortcuts + case providers + case agent + case prompts + case dictation + case audio + case dictionary + case license + case user } /// Testable ownership contract for the two settings surfaces that used to be /// mixed together. This is UI metadata only; it never participates in storage. enum SettingsPanelCapability: Hashable { - case apiKeys - case llmLanes - case workspaceRoots - case agentStatus - case mcpServers - case toolPermissions + case apiKeys + case llmLanes + case workspaceRoots + case agentStatus + case mcpServers + case toolPermissions } // Every rail section declares its product truth explicitly. The raw value is a @@ -178,116 +215,116 @@ enum SettingsPanelCapability: Hashable { // the user-visible name — rail, eyebrows, help copy, and dictionary-supporting // copy all derive from it, so renaming a tab is a one-line change. enum SettingsSection: String, CaseIterable, Identifiable { - case creator - case shortcuts - case keys - case agent - case prompts - case engine - case audio - case voiceLab - case license - case user - - var id: String { rawValue } - - var title: String { - switch self { - case .creator: return "Creator" - case .shortcuts: return "Hotkeys" - case .keys: return "Providers" - case .agent: return "Agent" - case .prompts: return "Prompts" - case .engine: return "Dictation" - case .audio: return "Audio" - case .voiceLab: return "Dictionary" - case .license: return "License" - case .user: return "User" - } - } - - var destination: SettingsPanelDestination { - switch self { - case .creator: return .creator - case .shortcuts: return .shortcuts - case .keys: return .providers - case .agent: return .agent - case .prompts: return .prompts - case .engine: return .dictation - case .audio: return .audio - case .voiceLab: return .dictionary - case .license: return .license - case .user: return .user - } - } - - var availability: SettingsSectionAvailability { - switch self { - case .creator, .shortcuts, .keys, .agent, .prompts, .engine, .audio, .voiceLab, .license, .user: - return .available - } - } - - var isInteractive: Bool { availability == .available } - - /// Sidebar grouping. A flat ten-item list forces the user to read every row; - /// native sidebars carry `Section` headers for free, so the rail states what - /// each area is FOR instead of relying on the reader's memory. - var group: SettingsSectionGroup { - switch self { - case .creator, .shortcuts, .audio: return .setup - case .keys, .agent, .prompts, .engine, .voiceLab: return .intelligence - case .license, .user: return .account - } - } - - /// SF Symbol for the sidebar row. System symbols follow the user's theme, - /// accent and accessibility sizes; the previous hand-drawn 7pt dots did not. - var symbol: String { - switch self { - case .creator: return "wand.and.stars" - case .shortcuts: return "keyboard" - case .keys: return "key.horizontal" - case .agent: return "cpu" - case .prompts: return "text.bubble" - case .engine: return "waveform" - case .audio: return "mic" - case .voiceLab: return "character.book.closed" - case .license: return "checkmark.seal" - case .user: return "person.crop.circle" - } - } - - /// Extra terms the settings search matches beyond the visible title, so the - /// user can look for what a panel DOES ("api key", "mikrofon") instead of - /// having to guess the tab name. - var searchKeywords: [String] { - switch self { - case .creator: return ["setup", "onboarding", "permissions", "quick start", "language"] - case .shortcuts: return ["hotkey", "keyboard", "shortcut", "trigger", "hold", "toggle"] - case .keys: return ["api key", "provider", "openai", "anthropic", "endpoint", "model", "token"] - case .agent: return ["mcp", "tools", "workspace", "permissions", "server"] - case .prompts: return ["system prompt", "persona", "assistive", "instructions"] - case .engine: return ["stt", "whisper", "apple", "speech", "transcription", "final pass"] - case .audio: return ["microphone", "mikrofon", "input", "device", "levels"] - case .voiceLab: return ["lexicon", "dictionary", "vocabulary", "corrections", "słownik"] - case .license: return ["subscription", "activation", "trial", "billing"] - case .user: return ["account", "profile", "sign in", "identity"] - } - } - - /// Sections a query should reveal. An empty query keeps the full rail; a - /// query matches the visible title first, then the keyword aliases. Pure so - /// the search contract is testable without rendering the window. - static func matching(query: String) -> [SettingsSection] { - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let visible = allCases.filter { $0.availability != .hidden } - guard !needle.isEmpty else { return visible } - return visible.filter { section in - section.title.lowercased().contains(needle) - || section.searchKeywords.contains { $0.contains(needle) } - } - } + case creator + case shortcuts + case keys + case agent + case prompts + case engine + case audio + case voiceLab + case license + case user + + var id: String { rawValue } + + var title: String { + switch self { + case .creator: return "Creator" + case .shortcuts: return "Hotkeys" + case .keys: return "Providers" + case .agent: return "Agent" + case .prompts: return "Prompts" + case .engine: return "Dictation" + case .audio: return "Audio" + case .voiceLab: return "Dictionary" + case .license: return "License" + case .user: return "User" + } + } + + var destination: SettingsPanelDestination { + switch self { + case .creator: return .creator + case .shortcuts: return .shortcuts + case .keys: return .providers + case .agent: return .agent + case .prompts: return .prompts + case .engine: return .dictation + case .audio: return .audio + case .voiceLab: return .dictionary + case .license: return .license + case .user: return .user + } + } + + var availability: SettingsSectionAvailability { + switch self { + case .creator, .shortcuts, .keys, .agent, .prompts, .engine, .audio, .voiceLab, .license, .user: + return .available + } + } + + var isInteractive: Bool { availability == .available } + + /// Sidebar grouping. A flat ten-item list forces the user to read every row; + /// native sidebars carry `Section` headers for free, so the rail states what + /// each area is FOR instead of relying on the reader's memory. + var group: SettingsSectionGroup { + switch self { + case .creator, .shortcuts, .audio: return .setup + case .keys, .agent, .prompts, .engine, .voiceLab: return .intelligence + case .license, .user: return .account + } + } + + /// SF Symbol for the sidebar row. System symbols follow the user's theme, + /// accent and accessibility sizes; the previous hand-drawn 7pt dots did not. + var symbol: String { + switch self { + case .creator: return "wand.and.stars" + case .shortcuts: return "keyboard" + case .keys: return "key.horizontal" + case .agent: return "cpu" + case .prompts: return "text.bubble" + case .engine: return "waveform" + case .audio: return "mic" + case .voiceLab: return "character.book.closed" + case .license: return "checkmark.seal" + case .user: return "person.crop.circle" + } + } + + /// Extra terms the settings search matches beyond the visible title, so the + /// user can look for what a panel DOES ("api key", "mikrofon") instead of + /// having to guess the tab name. + var searchKeywords: [String] { + switch self { + case .creator: return ["setup", "onboarding", "permissions", "quick start", "language"] + case .shortcuts: return ["hotkey", "keyboard", "shortcut", "trigger", "hold", "toggle"] + case .keys: return ["api key", "provider", "openai", "anthropic", "endpoint", "model", "token"] + case .agent: return ["mcp", "tools", "workspace", "permissions", "server"] + case .prompts: return ["system prompt", "persona", "assistive", "instructions"] + case .engine: return ["stt", "whisper", "apple", "speech", "transcription", "final pass"] + case .audio: return ["microphone", "mikrofon", "input", "device", "levels"] + case .voiceLab: return ["lexicon", "dictionary", "vocabulary", "corrections", "słownik"] + case .license: return ["subscription", "activation", "trial", "billing"] + case .user: return ["account", "profile", "sign in", "identity"] + } + } + + /// Sections a query should reveal. An empty query keeps the full rail; a + /// query matches the visible title first, then the keyword aliases. Pure so + /// the search contract is testable without rendering the window. + static func matching(query: String) -> [SettingsSection] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let visible = allCases.filter { $0.availability != .hidden } + guard !needle.isEmpty else { return visible } + return visible.filter { section in + section.title.lowercased().contains(needle) + || section.searchKeywords.contains { $0.contains(needle) } + } + } } /// A page inside a section. Long panels (Agent stacks five independent @@ -299,222 +336,229 @@ enum SettingsSection: String, CaseIterable, Identifiable { /// footer pointing at a broken lane could never land on it. As tree children /// they are addressable by exactly the same mechanisms as a top-level section. enum SettingsPage: String, CaseIterable, Identifiable { - // Agent - case agentLanes - case agentWorkspace - case agentStatus - case agentTools - case agentMcp - // Prompts — one page per prompt file; the stacked editor was four - // TextEditors in one scroll ("scrollowany potworek", operator 2026-08-09). - case promptCorrection - case promptSmart - case promptMax - case promptAssistive - - var id: String { rawValue } - - var section: SettingsSection { - switch self { - case .agentLanes, .agentWorkspace, .agentStatus, .agentTools, .agentMcp: - return .agent - case .promptCorrection, .promptSmart, .promptMax, .promptAssistive: - return .prompts - } - } - - var title: String { - switch self { - case .agentLanes: return "LLM lanes" - case .agentWorkspace: return "Workspace roots" - case .agentStatus: return "Capabilities" - case .agentTools: return "Tool permissions" - case .agentMcp: return "MCP servers" - case .promptCorrection: return "Correction" - case .promptSmart: return "Smart" - case .promptMax: return "Max" - case .promptAssistive: return "Assistive" - } - } - - var symbol: String { - switch self { - case .agentLanes: return "arrow.triangle.branch" - case .agentWorkspace: return "folder" - case .agentStatus: return "checklist" - case .agentTools: return "lock.shield" - case .agentMcp: return "server.rack" - case .promptCorrection: return "text.badge.checkmark" - case .promptSmart: return "wand.and.stars" - case .promptMax: return "text.alignleft" - case .promptAssistive: return "person.wave.2" - } - } - - var searchKeywords: [String] { - switch self { - case .agentLanes: return ["provider", "model", "endpoint", "assistive", "formatting"] - case .agentWorkspace: return ["roots", "directory", "repo", "path"] - case .agentStatus: return ["capability", "native", "enhanced", "readiness"] - case .agentTools: return ["permission", "allow", "ask", "deny", "tool"] - case .agentMcp: return ["mcp", "server", "stdio", "transport"] - case .promptCorrection: return ["prompt", "formatting", "correction", "formatting.txt"] - case .promptSmart: return ["prompt", "smart", "formatting-smart"] - case .promptMax: return ["prompt", "max", "prose", "formatting-max"] - case .promptAssistive: return ["prompt", "assistive", "assistant", "system"] - } - } - - static func pages(in section: SettingsSection) -> [SettingsPage] { - allCases.filter { $0.section == section } - } - - /// Pages a query should surface, so search reaches inside a long section - /// instead of stopping at its title. - static func matching(query: String) -> [SettingsPage] { - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !needle.isEmpty else { return allCases } - return allCases.filter { page in - page.title.lowercased().contains(needle) - || page.searchKeywords.contains { $0.contains(needle) } - } - } + // Agent + case agentLanes + case agentWorkspace + case agentStatus + case agentTools + case agentMcp + // Prompts — one page per prompt file; the stacked editor was four + // TextEditors in one scroll ("scrollowany potworek", operator 2026-08-09). + case promptCorrection + case promptSmart + case promptMax + case promptAssistive + + var id: String { rawValue } + + var section: SettingsSection { + switch self { + case .agentLanes, .agentWorkspace, .agentStatus, .agentTools, .agentMcp: + return .agent + case .promptCorrection, .promptSmart, .promptMax, .promptAssistive: + return .prompts + } + } + + var title: String { + switch self { + case .agentLanes: return "LLM lanes" + case .agentWorkspace: return "Workspace roots" + case .agentStatus: return "Capabilities" + case .agentTools: return "Tool permissions" + case .agentMcp: return "MCP servers" + case .promptCorrection: return "Correction" + case .promptSmart: return "Smart" + case .promptMax: return "Max" + case .promptAssistive: return "Assistive" + } + } + + var symbol: String { + switch self { + case .agentLanes: return "arrow.triangle.branch" + case .agentWorkspace: return "folder" + case .agentStatus: return "checklist" + case .agentTools: return "lock.shield" + case .agentMcp: return "server.rack" + case .promptCorrection: return "text.badge.checkmark" + case .promptSmart: return "wand.and.stars" + case .promptMax: return "text.alignleft" + case .promptAssistive: return "person.wave.2" + } + } + + var searchKeywords: [String] { + switch self { + case .agentLanes: return ["provider", "model", "endpoint", "assistive", "formatting"] + case .agentWorkspace: return ["roots", "directory", "repo", "path"] + case .agentStatus: return ["capability", "native", "enhanced", "readiness"] + case .agentTools: return ["permission", "allow", "ask", "deny", "tool"] + case .agentMcp: return ["mcp", "server", "stdio", "transport"] + case .promptCorrection: return ["prompt", "formatting", "correction", "formatting.txt"] + case .promptSmart: return ["prompt", "smart", "formatting-smart"] + case .promptMax: return ["prompt", "max", "prose", "formatting-max"] + case .promptAssistive: return ["prompt", "assistive", "assistant", "system"] + } + } + + static func pages(in section: SettingsSection) -> [SettingsPage] { + allCases.filter { $0.section == section } + } + + /// Pages a query should surface, so search reaches inside a long section + /// instead of stopping at its title. + static func matching(query: String) -> [SettingsPage] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !needle.isEmpty else { return allCases } + return allCases.filter { page in + page.title.lowercased().contains(needle) + || page.searchKeywords.contains { $0.contains(needle) } + } + } } /// One selectable row in the rail: a section, or a page inside it. enum SettingsRoute: Hashable, Identifiable { - case section(SettingsSection) - case page(SettingsPage) + case section(SettingsSection) + case page(SettingsPage) - var id: String { - switch self { - case .section(let section): return "section:\(section.rawValue)" - case .page(let page): return "page:\(page.rawValue)" - } + var id: String { + switch self { + case .section(let section): return "section:\(section.rawValue)" + case .page(let page): return "page:\(page.rawValue)" } + } - var section: SettingsSection { - switch self { - case .section(let section): return section - case .page(let page): return page.section - } + var section: SettingsSection { + switch self { + case .section(let section): return section + case .page(let page): return page.section } + } - var page: SettingsPage? { - switch self { - case .section: return nil - case .page(let page): return page - } + var page: SettingsPage? { + switch self { + case .section: return nil + case .page(let page): return page } + } } /// Sidebar sections. Order is the rail's top-to-bottom order. enum SettingsSectionGroup: String, CaseIterable, Identifiable { - case setup - case intelligence - case account + case setup + case intelligence + case account - var id: String { rawValue } + var id: String { rawValue } - var title: String { - switch self { - case .setup: return "Setup" - case .intelligence: return "Intelligence" - case .account: return "Account" - } + var title: String { + switch self { + case .setup: return "Setup" + case .intelligence: return "Intelligence" + case .account: return "Account" } + } } enum SettingsKeyState: Equatable { - case available - case missing - case unknown + case available + case missing + case unknown } enum SettingsHealthLevel: Equatable { - case healthy - case degraded - case offline - case unknown + case healthy + case degraded + case offline + case unknown } struct SettingsHealthState: Equatable { - let level: SettingsHealthLevel - let message: String - let targetSection: SettingsSection? + let level: SettingsHealthLevel + let message: String + let targetSection: SettingsSection? } /// Pure aggregate used by the rail footer and its XCTest matrix. Known failures /// beat unknown inputs so the footer never hides a concrete problem behind a /// muted "unknown" state. func healthState( - stt: Bool?, - keys: SettingsKeyState, - agent: Bool? + stt: Bool?, + keys: SettingsKeyState, + agent: Bool? ) -> SettingsHealthState { - if stt == false { - return SettingsHealthState( - level: .offline, - message: "speech engine: unavailable", - targetSection: .engine - ) - } - if keys == .missing { - return SettingsHealthState( - level: .degraded, - message: "assistive lane: no key", - targetSection: .keys - ) - } - if agent == false { - return SettingsHealthState( - level: .offline, - message: "assistive lane: not ready", - targetSection: .engine - ) - } - if stt == nil || keys == .unknown || agent == nil { - return SettingsHealthState( - level: .unknown, - message: "system health: unknown", - targetSection: .engine - ) - } + if stt == false { + return SettingsHealthState( + level: .offline, + message: "speech engine: unavailable", + targetSection: .engine + ) + } + if keys == .missing { + return SettingsHealthState( + level: .degraded, + message: "assistive lane: no key", + targetSection: .keys + ) + } + if agent == false { + return SettingsHealthState( + level: .offline, + message: "assistive lane: not ready", + targetSection: .engine + ) + } + if stt == nil || keys == .unknown || agent == nil { return SettingsHealthState( - level: .healthy, - message: "systems ready", - targetSection: nil + level: .unknown, + message: "system health: unknown", + targetSection: .engine ) + } + return SettingsHealthState( + level: .healthy, + message: "systems ready", + targetSection: nil + ) } struct AppBuildInfo: Equatable { - let version: String - let build: String - let commit: String - let builtAt: String - - static func current(bundle: Bundle = .main) -> AppBuildInfo { - let info = bundle.infoDictionary ?? [:] - return AppBuildInfo( - version: info["CFBundleShortVersionString"] as? String ?? "unknown", - build: info["CFBundleVersion"] as? String ?? "unknown", - commit: info["CSBuildCommit"] as? String ?? "unknown", - builtAt: info["CSBuiltAt"] as? String ?? "unknown" - ) - } + let version: String + let build: String + let commit: String + let builtAt: String + + static func current(bundle: Bundle = .main) -> AppBuildInfo { + let info = bundle.infoDictionary ?? [:] + return AppBuildInfo( + version: info["CFBundleShortVersionString"] as? String ?? "unknown", + build: info["CFBundleVersion"] as? String ?? "unknown", + commit: info["CSBuildCommit"] as? String ?? "unknown", + builtAt: info["CSBuiltAt"] as? String ?? "unknown" + ) + } } func resetConfirmationMatches(_ text: String) -> Bool { - text == "RESET" + text == "RESET" +} + +/// Rust marks failures that occurred after the first irreversible data move. +/// Those are errors for audit/recovery, but staying in the current process is +/// no longer safe because its app-data plane is deliberately latched. +func resetFailureRequiresRelaunch(_ description: String) -> Bool { + description.contains("CODESCRIBE_RESET_RELAUNCH_REQUIRED") } func resetImpactSummary(_ preview: CsResetPreview) -> String { - let recordings = preview.audioFiles == 1 ? "recording" : "recordings" - let days = preview.transcriptDays == 1 ? "day" : "days" - let threads = preview.threads == 1 ? "thread" : "threads" - let megabytes = Double(preview.totalBytes) / 1_048_576.0 - return "\(preview.audioFiles) \(recordings) from \(preview.transcriptDays) \(days), " - + "\(preview.threads) \(threads) (\(String(format: "%.1f", megabytes)) MB)" + let recordings = preview.audioFiles == 1 ? "recording" : "recordings" + let days = preview.transcriptDays == 1 ? "day" : "days" + let threads = preview.threads == 1 ? "thread" : "threads" + let megabytes = Double(preview.totalBytes) / 1_048_576.0 + return "\(preview.audioFiles) \(recordings) from \(preview.transcriptDays) \(days), " + + "\(preview.threads) \(threads) (\(String(format: "%.1f", megabytes)) MB)" } /// One-shot deep-link target for the Settings window. A surface outside Settings @@ -524,216 +568,217 @@ func resetImpactSummary(_ preview: CsResetPreview) -> String { /// last/default section". @MainActor enum SettingsDeepLink { - static let pendingSectionDidChange = Notification.Name("codescribe.settingsDeepLink.pendingSectionDidChange") - static let agentConfigurationSection: SettingsSection = .agent - - static var pendingSection: SettingsSection? { - didSet { - guard pendingSection != nil else { return } - NotificationCenter.default.post(name: pendingSectionDidChange, object: nil) - } - } - - /// Take the pending target (if any), clearing it so a later open is unaffected. - static func consume() -> SettingsSection? { - guard let target = pendingSection else { return nil } - pendingSection = nil - return target - } + static let pendingSectionDidChange = Notification.Name( + "codescribe.settingsDeepLink.pendingSectionDidChange") + static let agentConfigurationSection: SettingsSection = .agent + + static var pendingSection: SettingsSection? { + didSet { + guard pendingSection != nil else { return } + NotificationCenter.default.post(name: pendingSectionDidChange, object: nil) + } + } + + /// Take the pending target (if any), clearing it so a later open is unaffected. + static func consume() -> SettingsSection? { + guard let target = pendingSection else { return nil } + pendingSection = nil + return target + } } enum LLMLane: String, CaseIterable, Identifiable { - case assistive - case formatting - case main + case assistive + case formatting + case main - var id: String { rawValue } + var id: String { rawValue } - var bridgeLane: CsLlmLane { - switch self { - case .assistive: return .assistive - case .formatting: return .formatting - case .main: return .main - } + var bridgeLane: CsLlmLane { + switch self { + case .assistive: return .assistive + case .formatting: return .formatting + case .main: return .main } + } - var title: String { - switch self { - case .assistive: return "Assistive" - case .formatting: return "Formatting" - case .main: return "Main" - } + var title: String { + switch self { + case .assistive: return "Assistive" + case .formatting: return "Formatting" + case .main: return "Main" } + } - var subtitle: String { - switch self { - case .assistive: return "Agent and voice-assistant requests" - case .formatting: return "Transcript cleanup and formatting" - case .main: return "Default LLM fallback lane" - } + var subtitle: String { + switch self { + case .assistive: return "Agent and voice-assistant requests" + case .formatting: return "Transcript cleanup and formatting" + case .main: return "Default LLM fallback lane" } + } - var endpointKey: String { - switch self { - case .assistive: return "LLM_ASSISTIVE_ENDPOINT" - case .formatting: return "LLM_FORMATTING_ENDPOINT" - case .main: return "LLM_ENDPOINT" - } + var endpointKey: String { + switch self { + case .assistive: return "LLM_ASSISTIVE_ENDPOINT" + case .formatting: return "LLM_FORMATTING_ENDPOINT" + case .main: return "LLM_ENDPOINT" } + } - var modelKey: String { - switch self { - case .assistive: return "LLM_ASSISTIVE_MODEL" - case .formatting: return "LLM_FORMATTING_MODEL" - case .main: return "LLM_MODEL" - } + var modelKey: String { + switch self { + case .assistive: return "LLM_ASSISTIVE_MODEL" + case .formatting: return "LLM_FORMATTING_MODEL" + case .main: return "LLM_MODEL" } + } - var endpointPath: WritableKeyPath { - switch self { - case .assistive: return \CsSettings.llmAssistiveEndpoint - case .formatting: return \CsSettings.llmFormattingEndpoint - case .main: return \CsSettings.llmEndpoint - } + var endpointPath: WritableKeyPath { + switch self { + case .assistive: return \CsSettings.llmAssistiveEndpoint + case .formatting: return \CsSettings.llmFormattingEndpoint + case .main: return \CsSettings.llmEndpoint } + } - var modelPath: WritableKeyPath { - switch self { - case .assistive: return \CsSettings.llmAssistiveModel - case .formatting: return \CsSettings.llmFormattingModel - case .main: return \CsSettings.llmModel - } + var modelPath: WritableKeyPath { + switch self { + case .assistive: return \CsSettings.llmAssistiveModel + case .formatting: return \CsSettings.llmFormattingModel + case .main: return \CsSettings.llmModel } + } } /// One read model for a request lane. Both Settings panels consume this snapshot, /// so provider resolution, model discovery, and manual-entry rules cannot drift. struct LLMLaneModel { - let lane: LLMLane - let providerId: String - let provider: CsProviderOption? - let resolvedEndpoint: String - let configuredModel: String - let resolvedModel: String - let discoveryEndpoint: String - let discovery: CsModelDiscovery - - var modelOptions: [CsModelOption] { discovery.models } - - var manualModelReason: String? { - guard providerId == "openai-responses" else { return nil } - guard URL(string: resolvedEndpoint)?.host?.lowercased() == "api.openai.com" else { - return "Custom endpoint — enter its model ID manually" - } - guard lane == .assistive || resolvedEndpoint == discoveryEndpoint else { - return "Endpoint differs from OpenAI discovery — enter its model ID manually" - } - return nil - } - - var usesDiscoveredPicker: Bool { - manualModelReason == nil && !modelOptions.isEmpty && discovery.status == "fresh" - } - - var discoveryDescription: String { - if let manualModelReason { return manualModelReason } - switch discovery.status { - case "fresh": - let count = modelOptions.count - return count == 0 - ? "no models returned by provider" - : "\(count) \(count == 1 ? "model" : "models") discovered from provider" - case "cached": - if let message = discovery.message, !message.isEmpty { - return "using cached models — \(message)" - } - return "using cached models" - case "no_key": return "Add API key to discover models" - case "loading": return "discovering models…" - default: - if let message = discovery.message, !message.isEmpty { - return "model discovery failed — \(message)" - } - return "model discovery failed" - } + let lane: LLMLane + let providerId: String + let provider: CsProviderOption? + let resolvedEndpoint: String + let configuredModel: String + let resolvedModel: String + let discoveryEndpoint: String + let discovery: CsModelDiscovery + + var modelOptions: [CsModelOption] { discovery.models } + + var manualModelReason: String? { + guard providerId == "openai-responses" else { return nil } + guard URL(string: resolvedEndpoint)?.host?.lowercased() == "api.openai.com" else { + return "Custom endpoint — enter its model ID manually" + } + guard lane == .assistive || resolvedEndpoint == discoveryEndpoint else { + return "Endpoint differs from OpenAI discovery — enter its model ID manually" + } + return nil + } + + var usesDiscoveredPicker: Bool { + manualModelReason == nil && !modelOptions.isEmpty && discovery.status == "fresh" + } + + var discoveryDescription: String { + if let manualModelReason { return manualModelReason } + switch discovery.status { + case "fresh": + let count = modelOptions.count + return count == 0 + ? "no models returned by provider" + : "\(count) \(count == 1 ? "model" : "models") discovered from provider" + case "cached": + if let message = discovery.message, !message.isEmpty { + return "using cached models — \(message)" + } + return "using cached models" + case "no_key": return "Add API key to discover models" + case "loading": return "discovering models…" + default: + if let message = discovery.message, !message.isEmpty { + return "model discovery failed — \(message)" + } + return "model discovery failed" } + } } // MARK: - Preview timing domain (Dictation-owned; model + panel both consume) enum PreviewTimingPreset: String, CaseIterable, Identifiable, Equatable { - case smooth = "Smooth" - case snappy = "Snappy" - case relaxed = "Relaxed" - case off = "Off" - case custom = "Custom" + case smooth = "Smooth" + case snappy = "Snappy" + case relaxed = "Relaxed" + case off = "Off" + case custom = "Custom" - var id: String { rawValue } + var id: String { rawValue } } struct PreviewTimingValues: Equatable { - let bufferDelayMs: UInt64 - let typingCps: Float - let emitWordsMax: UInt64 - let interimSeconds: Float - - // Source: operator-tested C5b values (2026-06-11). Smooth is the - // recommended default; Snappy/Relaxed retain the original values without - // the optional +/-20% retuning because all are inside current clamps. - static let smooth = PreviewTimingValues( - bufferDelayMs: 1038, - typingCps: 10.6, - emitWordsMax: 5, - interimSeconds: 8.0 - ) - static let snappy = PreviewTimingValues( - bufferDelayMs: 350, - typingCps: 28.0, - emitWordsMax: 3, - interimSeconds: 4.0 - ) - static let relaxed = PreviewTimingValues( - bufferDelayMs: 1500, - typingCps: 8.0, - emitWordsMax: 8, - interimSeconds: 8.0 - ) + let bufferDelayMs: UInt64 + let typingCps: Float + let emitWordsMax: UInt64 + let interimSeconds: Float + + // Source: operator-tested C5b values (2026-06-11). Smooth is the + // recommended default; Snappy/Relaxed retain the original values without + // the optional +/-20% retuning because all are inside current clamps. + static let smooth = PreviewTimingValues( + bufferDelayMs: 1038, + typingCps: 10.6, + emitWordsMax: 5, + interimSeconds: 8.0 + ) + static let snappy = PreviewTimingValues( + bufferDelayMs: 350, + typingCps: 28.0, + emitWordsMax: 3, + interimSeconds: 4.0 + ) + static let relaxed = PreviewTimingValues( + bufferDelayMs: 1500, + typingCps: 8.0, + emitWordsMax: 8, + interimSeconds: 8.0 + ) } struct PreviewTimingConfiguration: Equatable { - let overlayEnabled: Bool - let values: PreviewTimingValues + let overlayEnabled: Bool + let values: PreviewTimingValues } func presetValues(_ preset: PreviewTimingPreset) -> PreviewTimingValues? { - switch preset { - case .smooth: return .smooth - case .snappy: return .snappy - case .relaxed: return .relaxed - case .off, .custom: return nil - } + switch preset { + case .smooth: return .smooth + case .snappy: return .snappy + case .relaxed: return .relaxed + case .off, .custom: return nil + } } func detectPreset(_ configuration: PreviewTimingConfiguration) -> PreviewTimingPreset { - guard configuration.overlayEnabled else { return .off } - for preset in [PreviewTimingPreset.smooth, .snappy, .relaxed] { - guard let values = presetValues(preset) else { continue } - let current = configuration.values - let bufferClose = current.bufferDelayMs.absDiff(values.bufferDelayMs) <= 10 - let cpsClose = abs(current.typingCps - values.typingCps) <= 0.15 - let wordsMatch = current.emitWordsMax == values.emitWordsMax - let interimClose = abs(current.interimSeconds - values.interimSeconds) <= 0.15 - if bufferClose, cpsClose, wordsMatch, interimClose { - return preset - } - } - return .custom + guard configuration.overlayEnabled else { return .off } + for preset in [PreviewTimingPreset.smooth, .snappy, .relaxed] { + guard let values = presetValues(preset) else { continue } + let current = configuration.values + let bufferClose = current.bufferDelayMs.absDiff(values.bufferDelayMs) <= 10 + let cpsClose = abs(current.typingCps - values.typingCps) <= 0.15 + let wordsMatch = current.emitWordsMax == values.emitWordsMax + let interimClose = abs(current.interimSeconds - values.interimSeconds) <= 0.15 + if bufferClose, cpsClose, wordsMatch, interimClose { + return preset + } + } + return .custom } -private extension UInt64 { - func absDiff(_ other: UInt64) -> UInt64 { - self >= other ? self - other : other - self - } +extension UInt64 { + fileprivate func absDiff(_ other: UInt64) -> UInt64 { + self >= other ? self - other : other - self + } } /// Clears the app's preferences domain and relaunches a fresh instance. Used by @@ -743,74 +788,74 @@ private extension UInt64 { /// guard would terminate the freshly-launched copy. @MainActor enum AppRelaunch { - static func clearDefaultsAndRelaunch() { - if let bundleId = Bundle.main.bundleIdentifier { - UserDefaults.standard.removePersistentDomain(forName: bundleId) - UserDefaults.standard.synchronize() - } - let bundlePath = Bundle.main.bundlePath - let task = Process() - task.launchPath = "/bin/sh" - // `$0` carries the bundle path as a positional arg so it is safely quoted, - // never interpolated into the script string. - task.arguments = ["-c", "sleep 1; open \"$0\"", bundlePath] - try? task.run() - NSApp.terminate(nil) - } + static func clearDefaultsAndRelaunch() { + if let bundleId = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleId) + UserDefaults.standard.synchronize() + } + let bundlePath = Bundle.main.bundlePath + let task = Process() + task.launchPath = "/bin/sh" + // `$0` carries the bundle path as a positional arg so it is safely quoted, + // never interpolated into the script string. + task.arguments = ["-c", "sleep 1; open \"$0\"", bundlePath] + try? task.run() + NSApp.terminate(nil) + } } private struct BackgroundSettingsEngine: @unchecked Sendable { - let engine: SettingsEngine + let engine: SettingsEngine } extension CsWhisperModelStatus { - /// Placeholder for canvas / engine-less previews (no network, no disk probe). - static let sampleUnavailable = CsWhisperModelStatus( - available: false, - embedded: false, - path: nil, - modelId: "whisper-large-v3-turbo-mlx-q8", - repo: "LibraxisAI/whisper-large-v3-turbo-mlx-q8", - sizeHint: "~900 MB" - ) + /// Placeholder for canvas / engine-less previews (no network, no disk probe). + static let sampleUnavailable = CsWhisperModelStatus( + available: false, + embedded: false, + path: nil, + modelId: "whisper-large-v3-turbo", + repo: "mlx-community/whisper-large-v3-turbo", + sizeHint: "~1.6 GB" + ) } /// Bridges UniFFI download callbacks onto the main-actor SettingsViewModel. final class WhisperDownloadProgressSink: CsWhisperDownloadListener, @unchecked Sendable { - weak var model: SettingsViewModel? - - init(model: SettingsViewModel) { - self.model = model - } - - func onProgress(file: String, bytesDone: UInt64, bytesTotal: Int64) { - let fraction: Double? - if bytesTotal > 0 { - fraction = min(1.0, Double(bytesDone) / Double(bytesTotal)) - } else { - fraction = nil - } - let mbDone = Double(bytesDone) / 1_048_576.0 - let detail: String - if bytesTotal > 0 { - let mbTotal = Double(bytesTotal) / 1_048_576.0 - detail = String(format: "%@ · %.0f / %.0f MB", file, mbDone, mbTotal) - } else { - detail = String(format: "%@ · %.0f MB", file, mbDone) - } - DispatchQueue.main.async { [weak self] in - self?.model?.applyWhisperDownloadProgress(detail: detail, fraction: fraction) - } - } - - func onComplete(path: String) { - DispatchQueue.main.async { [weak self] in - self?.model?.applyWhisperDownloadProgress( - detail: "Saved · \(path)", - fraction: 1.0 - ) - } - } + weak var model: SettingsViewModel? + + init(model: SettingsViewModel) { + self.model = model + } + + func onProgress(file: String, bytesDone: UInt64, bytesTotal: Int64) { + let fraction: Double? + if bytesTotal > 0 { + fraction = min(1.0, Double(bytesDone) / Double(bytesTotal)) + } else { + fraction = nil + } + let mbDone = Double(bytesDone) / 1_048_576.0 + let detail: String + if bytesTotal > 0 { + let mbTotal = Double(bytesTotal) / 1_048_576.0 + detail = String(format: "%@ · %.0f / %.0f MB", file, mbDone, mbTotal) + } else { + detail = String(format: "%@ · %.0f MB", file, mbDone) + } + DispatchQueue.main.async { [weak self] in + self?.model?.applyWhisperDownloadProgress(detail: detail, fraction: fraction) + } + } + + func onComplete(path: String) { + DispatchQueue.main.async { [weak self] in + self?.model?.applyWhisperDownloadProgress( + detail: "Saved · \(path)", + fraction: 1.0 + ) + } + } } /// Quick-start actions from the Creator panel's cards. Navigation cases route @@ -818,1561 +863,1636 @@ final class WhisperDownloadProgressSink: CsWhisperDownloadListener, @unchecked S /// injectable seam so the cards are never inert decorations again /// (UI_DIVERGENCE_AUDIT pkt 4 — fake UX). enum SettingsQuickStartAction: String, CaseIterable { - case testMic - case openOverlay - case tuneShortcuts + case testMic + case openOverlay + case tuneShortcuts } @MainActor final class SettingsViewModel: ObservableObject { - @Published var section: SettingsSection = .creator - /// Page within `section`, when that section is paginated. Always kept - /// consistent with `section` by the `select` overloads — never written raw. - @Published private(set) var page: SettingsPage? - - /// Dictation seam for the "Open overlay" quick-start card. Defaulted to the - /// live tray toggle but only dereferenced on click, so unit tests can inject - /// a spy without ever waking `AppModel.shared`. - var onQuickStartDictation: () -> Void = { AppModel.shared.tray.toggleDictation() } - - func performQuickStart(_ action: SettingsQuickStartAction) { - switch action { - case .testMic: section = .audio - case .tuneShortcuts: section = .shortcuts - case .openOverlay: onQuickStartDictation() - } - } - - @Published private(set) var permissions: PermissionSnapshot - @Published private(set) var settings: CsSettings - @Published private(set) var keyStatus: CsKeyStatus - @Published private(set) var providers: [CsProviderOption] - @Published private var modelDiscoveries: [String: CsModelDiscovery] = [:] - @Published private(set) var configDir: String - @Published private(set) var needsOnboarding: Bool - @Published private(set) var agentReadiness: CsAgenticReadiness - @Published private(set) var mcpStatus: CsMcpStatusReport - /// Native / enhanced / unavailable rows from `CodescribeAgentStatus.capabilityMatrix()`. - @Published private(set) var capabilityMatrix: [CsCapabilityRow] = [] - @Published private(set) var mcpServers: [CsMcpServer] = [] - @Published private(set) var mcpTestResults: [String: CsMcpTestResult] = [:] - @Published private(set) var mcpTestPending: Set = [] - @Published private(set) var toolCapabilities: [CsToolCapability] = [] - @Published private(set) var permissionPolicy: CsPermissionPolicy = CsPermissionPolicy( - defaultLevel: "ask", - readOnlyDefault: "allow", - sideEffectDefault: "ask", - tools: [], - servers: [] - ) - @Published private(set) var keyProbeResults: [String: CsApiKeyProbeResult] = [:] - @Published private(set) var keyProbePending: Set = [] - @Published private(set) var qualityRecords: [CsQualityRecord] = [] - @Published private(set) var customLexiconEntries: [CsLexiconEntry] = [] - @Published private(set) var voiceLabReadError: String? - @Published private(set) var voiceLabEditPending: Set = [] - @Published private(set) var voiceLabEditErrors: [String: String] = [:] - /// Honest per-row save note: "Saved — N rules learned" or the zero/failed - /// variants. The save itself succeeded whenever a note is present. - @Published private(set) var voiceLabEditNotes: [String: String] = [:] - @Published private(set) var voiceLabTeachPending: Bool = false - @Published private(set) var voiceLabTeachMessage: String? - @Published private(set) var audioInput: CsAudioInputSnapshot - @Published private(set) var audioInputReadError: String? - @Published private(set) var resetPreview: CsResetPreview - @Published private(set) var licenseStatus: CsLicenseStatus - /// Provider ids with a "Sign in with ChatGPT" flow in flight (browser open, - /// local callback server listening). Guards double-clicks. - @Published private(set) var accountLoginPending: Set = [] - /// Last terminal outcome of an account login per provider ("timeout", - /// "failed" …) — honest status for the row without raising a modal error. - @Published private(set) var accountLoginNotices: [String: String] = [:] - @Published var lastError: String? - - // MARK: - Local Whisper download (Settings → Dictation) - - /// Live availability of default Whisper weights (embedded or on disk). - /// Named `localWhisperStatus` so it does not shadow UniFFI free functions - /// `whisperModelStatus()` / `downloadWhisperModel(...)`. - @Published private(set) var localWhisperStatus: CsWhisperModelStatus = .sampleUnavailable - @Published private(set) var whisperDownloadInFlight = false - /// Human status under the download control (file name + progress). - @Published private(set) var whisperDownloadDetail: String? - /// 0...1 when Content-Length is known; nil for indeterminate. - @Published private(set) var whisperDownloadFraction: Double? - private var whisperDownloadSink: WhisperDownloadProgressSink? - - // MARK: - Hotkeys (mode bindings) - - /// Persisted per-mode bindings as last read from disk. - @Published private(set) var modeBindings: [CsModeBinding] = [] - /// The closed set of selectable gestures for the pickers. - @Published private(set) var bindingOptions: [CsBindingOption] = [] - /// Editable copy the Shortcuts panel mutates before a save. - @Published private(set) var draftBindings: [CsModeBinding] = [] - /// Conflicts for the CURRENT draft (recomputed on every edit). - @Published private(set) var bindingConflicts: [CsHotkeyConflict] = [] - - /// Build provenance comes from the running app bundle. The build pipeline - /// writes all four fields in project.yml / scripts/build-app.sh. - let buildInfo: AppBuildInfo - var appVersion: String { buildInfo.version } - - private let engine: SettingsEngine? - private let permissionProbe: PermissionProbing - private let agentStatus: AgentStatusEngine? - private let mcpAdmin: MCPAdminEngine? - private let hotkeys: HotkeysEngine? - private let licenseService: LicenseService - private let laneTruthProvider: (CsLlmLane) -> CsLaneTruthSnapshot - private var modelDiscoveryGenerations: [String: Int] = [:] - private var assistiveModelEditGeneration = 0 - private var pendingAssistiveModelSelection: ( - providerId: String, - modelEditGeneration: Int + @Published var section: SettingsSection = .creator + /// Page within `section`, when that section is paginated. Always kept + /// consistent with `section` by the `select` overloads — never written raw. + @Published private(set) var page: SettingsPage? + + /// Dictation seam for the "Open overlay" quick-start card. Defaulted to the + /// live tray toggle but only dereferenced on click, so unit tests can inject + /// a spy without ever waking `AppModel.shared`. + var onQuickStartDictation: () -> Void = { AppModel.shared.tray.toggleDictation() } + + func performQuickStart(_ action: SettingsQuickStartAction) { + switch action { + case .testMic: section = .audio + case .tuneShortcuts: section = .shortcuts + case .openOverlay: onQuickStartDictation() + } + } + + @Published private(set) var permissions: PermissionSnapshot + @Published private(set) var settings: CsSettings + @Published private(set) var keyStatus: CsKeyStatus + @Published private(set) var providers: [CsProviderOption] + @Published private var modelDiscoveries: [String: CsModelDiscovery] = [:] + @Published private(set) var configDir: String + @Published private(set) var needsOnboarding: Bool + @Published private(set) var agentReadiness: CsAgenticReadiness + @Published private(set) var mcpStatus: CsMcpStatusReport + /// Native / enhanced / unavailable rows from `CodescribeAgentStatus.capabilityMatrix()`. + @Published private(set) var capabilityMatrix: [CsCapabilityRow] = [] + @Published private(set) var mcpServers: [CsMcpServer] = [] + @Published private(set) var mcpTestResults: [String: CsMcpTestResult] = [:] + @Published private(set) var mcpTestPending: Set = [] + @Published private(set) var toolCapabilities: [CsToolCapability] = [] + @Published private(set) var permissionPolicy: CsPermissionPolicy = CsPermissionPolicy( + defaultLevel: "ask", + readOnlyDefault: "allow", + sideEffectDefault: "ask", + tools: [], + servers: [] + ) + @Published private(set) var keyProbeResults: [String: CsApiKeyProbeResult] = [:] + @Published private(set) var keyProbePending: Set = [] + @Published private(set) var qualityRecords: [CsQualityRecord] = [] + @Published private(set) var customLexiconEntries: [CsLexiconEntry] = [] + @Published private(set) var voiceLabReadError: String? + @Published private(set) var voiceLabEditPending: Set = [] + @Published private(set) var voiceLabEditErrors: [String: String] = [:] + /// Honest per-row save note: "Saved — N rules learned" or the zero/failed + /// variants. The save itself succeeded whenever a note is present. + @Published private(set) var voiceLabEditNotes: [String: String] = [:] + @Published private(set) var voiceLabTeachPending: Bool = false + @Published private(set) var voiceLabTeachMessage: String? + @Published private(set) var audioInput: CsAudioInputSnapshot + @Published private(set) var audioInputReadError: String? + @Published private(set) var resetPreview: CsResetPreview + @Published private(set) var licenseStatus: CsLicenseStatus + /// Provider ids with a "Sign in with ChatGPT" flow in flight (browser open, + /// local callback server listening). Guards double-clicks. + @Published private(set) var accountLoginPending: Set = [] + /// Last terminal outcome of an account login per provider ("timeout", + /// "failed" …) — honest status for the row without raising a modal error. + @Published private(set) var accountLoginNotices: [String: String] = [:] + @Published var lastError: String? + + // MARK: - Local Whisper download (Settings → Dictation) + + /// Live availability of default Whisper weights (embedded or on disk). + /// Named `localWhisperStatus` so it does not shadow UniFFI free functions + /// `whisperModelStatus()` / `downloadWhisperModel(...)`. + @Published private(set) var localWhisperStatus: CsWhisperModelStatus = .sampleUnavailable + @Published private(set) var whisperDownloadInFlight = false + /// Human status under the download control (file name + progress). + @Published private(set) var whisperDownloadDetail: String? + /// 0...1 when Content-Length is known; nil for indeterminate. + @Published private(set) var whisperDownloadFraction: Double? + private var whisperDownloadSink: WhisperDownloadProgressSink? + + // MARK: - Hotkeys (mode bindings) + + /// Persisted per-mode bindings as last read from disk. + @Published private(set) var modeBindings: [CsModeBinding] = [] + /// The closed set of selectable gestures for the pickers. + @Published private(set) var bindingOptions: [CsBindingOption] = [] + /// Editable copy the Shortcuts panel mutates before a save. + @Published private(set) var draftBindings: [CsModeBinding] = [] + /// Conflicts for the CURRENT draft (recomputed on every edit). + @Published private(set) var bindingConflicts: [CsHotkeyConflict] = [] + + /// Build provenance comes from the running app bundle. The build pipeline + /// writes all four fields in project.yml / scripts/build-app.sh. + let buildInfo: AppBuildInfo + var appVersion: String { buildInfo.version } + + private let engine: SettingsEngine? + private let permissionProbe: PermissionProbing + private let agentStatus: AgentStatusEngine? + private let mcpAdmin: MCPAdminEngine? + private let hotkeys: HotkeysEngine? + private let licenseService: LicenseService + private let laneTruthProvider: (CsLlmLane) -> CsLaneTruthSnapshot + private var modelDiscoveryGenerations: [String: Int] = [:] + private var assistiveModelEditGeneration = 0 + private var pendingAssistiveModelSelection: + ( + providerId: String, + modelEditGeneration: Int )? - private var holdBadgeObserver: NSObjectProtocol? - - init( - engine: SettingsEngine? = nil, - permissionProbe: PermissionProbing = NativePermissionProbe(), - agentStatus: AgentStatusEngine? = nil, - mcpAdmin: MCPAdminEngine? = nil, - hotkeys: HotkeysEngine? = nil, - licenseService: LicenseService? = nil, - buildInfo: AppBuildInfo = .current(), - laneTruthProvider: @escaping (CsLlmLane) -> CsLaneTruthSnapshot = { lane in - laneTruthSnapshot(lane: lane) - }, - servingStatusProvider: @escaping () -> LastServingVerdict? = { - guard let verdict = currentServingVerdict() else { return nil } - return LastServingVerdict( - engine: verdict.engine, - routingMode: verdict.routingMode, - disposition: verdict.disposition, - fallbackUsed: verdict.fallbackUsed - ) - } - ) { - self.engine = engine - self.permissionProbe = permissionProbe - self.agentStatus = agentStatus - self.mcpAdmin = mcpAdmin - self.hotkeys = hotkeys - self.licenseService = licenseService ?? .preview - self.buildInfo = buildInfo - self.laneTruthProvider = laneTruthProvider - self.servingStatusProvider = servingStatusProvider - - // Keep construction side-effect free. SwiftUI may instantiate the - // Settings scene at app launch; live config/keychain reads happen in - // `refresh()` when the Settings window actually appears. - self.permissions = permissionProbe.snapshot() - self.settings = .sample - self.keyStatus = .sampleAllSet - self.providers = CsProviderOption.sampleProviders - self.configDir = "" - self.needsOnboarding = false - self.agentReadiness = .sample - self.mcpStatus = .sample - self.capabilityMatrix = CsCapabilityRow.sampleMatrix - self.voiceLabReadError = nil - self.audioInput = .sample - self.audioInputReadError = nil - self.resetPreview = .sample - self.licenseStatus = self.licenseService.status - // K4: tray cycles arrive on the bus; reload Settings badge display. - // Register after every stored property is initialized (Swift init order). - holdBadgeObserver = NotificationCenter.default.addObserver( - forName: ConfigChangeBus.holdBadgeDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.reloadHoldBadgeFromDisk() - } - } - } - - deinit { - if let holdBadgeObserver { - NotificationCenter.default.removeObserver(holdBadgeObserver) - } - } - - /// Re-read live state (permissions can change while the window is open). - func refresh() { - permissions = permissionProbe.snapshot() - refreshServingStatus() - // A permission granted while Settings is open (e.g. via the checklist's - // "Open System Settings") should bring hotkeys live without an app - // restart. Idempotent bridge call — a no-op once the tap is already armed. - hotkeys?.rearmAfterPermissionGrant() - if let engine { - settings = engine.loadSettings() - keyStatus = engine.keyStatus() - providers = engine.availableProviders() - configDir = engine.configDir() - needsOnboarding = engine.shouldShowOnboarding() - refreshModelDiscoveries(providerIds: [llmLane(.assistive).providerId, "openai-responses"]) - refreshVoiceLab() - refreshAudioInput() - } - refreshWhisperModelStatus() - refreshAgentStatus() - reloadMcpServers() - loadHotkeys() - licenseService.refresh() - licenseStatus = licenseService.status - } - - var licenseError: String? { licenseService.lastError } - - @discardableResult - func activateLicense(_ key: String) -> Bool { - let activated = licenseService.activate(key) - licenseStatus = licenseService.status - return activated - } - - func removeLicense() { - licenseService.removeLicense() - licenseStatus = licenseService.status - } - - /// Re-probe Whisper install state (embedded / on-disk / missing). - func refreshWhisperModelStatus() { - // Live UniFFI path; sample mode (engine == nil in pure previews) keeps - // the static placeholder so canvas previews stay offline-safe. - guard engine != nil else { return } - localWhisperStatus = whisperModelStatus() - } - - /// Called from UniFFI download callbacks (main-queue hopped). - fileprivate func applyWhisperDownloadProgress(detail: String, fraction: Double?) { - whisperDownloadDetail = detail - whisperDownloadFraction = fraction - } - - /// Download default Whisper into `~/.codescribe/models/…` (idempotent). - func startWhisperDownload() { - guard engine != nil else { return } - guard !whisperDownloadInFlight else { return } - whisperDownloadInFlight = true - whisperDownloadDetail = "Starting download…" - whisperDownloadFraction = nil - lastError = nil - let sink = WhisperDownloadProgressSink(model: self) - whisperDownloadSink = sink - Task { @MainActor [weak self] in - guard let self else { return } - do { - let status = try await downloadWhisperModel(listener: sink) - self.localWhisperStatus = status - self.whisperDownloadDetail = status.available - ? "Ready · \(status.path ?? status.modelId)" - : "Download finished but model still unavailable" - self.whisperDownloadFraction = status.available ? 1.0 : nil - } catch { - self.lastError = String(describing: error) - self.whisperDownloadDetail = "Download failed" - self.whisperDownloadFraction = nil - } - self.whisperDownloadInFlight = false - self.whisperDownloadSink = nil - } - } - - /// Re-probe just the agent substrate (readiness + MCP + capability matrix). - /// Cheap on-disk reads; used by the Agent panel's "Refresh" action so - /// re-checking MCP does not disturb the rest of the panel. - func refreshAgentStatus() { - guard let agentStatus else { return } - agentReadiness = agentStatus.agenticReadiness() - mcpStatus = agentStatus.mcpStatus() - capabilityMatrix = agentStatus.capabilityMatrix() - } - - // MARK: - Hotkeys (mode-binding editor) - - /// Re-read persisted bindings + the option catalog, then reset the editable - /// draft to match disk and revalidate. A missing engine leaves the seeds. - func loadHotkeys() { - guard let hotkeys else { return } - modeBindings = hotkeys.modeBindings() - bindingOptions = hotkeys.availableBindings() - draftBindings = modeBindings - revalidateBindings() - } - - /// Any blocking (reachability / system) conflict in the current draft. - var hasBlockingBindingConflicts: Bool { bindingConflicts.contains { $0.blocking } } - - /// The draft differs from persisted state (something to save). - var hasPendingBindingChanges: Bool { - draftBindings.map(\.binding) != modeBindings.map(\.binding) - } - - /// Save is allowed only for a changed, conflict-clean draft. - var canSaveBindings: Bool { hasPendingBindingChanges && !hasBlockingBindingConflicts } - - /// Stage a binding change for one mode WITHOUT persisting, then re-validate so - /// conflicts surface inline before the user commits. - func editDraftBinding(mode: CsWorkMode, binding: CsShortcutBinding) { - guard let index = draftBindings.firstIndex(where: { $0.mode == mode }) else { return } - let label = bindingOptions.first { $0.binding == binding }?.label - ?? draftBindings[index].bindingLabel - draftBindings[index] = CsModeBinding( - mode: mode, - modeLabel: draftBindings[index].modeLabel, - modeDescription: draftBindings[index].modeDescription, - binding: binding, - bindingLabel: label + private var holdBadgeObserver: NSObjectProtocol? + + init( + engine: SettingsEngine? = nil, + permissionProbe: PermissionProbing = NativePermissionProbe(), + agentStatus: AgentStatusEngine? = nil, + mcpAdmin: MCPAdminEngine? = nil, + hotkeys: HotkeysEngine? = nil, + licenseService: LicenseService? = nil, + buildInfo: AppBuildInfo = .current(), + laneTruthProvider: @escaping (CsLlmLane) -> CsLaneTruthSnapshot = { lane in + laneTruthSnapshot(lane: lane) + }, + servingStatusProvider: @escaping () -> LastServingVerdict? = { + guard let verdict = currentServingVerdict() else { return nil } + return LastServingVerdict( + engine: verdict.engine, + routingMode: verdict.routingMode, + disposition: verdict.disposition, + fallbackUsed: verdict.fallbackUsed + ) + } + ) { + self.engine = engine + self.permissionProbe = permissionProbe + self.agentStatus = agentStatus + self.mcpAdmin = mcpAdmin + self.hotkeys = hotkeys + self.licenseService = licenseService ?? .preview + self.buildInfo = buildInfo + self.laneTruthProvider = laneTruthProvider + self.servingStatusProvider = servingStatusProvider + + // Reading the settings snapshot is passive: it does not write config + // or touch Keychain. Seed the picker from that same canonical snapshot + // so reopening Shortcuts reflects the persisted chord before a user + // changes anything. + self.permissions = permissionProbe.snapshot() + let initialSettings = engine?.loadSettings() ?? .sample + self.settings = initialSettings + self.deferredInsertShortcut = DeferredInsertShortcutOption( + wireId: initialSettings.deferredInsertShortcut + ) + self.keyStatus = .sampleAllSet + self.providers = CsProviderOption.sampleProviders + self.configDir = "" + self.needsOnboarding = false + self.agentReadiness = .sample + self.mcpStatus = .sample + self.capabilityMatrix = CsCapabilityRow.sampleMatrix + self.voiceLabReadError = nil + self.audioInput = .sample + self.audioInputReadError = nil + self.resetPreview = .sample + self.licenseStatus = self.licenseService.status + // K4: tray cycles arrive on the bus; reload Settings badge display. + // Register after every stored property is initialized (Swift init order). + holdBadgeObserver = NotificationCenter.default.addObserver( + forName: ConfigChangeBus.holdBadgeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.reloadHoldBadgeFromDisk() + } + } + } + + deinit { + if let holdBadgeObserver { + NotificationCenter.default.removeObserver(holdBadgeObserver) + } + } + + /// Re-read live state (permissions can change while the window is open). + func refresh() { + permissions = permissionProbe.snapshot() + refreshServingStatus() + // A permission granted while Settings is open (e.g. via the checklist's + // "Open System Settings") should bring hotkeys live without an app + // restart. Idempotent bridge call — a no-op once the tap is already armed. + hotkeys?.rearmAfterPermissionGrant() + if let engine { + applyLoadedSettings(engine.loadSettings()) + keyStatus = engine.keyStatus() + providers = engine.availableProviders() + configDir = engine.configDir() + needsOnboarding = engine.shouldShowOnboarding() + refreshModelDiscoveries(providerIds: [llmLane(.assistive).providerId, "openai-responses"]) + refreshVoiceLab() + refreshAudioInput() + } + refreshWhisperModelStatus() + refreshAgentStatus() + reloadMcpServers() + loadHotkeys() + licenseService.refresh() + licenseStatus = licenseService.status + } + + var licenseError: String? { licenseService.lastError } + + @discardableResult + func activateLicense(_ key: String) -> Bool { + let activated = licenseService.activate(key) + licenseStatus = licenseService.status + return activated + } + + func removeLicense() { + licenseService.removeLicense() + licenseStatus = licenseService.status + } + + /// Re-probe Whisper install state (embedded / on-disk / missing). + func refreshWhisperModelStatus() { + // Live UniFFI path; sample mode (engine == nil in pure previews) keeps + // the static placeholder so canvas previews stay offline-safe. + guard engine != nil else { return } + localWhisperStatus = whisperModelStatus() + } + + /// Called from UniFFI download callbacks (main-queue hopped). + fileprivate func applyWhisperDownloadProgress(detail: String, fraction: Double?) { + whisperDownloadDetail = detail + whisperDownloadFraction = fraction + } + + /// Download default Whisper into `~/.codescribe/models/…` (idempotent). + func startWhisperDownload() { + guard engine != nil else { return } + guard !whisperDownloadInFlight else { return } + whisperDownloadInFlight = true + whisperDownloadDetail = "Starting download…" + whisperDownloadFraction = nil + lastError = nil + let sink = WhisperDownloadProgressSink(model: self) + whisperDownloadSink = sink + Task { @MainActor [weak self] in + guard let self else { return } + do { + let status = try await downloadWhisperModel(listener: sink) + self.localWhisperStatus = status + self.whisperDownloadDetail = + status.available + ? "Ready · \(status.path ?? status.modelId)" + : "Download finished but model still unavailable" + self.whisperDownloadFraction = status.available ? 1.0 : nil + } catch { + self.lastError = String(describing: error) + self.whisperDownloadDetail = "Download failed" + self.whisperDownloadFraction = nil + } + self.whisperDownloadInFlight = false + self.whisperDownloadSink = nil + } + } + + /// Re-probe just the agent substrate (readiness + MCP + capability matrix). + /// Cheap on-disk reads; used by the Agent panel's "Refresh" action so + /// re-checking MCP does not disturb the rest of the panel. + func refreshAgentStatus() { + guard let agentStatus else { return } + agentReadiness = agentStatus.agenticReadiness() + mcpStatus = agentStatus.mcpStatus() + capabilityMatrix = agentStatus.capabilityMatrix() + } + + // MARK: - Hotkeys (mode-binding editor) + + /// Re-read persisted bindings + the option catalog, then reset the editable + /// draft to match disk and revalidate. A missing engine leaves the seeds. + func loadHotkeys() { + guard let hotkeys else { return } + modeBindings = hotkeys.modeBindings() + bindingOptions = hotkeys.availableBindings() + draftBindings = modeBindings + revalidateBindings() + } + + /// Any blocking (reachability / system) conflict in the current draft. + var hasBlockingBindingConflicts: Bool { bindingConflicts.contains { $0.blocking } } + + /// The draft differs from persisted state (something to save). + var hasPendingBindingChanges: Bool { + draftBindings.map(\.binding) != modeBindings.map(\.binding) + } + + /// Save is allowed only for a changed, conflict-clean draft. + var canSaveBindings: Bool { hasPendingBindingChanges && !hasBlockingBindingConflicts } + + /// Stage a binding change for one mode WITHOUT persisting, then re-validate so + /// conflicts surface inline before the user commits. + func editDraftBinding(mode: CsWorkMode, binding: CsShortcutBinding) { + guard let index = draftBindings.firstIndex(where: { $0.mode == mode }) else { return } + let label = + bindingOptions.first { $0.binding == binding }?.label + ?? draftBindings[index].bindingLabel + draftBindings[index] = CsModeBinding( + mode: mode, + modeLabel: draftBindings[index].modeLabel, + modeDescription: draftBindings[index].modeDescription, + binding: binding, + bindingLabel: label + ) + revalidateBindings() + } + + /// Recompute conflicts for the current draft via the revived shortcut registry. + func revalidateBindings() { + guard let hotkeys else { + bindingConflicts = [] + return + } + bindingConflicts = hotkeys.validate(candidate: draftBindings) + } + + /// Persist every changed mode through the core `set_mode_binding` contract + /// (each write live-reloads the detector), then re-read disk truth. Guarded by + /// `canSaveBindings`, so a conflicted or unchanged draft never writes. + func saveBindings() { + guard let hotkeys, canSaveBindings else { return } + do { + for draft in draftBindings { + let current = modeBindings.first { $0.mode == draft.mode } + if current?.binding != draft.binding { + try hotkeys.setModeBinding(mode: draft.mode, binding: draft.binding) + } + } + loadHotkeys() + } catch { + lastError = String(describing: error) + } + } + + /// Reset all bindings to the built-in defaults and re-read. + func resetBindingsToDefaults() { + guard let hotkeys else { return } + do { + try hotkeys.resetToDefaults() + loadHotkeys() + } catch { + lastError = String(describing: error) + } + } + + // MARK: - MCP server management (writes through the atomic config store) + + /// Re-read the configured MCP servers from `mcp.json`. A missing config is an + /// empty list, not an error. + func reloadMcpServers() { + guard let mcpAdmin else { return } + do { + mcpServers = try mcpAdmin.listServers() + } catch { + lastError = String(describing: error) + mcpServers = [] + } + } + + // MARK: - Tool permissions (B2 gateway) + + enum PermissionDefaultKind { + case global + case readOnly + case sideEffect + } + + /// Reload durable policy + live capability list from the same registry the + /// agent dispatcher builds. + /// + /// NEVER on the main thread: building the capability list spawns and + /// handshakes every configured MCP server (`discover_mcp_tools_blocking`), + /// which costs seconds with a dozen servers configured. The 2026-08-13 + /// beachball sample held the whole app in `pthread_join` inside this call — + /// on panel open AND on every permission click. Snapshot off-main, publish + /// back on the main actor; a stale list for a moment beats a frozen app. + func reloadToolPermissions() { + guard let mcpAdmin else { return } + Task.detached(priority: .userInitiated) { [weak self] in + let policy = mcpAdmin.getPermissionPolicy() + let capabilities = mcpAdmin.listToolCapabilities() + await MainActor.run { [weak self] in + guard let self else { return } + self.permissionPolicy = policy + self.toolCapabilities = capabilities + } + } + } + + func setPermissionDefault(kind: PermissionDefaultKind, level: String) { + guard let mcpAdmin else { return } + var defaultLevel = permissionPolicy.defaultLevel + var readOnly = permissionPolicy.readOnlyDefault + var sideEffect = permissionPolicy.sideEffectDefault + switch kind { + case .global: defaultLevel = level + case .readOnly: readOnly = level + case .sideEffect: sideEffect = level + } + do { + try mcpAdmin.setPermissionDefaults( + defaultLevel: defaultLevel, + readOnlyDefault: readOnly, + sideEffectDefault: sideEffect + ) + reloadToolPermissions() + } catch { + lastError = String(describing: error) + } + } + + func setToolPermission(identity: String, level: String) { + guard let mcpAdmin else { return } + do { + try mcpAdmin.setToolPermission(identity: identity, level: level) + reloadToolPermissions() + } catch { + lastError = String(describing: error) + } + } + + /// Add a server from the form. `args` is already split into tokens. On success + /// the list + readiness re-probe so the panel reflects the new state. + func addMcpServer( + name: String, command: String, args: [String], + endpoint: String = "", token: String = "" + ) { + guard let mcpAdmin else { return } + do { + try mcpAdmin.addServer( + CsMcpServerInput( + name: name, command: command, args: args, enabled: true, + endpoint: endpoint, authRef: "", token: token ) - revalidateBindings() - } - - /// Recompute conflicts for the current draft via the revived shortcut registry. - func revalidateBindings() { - guard let hotkeys else { - bindingConflicts = [] - return - } - bindingConflicts = hotkeys.validate(candidate: draftBindings) - } - - /// Persist every changed mode through the core `set_mode_binding` contract - /// (each write live-reloads the detector), then re-read disk truth. Guarded by - /// `canSaveBindings`, so a conflicted or unchanged draft never writes. - func saveBindings() { - guard let hotkeys, canSaveBindings else { return } - do { - for draft in draftBindings { - let current = modeBindings.first { $0.mode == draft.mode } - if current?.binding != draft.binding { - try hotkeys.setModeBinding(mode: draft.mode, binding: draft.binding) - } - } - loadHotkeys() - } catch { - lastError = String(describing: error) - } - } - - /// Reset all bindings to the built-in defaults and re-read. - func resetBindingsToDefaults() { - guard let hotkeys else { return } - do { - try hotkeys.resetToDefaults() - loadHotkeys() - } catch { - lastError = String(describing: error) - } - } - - // MARK: - MCP server management (writes through the atomic config store) - - /// Re-read the configured MCP servers from `mcp.json`. A missing config is an - /// empty list, not an error. - func reloadMcpServers() { - guard let mcpAdmin else { return } - do { - mcpServers = try mcpAdmin.listServers() - } catch { - lastError = String(describing: error) - mcpServers = [] - } - } - - // MARK: - Tool permissions (B2 gateway) - - enum PermissionDefaultKind { - case global - case readOnly - case sideEffect - } - - /// Reload durable policy + live capability list from the same registry the - /// agent dispatcher builds. - func reloadToolPermissions() { - guard let mcpAdmin else { return } - permissionPolicy = mcpAdmin.getPermissionPolicy() - toolCapabilities = mcpAdmin.listToolCapabilities() - } - - func setPermissionDefault(kind: PermissionDefaultKind, level: String) { - guard let mcpAdmin else { return } - var defaultLevel = permissionPolicy.defaultLevel - var readOnly = permissionPolicy.readOnlyDefault - var sideEffect = permissionPolicy.sideEffectDefault - switch kind { - case .global: defaultLevel = level - case .readOnly: readOnly = level - case .sideEffect: sideEffect = level - } - do { - try mcpAdmin.setPermissionDefaults( - defaultLevel: defaultLevel, - readOnlyDefault: readOnly, - sideEffectDefault: sideEffect - ) - reloadToolPermissions() - } catch { - lastError = String(describing: error) - } - } - - func setToolPermission(identity: String, level: String) { - guard let mcpAdmin else { return } - do { - try mcpAdmin.setToolPermission(identity: identity, level: level) - reloadToolPermissions() - } catch { - lastError = String(describing: error) - } - } - - /// Add a server from the form. `args` is already split into tokens. On success - /// the list + readiness re-probe so the panel reflects the new state. - func addMcpServer( - name: String, command: String, args: [String], - endpoint: String = "", token: String = "" - ) { - guard let mcpAdmin else { return } - do { - try mcpAdmin.addServer( - CsMcpServerInput( - name: name, command: command, args: args, enabled: true, - endpoint: endpoint, authRef: "", token: token - ) - ) - reloadMcpServers() - refreshAgentStatus() - } catch { - lastError = String(describing: error) - } - } - - /// Flip a server's `enabled` flag, preserving its command / args / env. - func toggleMcpServer(_ server: CsMcpServer) { - guard let mcpAdmin else { return } - do { - try mcpAdmin.updateServer( - name: server.name, - input: CsMcpServerInput( - name: server.name, command: server.command, - args: server.args, enabled: !server.enabled, - endpoint: server.endpoint, authRef: server.authRef, token: "" - ) - ) - reloadMcpServers() - refreshAgentStatus() - } catch { - lastError = String(describing: error) - } - } - - /// Remove a server and drop any cached test result for it. - func removeMcpServer(_ name: String) { - guard let mcpAdmin else { return } - do { - try mcpAdmin.removeServer(name: name) - mcpTestResults[name] = nil - reloadMcpServers() - refreshAgentStatus() - } catch { - lastError = String(describing: error) - } - } - - /// Spawn + handshake the named server and record the result inline. Runs off - /// the main actor (the engine detaches) so the up-to-10s test never freezes - /// the window; `mcpTestPending` drives a spinner in the row. - func testMcpServer(_ name: String) { - guard let mcpAdmin else { return } - guard !mcpTestPending.contains(name) else { return } - mcpTestPending.insert(name) - Task { - let result = await mcpAdmin.testServer(name) - mcpTestPending.remove(name) - mcpTestResults[name] = result - } - } - - func select(_ target: SettingsSection) { - guard target.availability == .available else { return } - section = target - // Landing on a section shows its first page; sections without pages keep - // page == nil and render whole. - page = SettingsPage.pages(in: target).first - if target == .agent { - refreshAssistiveModelDiscovery() - } - if target == .engine { - refreshServingStatus() - } - } - - /// Select a specific page. Routes through `select` so the section's refresh - /// side effects fire exactly once regardless of which row the user clicked. - func select(_ target: SettingsPage) { - select(target.section) - page = target - } - - func select(_ route: SettingsRoute) { - switch route { - case .section(let section): select(section) - case .page(let page): select(page) - } - } - - /// The rail row that should read as selected for the current state. - var route: SettingsRoute { - if let page { return .page(page) } - return .section(section) - } - - // MARK: - Reset app data (recoverable destructive action) - - func refreshResetPreview() { - guard let engine else { return } - resetPreview = engine.resetPreview() - } - - func resetImpactDescription(includeKeys: Bool, includePrompts: Bool) -> String { - var message = "Moves \(resetImpactSummary(resetPreview)) to Trash." - if includePrompts { - message += " Your assistive.txt and three formatting prompt files will also move to Trash." - } else { - message += " Your assistive.txt and three formatting prompt files will be preserved." - } - if includeKeys { - message += " API keys will also be removed from Keychain and are not recoverable from Trash." - } - return message + " Codescribe will relaunch as a fresh install." - } - - /// Move all local app data to Trash through the Rust bridge, clear the app's - /// UserDefaults domain, then relaunch so codescribe comes up fresh (first-run - /// wizard from the top). `includeKeys` also removes the Keychain API keys. - /// On failure the error surfaces in `lastError` and nothing is relaunched. - func resetAppData(includeKeys: Bool, includePrompts: Bool) { - guard let engine else { return } - do { - try engine.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) - } catch { - lastError = String(describing: error) - return - } - AppRelaunch.clearDefaultsAndRelaunch() - } - - func clearMcpConfiguration() { - guard let engine else { return } - do { - try engine.clearMcpConfiguration() - mcpTestResults = [:] - reloadMcpServers() - refreshAgentStatus() - } catch { - lastError = String(describing: error) - } - } - - // MARK: - Engine-panel derived values (runtime truth) - - /// Last serving verdict published by the runtime owner (not config). - /// Tests inject directly; live path refreshes via `servingStatusProvider` - /// (UniFFI `currentServingVerdict()`) in `refresh()` and on panel entry. - var lastServingVerdict: LastServingVerdict? - - /// Runtime serving-truth source — defaults to the UniFFI bridge snapshot. - let servingStatusProvider: () -> LastServingVerdict? - - /// Pull the latest stop-path serving verdict from the runtime owner. - func refreshServingStatus() { - lastServingVerdict = servingStatusProvider() - } - - /// Active STT row — consumes runtime serving truth only. - /// Configured engine/mode are preference controls, not this label. - var activeSTT: String { - formatActiveSTT(lastServing: lastServingVerdict) - } - - /// STT is "healthy" (olive dot) when a local model is configured, or when a - /// cloud endpoint is set. We can't probe live Whisper load state from the - /// config engine alone — that lives on `CodescribeDictation` (tracked gap). - var sttHealthy: Bool { - settings.useLocalStt ? !settings.localModel.isEmpty - : (settings.sttEndpoint?.isEmpty == false) - } - - var whisperLanguageCode: String { settings.whisperLanguage.shortCode } - - var sttModelDescription: String { - settings.useLocalStt ? settings.localModel - : (settings.sttEndpoint ?? "cloud default") - } - - private var assistiveKeyState: SettingsKeyState { - guard let provider = llmLane(.assistive).provider else { return .unknown } - let keyAvailable = provider.accountSignedIn - || provider.apiKeySet - || keyStatus.isSet(account: provider.apiKeyAccount) - return keyAvailable ? .available : .missing - } - - var settingsHealth: SettingsHealthState { - healthState( - stt: sttHealthy, - keys: assistiveKeyState, - agent: agentReadiness.ready + ) + reloadMcpServers() + refreshAgentStatus() + } catch { + lastError = String(describing: error) + } + } + + /// Flip a server's `enabled` flag, preserving its command / args / env. + func toggleMcpServer(_ server: CsMcpServer) { + guard let mcpAdmin else { return } + do { + try mcpAdmin.updateServer( + name: server.name, + input: CsMcpServerInput( + name: server.name, command: server.command, + args: server.args, enabled: !server.enabled, + endpoint: server.endpoint, authRef: server.authRef, token: "" ) - } + ) + reloadMcpServers() + refreshAgentStatus() + } catch { + lastError = String(describing: error) + } + } + + /// Remove a server and drop any cached test result for it. + func removeMcpServer(_ name: String) { + guard let mcpAdmin else { return } + do { + try mcpAdmin.removeServer(name: name) + mcpTestResults[name] = nil + reloadMcpServers() + refreshAgentStatus() + } catch { + lastError = String(describing: error) + } + } + + /// Spawn + handshake the named server and record the result inline. Runs off + /// the main actor (the engine detaches) so the up-to-10s test never freezes + /// the window; `mcpTestPending` drives a spinner in the row. + func testMcpServer(_ name: String) { + guard let mcpAdmin else { return } + guard !mcpTestPending.contains(name) else { return } + mcpTestPending.insert(name) + Task { + let result = await mcpAdmin.testServer(name) + mcpTestPending.remove(name) + mcpTestResults[name] = result + } + } + + func select(_ target: SettingsSection) { + guard target.availability == .available else { return } + section = target + // Landing on a section shows its first page; sections without pages keep + // page == nil and render whole. + page = SettingsPage.pages(in: target).first + if target == .agent { + refreshAssistiveModelDiscovery() + } + if target == .engine { + refreshServingStatus() + } + } + + /// Select a specific page. Routes through `select` so the section's refresh + /// side effects fire exactly once regardless of which row the user clicked. + func select(_ target: SettingsPage) { + select(target.section) + page = target + } + + func select(_ route: SettingsRoute) { + switch route { + case .section(let section): select(section) + case .page(let page): select(page) + } + } + + /// The rail row that should read as selected for the current state. + var route: SettingsRoute { + if let page { return .page(page) } + return .section(section) + } + + // MARK: - Reset app data (recoverable destructive action) + + func refreshResetPreview() { + guard let engine else { return } + resetPreview = engine.resetPreview() + } + + func resetImpactDescription(includeKeys: Bool, includePrompts: Bool) -> String { + var message = "Moves \(resetImpactSummary(resetPreview)) to Trash." + if includePrompts { + message += " Your assistive.txt and three formatting prompt files will also move to Trash." + } else { + message += " Your assistive.txt and three formatting prompt files will be preserved." + } + if includeKeys { + message += " API keys will also be removed from Keychain and are not recoverable from Trash." + } + return message + " Codescribe will relaunch as a fresh install." + } + + /// Move all local app data to Trash through the Rust bridge, clear the app's + /// UserDefaults domain, then relaunch so codescribe comes up fresh (first-run + /// wizard from the top). `includeKeys` also removes the Keychain API keys. + /// Pre-move failures stay in-process. A post-move failure carries a stable + /// Rust marker and still forces relaunch, because continuing in a partially + /// reset, permanently fenced process would be worse than the reported error. + func resetAppData(includeKeys: Bool, includePrompts: Bool) { + guard let engine else { return } + do { + try engine.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) + } catch { + let description = String(describing: error) + lastError = description + if resetFailureRequiresRelaunch(description) { + AppRelaunch.clearDefaultsAndRelaunch() + } + return + } + AppRelaunch.clearDefaultsAndRelaunch() + } + + func clearMcpConfiguration() { + guard let engine else { return } + do { + try engine.clearMcpConfiguration() + mcpTestResults = [:] + reloadMcpServers() + refreshAgentStatus() + } catch { + lastError = String(describing: error) + } + } + + // MARK: - Engine-panel derived values (runtime truth) + + /// Last serving verdict published by the runtime owner (not config). + /// Tests inject directly; live path refreshes via `servingStatusProvider` + /// (UniFFI `currentServingVerdict()`) in `refresh()` and on panel entry. + var lastServingVerdict: LastServingVerdict? + + /// Runtime serving-truth source — defaults to the UniFFI bridge snapshot. + let servingStatusProvider: () -> LastServingVerdict? + + /// Pull the latest stop-path serving verdict from the runtime owner. + func refreshServingStatus() { + lastServingVerdict = servingStatusProvider() + } + + /// Active STT row — consumes runtime serving truth only. + /// Configured engine/mode are preference controls, not this label. + var activeSTT: String { + formatActiveSTT(lastServing: lastServingVerdict) + } + + /// STT is "healthy" (olive dot) when a local model is configured, or when a + /// cloud endpoint is set. We can't probe live Whisper load state from the + /// config engine alone — that lives on `CodescribeDictation` (tracked gap). + var sttHealthy: Bool { + settings.useLocalStt + ? !settings.localModel.isEmpty + : (settings.sttEndpoint?.isEmpty == false) + } + + var whisperLanguageCode: String { settings.whisperLanguage.shortCode } + + var sttModelDescription: String { + settings.useLocalStt + ? settings.localModel + : (settings.sttEndpoint ?? "cloud default") + } + + private var assistiveKeyState: SettingsKeyState { + guard let provider = llmLane(.assistive).provider else { return .unknown } + let keyAvailable = + provider.accountSignedIn + || provider.apiKeySet + || keyStatus.isSet(account: provider.apiKeyAccount) + return keyAvailable ? .available : .missing + } + + var settingsHealth: SettingsHealthState { + healthState( + stt: sttHealthy, + keys: assistiveKeyState, + agent: agentReadiness.ready + ) + } + + /// Effective lane state after provider/shared fallbacks. + func llmLane(_ lane: LLMLane) -> LLMLaneModel { + let truth = laneTruthProvider(lane.bridgeLane) + let providerId = truth.providerId + let configuredModel = + settings[keyPath: lane.modelPath]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let discoveryProviderId = lane == .assistive ? providerId : "openai-responses" + + return LLMLaneModel( + lane: lane, + providerId: providerId, + provider: providers.first { $0.id == providerId } ?? providers.first, + resolvedEndpoint: truth.endpoint, + configuredModel: configuredModel, + resolvedModel: truth.model, + discoveryEndpoint: lane == .assistive + ? truth.endpoint + : resolvedOpenAIEndpoint(for: .assistive), + discovery: modelDiscoveries[discoveryProviderId] + ?? CsModelDiscovery.sample(for: discoveryProviderId) + ) + } + + private func refreshAssistiveModelDiscovery(includeOpenAI: Bool = false) { + let providerId = llmLane(.assistive).providerId + refreshModelDiscoveries( + providerIds: includeOpenAI ? [providerId, "openai-responses"] : [providerId]) + } + + private func resolvedOpenAIEndpoint(for lane: LLMLane) -> String { + // P2-05: lane/shared/default resolution stays here (UI settings surface); + // suffix normalization is now delegated to core via FFI (single truth in + // lane_truth::normalize_openai_responses_endpoint, exposed in bridge/config). + let laneValue = + settings[keyPath: lane.endpointPath]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let sharedValue = + settings[keyPath: LLMLane.main.endpointPath]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let base = + !laneValue.isEmpty + ? laneValue + : (!sharedValue.isEmpty + ? sharedValue + : "https://api.openai.com/v1/responses") + + if let engine { + return engine.normalizeOpenaiResponsesEndpoint(base) + } + // Fallback for previews / no-engine (kept tiny; real path always has engine). + // NOTE: suffix list duplication removed (L2 over-correct); core lane_truth::normalize + // (via bridge) is the single source of truth for responses endpoint. Fallback does + // minimal /v1 strip only to avoid duplicating known-suffixes array. + var b = base.trimmingCharacters(in: .whitespacesAndNewlines.union(.init(charactersIn: "/"))) + if b.hasSuffix("/v1") { b.removeLast(3) } + return b + "/v1/responses" + } + + /// Persist an endpoint override for one LLM lane. Whitespace-only input is + /// the reset signal: the core removes the optional JSON path so the next + /// resolved fallback becomes effective immediately. + func setLLMEndpoint(_ value: String, for lane: LLMLane) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + settings[keyPath: lane.endpointPath] = trimmed.isEmpty ? nil : trimmed + persist(lane.endpointKey, trimmed) + refreshAgentStatus() + if lane == .assistive { + refreshAssistiveModelDiscovery(includeOpenAI: true) + } + } + + /// Persist a model override for one LLM lane. Empty clears the JSON override. + func setLLMModel(_ value: String, for lane: LLMLane) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if lane == .assistive { + assistiveModelEditGeneration += 1 + pendingAssistiveModelSelection = nil + } + settings[keyPath: lane.modelPath] = trimmed.isEmpty ? nil : trimmed + persist(lane.modelKey, trimmed) + } + + var formattingDescription: String { + guard settings.aiFormattingEnabled else { return "disabled · compatibility gate" } + return FormattingPolicyOption(storedValue: settings.formattingLevel)?.visibleName + ?? "invalid policy" + } + + /// Any LLM/STT provider key present (GitHub token is shown separately). + var apiKeysStored: Bool { + keyStatus.llmApiKeySet || keyStatus.llmAssistiveApiKeySet + || keyStatus.llmAnthropicApiKeySet || keyStatus.llmFormattingApiKeySet + || keyStatus.sttApiKeySet + } + + var apiKeysDescription: String { + apiKeysStored ? "Stored in Keychain" : "Not configured" + } + + // MARK: - Creator mutations (write through the core router) + + func setLanguage(_ lang: CsLanguage) { + settings.whisperLanguage = lang + persist("WHISPER_LANGUAGE", lang.shortCode) + } + + func setFormattingEnabled(_ on: Bool) { + settings.aiFormattingEnabled = on + persist("AI_FORMATTING_ENABLED", on ? "1" : "0") + } + + func setFormattingLevel(_ level: String) { + guard let policy = FormattingPolicyOption(storedValue: level) else { + lastError = "Unknown formatting policy: \(level)" + return + } + settings.formattingLevel = policy.rawValue + persist("FORMATTING_LEVEL", policy.rawValue) + } + + // MARK: - User panel (local-first product truth) + + var transcriptsPath: String { + guard !configDir.isEmpty else { return "" } + return URL(fileURLWithPath: configDir).appendingPathComponent("transcriptions").path + } + + var transcriptTagPreview: String { + transcriptTagTemplatePreview(settings.transcriptTagTemplate) + } + + var transcriptTagTemplateWarning: String? { + transcriptTagTemplateAppendWarning(settings.transcriptTagTemplate) + } + + func setTranscriptTaggingEnabled(_ enabled: Bool) { + settings.transcriptTaggingEnabled = enabled + persist("TRANSCRIPT_TAGGING_ENABLED", enabled ? "1" : "0") + } + + func setTranscriptTagTemplate(_ template: String) { + settings.transcriptTagTemplate = template + persist("TRANSCRIPT_TAG_TEMPLATE", template) + } + + func restoreDefaultTranscriptTagTemplate() { + setTranscriptTagTemplate(defaultTranscriptTagTemplate) + } + + // MARK: - Audio (live hardware + existing settings contract) + + func refreshAudioInput() { + guard let engine else { return } + do { + audioInput = try engine.loadAudioInputSnapshot() + audioInputReadError = nil + } catch { + audioInput = CsAudioInputSnapshot( + devices: [], + configuredDevice: settings.audioInputDevice, + runtimeDevice: nil, + configuredDeviceAvailable: false, + fallbackToDefault: false, + runtimeConfigurationMatches: false + ) + audioInputReadError = String(describing: error) + } + } + + func setAudioInputDevice(_ device: String) { + settings.audioInputDevice = device + persist("AUDIO_INPUT_DEVICE", device) + refreshAudioInput() + } + + func resetAudioInputDevice() { + guard let engine else { return } + do { + try engine.resetAudioInputDevice() + applyLoadedSettings(engine.loadSettings()) + refreshAudioInput() + } catch { + lastError = String(describing: error) + } + } + + func setToggleSilenceSeconds(_ seconds: Float) { + settings.toggleSilenceSec = seconds + persist("TOGGLE_SILENCE_SEC", String(format: "%.1f", seconds)) + } + + func setSoundFeedbackEnabled(_ enabled: Bool) { + settings.beepOnStart = enabled + persist("BEEP_ON_START", enabled ? "1" : "0") + } + + func setSoundVolume(_ volume: Float) { + settings.soundVolume = volume + persist("SOUND_VOLUME", String(format: "%.2f", volume)) + } + + // MARK: - Voice Lab (live quality truth + preview timing) + + func refreshVoiceLab() { + guard let engine else { return } + do { + qualityRecords = try engine.loadQualityRecentRecords(limit: 50) + customLexiconEntries = try engine.loadLexiconCustomEntries() + voiceLabReadError = nil + } catch { + qualityRecords = [] + customLexiconEntries = [] + voiceLabReadError = String(describing: error) + } + } + + /// Mine corrections.jsonl + proposed lexicon into the live custom dictionary. + /// Teach replays the whole correction store and rewrites the lexicon — real + /// disk I/O whose cost scales with the corpus. Running it inline froze + /// Settings for the duration; it now runs off the main actor like the key + /// probe above, with `voiceLabTeachPending` keeping the button honest until + /// the result lands. + func teachDictionaryFromStore() { + guard let engine, !voiceLabTeachPending else { return } + voiceLabTeachPending = true + voiceLabTeachMessage = nil + let backgroundEngine = BackgroundSettingsEngine(engine: engine) + + DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine] in + let outcome: Result + do { + outcome = .success(try backgroundEngine.engine.teachDictionaryFromStore()) + } catch { + outcome = .failure(error) + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.voiceLabTeachPending = false + switch outcome { + case .success(let result): + self.voiceLabTeachMessage = + "Taught +\(result.fromCorrections) from corrections, +\(result.fromProposed) from proposed → \(result.totalRules) live rules (\(result.rulesFromCorrectionSource) correction-sourced)." + self.refreshVoiceLab() + case .failure(let error): + let message = String(describing: error) + self.voiceLabTeachMessage = "Teach failed: \(message)" + self.lastError = message + } + } + } + } + + @discardableResult + func finalizeVoiceLabCorrection(id: String, canonical: String) -> Bool { + guard let engine else { return false } + let canonical = canonical.trimmingCharacters(in: .whitespacesAndNewlines) + guard !canonical.isEmpty, !voiceLabEditPending.contains(id) else { return false } + + voiceLabEditPending.insert(id) + voiceLabEditErrors[id] = nil + voiceLabEditNotes[id] = nil + defer { voiceLabEditPending.remove(id) } + do { + let outcome = try engine.finalizeVoiceLabCorrection(id: id, canonical: canonical) + voiceLabEditNotes[id] = Self.voiceLabSaveNote(outcome) + refreshVoiceLab() + return voiceLabReadError == nil + } catch { + let message = String(describing: error) + voiceLabEditErrors[id] = message + lastError = message + return false + } + } + + /// The revision is persisted whenever the engine returns — the note only + /// tells the truth about what the dictionary derived from the edit. + static func voiceLabSaveNote(_ outcome: CsVoiceLabSaveResult) -> String { + if let lexiconError = outcome.lexiconError { + return "Saved — dictionary learning failed: \(lexiconError)" + } + switch outcome.pairsLearned { + case 0: return "Saved; no dictionary rule derived" + case 1: return "Saved — 1 rule learned" + default: return "Saved — \(outcome.pairsLearned) rules learned" + } + } + + var previewTimingConfiguration: PreviewTimingConfiguration { + PreviewTimingConfiguration( + overlayEnabled: settings.transcriptionOverlayEnabled, + values: PreviewTimingValues( + bufferDelayMs: settings.bufferDelayMs ?? PreviewTimingValues.smooth.bufferDelayMs, + typingCps: settings.typingCps ?? PreviewTimingValues.smooth.typingCps, + emitWordsMax: settings.emitWordsMax ?? PreviewTimingValues.smooth.emitWordsMax, + interimSeconds: settings.bufferedInterimSec ?? PreviewTimingValues.smooth.interimSeconds + ) + ) + } - /// Effective lane state after provider/shared fallbacks. - func llmLane(_ lane: LLMLane) -> LLMLaneModel { - let truth = laneTruthProvider(lane.bridgeLane) - let providerId = truth.providerId - let configuredModel = settings[keyPath: lane.modelPath]? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let discoveryProviderId = lane == .assistive ? providerId : "openai-responses" + var previewTimingPreset: PreviewTimingPreset { + detectPreset(previewTimingConfiguration) + } - return LLMLaneModel( - lane: lane, + /// Preset writes go through the existing batch router: one settings.json + /// transaction for overlay state plus all four coupled timing values. + func applyPreviewTimingPreset(_ preset: PreviewTimingPreset) { + switch preset { + case .custom: + return + case .off: + persistMany([ + CsConfigEntry(key: "TRANSCRIPTION_OVERLAY_ENABLED", value: "0") + ]) + case .smooth, .snappy, .relaxed: + guard let values = presetValues(preset) else { return } + persistMany([ + CsConfigEntry(key: "TRANSCRIPTION_OVERLAY_ENABLED", value: "1"), + CsConfigEntry( + key: "CODESCRIBE_BUFFER_DELAY_MS", + value: String(values.bufferDelayMs) + ), + CsConfigEntry( + key: "CODESCRIBE_TYPING_CPS", + value: String(format: "%.1f", values.typingCps) + ), + CsConfigEntry( + key: "CODESCRIBE_EMIT_WORDS_MAX", + value: String(values.emitWordsMax) + ), + CsConfigEntry( + key: "CODESCRIBE_BUFFERED_INTERIM_SEC", + value: String(format: "%.1f", values.interimSeconds) + ), + ]) + } + } + + func setPreviewBufferDelayMs(_ value: UInt64) { + settings.bufferDelayMs = value + persist("CODESCRIBE_BUFFER_DELAY_MS", String(value)) + } + + func setPreviewTypingCps(_ value: Float) { + settings.typingCps = value + persist("CODESCRIBE_TYPING_CPS", String(format: "%.1f", value)) + } + + func setPreviewEmitWordsMax(_ value: UInt64) { + settings.emitWordsMax = value + persist("CODESCRIBE_EMIT_WORDS_MAX", String(value)) + } + + func setPreviewInterimSeconds(_ value: Float) { + settings.bufferedInterimSec = value + persist("CODESCRIBE_BUFFERED_INTERIM_SEC", String(format: "%.1f", value)) + } + + // MARK: - STT engine / layered transcription (Engine panel controls) + + /// Selected STT engine id ("auto" | "apple" | "whisper"); empty → product default Apple. + var sttEngineId: String { + let raw = (settings.sttEngine ?? "apple").lowercased() + switch raw { + case "auto", "apple", "whisper", "candle", "onnx": return raw == "candle" ? "whisper" : raw + default: return "apple" + } + } + + /// Display label for the current STT engine selection. + var sttEngineLabel: String { + switch sttEngineId { + case "apple": return "Apple (live)" + case "whisper", "candle": return "Whisper (Candle)" + case "auto": return "Auto (Apple-first)" + default: return "Apple (live)" + } + } + + /// Honest dual-brain note when preference and Active STT last run diverge. + var sttEngineTruthNote: String? { + let pref = sttEngineId + let active = activeSTT.lowercased() + if active.contains("not yet") || active.isEmpty { return nil } + // Preference Apple but last run was Whisper recovery / file pass is OK + // to mention once when the chip is clearly whisper while user picked apple. + if pref == "apple", active.contains("whisper") { + return + "Preference: Apple live · last take used Whisper (final/recovery). Live partials stay Apple." + } + if pref == "whisper" || pref == "candle", active.contains("apple") { + return "Preference: Whisper · last take was Apple live — check env override or restart." + } + return nil + } + + func setSttEngine(_ id: String) { + let normalized: String + switch id.lowercased() { + case "auto": normalized = "auto" + case "whisper", "candle": normalized = "whisper" + case "onnx": normalized = "onnx" + default: normalized = "apple" + } + settings.sttEngine = normalized + // Persist promotes to settings.json AND reconciles process env + .env + // (single brain — no CODESCRIBE_STT_ENGINE lottery). + persist("CODESCRIBE_STT_ENGINE", normalized) + } + + /// Final-pass routing: always | smart | off (default smart). + var finalPassModeId: String { + let raw = (settings.finalPassMode ?? "smart").lowercased() + switch raw { + case "always", "off": return raw + default: return "smart" + } + } + + var finalPassModeLabel: String { + switch finalPassModeId { + case "always": return "Always" + case "off": return "Off" + default: return "Smart" + } + } + + func setFinalPassMode(_ id: String) { + let normalized: String + switch id.lowercased() { + case "always": normalized = "always" + case "off": normalized = "off" + default: normalized = "smart" + } + settings.finalPassMode = normalized + persist("FINAL_PASS_MODE", normalized) + } + + /// ON for any phase value ("phase1".."phase4" or bare "1".."4"); explicit + /// "off" disarms. Absent matches the core default (`unset` → phase1). + var layeredTranscriptionEnabled: Bool { + let value = settings.layeredTranscription ?? "phase1" + return value.hasPrefix("phase") || Int(value) != nil + } + + /// The GUI only exposes Phase 1 (Apple live layer + Whisper tail patch); + /// phases 2-4 do not exist as features yet. + func setLayeredTranscription(_ on: Bool) { + let value = on ? "phase1" : "off" + settings.layeredTranscription = value + persist("CODESCRIBE_LAYERED_TRANSCRIPTION", value) + } + + var holdBadgeOption: HoldBadgeOption { + HoldBadgeOption( + indicatorEnabled: settings.holdIndicator, + size: settings.holdBadgeSize + ) + } + + /// Off changes visibility only, preserving the stored size. A concrete size + /// enables the indicator and writes both existing keys atomically. + /// + /// K3 (W10-E): persists immediately; takes effect at the *next* badge show + /// (no live redraw of a visible caret badge). + /// K4: posts `ConfigChangeBus.holdBadgeDidChange` so the tray reflects it. + func setHoldBadgeOption(_ option: HoldBadgeOption) { + guard let size = option.size else { + settings.holdIndicator = false + persist("HOLD_INDICATOR", "0") + ConfigChangeBus.postHoldBadgeChanged() + return + } + settings.holdIndicator = true + settings.holdBadgeSize = size + persistMany([ + CsConfigEntry(key: "HOLD_INDICATOR", value: "1"), + CsConfigEntry(key: "HOLD_BADGE_SIZE", value: String(size)), + ]) + ConfigChangeBus.postHoldBadgeChanged() + } + + /// Reload badge fields from the engine after a peer surface (tray) wrote them. + func reloadHoldBadgeFromDisk() { + guard let engine else { return } + applyLoadedSettings(engine.loadSettings()) + objectWillChange.send() + } + + /// Assistive-arm modifier on the hold base: `"shift"` (default) or `"cmd"`. + var holdArmModifier: String { + let raw = settings.holdArmModifier.lowercased() + return (raw == "cmd" || raw == "command") ? "cmd" : "shift" + } + + func setHoldArmModifier(_ value: String) { + let normalized = + (value.lowercased() == "cmd" || value.lowercased() == "command") + ? "cmd" : "shift" + settings.holdArmModifier = normalized + persist("HOLD_ARM_MODIFIER", normalized) + } + + /// Deferred-insert chord from the canonical persisted settings snapshot. + @Published private(set) var deferredInsertShortcut: DeferredInsertShortcutOption = .disabled + + /// Persists `CODESCRIBE_DEFERRED_INSERT_SHORTCUT` through the config + /// router (auto-tiered → settings.json since the 2d3e2e27 promotion; the + /// running detector live-reloads on write). Optimistic like every other + /// setter here: the selection sticks and a failed write surfaces in + /// `lastError`. + func setDeferredInsertShortcut(_ option: DeferredInsertShortcutOption) { + deferredInsertShortcut = option + persist("CODESCRIBE_DEFERRED_INSERT_SHORTCUT", option.wireId) + } + + // MARK: - Agent workspace roots (list_projects tool) + + /// Effective workspace roots the `list_projects` tool scans. Never empty — + /// the bridge fills the built-in default (`~/.codescribe`) when unset. + var agentWorkspaceRoots: [String] { settings.agentWorkspaceRoots } + + /// Persist the workspace roots as the colon-joined `AGENT_WORKSPACE_ROOTS` + /// value. Blank/whitespace rows are dropped; an all-empty list clears the + /// override so the core falls back to `~/.codescribe`. + func setAgentWorkspaceRoots(_ roots: [String]) { + let cleaned = + roots + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + settings.agentWorkspaceRoots = cleaned.isEmpty ? ["~/.codescribe"] : cleaned + persist("AGENT_WORKSPACE_ROOTS", cleaned.joined(separator: ":")) + } + + /// Current cloud STT endpoint override, empty when the provider default applies. + var sttEndpoint: String { settings.sttEndpoint ?? "" } + + /// Persist the cloud STT endpoint (`STT_ENDPOINT`). Blank clears the override + /// so cloud lanes fall back to the provider default. Restart-scoped, like the + /// env contract says — the field exists so the key's companion endpoint no + /// longer requires hand-editing ~/.codescribe/.env. + func setSttEndpoint(_ value: String) { + persist("STT_ENDPOINT", value.trimmingCharacters(in: .whitespaces)) + } + + private func persist(_ key: String, _ value: String) { + guard let engine else { return } + do { + try engine.updateConfig(key: key, value: value) + applyLoadedSettings(engine.loadSettings()) + } catch { + lastError = String(describing: error) + } + } + + private func persistMany(_ entries: [CsConfigEntry]) { + guard let engine else { return } + do { + try engine.updateConfigMany(entries: entries) + applyLoadedSettings(engine.loadSettings()) + } catch { + lastError = String(describing: error) + } + } + + /// Applies an already-read bridge snapshot without writing it back. Keep + /// this as the only read path so passive reloads cannot reset the picker to + /// a UI default while persisted truth says otherwise. + private func applyLoadedSettings(_ loaded: CsSettings) { + settings = loaded + deferredInsertShortcut = DeferredInsertShortcutOption( + wireId: loaded.deferredInsertShortcut + ) + } + + // MARK: - Keys (Keychain-backed; secrets never read back) + + /// Friendly labels for the canonical Keychain accounts. + static func keyLabel(for account: String) -> String { + switch account { + case "LLM_API_KEY": return "LLM API key" + case "STT_API_KEY": return "Speech-to-text API key" + case "LLM_FORMATTING_API_KEY": return "Formatting API key" + case "LLM_ASSISTIVE_API_KEY": return "Assistive API key (OpenAI)" + case "LLM_ANTHROPIC_API_KEY": return "Anthropic API key" + case "LLM_XAI_API_KEY": return "xAI (Grok) API key" + case "GITHUB_TOKEN": return "GitHub token" + default: return account + } + } + + var keyAccounts: [String] { engine?.keyAccounts() ?? [] } + + // MARK: - Agent provider selection (assistive lane) + + func setAssistiveProvider(_ id: String) { + settings.llmAssistiveProvider = id + persist("LLM_ASSISTIVE_PROVIDER", id) + // The stored model belonged to the previous provider; keeping it would make + // the first send hit a model the new provider doesn't serve. Clear it so + // the provider default applies immediately, then + // allow only a fresh discovery to re-anchor it. Any manual model edit + // cancels this pending auto-selection. + setLLMModel("", for: .assistive) + pendingAssistiveModelSelection = ( + providerId: id, + modelEditGeneration: assistiveModelEditGeneration + ) + refreshModelDiscoveries(providerIds: [id, "openai-responses"]) + refreshAgentStatus() + } + + func saveKey(account: String, secret: String) { + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let engine else { return } + do { + try engine.setApiKey(account: account, secret: trimmed) + keyProbeResults[account] = nil + keyStatus = engine.keyStatus() + providers = engine.availableProviders() + if account == llmLane(.assistive).provider?.apiKeyAccount { + refreshAssistiveModelDiscovery() + } + refreshAgentStatus() + } catch { + lastError = String(describing: error) + } + } + + func clearKey(account: String) { + guard let engine else { return } + do { + try engine.clearApiKey(account: account) + keyProbeResults[account] = nil + keyStatus = engine.keyStatus() + providers = engine.availableProviders() + if account == llmLane(.assistive).provider?.apiKeyAccount { + refreshAssistiveModelDiscovery() + } + refreshAgentStatus() + } catch { + lastError = String(describing: error) + } + } + + func testKey(account: String) { + guard let engine else { return } + guard !keyProbePending.contains(account) else { return } + let backgroundEngine = BackgroundSettingsEngine(engine: engine) + keyProbePending.insert(account) + + DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine, account] in + let result: Result + do { + result = .success(try backgroundEngine.engine.testApiKey(account: account)) + } catch { + result = .failure(error) + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.keyProbePending.remove(account) + switch result { + case .success(let probe): + self.keyProbeResults[account] = probe + case .failure(let error): + self.keyProbeResults[account] = CsApiKeyProbeResult( + account: account, + status: .network, + message: String(describing: error), + probedEndpoint: nil + ) + self.lastError = String(describing: error) + } + } + } + } + + /// Match any assistive provider row whose Keychain API-key account equals + /// `account`. Used so OpenAI, Anthropic, and xAI each render their own + /// account-login row under their key card (not OpenAI-only). + func providerForKeyAccount(_ account: String) -> CsProviderOption? { + providers.first { $0.apiKeyAccount == account } + } + + /// Full "Sign in with ChatGPT" click-through: start the local callback + /// server, open the authorize URL in the default browser, then await the + /// roundtrip on a background queue. The await result (signed in / failed / + /// timeout) refreshes the provider row — no restart, no zombie port. + func startAccountLogin(providerId: String) { + guard let engine else { return } + guard !accountLoginPending.contains(providerId) else { return } + + let result: CsAccountLoginResult + do { + result = try engine.startAccountLogin(providerId: providerId) + } catch { + lastError = String(describing: error) + return + } + guard let authUrl = result.authUrl, let url = URL(string: authUrl) else { + accountLoginNotices[providerId] = result.message + return + } + + accountLoginPending.insert(providerId) + accountLoginNotices[providerId] = nil + NSWorkspace.shared.open(url) + + let backgroundEngine = BackgroundSettingsEngine(engine: engine) + DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine, providerId] in + let outcome: Result + do { + outcome = .success( + try backgroundEngine.engine.awaitAccountLogin( providerId: providerId, - provider: providers.first { $0.id == providerId } ?? providers.first, - resolvedEndpoint: truth.endpoint, - configuredModel: configuredModel, - resolvedModel: truth.model, - discoveryEndpoint: lane == .assistive - ? truth.endpoint - : resolvedOpenAIEndpoint(for: .assistive), - discovery: modelDiscoveries[discoveryProviderId] - ?? CsModelDiscovery.sample(for: discoveryProviderId) + // P2-09: 300s chosen as pragmatic cap for OAuth browser roundtrip + // (user may need to 2FA, switch windows, consent). No new Settings + // knob (per charter). Cancel path: second start or sign-out flow + // or app close (server is torn down on timeout/failure). + // Discovery (P2-08) uses the same await; partial cancel support + // exists via pending set + supersede in core. + timeoutSeconds: 300 + ) ) - } - - private func refreshAssistiveModelDiscovery(includeOpenAI: Bool = false) { - let providerId = llmLane(.assistive).providerId - refreshModelDiscoveries(providerIds: includeOpenAI ? [providerId, "openai-responses"] : [providerId]) - } - - private func resolvedOpenAIEndpoint(for lane: LLMLane) -> String { - // P2-05: lane/shared/default resolution stays here (UI settings surface); - // suffix normalization is now delegated to core via FFI (single truth in - // lane_truth::normalize_openai_responses_endpoint, exposed in bridge/config). - let laneValue = settings[keyPath: lane.endpointPath]? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let sharedValue = settings[keyPath: LLMLane.main.endpointPath]? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let base = !laneValue.isEmpty - ? laneValue - : (!sharedValue.isEmpty - ? sharedValue - : "https://api.openai.com/v1/responses") - - if let engine { - return engine.normalizeOpenaiResponsesEndpoint(base) - } - // Fallback for previews / no-engine (kept tiny; real path always has engine). - // NOTE: suffix list duplication removed (L2 over-correct); core lane_truth::normalize - // (via bridge) is the single source of truth for responses endpoint. Fallback does - // minimal /v1 strip only to avoid duplicating known-suffixes array. - var b = base.trimmingCharacters(in: .whitespacesAndNewlines.union(.init(charactersIn: "/"))) - if b.hasSuffix("/v1") { b.removeLast(3) } - return b + "/v1/responses" - } - - /// Persist an endpoint override for one LLM lane. Whitespace-only input is - /// the reset signal: the core removes the optional JSON path so the next - /// resolved fallback becomes effective immediately. - func setLLMEndpoint(_ value: String, for lane: LLMLane) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - settings[keyPath: lane.endpointPath] = trimmed.isEmpty ? nil : trimmed - persist(lane.endpointKey, trimmed) - refreshAgentStatus() - if lane == .assistive { - refreshAssistiveModelDiscovery(includeOpenAI: true) - } - } - - /// Persist a model override for one LLM lane. Empty clears the JSON override. - func setLLMModel(_ value: String, for lane: LLMLane) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if lane == .assistive { - assistiveModelEditGeneration += 1 - pendingAssistiveModelSelection = nil - } - settings[keyPath: lane.modelPath] = trimmed.isEmpty ? nil : trimmed - persist(lane.modelKey, trimmed) - } - - var formattingDescription: String { - guard settings.aiFormattingEnabled else { return "disabled · compatibility gate" } - return FormattingPolicyOption(storedValue: settings.formattingLevel)?.visibleName - ?? "invalid policy" - } - - /// Any LLM/STT provider key present (GitHub token is shown separately). - var apiKeysStored: Bool { - keyStatus.llmApiKeySet || keyStatus.llmAssistiveApiKeySet - || keyStatus.llmAnthropicApiKeySet || keyStatus.llmFormattingApiKeySet || keyStatus.sttApiKeySet - } - - var apiKeysDescription: String { - apiKeysStored ? "Stored in Keychain" : "Not configured" - } - - // MARK: - Creator mutations (write through the core router) - - func setLanguage(_ lang: CsLanguage) { - settings.whisperLanguage = lang - persist("WHISPER_LANGUAGE", lang.shortCode) - } - - func setFormattingEnabled(_ on: Bool) { - settings.aiFormattingEnabled = on - persist("AI_FORMATTING_ENABLED", on ? "1" : "0") - } - - func setFormattingLevel(_ level: String) { - guard let policy = FormattingPolicyOption(storedValue: level) else { - lastError = "Unknown formatting policy: \(level)" - return - } - settings.formattingLevel = policy.rawValue - persist("FORMATTING_LEVEL", policy.rawValue) - } - - // MARK: - User panel (local-first product truth) - - var transcriptsPath: String { - guard !configDir.isEmpty else { return "" } - return URL(fileURLWithPath: configDir).appendingPathComponent("transcriptions").path - } - - var transcriptTagPreview: String { - transcriptTagTemplatePreview(settings.transcriptTagTemplate) - } - - var transcriptTagTemplateWarning: String? { - transcriptTagTemplateAppendWarning(settings.transcriptTagTemplate) - } - - func setTranscriptTaggingEnabled(_ enabled: Bool) { - settings.transcriptTaggingEnabled = enabled - persist("TRANSCRIPT_TAGGING_ENABLED", enabled ? "1" : "0") - } - - func setTranscriptTagTemplate(_ template: String) { - settings.transcriptTagTemplate = template - persist("TRANSCRIPT_TAG_TEMPLATE", template) - } - - func restoreDefaultTranscriptTagTemplate() { - setTranscriptTagTemplate(defaultTranscriptTagTemplate) - } - - // MARK: - Audio (live hardware + existing settings contract) - - func refreshAudioInput() { - guard let engine else { return } - do { - audioInput = try engine.loadAudioInputSnapshot() - audioInputReadError = nil - } catch { - audioInput = CsAudioInputSnapshot( - devices: [], - configuredDevice: settings.audioInputDevice, - runtimeDevice: nil, - configuredDeviceAvailable: false, - fallbackToDefault: false, - runtimeConfigurationMatches: false - ) - audioInputReadError = String(describing: error) - } - } - - func setAudioInputDevice(_ device: String) { - settings.audioInputDevice = device - persist("AUDIO_INPUT_DEVICE", device) - refreshAudioInput() - } - - func resetAudioInputDevice() { - guard let engine else { return } - do { - try engine.resetAudioInputDevice() - settings = engine.loadSettings() - refreshAudioInput() - } catch { - lastError = String(describing: error) - } - } - - func setToggleSilenceSeconds(_ seconds: Float) { - settings.toggleSilenceSec = seconds - persist("TOGGLE_SILENCE_SEC", String(format: "%.1f", seconds)) - } - - func setSoundFeedbackEnabled(_ enabled: Bool) { - settings.beepOnStart = enabled - persist("BEEP_ON_START", enabled ? "1" : "0") - } - - func setSoundVolume(_ volume: Float) { - settings.soundVolume = volume - persist("SOUND_VOLUME", String(format: "%.2f", volume)) - } - - // MARK: - Voice Lab (live quality truth + preview timing) - - func refreshVoiceLab() { - guard let engine else { return } - do { - qualityRecords = try engine.loadQualityRecentRecords(limit: 50) - customLexiconEntries = try engine.loadLexiconCustomEntries() - voiceLabReadError = nil - } catch { - qualityRecords = [] - customLexiconEntries = [] - voiceLabReadError = String(describing: error) - } - } - - /// Mine corrections.jsonl + proposed lexicon into the live custom dictionary. - /// Teach replays the whole correction store and rewrites the lexicon — real - /// disk I/O whose cost scales with the corpus. Running it inline froze - /// Settings for the duration; it now runs off the main actor like the key - /// probe above, with `voiceLabTeachPending` keeping the button honest until - /// the result lands. - func teachDictionaryFromStore() { - guard let engine, !voiceLabTeachPending else { return } - voiceLabTeachPending = true - voiceLabTeachMessage = nil - let backgroundEngine = BackgroundSettingsEngine(engine: engine) - - DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine] in - let outcome: Result - do { - outcome = .success(try backgroundEngine.engine.teachDictionaryFromStore()) - } catch { - outcome = .failure(error) - } - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.voiceLabTeachPending = false - switch outcome { - case .success(let result): - self.voiceLabTeachMessage = - "Taught +\(result.fromCorrections) from corrections, +\(result.fromProposed) from proposed → \(result.totalRules) live rules (\(result.rulesFromCorrectionSource) correction-sourced)." - self.refreshVoiceLab() - case .failure(let error): - let message = String(describing: error) - self.voiceLabTeachMessage = "Teach failed: \(message)" - self.lastError = message - } - } - } - } - - @discardableResult - func finalizeVoiceLabCorrection(id: String, canonical: String) -> Bool { - guard let engine else { return false } - let canonical = canonical.trimmingCharacters(in: .whitespacesAndNewlines) - guard !canonical.isEmpty, !voiceLabEditPending.contains(id) else { return false } - - voiceLabEditPending.insert(id) - voiceLabEditErrors[id] = nil - voiceLabEditNotes[id] = nil - defer { voiceLabEditPending.remove(id) } - do { - let outcome = try engine.finalizeVoiceLabCorrection(id: id, canonical: canonical) - voiceLabEditNotes[id] = Self.voiceLabSaveNote(outcome) - refreshVoiceLab() - return voiceLabReadError == nil - } catch { - let message = String(describing: error) - voiceLabEditErrors[id] = message - lastError = message - return false - } - } - - /// The revision is persisted whenever the engine returns — the note only - /// tells the truth about what the dictionary derived from the edit. - static func voiceLabSaveNote(_ outcome: CsVoiceLabSaveResult) -> String { - if let lexiconError = outcome.lexiconError { - return "Saved — dictionary learning failed: \(lexiconError)" - } - switch outcome.pairsLearned { - case 0: return "Saved; no dictionary rule derived" - case 1: return "Saved — 1 rule learned" - default: return "Saved — \(outcome.pairsLearned) rules learned" - } - } - - var previewTimingConfiguration: PreviewTimingConfiguration { - PreviewTimingConfiguration( - overlayEnabled: settings.transcriptionOverlayEnabled, - values: PreviewTimingValues( - bufferDelayMs: settings.bufferDelayMs ?? PreviewTimingValues.smooth.bufferDelayMs, - typingCps: settings.typingCps ?? PreviewTimingValues.smooth.typingCps, - emitWordsMax: settings.emitWordsMax ?? PreviewTimingValues.smooth.emitWordsMax, - interimSeconds: settings.bufferedInterimSec ?? PreviewTimingValues.smooth.interimSeconds - ) - ) - } - - var previewTimingPreset: PreviewTimingPreset { - detectPreset(previewTimingConfiguration) - } - - /// Preset writes go through the existing batch router: one settings.json - /// transaction for overlay state plus all four coupled timing values. - func applyPreviewTimingPreset(_ preset: PreviewTimingPreset) { - switch preset { - case .custom: - return - case .off: - persistMany([ - CsConfigEntry(key: "TRANSCRIPTION_OVERLAY_ENABLED", value: "0"), - ]) - case .smooth, .snappy, .relaxed: - guard let values = presetValues(preset) else { return } - persistMany([ - CsConfigEntry(key: "TRANSCRIPTION_OVERLAY_ENABLED", value: "1"), - CsConfigEntry( - key: "CODESCRIBE_BUFFER_DELAY_MS", - value: String(values.bufferDelayMs) - ), - CsConfigEntry( - key: "CODESCRIBE_TYPING_CPS", - value: String(format: "%.1f", values.typingCps) - ), - CsConfigEntry( - key: "CODESCRIBE_EMIT_WORDS_MAX", - value: String(values.emitWordsMax) - ), - CsConfigEntry( - key: "CODESCRIBE_BUFFERED_INTERIM_SEC", - value: String(format: "%.1f", values.interimSeconds) - ), - ]) - } - } - - func setPreviewBufferDelayMs(_ value: UInt64) { - settings.bufferDelayMs = value - persist("CODESCRIBE_BUFFER_DELAY_MS", String(value)) - } - - func setPreviewTypingCps(_ value: Float) { - settings.typingCps = value - persist("CODESCRIBE_TYPING_CPS", String(format: "%.1f", value)) - } - - func setPreviewEmitWordsMax(_ value: UInt64) { - settings.emitWordsMax = value - persist("CODESCRIBE_EMIT_WORDS_MAX", String(value)) - } - - func setPreviewInterimSeconds(_ value: Float) { - settings.bufferedInterimSec = value - persist("CODESCRIBE_BUFFERED_INTERIM_SEC", String(format: "%.1f", value)) - } - - // MARK: - STT engine / layered transcription (Engine panel controls) - - /// Selected STT engine id ("auto" | "apple" | "whisper"); empty → product default Apple. - var sttEngineId: String { - let raw = (settings.sttEngine ?? "apple").lowercased() - switch raw { - case "auto", "apple", "whisper", "candle", "onnx": return raw == "candle" ? "whisper" : raw - default: return "apple" - } - } - - /// Display label for the current STT engine selection. - var sttEngineLabel: String { - switch sttEngineId { - case "apple": return "Apple (live)" - case "whisper", "candle": return "Whisper (Candle)" - case "auto": return "Auto (Apple-first)" - default: return "Apple (live)" - } - } - - /// Honest dual-brain note when preference and Active STT last run diverge. - var sttEngineTruthNote: String? { - let pref = sttEngineId - let active = activeSTT.lowercased() - if active.contains("not yet") || active.isEmpty { return nil } - // Preference Apple but last run was Whisper recovery / file pass is OK - // to mention once when the chip is clearly whisper while user picked apple. - if pref == "apple", active.contains("whisper") { - return "Preference: Apple live · last take used Whisper (final/recovery). Live partials stay Apple." - } - if pref == "whisper" || pref == "candle", active.contains("apple") { - return "Preference: Whisper · last take was Apple live — check env override or restart." - } - return nil - } - - func setSttEngine(_ id: String) { - let normalized: String - switch id.lowercased() { - case "auto": normalized = "auto" - case "whisper", "candle": normalized = "whisper" - case "onnx": normalized = "onnx" - default: normalized = "apple" - } - settings.sttEngine = normalized - // Persist promotes to settings.json AND reconciles process env + .env - // (single brain — no CODESCRIBE_STT_ENGINE lottery). - persist("CODESCRIBE_STT_ENGINE", normalized) - } - - /// Final-pass routing: always | smart | off (default smart). - var finalPassModeId: String { - let raw = (settings.finalPassMode ?? "smart").lowercased() - switch raw { - case "always", "off": return raw - default: return "smart" - } - } - - var finalPassModeLabel: String { - switch finalPassModeId { - case "always": return "Always" - case "off": return "Off" - default: return "Smart" - } - } - - func setFinalPassMode(_ id: String) { - let normalized: String - switch id.lowercased() { - case "always": normalized = "always" - case "off": normalized = "off" - default: normalized = "smart" - } - settings.finalPassMode = normalized - persist("FINAL_PASS_MODE", normalized) - } - - /// ON for any phase value ("phase1".."phase4" or bare "1".."4"); anything - /// else (including "off"/absent) is OFF — mirrors the core `layered_phase`. - var layeredTranscriptionEnabled: Bool { - let value = settings.layeredTranscription ?? "off" - return value.hasPrefix("phase") || Int(value) != nil - } - - /// The GUI only exposes Phase 1 (Apple live layer + Whisper tail patch); - /// phases 2-4 do not exist as features yet. - func setLayeredTranscription(_ on: Bool) { - let value = on ? "phase1" : "off" - settings.layeredTranscription = value - persist("CODESCRIBE_LAYERED_TRANSCRIPTION", value) - } - - var holdBadgeOption: HoldBadgeOption { - HoldBadgeOption( - indicatorEnabled: settings.holdIndicator, - size: settings.holdBadgeSize - ) - } - - /// Off changes visibility only, preserving the stored size. A concrete size - /// enables the indicator and writes both existing keys atomically. - /// - /// K3 (W10-E): persists immediately; takes effect at the *next* badge show - /// (no live redraw of a visible caret badge). - /// K4: posts `ConfigChangeBus.holdBadgeDidChange` so the tray reflects it. - func setHoldBadgeOption(_ option: HoldBadgeOption) { - guard let size = option.size else { - settings.holdIndicator = false - persist("HOLD_INDICATOR", "0") - ConfigChangeBus.postHoldBadgeChanged() - return - } - settings.holdIndicator = true - settings.holdBadgeSize = size - persistMany([ - CsConfigEntry(key: "HOLD_INDICATOR", value: "1"), - CsConfigEntry(key: "HOLD_BADGE_SIZE", value: String(size)), - ]) - ConfigChangeBus.postHoldBadgeChanged() - } - - /// Reload badge fields from the engine after a peer surface (tray) wrote them. - func reloadHoldBadgeFromDisk() { - guard let engine else { return } - settings = engine.loadSettings() - objectWillChange.send() - } - - /// Assistive-arm modifier on the hold base: `"shift"` (default) or `"cmd"`. - var holdArmModifier: String { - let raw = settings.holdArmModifier.lowercased() - return (raw == "cmd" || raw == "command") ? "cmd" : "shift" - } - - func setHoldArmModifier(_ value: String) { - let normalized = (value.lowercased() == "cmd" || value.lowercased() == "command") - ? "cmd" : "shift" - settings.holdArmModifier = normalized - persist("HOLD_ARM_MODIFIER", normalized) - } - - // MARK: - Agent workspace roots (list_projects tool) - - /// Effective workspace roots the `list_projects` tool scans. Never empty — - /// the bridge fills the built-in default (`~/.codescribe`) when unset. - var agentWorkspaceRoots: [String] { settings.agentWorkspaceRoots } - - /// Persist the workspace roots as the colon-joined `AGENT_WORKSPACE_ROOTS` - /// value. Blank/whitespace rows are dropped; an all-empty list clears the - /// override so the core falls back to `~/.codescribe`. - func setAgentWorkspaceRoots(_ roots: [String]) { - let cleaned = roots - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - settings.agentWorkspaceRoots = cleaned.isEmpty ? ["~/.codescribe"] : cleaned - persist("AGENT_WORKSPACE_ROOTS", cleaned.joined(separator: ":")) - } - - private func persist(_ key: String, _ value: String) { - guard let engine else { return } - do { - try engine.updateConfig(key: key, value: value) - settings = engine.loadSettings() - } catch { - lastError = String(describing: error) - } - } - - private func persistMany(_ entries: [CsConfigEntry]) { - guard let engine else { return } - do { - try engine.updateConfigMany(entries: entries) - settings = engine.loadSettings() - } catch { - lastError = String(describing: error) - } - } - - // MARK: - Keys (Keychain-backed; secrets never read back) - - /// Friendly labels for the canonical Keychain accounts. - static func keyLabel(for account: String) -> String { - switch account { - case "LLM_API_KEY": return "LLM API key" - case "STT_API_KEY": return "Speech-to-text API key" - case "LLM_FORMATTING_API_KEY": return "Formatting API key" - case "LLM_ASSISTIVE_API_KEY": return "Assistive API key (OpenAI)" - case "LLM_ANTHROPIC_API_KEY": return "Anthropic API key" - case "LLM_XAI_API_KEY": return "xAI (Grok) API key" - case "GITHUB_TOKEN": return "GitHub token" - default: return account - } - } - - var keyAccounts: [String] { engine?.keyAccounts() ?? [] } - - // MARK: - Agent provider selection (assistive lane) - - func setAssistiveProvider(_ id: String) { - settings.llmAssistiveProvider = id - persist("LLM_ASSISTIVE_PROVIDER", id) - // The stored model belonged to the previous provider; keeping it would make - // the first send hit a model the new provider doesn't serve. Clear it so - // the provider default applies immediately, then - // allow only a fresh discovery to re-anchor it. Any manual model edit - // cancels this pending auto-selection. - setLLMModel("", for: .assistive) - pendingAssistiveModelSelection = ( - providerId: id, - modelEditGeneration: assistiveModelEditGeneration + } catch { + outcome = .failure(error) + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.accountLoginPending.remove(providerId) + switch outcome { + case .success(let login): + // "signed_in" needs no banner — the row status flips on the + // provider refresh below. Everything else is surfaced as-is. + self.accountLoginNotices[providerId] = + login.status == "signed_in" ? nil : login.message + case .failure(let error): + self.accountLoginNotices[providerId] = String(describing: error) + } + if let engine = self.engine { + self.providers = engine.availableProviders() + } + self.refreshAgentStatus() + } + } + } + + /// Sign out of the provider account (clears the stored tokens). API keys + /// are untouched. + func signOutAccount(providerId: String) { + guard let engine else { return } + do { + try engine.signOutAccount(providerId: providerId) + accountLoginNotices[providerId] = nil + providers = engine.availableProviders() + refreshAgentStatus() + } catch { + lastError = String(describing: error) + } + } + + /// Persist the OAuth client id (non-secret; settings.json) for the provider + /// that owns it. Takes effect on the next click — the core re-reads settings + /// per resolution. Advanced override only; shipped defaults cover OpenAI + xAI. + func saveOauthClientId(providerId: String, value: String) { + let settingKey: String + switch providerId { + case "openai-responses": + settingKey = "LLM_OPENAI_OAUTH_CLIENT_ID" + case "anthropic-messages": + settingKey = "LLM_ANTHROPIC_OAUTH_CLIENT_ID" + case "xai-responses": + settingKey = "LLM_XAI_OAUTH_CLIENT_ID" + default: + lastError = "No OAuth client-id setting for provider \(providerId)" + return + } + persist(settingKey, value.trimmingCharacters(in: .whitespacesAndNewlines)) + accountLoginNotices[providerId] = nil + if let engine { + providers = engine.availableProviders() + } + refreshAgentStatus() + } + + /// The single discovery path for every lane/provider. Generation checks drop + /// stale network results. Provider-switch auto-selection is held separately + /// so a newer endpoint refresh inherits it while a manual model edit cancels it. + private func refreshModelDiscoveries(providerIds: [String]) { + let providerIds = Array(Set(providerIds)) + var generations: [String: Int] = [:] + for providerId in providerIds { + modelDiscoveryGenerations[providerId, default: 0] += 1 + generations[providerId] = modelDiscoveryGenerations[providerId] + } + guard let engine else { + for providerId in providerIds { + let discovery = CsModelDiscovery.sample(for: providerId) + modelDiscoveries[providerId] = discovery + applyPendingAssistiveModelSelection( + providerId: providerId, + discovery: discovery ) - refreshModelDiscoveries(providerIds: [id, "openai-responses"]) - refreshAgentStatus() - } - - func saveKey(account: String, secret: String) { - let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let engine else { return } - do { - try engine.setApiKey(account: account, secret: trimmed) - keyProbeResults[account] = nil - keyStatus = engine.keyStatus() - providers = engine.availableProviders() - if account == llmLane(.assistive).provider?.apiKeyAccount { - refreshAssistiveModelDiscovery() - } - refreshAgentStatus() - } catch { - lastError = String(describing: error) - } - } - - func clearKey(account: String) { - guard let engine else { return } - do { - try engine.clearApiKey(account: account) - keyProbeResults[account] = nil - keyStatus = engine.keyStatus() - providers = engine.availableProviders() - if account == llmLane(.assistive).provider?.apiKeyAccount { - refreshAssistiveModelDiscovery() - } - refreshAgentStatus() - } catch { - lastError = String(describing: error) - } - } - - func testKey(account: String) { - guard let engine else { return } - guard !keyProbePending.contains(account) else { return } - let backgroundEngine = BackgroundSettingsEngine(engine: engine) - keyProbePending.insert(account) - - DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine, account] in - let result: Result - do { - result = .success(try backgroundEngine.engine.testApiKey(account: account)) - } catch { - result = .failure(error) - } - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.keyProbePending.remove(account) - switch result { - case .success(let probe): - self.keyProbeResults[account] = probe - case .failure(let error): - self.keyProbeResults[account] = CsApiKeyProbeResult( - account: account, - status: .network, - message: String(describing: error), - probedEndpoint: nil - ) - self.lastError = String(describing: error) - } - } - } - } - - /// Match any assistive provider row whose Keychain API-key account equals - /// `account`. Used so OpenAI, Anthropic, and xAI each render their own - /// account-login row under their key card (not OpenAI-only). - func providerForKeyAccount(_ account: String) -> CsProviderOption? { - providers.first { $0.apiKeyAccount == account } - } - - /// Full "Sign in with ChatGPT" click-through: start the local callback - /// server, open the authorize URL in the default browser, then await the - /// roundtrip on a background queue. The await result (signed in / failed / - /// timeout) refreshes the provider row — no restart, no zombie port. - func startAccountLogin(providerId: String) { - guard let engine else { return } - guard !accountLoginPending.contains(providerId) else { return } - - let result: CsAccountLoginResult - do { - result = try engine.startAccountLogin(providerId: providerId) - } catch { - lastError = String(describing: error) - return - } - guard let authUrl = result.authUrl, let url = URL(string: authUrl) else { - accountLoginNotices[providerId] = result.message - return - } - - accountLoginPending.insert(providerId) - accountLoginNotices[providerId] = nil - NSWorkspace.shared.open(url) - - let backgroundEngine = BackgroundSettingsEngine(engine: engine) - DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine, providerId] in - let outcome: Result - do { - outcome = .success( - try backgroundEngine.engine.awaitAccountLogin( - providerId: providerId, - // P2-09: 300s chosen as pragmatic cap for OAuth browser roundtrip - // (user may need to 2FA, switch windows, consent). No new Settings - // knob (per charter). Cancel path: second start or sign-out flow - // or app close (server is torn down on timeout/failure). - // Discovery (P2-08) uses the same await; partial cancel support - // exists via pending set + supersede in core. - timeoutSeconds: 300 - ) - ) - } catch { - outcome = .failure(error) - } - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.accountLoginPending.remove(providerId) - switch outcome { - case .success(let login): - // "signed_in" needs no banner — the row status flips on the - // provider refresh below. Everything else is surfaced as-is. - self.accountLoginNotices[providerId] = - login.status == "signed_in" ? nil : login.message - case .failure(let error): - self.accountLoginNotices[providerId] = String(describing: error) - } - if let engine = self.engine { - self.providers = engine.availableProviders() - } - self.refreshAgentStatus() - } - } - } - - /// Sign out of the provider account (clears the stored tokens). API keys - /// are untouched. - func signOutAccount(providerId: String) { - guard let engine else { return } - do { - try engine.signOutAccount(providerId: providerId) - accountLoginNotices[providerId] = nil - providers = engine.availableProviders() - refreshAgentStatus() - } catch { - lastError = String(describing: error) - } - } - - /// Persist the OAuth client id (non-secret; settings.json) for the provider - /// that owns it. Takes effect on the next click — the core re-reads settings - /// per resolution. Advanced override only; shipped defaults cover OpenAI + xAI. - func saveOauthClientId(providerId: String, value: String) { - let settingKey: String - switch providerId { - case "openai-responses": - settingKey = "LLM_OPENAI_OAUTH_CLIENT_ID" - case "anthropic-messages": - settingKey = "LLM_ANTHROPIC_OAUTH_CLIENT_ID" - case "xai-responses": - settingKey = "LLM_XAI_OAUTH_CLIENT_ID" - default: - lastError = "No OAuth client-id setting for provider \(providerId)" - return - } - persist(settingKey, value.trimmingCharacters(in: .whitespacesAndNewlines)) - accountLoginNotices[providerId] = nil - if let engine { - providers = engine.availableProviders() - } - refreshAgentStatus() - } - - /// The single discovery path for every lane/provider. Generation checks drop - /// stale network results. Provider-switch auto-selection is held separately - /// so a newer endpoint refresh inherits it while a manual model edit cancels it. - private func refreshModelDiscoveries(providerIds: [String]) { - let providerIds = Array(Set(providerIds)) - var generations: [String: Int] = [:] - for providerId in providerIds { - modelDiscoveryGenerations[providerId, default: 0] += 1 - generations[providerId] = modelDiscoveryGenerations[providerId] - } - guard let engine else { - for providerId in providerIds { - let discovery = CsModelDiscovery.sample(for: providerId) - modelDiscoveries[providerId] = discovery - applyPendingAssistiveModelSelection( - providerId: providerId, - discovery: discovery - ) - } - return - } - - for providerId in providerIds { - let loading = CsModelDiscovery( - providerId: providerId, - status: "loading", - message: nil, - models: [] - ) - modelDiscoveries[providerId] = loading - } - - let backgroundEngine = BackgroundSettingsEngine(engine: engine) - DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine, providerIds, generations] in - let discoveries = providerIds.map { providerId in - (providerId, backgroundEngine.engine.discoverModels(providerId: providerId)) - } - - DispatchQueue.main.async { [weak self, discoveries, generations] in - guard let self else { return } - for (providerId, discovery) in discoveries { - guard self.modelDiscoveryGenerations[providerId] == generations[providerId] else { - continue - } - self.modelDiscoveries[providerId] = discovery - self.applyPendingAssistiveModelSelection( - providerId: providerId, - discovery: discovery - ) - } - } - } - } - - private func applyPendingAssistiveModelSelection( - providerId: String, - discovery: CsModelDiscovery - ) { - guard let pending = pendingAssistiveModelSelection, - pending.providerId == providerId - else { return } - - let activeProviderId = settings.llmAssistiveProvider ?? "openai-responses" - guard pending.modelEditGeneration == assistiveModelEditGeneration, - activeProviderId == providerId - else { - pendingAssistiveModelSelection = nil - return - } - - guard discovery.status == "fresh", - let firstModel = discovery.models.first?.id, - !firstModel.isEmpty - else { return } - - setLLMModel(firstModel, for: .assistive) - } - - // MARK: - Prompts (editable BASE prompts) - - func formattingPrompt() -> String { formattingPromptSnapshot().content } - func assistivePrompt() -> String { assistivePromptSnapshot().content } - func formattingPromptSnapshot() -> CsPromptSnapshot { - engine?.formattingPromptSnapshot() ?? .sampleFormatting - } - func formattingPromptSnapshot(level: FormattingPolicyOption) -> CsPromptSnapshot? { - guard let engine else { return nil } - do { - return try engine.formattingPromptSnapshot(level: level.rawValue) - } catch { - lastError = String(describing: error) - return nil - } - } - func assistivePromptSnapshot() -> CsPromptSnapshot { - engine?.assistivePromptSnapshot() ?? .sampleAssistive - } - func defaultFormattingPrompt() -> String { - engine?.defaultFormattingPrompt() ?? CsSettings.samplePrompt - } - func defaultAssistivePrompt() -> String { - engine?.defaultAssistivePrompt() ?? CsSettings.sampleAssistivePrompt - } - - @discardableResult - func saveFormattingPrompt(_ content: String) -> CsPromptSnapshot? { - saveFormattingPrompt(.correction, content: content) - } - - @discardableResult - func saveFormattingPrompt( - _ level: FormattingPolicyOption, - content: String - ) -> CsPromptSnapshot? { - guard let engine else { return nil } - do { - try engine.setFormattingPrompt(level: level.rawValue, content: content) - return try engine.formattingPromptSnapshot(level: level.rawValue) - } catch { - lastError = String(describing: error) - return nil - } - } - - @discardableResult - func saveAssistivePrompt(_ content: String) -> CsPromptSnapshot? { - guard let engine else { return nil } - do { - try engine.setAssistivePrompt(content: content) - return engine.assistivePromptSnapshot() - } catch { - lastError = String(describing: error) - return nil - } - } - - @discardableResult - func restoreFormattingPromptToDefault() -> CsPromptSnapshot? { - restoreFormattingPromptToDefault(.correction) - } - - @discardableResult - func restoreFormattingPromptToDefault( - _ level: FormattingPolicyOption - ) -> CsPromptSnapshot? { - guard let engine else { return nil } - do { - try engine.restoreFormattingPromptToDefault(level: level.rawValue) - return try engine.formattingPromptSnapshot(level: level.rawValue) - } catch { - lastError = String(describing: error) - return nil - } - } - - @discardableResult - func restoreAssistivePromptToDefault() -> CsPromptSnapshot? { - guard let engine else { return nil } - do { - try engine.restoreAssistivePromptToDefault() - return engine.assistivePromptSnapshot() - } catch { - lastError = String(describing: error) - return nil - } - } - - // MARK: - Preview seed - - static var preview: SettingsViewModel { preview(.creator) } - - static func preview(_ section: SettingsSection) -> SettingsViewModel { - let model = SettingsViewModel( - engine: MockSettingsEngine(), - permissionProbe: MockPermissionProbe(.allGranted), - agentStatus: MockAgentStatusEngine(), - mcpAdmin: MockMCPAdminEngine(), - hotkeys: MockHotkeysEngine(), - laneTruthProvider: { lane in - CsLaneTruthSnapshot( - lane: lane, - providerId: "openai-responses", - endpoint: "https://api.openai.com/v1/responses", - model: "gpt-5.2", - keyAccount: "LLM_ASSISTIVE_API_KEY", - keyPresent: true, - accountAuth: false, - available: true, - unavailableReason: nil - ) - } + } + return + } + + for providerId in providerIds { + let loading = CsModelDiscovery( + providerId: providerId, + status: "loading", + message: nil, + models: [] + ) + modelDiscoveries[providerId] = loading + } + + let backgroundEngine = BackgroundSettingsEngine(engine: engine) + DispatchQueue.global(qos: .userInitiated).async { + [backgroundEngine, providerIds, generations] in + let discoveries = providerIds.map { providerId in + (providerId, backgroundEngine.engine.discoverModels(providerId: providerId)) + } + + DispatchQueue.main.async { [weak self, discoveries, generations] in + guard let self else { return } + for (providerId, discovery) in discoveries { + guard self.modelDiscoveryGenerations[providerId] == generations[providerId] else { + continue + } + self.modelDiscoveries[providerId] = discovery + self.applyPendingAssistiveModelSelection( + providerId: providerId, + discovery: discovery + ) + } + } + } + } + + private func applyPendingAssistiveModelSelection( + providerId: String, + discovery: CsModelDiscovery + ) { + guard let pending = pendingAssistiveModelSelection, + pending.providerId == providerId + else { return } + + let activeProviderId = settings.llmAssistiveProvider ?? "openai-responses" + guard pending.modelEditGeneration == assistiveModelEditGeneration, + activeProviderId == providerId + else { + pendingAssistiveModelSelection = nil + return + } + + guard discovery.status == "fresh", + let firstModel = discovery.models.first?.id, + !firstModel.isEmpty + else { return } + + setLLMModel(firstModel, for: .assistive) + } + + // MARK: - Prompts (editable BASE prompts) + + func formattingPrompt() -> String { formattingPromptSnapshot().content } + func assistivePrompt() -> String { assistivePromptSnapshot().content } + func formattingPromptSnapshot() -> CsPromptSnapshot { + engine?.formattingPromptSnapshot() ?? .sampleFormatting + } + func formattingPromptSnapshot(level: FormattingPolicyOption) -> CsPromptSnapshot? { + guard let engine else { return nil } + do { + return try engine.formattingPromptSnapshot(level: level.rawValue) + } catch { + lastError = String(describing: error) + return nil + } + } + func assistivePromptSnapshot() -> CsPromptSnapshot { + engine?.assistivePromptSnapshot() ?? .sampleAssistive + } + func defaultFormattingPrompt() -> String { + engine?.defaultFormattingPrompt() ?? CsSettings.samplePrompt + } + func defaultAssistivePrompt() -> String { + engine?.defaultAssistivePrompt() ?? CsSettings.sampleAssistivePrompt + } + + @discardableResult + func saveFormattingPrompt(_ content: String) -> CsPromptSnapshot? { + saveFormattingPrompt(.correction, content: content) + } + + @discardableResult + func saveFormattingPrompt( + _ level: FormattingPolicyOption, + content: String + ) -> CsPromptSnapshot? { + guard let engine else { return nil } + do { + try engine.setFormattingPrompt(level: level.rawValue, content: content) + return try engine.formattingPromptSnapshot(level: level.rawValue) + } catch { + lastError = String(describing: error) + return nil + } + } + + @discardableResult + func saveAssistivePrompt(_ content: String) -> CsPromptSnapshot? { + guard let engine else { return nil } + do { + try engine.setAssistivePrompt(content: content) + return engine.assistivePromptSnapshot() + } catch { + lastError = String(describing: error) + return nil + } + } + + @discardableResult + func restoreFormattingPromptToDefault() -> CsPromptSnapshot? { + restoreFormattingPromptToDefault(.correction) + } + + @discardableResult + func restoreFormattingPromptToDefault( + _ level: FormattingPolicyOption + ) -> CsPromptSnapshot? { + guard let engine else { return nil } + do { + try engine.restoreFormattingPromptToDefault(level: level.rawValue) + return try engine.formattingPromptSnapshot(level: level.rawValue) + } catch { + lastError = String(describing: error) + return nil + } + } + + @discardableResult + func restoreAssistivePromptToDefault() -> CsPromptSnapshot? { + guard let engine else { return nil } + do { + try engine.restoreAssistivePromptToDefault() + return engine.assistivePromptSnapshot() + } catch { + lastError = String(describing: error) + return nil + } + } + + // MARK: - Preview seed + + static var preview: SettingsViewModel { preview(.creator) } + + static func preview(_ section: SettingsSection) -> SettingsViewModel { + let model = SettingsViewModel( + engine: MockSettingsEngine(), + permissionProbe: MockPermissionProbe(.allGranted), + agentStatus: MockAgentStatusEngine(), + mcpAdmin: MockMCPAdminEngine(), + hotkeys: MockHotkeysEngine(), + laneTruthProvider: { lane in + CsLaneTruthSnapshot( + lane: lane, + providerId: "openai-responses", + endpoint: "https://api.openai.com/v1/responses", + model: "gpt-5.2", + keyAccount: "LLM_ASSISTIVE_API_KEY", + keyPresent: true, + accountAuth: false, + available: true, + unavailableReason: nil ) - model.section = section - model.reloadMcpServers() - model.loadHotkeys() - return model - } + } + ) + model.section = section + model.reloadMcpServers() + model.loadHotkeys() + return model + } } diff --git a/macos/Codescribe/Screens/Settings/ShortcutsPanel.swift b/macos/Codescribe/Screens/Settings/ShortcutsPanel.swift index d43a6a49..006a4274 100644 --- a/macos/Codescribe/Screens/Settings/ShortcutsPanel.swift +++ b/macos/Codescribe/Screens/Settings/ShortcutsPanel.swift @@ -10,414 +10,480 @@ import SwiftUI // change takes effect on the running CGEventTap without a restart. struct ShortcutsPanel: View { - @ObservedObject var model: SettingsViewModel + @ObservedObject var model: SettingsViewModel - private var permissionDegraded: Bool { - !model.permissions.inputMonitoring.isGranted - || !model.permissions.accessibility.isGranted - } + private var permissionDegraded: Bool { + !model.permissions.inputMonitoring.isGranted + || !model.permissions.accessibility.isGranted + } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - header + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header - if permissionDegraded { - permissionNote.padding(.top, 18) - } + if permissionDegraded { + permissionNote.padding(.top, 18) + } - bindingRows.padding(.top, 20) - badgeLegend.padding(.top, 12) + bindingRows.padding(.top, 20) + deferredInsertSection.padding(.top, 12) + badgeLegend.padding(.top, 12) - if !model.bindingConflicts.isEmpty { - conflictList.padding(.top, 16) - } + if !model.bindingConflicts.isEmpty { + conflictList.padding(.top, 16) + } - actions.padding(.top, 22) - hint.padding(.top, 14) - } - .padding(.horizontal, 28) - .padding(.vertical, 24) + actions.padding(.top, 22) + hint.padding(.top, 14) } - - // MARK: Header - - private var header: some View { - VStack(alignment: .leading, spacing: 6) { - EyebrowLabel(text: "Settings · Hotkeys") - Text("Trigger keys.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - Text("One gesture per mode. Changes apply immediately — no restart.") - .font(CSFont.ui(13, .medium)) - .foregroundStyle(CSColor.textMuted) - } + .padding(.horizontal, 28) + .padding(.vertical, 24) + } + + // MARK: Header + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + EyebrowLabel(text: "Settings · Hotkeys") + Text("Trigger keys.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + Text("One gesture per mode. Changes apply immediately — no restart.") + .font(CSFont.ui(13, .medium)) + .foregroundStyle(CSColor.textMuted) } + } - // MARK: Per-mode binding rows + // MARK: Per-mode binding rows - private var bindingRows: some View { - VStack(spacing: 0) { - ForEach(Array(model.draftBindings.enumerated()), id: \.element.modeLabel) { index, row in - if index > 0 { divider } - bindingRow(row) - } - } - .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 13, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) + private var bindingRows: some View { + VStack(spacing: 0) { + ForEach(Array(model.draftBindings.enumerated()), id: \.element.modeLabel) { index, row in + if index > 0 { divider } + bindingRow(row) + } } - - private func bindingRow(_ row: CsModeBinding) -> some View { - VStack(alignment: .leading, spacing: 11) { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(row.modeLabel) - .font(CSFont.ui(13.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - Text(row.modeDescription) - .font(CSFont.ui(11.5, .medium)) - .foregroundStyle(CSColor.textMuted) - } - .frame(maxWidth: .infinity, alignment: .leading) - - bindingPicker(row) - } - - if row.mode == .assistive { - assistiveModeSplit(row) - } + .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } + + private func bindingRow(_ row: CsModeBinding) -> some View { + VStack(alignment: .leading, spacing: 11) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(row.modeLabel) + .font(CSFont.ui(13.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + Text(row.modeDescription) + .font(CSFont.ui(11.5, .medium)) + .foregroundStyle(CSColor.textMuted) } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .background(CSColor.surfaceRaised(0.02)) - } + .frame(maxWidth: .infinity, alignment: .leading) + + bindingPicker(row) + } - private func bindingPicker(_ row: CsModeBinding) -> some View { - Menu { - ForEach(model.bindingOptions, id: \.label) { option in - Button { - model.editDraftBinding(mode: row.mode, binding: option.binding) - } label: { - if option.binding == row.binding { - Label(option.label, systemImage: "checkmark") - } else { - Text(option.label) - } - } - } + if row.mode == .assistive { + assistiveModeSplit(row) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(CSColor.surfaceRaised(0.02)) + } + + private func bindingPicker(_ row: CsModeBinding) -> some View { + Menu { + ForEach(model.bindingOptions, id: \.label) { option in + Button { + model.editDraftBinding(mode: row.mode, binding: option.binding) } label: { - HStack(spacing: 8) { - Text(row.bindingLabel) - .font(CSFont.mono(12, .semibold)) - .foregroundStyle(CSColor.terracottaLight) - CSIconView(icon: .chevronUpDown, size: 9, weight: .semibold, color: CSColor.textMuted) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.09), lineWidth: 1) - ) + if option.binding == row.binding { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) + } } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() + } + } label: { + HStack(spacing: 8) { + Text(row.bindingLabel) + .font(CSFont.mono(12, .semibold)) + .foregroundStyle(CSColor.terracottaLight) + CSIconView(icon: .chevronUpDown, size: 9, weight: .semibold, color: CSColor.textMuted) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.09), lineWidth: 1) + ) } - - private func assistiveModeSplit(_ row: CsModeBinding) -> some View { - VStack(alignment: .leading, spacing: 7) { - assistiveModeVariant( - title: "Voice chat", - gesture: armGestureLabel, - description: "Talk to the agent." - ) - assistiveModeVariant( - title: "Act on selection", - gesture: selectionAssistiveGesture(row), - description: "Select text, then speak an instruction." - ) - // W10-B: customize arm modifier (default Shift; Cmd alternative). - HStack(spacing: 8) { - Text("Arm with") - .font(CSFont.ui(11, .medium)) - .foregroundStyle(CSColor.textMuted) - Picker("Arm modifier", selection: armModifierBinding) { - Text("Shift").tag("shift") - Text("Command").tag("cmd") - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 180) - } - .padding(.top, 2) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + + private func assistiveModeSplit(_ row: CsModeBinding) -> some View { + VStack(alignment: .leading, spacing: 7) { + assistiveModeVariant( + title: "Voice chat", + gesture: armGestureLabel, + description: "Talk to the agent." + ) + assistiveModeVariant( + title: "Act on selection", + gesture: selectionAssistiveGesture(row), + description: "Select text, then speak an instruction." + ) + // W10-B: customize arm modifier (default Shift; Cmd alternative). + HStack(spacing: 8) { + Text("Arm with") + .font(CSFont.ui(11, .medium)) + .foregroundStyle(CSColor.textMuted) + Picker("Arm modifier", selection: armModifierBinding) { + Text("Shift").tag("shift") + Text("Command").tag("cmd") } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(CSColor.assistive.opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(CSColor.assistive.opacity(0.18), lineWidth: 1) - ) + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 180) + } + .padding(.top, 2) } - - private func assistiveModeVariant(title: String, gesture: String, description: String) -> some View { - HStack(alignment: .top, spacing: 9) { - Circle() - .fill(CSColor.assistive) - .frame(width: 6, height: 6) - .padding(.top, 5) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(CSFont.ui(11.5, .semibold)) - .foregroundStyle(CSColor.assistiveLight) - Text(description) - .font(CSFont.ui(11, .medium)) - .foregroundStyle(CSColor.textMuted) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 8) - Text(gesture) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.textBodyAlt) - .multilineTextAlignment(.trailing) - .fixedSize(horizontal: false, vertical: true) - } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(CSColor.assistive.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(CSColor.assistive.opacity(0.18), lineWidth: 1) + ) + } + + private func assistiveModeVariant(title: String, gesture: String, description: String) + -> some View + { + HStack(alignment: .top, spacing: 9) { + Circle() + .fill(CSColor.assistive) + .frame(width: 6, height: 6) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(CSColor.assistiveLight) + Text(description) + .font(CSFont.ui(11, .medium)) + .foregroundStyle(CSColor.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Text(gesture) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.textBodyAlt) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) } - - /// Derived from the configured arm modifier — never hardcode Fn+Command. - private var armGestureLabel: String { - ArmGestureCopy.label(for: model.holdArmModifier) + } + + /// Derived from the configured arm modifier — never hardcode Fn+Command. + private var armGestureLabel: String { + ArmGestureCopy.label(for: model.holdArmModifier) + } + + private func selectionAssistiveGesture(_ row: CsModeBinding) -> String { + // Act-on-selection is the same arm gesture when a selection is present + // (W10-D lane). Copy must match the configured binding, not a dead Cmd. + if row.binding == .disabled { + return armGestureLabel } - - private func selectionAssistiveGesture(_ row: CsModeBinding) -> String { - // Act-on-selection is the same arm gesture when a selection is present - // (W10-D lane). Copy must match the configured binding, not a dead Cmd. - if row.binding == .disabled { - return armGestureLabel + return "\(row.bindingLabel) or \(armGestureLabel)" + } + + private var armModifierBinding: Binding { + Binding( + get: { model.holdArmModifier }, + set: { model.setHoldArmModifier($0) } + ) + } + + private var badgeLegend: some View { + VStack(alignment: .leading, spacing: 8) { + SettingsSectionLabel("Dot colors") + HStack(spacing: 12) { + legendItem(color: CSColor.terracotta, text: "Red — dictation or formatting is recording") + legendItem(color: CSColor.assistive, text: "Purple — voice goes to the agent") + legendItem(color: CSColor.amber, text: "Orange — processing after recording") + } + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Pointer indicator") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text("Base size; Agent mode stays proportionally larger") + .font(CSFont.ui(10.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) } - return "\(row.bindingLabel) or \(armGestureLabel)" - } + .frame(maxWidth: .infinity, alignment: .leading) - private var armModifierBinding: Binding { - Binding( - get: { model.holdArmModifier }, - set: { model.setHoldArmModifier($0) } - ) + Picker("Pointer indicator", selection: holdBadgeBinding) { + ForEach(HoldBadgeOption.allCases) { option in + Text(option.visibleName).tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 230) + } + .padding(.top, 4) } - - private var badgeLegend: some View { - VStack(alignment: .leading, spacing: 8) { - SettingsSectionLabel("Dot colors") - HStack(spacing: 12) { - legendItem(color: CSColor.terracotta, text: "Red — dictation or formatting is recording") - legendItem(color: CSColor.assistive, text: "Purple — voice goes to the agent") - legendItem(color: CSColor.amber, text: "Orange — processing after recording") - } - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text("Pointer indicator") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text("Base size; Agent mode stays proportionally larger") - .font(CSFont.ui(10.5, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Picker("Pointer indicator", selection: holdBadgeBinding) { - ForEach(HoldBadgeOption.allCases) { option in - Text(option.visibleName).tag(option) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 230) - } - .padding(.top, 4) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(CSColor.surfaceRaised(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } + + private var holdBadgeBinding: Binding { + Binding( + get: { model.holdBadgeOption }, + set: { model.setHoldBadgeOption($0) } + ) + } + + // MARK: Deferred insert chord + + /// Command chord delivering an armed transcript at the caret. A closed + /// four-option set mirroring core `DeferredInsertShortcut`; writes go + /// through the same `update_config` brain as every other setting. Off by + /// default — the tap is listen-only, so a host app bound to the same chord + /// would also react (core/config/types.rs). + private var deferredInsertSection: some View { + VStack(alignment: .leading, spacing: 8) { + SettingsSectionLabel("Deferred insert") + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Insert armed transcript") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text( + "Global chord pastes the armed transcript at the caret. Apps bound to the same chord will also react." + ) + .font(CSFont.ui(10.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) } - .padding(.horizontal, 14) - .padding(.vertical, 11) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(CSColor.surfaceRaised(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) - ) - } - - private var holdBadgeBinding: Binding { - Binding( - get: { model.holdBadgeOption }, - set: { model.setHoldBadgeOption($0) } - ) - } - private func legendItem(color: Color, text: String) -> some View { - HStack(spacing: 6) { - Circle().fill(color).frame(width: 7, height: 7) - Text(text) - .font(CSFont.ui(11.5, .medium)) - .foregroundStyle(CSColor.textMuted) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + Picker("Deferred insert shortcut", selection: deferredInsertBinding) { + ForEach(DeferredInsertShortcutOption.allCases) { option in + Text(option.visibleName).tag(option) + } } - .frame(maxWidth: .infinity, alignment: .leading) + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 260) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(CSColor.surfaceRaised(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } + + private var deferredInsertBinding: Binding { + Binding( + get: { model.deferredInsertShortcut }, + set: { model.setDeferredInsertShortcut($0) } + ) + } + + private func legendItem(color: Color, text: String) -> some View { + HStack(spacing: 6) { + Circle().fill(color).frame(width: 7, height: 7) + Text(text) + .font(CSFont.ui(11.5, .medium)) + .foregroundStyle(CSColor.textMuted) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity, alignment: .leading) + } - // MARK: Conflicts (inline validation) + // MARK: Conflicts (inline validation) - private var conflictList: some View { - VStack(alignment: .leading, spacing: 8) { - SettingsSectionLabel("Conflicts") - ForEach(Array(model.bindingConflicts.enumerated()), id: \.offset) { _, conflict in - conflictRow(conflict) - } - } + private var conflictList: some View { + VStack(alignment: .leading, spacing: 8) { + SettingsSectionLabel("Conflicts") + ForEach(Array(model.bindingConflicts.enumerated()), id: \.offset) { _, conflict in + conflictRow(conflict) + } } - - private func conflictRow(_ conflict: CsHotkeyConflict) -> some View { - let accent = conflict.blocking ? CSColor.terracotta : CSColor.amber - let accentLight = conflict.blocking ? CSColor.terracottaLight : CSColor.amber - return HStack(alignment: .top, spacing: 9) { - Text(conflict.blocking ? "!" : "i") - .font(CSFont.ui(11, .bold)) - .foregroundStyle(accentLight) - .frame(width: 14) - VStack(alignment: .leading, spacing: 2) { - Text(conflict.gestureLabel) - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(accentLight) - Text(conflict.message) - .font(CSFont.ui(12, .medium)) - .foregroundStyle(CSColor.textBodyAlt) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous).fill(accent.opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(accent.opacity(0.2), lineWidth: 1) - ) + } + + private func conflictRow(_ conflict: CsHotkeyConflict) -> some View { + let accent = conflict.blocking ? CSColor.terracotta : CSColor.amber + let accentLight = conflict.blocking ? CSColor.terracottaLight : CSColor.amber + return HStack(alignment: .top, spacing: 9) { + Text(conflict.blocking ? "!" : "i") + .font(CSFont.ui(11, .bold)) + .foregroundStyle(accentLight) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + Text(conflict.gestureLabel) + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(accentLight) + Text(conflict.message) + .font(CSFont.ui(12, .medium)) + .foregroundStyle(CSColor.textBodyAlt) + .fixedSize(horizontal: false, vertical: true) + } } - - // MARK: Permission degradation - - private var permissionNote: some View { - HStack(alignment: .top, spacing: 9) { - Text("!") - .font(CSFont.ui(11, .bold)) - .foregroundStyle(CSColor.amber) - .frame(width: 14) - VStack(alignment: .leading, spacing: 2) { - Text("Shortcuts need Input Monitoring + Accessibility") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.amber) - Text("You can edit bindings here, but they won't fire until both are granted. Click to open System Settings.") - .font(CSFont.ui(12, .medium)) - .foregroundStyle(CSColor.textBodyAlt) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 11) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous).fill(CSColor.amber.opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(CSColor.amber.opacity(0.2), lineWidth: 1) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(accent.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(accent.opacity(0.2), lineWidth: 1) + ) + } + + // MARK: Permission degradation + + private var permissionNote: some View { + HStack(alignment: .top, spacing: 9) { + Text("!") + .font(CSFont.ui(11, .bold)) + .foregroundStyle(CSColor.amber) + .frame(width: 14) + VStack(alignment: .leading, spacing: 2) { + Text("Shortcuts need Input Monitoring + Accessibility") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.amber) + Text( + "You can edit bindings here, but they won't fire until both are granted. Click to open System Settings." ) - .contentShape(Rectangle()) - .onTapGesture { - if !model.permissions.inputMonitoring.isGranted { - PermissionKind.inputMonitoring.openSystemSettings() - } else { - PermissionKind.accessibility.openSystemSettings() - } - } + .font(CSFont.ui(12, .medium)) + .foregroundStyle(CSColor.textBodyAlt) + .fixedSize(horizontal: false, vertical: true) + } } - - // MARK: Actions - - private var actions: some View { - HStack(spacing: 12) { - Button { model.resetBindingsToDefaults() } label: { - Text("Reset to defaults") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textMuted) - } - .csFocusRing(cornerRadius: 8) - - Spacer(minLength: 0) - - Button { model.saveBindings() } label: { - Text("Save") - .font(CSFont.ui(12.5, .semibold)) - .padding(.horizontal, 18) - .padding(.vertical, 8) - .foregroundStyle(model.canSaveBindings ? CSColor.textHigh : CSColor.textFaint) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(model.canSaveBindings - ? CSColor.terracotta.opacity(0.9) - : CSColor.surfaceRaised(0.03)) - ) - } - .csFocusRing(cornerRadius: 8) - .disabled(!model.canSaveBindings) - } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(CSColor.amber.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(CSColor.amber.opacity(0.2), lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + if !model.permissions.inputMonitoring.isGranted { + PermissionKind.inputMonitoring.openSystemSettings() + } else { + PermissionKind.accessibility.openSystemSettings() + } } - - private var hint: some View { - HStack(spacing: 8) { - Text("●") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(model.hasBlockingBindingConflicts ? CSColor.terracotta : CSColor.olive) - Text(model.hasBlockingBindingConflicts - ? "Resolve the conflict above before saving" - : "Bindings persist to settings.json and reload the detector live") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - } + } + + // MARK: Actions + + private var actions: some View { + HStack(spacing: 12) { + Button { + model.resetBindingsToDefaults() + } label: { + Text("Reset to defaults") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textMuted) + } + .csFocusRing(cornerRadius: 8) + + Spacer(minLength: 0) + + Button { + model.saveBindings() + } label: { + Text("Save") + .font(CSFont.ui(12.5, .semibold)) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .foregroundStyle(model.canSaveBindings ? CSColor.textHigh : CSColor.textFaint) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill( + model.canSaveBindings + ? CSColor.terracotta.opacity(0.9) + : CSColor.surfaceRaised(0.03)) + ) + } + .csFocusRing(cornerRadius: 8) + .disabled(!model.canSaveBindings) } - - private var divider: some View { - Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + } + + private var hint: some View { + HStack(spacing: 8) { + Text("●") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(model.hasBlockingBindingConflicts ? CSColor.terracotta : CSColor.olive) + Text( + model.hasBlockingBindingConflicts + ? "Resolve the conflict above before saving" + : "Bindings persist to settings.json and reload the detector live" + ) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) } + } + + private var divider: some View { + Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) + } } /// Single production owner for assistive-arm gesture copy in Settings. enum ArmGestureCopy { - static func label(for modifier: String) -> String { - modifier == "cmd" ? "Hold Fn+Command" : "Hold Fn+Shift" - } + static func label(for modifier: String) -> String { + modifier == "cmd" ? "Hold Fn+Command" : "Hold Fn+Shift" + } } #if DEBUG -#Preview("Shortcuts panel") { + #Preview("Shortcuts panel") { ScrollView { ShortcutsPanel(model: .preview(.shortcuts)) } - .frame(width: 720, height: 620) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 620) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/ToolPermissionsSection.swift b/macos/Codescribe/Screens/Settings/ToolPermissionsSection.swift index 6f533285..7d074524 100644 --- a/macos/Codescribe/Screens/Settings/ToolPermissionsSection.swift +++ b/macos/Codescribe/Screens/Settings/ToolPermissionsSection.swift @@ -6,205 +6,207 @@ import SwiftUI // MCP admin bridge. Identity contract: `server:tool` / `native:name`. struct ToolPermissionsSection: View { - @ObservedObject var model: SettingsViewModel - @State private var toolsExpanded = false - @State private var searchText = "" - @State private var expandedServers: Set = [] + @ObservedObject var model: SettingsViewModel + @State private var toolsExpanded = false + @State private var searchText = "" + @State private var expandedServers: Set = [] - private var grouped: [(server: String, items: [ToolPermissionItem])] { - ToolPermissionGrouping.groups( - from: model.toolCapabilities.map(ToolPermissionItem.init(capability:)), - query: searchText - ) - } + private var grouped: [(server: String, items: [ToolPermissionItem])] { + ToolPermissionGrouping.groups( + from: model.toolCapabilities.map(ToolPermissionItem.init(capability:)), + query: searchText + ) + } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - SettingsSectionLabel("Tool permissions") + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Tool permissions") - Text("Allow · Ask · Deny. Defaults: read-only allow, side-effectful ask. " - + "\"Always allow\" from the approval card writes the same identity key.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - .padding(.top, 4) + Text( + "Allow · Ask · Deny. Defaults: read-only allow, side-effectful ask. " + + "\"Always allow\" from the approval card writes the same identity key." + ) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + .padding(.top, 4) - defaultsCard - .padding(.top, 11) + defaultsCard + .padding(.top, 11) - if model.toolCapabilities.isEmpty { - emptyCapabilities - .padding(.top, 12) - } else { - DisclosureGroup( - "Tool overrides · \(model.toolCapabilities.count)", - isExpanded: $toolsExpanded - ) { - hierarchyChrome - .padding(.top, 8) + if model.toolCapabilities.isEmpty { + emptyCapabilities + .padding(.top, 12) + } else { + DisclosureGroup( + "Tool overrides · \(model.toolCapabilities.count)", + isExpanded: $toolsExpanded + ) { + hierarchyChrome + .padding(.top, 8) - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(grouped, id: \.server) { group in - ServerPermissionGroup( - server: group.server, - items: group.items, - isExpanded: Binding( - get: { - // When searching, force open so hits are visible. - if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } - return expandedServers.contains(group.server) - }, - set: { open in - if open { - expandedServers.insert(group.server) - } else { - expandedServers.remove(group.server) - } - } - ), - onLevel: { identity, level in - model.setToolPermission(identity: identity, level: level) - } - ) - } - } + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(grouped, id: \.server) { group in + ServerPermissionGroup( + server: group.server, + items: group.items, + isExpanded: Binding( + get: { + // When searching, force open so hits are visible. + if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + return expandedServers.contains(group.server) + }, + set: { open in + if open { + expandedServers.insert(group.server) + } else { + expandedServers.remove(group.server) + } } - .frame(maxHeight: 360) - .padding(.top, 8) - } - .padding(.top, 12) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) + ), + onLevel: { identity, level in + model.setToolPermission(identity: identity, level: level) + } + ) + } } + } + .frame(maxHeight: 360) + .padding(.top, 8) } - .onAppear { - model.reloadToolPermissions() - seedExpandedServersIfNeeded() - } - .onChange(of: model.toolCapabilities.map(\.identity)) { _, _ in - seedExpandedServersIfNeeded() - } + .padding(.top, 12) + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + } + } + .onAppear { + model.reloadToolPermissions() + seedExpandedServersIfNeeded() + } + .onChange(of: model.toolCapabilities.map(\.identity)) { _, _ in + seedExpandedServersIfNeeded() } + } - private var hierarchyChrome: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(CSColor.textFaint) - TextField("Search server or tool", text: $searchText) - .textFieldStyle(.plain) - .font(CSFont.mono(11.5, .medium)) - .foregroundStyle(CSColor.textBody) - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) - ) + private var hierarchyChrome: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(CSColor.textFaint) + TextField("Search server or tool", text: $searchText) + .textFieldStyle(.plain) + .font(CSFont.mono(11.5, .medium)) + .foregroundStyle(CSColor.textBody) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) + ) - HStack(spacing: 12) { - Button("Expand all") { - expandedServers = Set(grouped.map(\.server)) - } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.oliveLight) + HStack(spacing: 12) { + Button("Expand all") { + expandedServers = Set(grouped.map(\.server)) + } + .csFocusRing(cornerRadius: 8) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.oliveLight) - Button("Collapse all") { - expandedServers.removeAll() - } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.textFaint) + Button("Collapse all") { + expandedServers.removeAll() + } + .csFocusRing(cornerRadius: 8) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.textFaint) - Spacer(minLength: 0) + Spacer(minLength: 0) - Text("\(grouped.count) server\(grouped.count == 1 ? "" : "s")") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - } - } + Text("\(grouped.count) server\(grouped.count == 1 ? "" : "s")") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + } } + } - private func seedExpandedServersIfNeeded() { - // First open: expand the first server so the hierarchy is discoverable - // without dumping every tool. Preserve operator choices after that. - guard expandedServers.isEmpty, let first = grouped.first?.server else { return } - expandedServers = [first] - } + private func seedExpandedServersIfNeeded() { + // First open: expand the first server so the hierarchy is discoverable + // without dumping every tool. Preserve operator choices after that. + guard expandedServers.isEmpty, let first = grouped.first?.server else { return } + expandedServers = [first] + } - private var defaultsCard: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Defaults") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) + private var defaultsCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Defaults") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) - HStack(spacing: 12) { - defaultPicker( - title: "Read-only", - selection: Binding( - get: { model.permissionPolicy.readOnlyDefault }, - set: { model.setPermissionDefault(kind: .readOnly, level: $0) } - ) - ) - defaultPicker( - title: "Side effects", - selection: Binding( - get: { model.permissionPolicy.sideEffectDefault }, - set: { model.setPermissionDefault(kind: .sideEffect, level: $0) } - ) - ) - defaultPicker( - title: "Global / unknown", - selection: Binding( - get: { model.permissionPolicy.defaultLevel }, - set: { model.setPermissionDefault(kind: .global, level: $0) } - ) - ) - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(CSColor.surfaceRaised(0.02)) + HStack(spacing: 12) { + defaultPicker( + title: "Read-only", + selection: Binding( + get: { model.permissionPolicy.readOnlyDefault }, + set: { model.setPermissionDefault(kind: .readOnly, level: $0) } + ) ) - .overlay( - RoundedRectangle(cornerRadius: 11, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + defaultPicker( + title: "Side effects", + selection: Binding( + get: { model.permissionPolicy.sideEffectDefault }, + set: { model.setPermissionDefault(kind: .sideEffect, level: $0) } + ) ) + defaultPicker( + title: "Global / unknown", + selection: Binding( + get: { model.permissionPolicy.defaultLevel }, + set: { model.setPermissionDefault(kind: .global, level: $0) } + ) + ) + } } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(CSColor.surfaceRaised(0.02)) + ) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + ) + } - private func defaultPicker(title: String, selection: Binding) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - Picker(title, selection: selection) { - Text("Allow").tag("allow") - Text("Ask").tag("ask") - Text("Deny").tag("deny") - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: 180) - } + private func defaultPicker(title: String, selection: Binding) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + Picker(title, selection: selection) { + Text("Allow").tag("allow") + Text("Ask").tag("ask") + Text("Deny").tag("deny") + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: 180) } + } - private var emptyCapabilities: some View { - Text("No tools registered yet — open the agent once or add an MCP server.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textFaint) - .padding(.vertical, 10) - } + private var emptyCapabilities: some View { + Text("No tools registered yet — open the agent once or add an MCP server.") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + .padding(.vertical, 10) + } } // MARK: - Hierarchy model (pure, testable) @@ -212,175 +214,178 @@ struct ToolPermissionsSection: View { /// Lightweight projection of `CsToolCapability` so grouping/filter unit tests /// do not need a live UniFFI registry. struct ToolPermissionItem: Equatable, Hashable, Identifiable { - var id: String { identity } - let name: String - let identity: String - let server: String - let origin: String - let risk: String - let effective: String + var id: String { identity } + let name: String + let identity: String + let server: String + let origin: String + let risk: String + let effective: String - init( - name: String, - identity: String, - server: String, - origin: String, - risk: String, - effective: String - ) { - self.name = name - self.identity = identity - self.server = server - self.origin = origin - self.risk = risk - self.effective = effective - } + init( + name: String, + identity: String, + server: String, + origin: String, + risk: String, + effective: String + ) { + self.name = name + self.identity = identity + self.server = server + self.origin = origin + self.risk = risk + self.effective = effective + } - init(capability: CsToolCapability) { - self.name = capability.name - self.identity = capability.identity - self.server = capability.server - self.origin = capability.origin - self.risk = capability.risk - self.effective = capability.effective - } + init(capability: CsToolCapability) { + self.name = capability.name + self.identity = capability.identity + self.server = capability.server + self.origin = capability.origin + self.risk = capability.risk + self.effective = capability.effective + } } enum ToolPermissionGrouping { - /// Group key for hierarchy: prefer the live `server` field, else the - /// identity prefix before `:`, else `native`. - static func groupKey(server: String, identity: String) -> String { - let trimmed = server.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - if let colon = identity.firstIndex(of: ":") { - let prefix = String(identity[.. String { + let trimmed = server.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + if let colon = identity.firstIndex(of: ":") { + let prefix = String(identity[.. Bool { - let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return true } - return item.name.lowercased().contains(q) - || item.identity.lowercased().contains(q) - || item.server.lowercased().contains(q) - || item.origin.lowercased().contains(q) - } + static func matches(_ item: ToolPermissionItem, query: String) -> Bool { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return true } + return item.name.lowercased().contains(q) + || item.identity.lowercased().contains(q) + || item.server.lowercased().contains(q) + || item.origin.lowercased().contains(q) + } - /// Filtered, then grouped by server, tools sorted by name within each group, - /// groups sorted by server name (native first when present). - static func groups( - from items: [ToolPermissionItem], - query: String - ) -> [(server: String, items: [ToolPermissionItem])] { - let filtered = items.filter { matches($0, query: query) } - var buckets: [String: [ToolPermissionItem]] = [:] - for item in filtered { - let key = groupKey(server: item.server, identity: item.identity) - buckets[key, default: []].append(item) - } - for key in buckets.keys { - buckets[key]?.sort { - if $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedSame { - return $0.identity.localizedCaseInsensitiveCompare($1.identity) == .orderedAscending - } - return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending - } - } - return buckets.keys.sorted { a, b in - if a == "native" { return true } - if b == "native" { return false } - return a.localizedCaseInsensitiveCompare(b) == .orderedAscending - }.compactMap { key in - guard let items = buckets[key], !items.isEmpty else { return nil } - return (server: key, items: items) + /// Filtered, then grouped by server, tools sorted by name within each group, + /// groups sorted by server name (native first when present). + static func groups( + from items: [ToolPermissionItem], + query: String + ) -> [(server: String, items: [ToolPermissionItem])] { + let filtered = items.filter { matches($0, query: query) } + var buckets: [String: [ToolPermissionItem]] = [:] + for item in filtered { + let key = groupKey(server: item.server, identity: item.identity) + buckets[key, default: []].append(item) + } + for key in buckets.keys { + buckets[key]?.sort { + if $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedSame { + return $0.identity.localizedCaseInsensitiveCompare($1.identity) == .orderedAscending } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + return buckets.keys.sorted { a, b in + if a == "native" { return true } + if b == "native" { return false } + return a.localizedCaseInsensitiveCompare(b) == .orderedAscending + }.compactMap { key in + guard let items = buckets[key], !items.isEmpty else { return nil } + return (server: key, items: items) } + } } // MARK: - Server group + capability row private struct ServerPermissionGroup: View { - let server: String - let items: [ToolPermissionItem] - @Binding var isExpanded: Bool - let onLevel: (String, String) -> Void + let server: String + let items: [ToolPermissionItem] + @Binding var isExpanded: Bool + let onLevel: (String, String) -> Void - var body: some View { - DisclosureGroup(isExpanded: $isExpanded) { - VStack(spacing: 6) { - ForEach(items) { item in - ToolCapabilityRow( - item: item, - onLevel: { onLevel(item.identity, $0) } - ) - } - } - .padding(.top, 6) - } label: { - HStack(spacing: 8) { - Text(server) - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(CSColor.textBody) - Text("\(items.count)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule(style: .continuous) - .fill(CSColor.surfaceRaised(0.05)) - ) - Spacer(minLength: 0) - } + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(spacing: 6) { + ForEach(items) { item in + ToolCapabilityRow( + item: item, + onLevel: { onLevel(item.identity, $0) } + ) } - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(CSColor.textBody) + } + .padding(.top, 6) + } label: { + HStack(spacing: 8) { + Text(server) + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.textBody) + Text("\(items.count)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule(style: .continuous) + .fill(CSColor.surfaceRaised(0.05)) + ) + Spacer(minLength: 0) + } } + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.textBody) + } } private struct ToolCapabilityRow: View { - let item: ToolPermissionItem - let onLevel: (String) -> Void + let item: ToolPermissionItem + let onLevel: (String) -> Void - var body: some View { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(item.name) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - .lineLimit(1) - Text(item.identity) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - .lineLimit(1) - Text("\(item.origin) · \(item.risk)") - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) - } - Spacer(minLength: 8) - Picker("Level", selection: Binding( - get: { item.effective }, - set: { onLevel($0) } - )) { - Text("Allow").tag("allow") - Text("Ask").tag("ask") - Text("Deny").tag("deny") - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 180) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(CSColor.surfaceRaised(0.02)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + .lineLimit(1) + Text(item.identity) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(1) + Text("\(item.origin) · \(item.risk)") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + } + Spacer(minLength: 8) + Picker( + "Level", + selection: Binding( + get: { item.effective }, + set: { onLevel($0) } ) + ) { + Text("Allow").tag("allow") + Text("Ask").tag("ask") + Text("Deny").tag("deny") + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 180) } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(CSColor.surfaceRaised(0.02)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(CSColor.hairline(0.06), lineWidth: 1) + ) + } } diff --git a/macos/Codescribe/Screens/Settings/UserPanel.swift b/macos/Codescribe/Screens/Settings/UserPanel.swift index c48eddf9..022b8d2f 100644 --- a/macos/Codescribe/Screens/Settings/UserPanel.swift +++ b/macos/Codescribe/Screens/Settings/UserPanel.swift @@ -3,259 +3,262 @@ import SwiftUI // Local-first identity surface. Codescribe has no account model, so this panel // reports the running build and local data truth instead of inventing a profile. struct UserPanel: View { - @ObservedObject var model: SettingsViewModel - @AppStorage(ActivationPing.optInDefaultsKey) private var activationPingOptIn = false + @ObservedObject var model: SettingsViewModel + @AppStorage(ActivationPing.optInDefaultsKey) private var activationPingOptIn = false - private static let docsURL = URL(string: "https://github.com/vetcoders/codescribe/tree/develop/docs")! - /// Public trust pages on the GitHub Pages site (base `/codescribe`). - private static let privacyURL = URL(string: "https://vetcoders.github.io/codescribe/privacy")! - private static let termsURL = URL(string: "https://vetcoders.github.io/codescribe/terms")! + private static let docsURL = URL( + string: "https://github.com/vetcoders/codescribe/tree/develop/docs")! + /// Public trust pages on the GitHub Pages site (base `/codescribe`). + private static let privacyURL = URL(string: "https://vetcoders.github.io/codescribe/privacy")! + private static let termsURL = URL(string: "https://vetcoders.github.io/codescribe/terms")! - var body: some View { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · User") - Text("Local by design.") - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) - Text("No account is required. Your configuration and transcript history stay on this Mac.") - .font(CSFont.ui(12.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) + var body: some View { + VStack(alignment: .leading, spacing: 0) { + EyebrowLabel(text: "Settings · User") + Text("Local by design.") + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) + Text("No account is required. Your configuration and transcript history stay on this Mac.") + .font(CSFont.ui(12.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) - SettingsSectionLabel("Running build") - .padding(.top, 24) - VStack(spacing: 0) { - infoRow("Version", "\(model.buildInfo.version) (\(model.buildInfo.build))") - divider - infoRow("Commit", model.buildInfo.commit) - divider - infoRow("Built", model.buildInfo.builtAt) - } - .padding(.top, 11) - .background(card) - .overlay(cardBorder) + SettingsSectionLabel("Running build") + .padding(.top, 24) + VStack(spacing: 0) { + infoRow("Version", "\(model.buildInfo.version) (\(model.buildInfo.build))") + divider + infoRow("Commit", model.buildInfo.commit) + divider + infoRow("Built", model.buildInfo.builtAt) + } + .padding(.top, 11) + .background(card) + .overlay(cardBorder) - SettingsSectionLabel("Local data") - .padding(.top, 24) - VStack(spacing: 0) { - pathRow("Config, logs & runtime data", model.configDir) - divider - pathRow("Transcripts", model.transcriptsPath) - } - .padding(.top, 11) - .background(card) - .overlay(cardBorder) + SettingsSectionLabel("Local data") + .padding(.top, 24) + VStack(spacing: 0) { + pathRow("Config, logs & runtime data", model.configDir) + divider + pathRow("Transcripts", model.transcriptsPath) + } + .padding(.top, 11) + .background(card) + .overlay(cardBorder) - SettingsSectionLabel("Anonymous activation") - .padding(.top, 24) - SettingsControlRow( - title: "Share anonymous activation ping", - subtitle: "Send one content-free event after your first successful dictation" - ) { - Toggle("", isOn: $activationPingOptIn) - .toggleStyle(.switch) - .labelsHidden() - .tint(CSColor.chromeAccent) - .accessibilityLabel("Share anonymous activation ping") - .accessibilityValue(activationPingOptIn ? "On" : "Off") - } - .padding(.top, 11) + SettingsSectionLabel("Anonymous activation") + .padding(.top, 24) + SettingsControlRow( + title: "Share anonymous activation ping", + subtitle: "Send one content-free event after your first successful dictation" + ) { + Toggle("", isOn: $activationPingOptIn) + .toggleStyle(.switch) + .labelsHidden() + .tint(CSColor.chromeAccent) + .accessibilityLabel("Share anonymous activation ping") + .accessibilityValue(activationPingOptIn ? "On" : "Off") + } + .padding(.top, 11) - Text("Off by default. The event contains only the app version and macOS version — never audio or transcript text.") - .font(CSFont.mono(10.5, .regular)) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 7) + Text( + "Off by default. The event contains only the app version and macOS version — never audio or transcript text." + ) + .font(CSFont.mono(10.5, .regular)) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 7) - SettingsSectionLabel("Agent transcript tagging") - .padding(.top, 24) - SettingsControlRow( - title: "Tag transcripts for AI agents", - subtitle: "Wrap delivered dictation in an explicit source tag" - ) { - Toggle("", isOn: taggingBinding) - .toggleStyle(.switch) - .labelsHidden() - .tint(CSColor.chromeAccent) - .accessibilityLabel("Tag transcripts for AI agents") - .accessibilityValue(model.settings.transcriptTaggingEnabled ? "On" : "Off") - } - .padding(.top, 11) + SettingsSectionLabel("Agent transcript tagging") + .padding(.top, 24) + SettingsControlRow( + title: "Tag transcripts for AI agents", + subtitle: "Wrap delivered dictation in an explicit source tag" + ) { + Toggle("", isOn: taggingBinding) + .toggleStyle(.switch) + .labelsHidden() + .tint(CSColor.chromeAccent) + .accessibilityLabel("Tag transcripts for AI agents") + .accessibilityValue(model.settings.transcriptTaggingEnabled ? "On" : "Off") + } + .padding(.top, 11) - Text("Template") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.textFaint) - .padding(.top, 12) - TextField("Transcript tag template", text: transcriptTemplateBinding, axis: .vertical) - .font(CSFont.mono(11.5, .regular)) - .foregroundStyle(CSColor.textBody) - .textFieldStyle(.plain) - .lineLimit(3...8) - .padding(12) - .background(card) - .overlay(cardBorder) - .accessibilityLabel("Transcript tag template editor") - .accessibilityValue(model.settings.transcriptTagTemplate) + Text("Template") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.textFaint) + .padding(.top, 12) + TextField("Transcript tag template", text: transcriptTemplateBinding, axis: .vertical) + .font(CSFont.mono(11.5, .regular)) + .foregroundStyle(CSColor.textBody) + .textFieldStyle(.plain) + .lineLimit(3...8) + .padding(12) + .background(card) + .overlay(cardBorder) + .accessibilityLabel("Transcript tag template editor") + .accessibilityValue(model.settings.transcriptTagTemplate) - if let warning = model.transcriptTagTemplateWarning { - Text(warning) - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.dangerLight) - .padding(.top, 7) - .accessibilityLabel("Transcript tag template warning") - .accessibilityValue(warning) - } + if let warning = model.transcriptTagTemplateWarning { + Text(warning) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.dangerLight) + .padding(.top, 7) + .accessibilityLabel("Transcript tag template warning") + .accessibilityValue(warning) + } - HStack(spacing: 6) { - ForEach(transcriptTagTemplatePlaceholders, id: \.self) { placeholder in - Text(placeholder) - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background( - Capsule(style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - Capsule(style: .continuous) - .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) - ) - } - Spacer(minLength: 0) - Button("Restore default") { - model.restoreDefaultTranscriptTagTemplate() - } - .csFocusRing(cornerRadius: 8) - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - .accessibilityLabel("Restore default transcript tag template") - } - .padding(.top, 9) - - Text("Live preview") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.textFaint) - .padding(.top, 12) - Text(model.transcriptTagPreview) - .font(CSFont.mono(11.5, .regular)) - .foregroundStyle(CSColor.textBodyAlt) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(card) - .overlay(cardBorder) - .accessibilityLabel("Transcript tag template preview") - .accessibilityValue(model.transcriptTagPreview) + HStack(spacing: 6) { + ForEach(transcriptTagTemplatePlaceholders, id: \.self) { placeholder in + Text(placeholder) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder(CSColor.hairline(0.10), lineWidth: 1) + ) + } + Spacer(minLength: 0) + Button("Restore default") { + model.restoreDefaultTranscriptTagTemplate() + } + .csFocusRing(cornerRadius: 8) + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + .accessibilityLabel("Restore default transcript tag template") + } + .padding(.top, 9) - SettingsSectionLabel("Legal & docs") - .padding(.top, 24) - VStack(alignment: .leading, spacing: 10) { - Link(destination: Self.privacyURL) { - HStack(spacing: 6) { - Text("Privacy Policy") - Text("↗") - } - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - } - .accessibilityLabel("Open Privacy Policy") + Text("Live preview") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.textFaint) + .padding(.top, 12) + Text(model.transcriptTagPreview) + .font(CSFont.mono(11.5, .regular)) + .foregroundStyle(CSColor.textBodyAlt) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(card) + .overlay(cardBorder) + .accessibilityLabel("Transcript tag template preview") + .accessibilityValue(model.transcriptTagPreview) - Link(destination: Self.termsURL) { - HStack(spacing: 6) { - Text("Terms of Use & EULA") - Text("↗") - } - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - } - .accessibilityLabel("Open Terms of Use and EULA") + SettingsSectionLabel("Legal & docs") + .padding(.top, 24) + VStack(alignment: .leading, spacing: 10) { + Link(destination: Self.privacyURL) { + HStack(spacing: 6) { + Text("Privacy Policy") + Text("↗") + } + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + } + .accessibilityLabel("Open Privacy Policy") - Link(destination: Self.docsURL) { - HStack(spacing: 6) { - Text("Open Codescribe documentation") - Text("↗") - } - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - } - .accessibilityLabel("Open Codescribe documentation") - } - .padding(.top, 11) + Link(destination: Self.termsURL) { + HStack(spacing: 6) { + Text("Terms of Use & EULA") + Text("↗") + } + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + } + .accessibilityLabel("Open Terms of Use and EULA") - ResetAppDataSection(model: model) - .padding(.top, 30) + Link(destination: Self.docsURL) { + HStack(spacing: 6) { + Text("Open Codescribe documentation") + Text("↗") + } + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) } - .padding(.horizontal, 28) - .padding(.vertical, 24) - } + .accessibilityLabel("Open Codescribe documentation") + } + .padding(.top, 11) - private var taggingBinding: Binding { - Binding( - get: { model.settings.transcriptTaggingEnabled }, - set: { model.setTranscriptTaggingEnabled($0) } - ) + ResetAppDataSection(model: model) + .padding(.top, 30) } + .padding(.horizontal, 28) + .padding(.vertical, 24) + } - private var transcriptTemplateBinding: Binding { - Binding( - get: { model.settings.transcriptTagTemplate }, - set: { model.setTranscriptTagTemplate($0) } - ) - } + private var taggingBinding: Binding { + Binding( + get: { model.settings.transcriptTaggingEnabled }, + set: { model.setTranscriptTaggingEnabled($0) } + ) + } - private func infoRow(_ label: String, _ value: String) -> some View { - HStack(spacing: 14) { - Text(label) - .font(CSFont.ui(12.5, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .frame(width: 90, alignment: .leading) - Text(value) - .font(CSFont.mono(11.5, .medium)) - .foregroundStyle(CSColor.textBody) - .textSelection(.enabled) - .accessibilityLabel(label) - .accessibilityValue(value) - Spacer(minLength: 0) - } - .padding(.horizontal, 15) - .padding(.vertical, 12) - } + private var transcriptTemplateBinding: Binding { + Binding( + get: { model.settings.transcriptTagTemplate }, + set: { model.setTranscriptTagTemplate($0) } + ) + } - private func pathRow(_ label: String, _ path: String) -> some View { - VStack(alignment: .leading, spacing: 5) { - Text(label) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text(path.isEmpty ? "not loaded yet" : path) - .font(CSFont.mono(10.5, .regular)) - .foregroundStyle(CSColor.textMutedAlt) - .textSelection(.enabled) - .lineLimit(2) - .truncationMode(.middle) - .accessibilityLabel(label) - .accessibilityValue(path.isEmpty ? "not loaded yet" : path) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 15) - .padding(.vertical, 12) + private func infoRow(_ label: String, _ value: String) -> some View { + HStack(spacing: 14) { + Text(label) + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .frame(width: 90, alignment: .leading) + Text(value) + .font(CSFont.mono(11.5, .medium)) + .foregroundStyle(CSColor.textBody) + .textSelection(.enabled) + .accessibilityLabel(label) + .accessibilityValue(value) + Spacer(minLength: 0) } + .padding(.horizontal, 15) + .padding(.vertical, 12) + } - private var divider: some View { - Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) + private func pathRow(_ label: String, _ path: String) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(label) + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text(path.isEmpty ? "not loaded yet" : path) + .font(CSFont.mono(10.5, .regular)) + .foregroundStyle(CSColor.textMutedAlt) + .textSelection(.enabled) + .lineLimit(2) + .truncationMode(.middle) + .accessibilityLabel(label) + .accessibilityValue(path.isEmpty ? "not loaded yet" : path) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(.vertical, 12) + } - private var card: some View { - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .fill(CSColor.surfaceRaised(0.025)) - } + private var divider: some View { + Rectangle().fill(CSColor.hairline(0.06)).frame(height: 1) + } - private var cardBorder: some View { - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - } + private var card: some View { + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .fill(CSColor.surfaceRaised(0.025)) + } + + private var cardBorder: some View { + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + } } // MARK: - Danger zone @@ -263,97 +266,103 @@ struct UserPanel: View { /// The full-data reset lives only at the foot of User settings, away from MCP /// editing. Data is recoverable from Trash; Keychain deletion remains opt-in. private struct ResetAppDataSection: View { - @ObservedObject var model: SettingsViewModel - @State private var includeKeys = false - @State private var includePrompts = false - @State private var confirming = false - @State private var confirmationText = "" + @ObservedObject var model: SettingsViewModel + @State private var includeKeys = false + @State private var includePrompts = false + @State private var confirming = false + @State private var confirmationText = "" - var body: some View { - VStack(alignment: .leading, spacing: 0) { - SettingsSectionLabel("Danger zone") - .foregroundStyle(CSColor.dangerLight) + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Danger zone") + .foregroundStyle(CSColor.dangerLight) - Text("Moves recordings, transcript history, conversations, logs, preferences, " - + "and local configuration to Trash so they can be recovered. " - + "Your assistive.txt and formatting.txt base prompts are preserved by default.") - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 6) + Text( + "Moves recordings, transcript history, conversations, logs, preferences, " + + "and local configuration to Trash so they can be recovered. " + + "Your assistive.txt and formatting.txt base prompts are preserved by default." + ) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 6) - Toggle(isOn: $includeKeys) { - Text("Also remove API keys from Keychain") - .font(CSFont.ui(12.5, .medium)) - .foregroundStyle(CSColor.textBody) - } - .toggleStyle(.checkbox) - .padding(.top, 13) + Toggle(isOn: $includeKeys) { + Text("Also remove API keys from Keychain") + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textBody) + } + .toggleStyle(.checkbox) + .padding(.top, 13) - Toggle(isOn: $includePrompts) { - Text("Also reset my base prompts (assistive.txt and formatting.txt)") - .font(CSFont.ui(12.5, .medium)) - .foregroundStyle(CSColor.textBody) - } - .toggleStyle(.checkbox) - .padding(.top, 9) - .accessibilityHint("Off by default. When enabled, both prompt files move to Trash with the rest of the app data.") + Toggle(isOn: $includePrompts) { + Text("Also reset my base prompts (assistive.txt and formatting.txt)") + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textBody) + } + .toggleStyle(.checkbox) + .padding(.top, 9) + .accessibilityHint( + "Off by default. When enabled, both prompt files move to Trash with the rest of the app data." + ) - Button(role: .destructive) { - model.refreshResetPreview() - confirmationText = "" - confirming = true - } label: { - Text("Move app data to Trash…") - .font(CSFont.ui(12, .semibold)) - .foregroundStyle(CSColor.dangerLight) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.danger.opacity(0.14)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.danger.opacity(0.42), lineWidth: 1) - ) - } - .csFocusRing(cornerRadius: 8) - .padding(.top, 13) - .accessibilityLabel("Reset app data. Destructive action.") - .accessibilityHint("Shows the live impact, names whether base prompts are preserved, and requires typing RESET before data moves to Trash.") - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .fill(CSColor.danger.opacity(0.055)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.danger.opacity(0.55), lineWidth: 1) - ) - .alert("Move app data to Trash?", isPresented: $confirming) { - TextField("Type RESET to continue", text: $confirmationText) - Button("Cancel", role: .cancel) { - confirmationText = "" - } - Button("Move to Trash & Relaunch", role: .destructive) { - model.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) - } - .disabled(!resetConfirmationMatches(confirmationText)) - } message: { - Text(model.resetImpactDescription(includeKeys: includeKeys, includePrompts: includePrompts)) - } + Button(role: .destructive) { + model.refreshResetPreview() + confirmationText = "" + confirming = true + } label: { + Text("Move app data to Trash…") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.dangerLight) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.danger.opacity(0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.danger.opacity(0.42), lineWidth: 1) + ) + } + .csFocusRing(cornerRadius: 8) + .padding(.top, 13) + .accessibilityLabel("Reset app data. Destructive action.") + .accessibilityHint( + "Shows the live impact, names whether base prompts are preserved, and requires typing RESET before data moves to Trash." + ) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .fill(CSColor.danger.opacity(0.055)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.danger.opacity(0.55), lineWidth: 1) + ) + .alert("Move app data to Trash?", isPresented: $confirming) { + TextField("Type RESET to continue", text: $confirmationText) + Button("Cancel", role: .cancel) { + confirmationText = "" + } + Button("Move to Trash & Relaunch", role: .destructive) { + model.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) + } + .disabled(!resetConfirmationMatches(confirmationText)) + } message: { + Text(model.resetImpactDescription(includeKeys: includeKeys, includePrompts: includePrompts)) + } + } } #if DEBUG -#Preview("User panel") { + #Preview("User panel") { ScrollView { UserPanel(model: .preview(.user)) } - .frame(width: 720, height: 720) - .background(SettingsView.windowGradient) - .preferredColorScheme(.dark) -} + .frame(width: 720, height: 720) + .background(SettingsView.windowGradient) + .preferredColorScheme(.dark) + } #endif diff --git a/macos/Codescribe/Screens/Settings/VoiceLabPanel.swift b/macos/Codescribe/Screens/Settings/VoiceLabPanel.swift index 23e4a862..278fba3d 100644 --- a/macos/Codescribe/Screens/Settings/VoiceLabPanel.swift +++ b/macos/Codescribe/Screens/Settings/VoiceLabPanel.swift @@ -6,91 +6,91 @@ import SwiftUI // from the live local quality loop. Preview timing lives in Dictation. struct VoiceLabCorrectionRow: Identifiable, Equatable { - let id: String - let revision: UInt64 - let rawText: String - let variant: String - let editedText: String - let action: String - let timestampMs: UInt64 - let avgLogprob: Float? - let speechPct: Float? - let confidenceFlags: [String] - - var isLowConfidence: Bool { - if let avgLogprob, avgLogprob <= -1.20 { return true } - return confidenceFlags.contains { flag in - let normalized = flag.lowercased() - return normalized.contains("low_logprob") - || normalized.contains("hallucination") - || normalized.contains("quality_gate") - } + let id: String + let revision: UInt64 + let rawText: String + let variant: String + let editedText: String + let action: String + let timestampMs: UInt64 + let avgLogprob: Float? + let speechPct: Float? + let confidenceFlags: [String] + + var isLowConfidence: Bool { + if let avgLogprob, avgLogprob <= -1.20 { return true } + return confidenceFlags.contains { flag in + let normalized = flag.lowercased() + return normalized.contains("low_logprob") + || normalized.contains("hallucination") + || normalized.contains("quality_gate") } + } - var confidenceSummary: String { - var parts: [String] = [] - if let avgLogprob { - parts.append(String(format: "Whisper logprob %.2f", avgLogprob)) - } - if let speechPct { - let percent = speechPct <= 1 ? speechPct * 100 : speechPct - parts.append(String(format: "Silero/VAD speech %.0f%%", percent)) - } - if !confidenceFlags.isEmpty { - parts.append(confidenceFlags.joined(separator: ", ")) - } - return parts.isEmpty ? "No confidence telemetry was recorded" : parts.joined(separator: " · ") + var confidenceSummary: String { + var parts: [String] = [] + if let avgLogprob { + parts.append(String(format: "Whisper logprob %.2f", avgLogprob)) } + if let speechPct { + let percent = speechPct <= 1 ? speechPct * 100 : speechPct + parts.append(String(format: "Silero/VAD speech %.0f%%", percent)) + } + if !confidenceFlags.isEmpty { + parts.append(confidenceFlags.joined(separator: ", ")) + } + return parts.isEmpty ? "No confidence telemetry was recorded" : parts.joined(separator: " · ") + } } struct VoiceLabEditorState: Equatable { - var correctionID: String? - var canonical = "" - - mutating func begin(_ row: VoiceLabCorrectionRow) { - correctionID = row.id - canonical = row.editedText - } - - mutating func cancel() { - correctionID = nil - canonical = "" - } + var correctionID: String? + var canonical = "" + + mutating func begin(_ row: VoiceLabCorrectionRow) { + correctionID = row.id + canonical = row.editedText + } + + mutating func cancel() { + correctionID = nil + canonical = "" + } } struct VoiceLabLexiconRow: Identifiable, Equatable { - let id: Int - let variant: String - let canonical: String - let source: String + let id: Int + let variant: String + let canonical: String + let source: String } func qualityCorrectionRows(_ records: [CsQualityRecord]) -> [VoiceLabCorrectionRow] { - records.map { record in - VoiceLabCorrectionRow( - id: record.id, - revision: record.revision, - rawText: record.rawText, - variant: record.variant, - editedText: record.editedText, - action: record.action, - timestampMs: record.timestampMs, - avgLogprob: record.avgLogprob, - speechPct: record.speechPct, - confidenceFlags: record.confidenceFlags - ) - } + records.map { record in + VoiceLabCorrectionRow( + id: record.id, + revision: record.revision, + rawText: record.rawText, + variant: record.variant, + editedText: record.editedText, + action: record.action, + timestampMs: record.timestampMs, + avgLogprob: record.avgLogprob, + speechPct: record.speechPct, + confidenceFlags: record.confidenceFlags + ) + } } func customLexiconRows(_ entries: [CsLexiconEntry]) -> [VoiceLabLexiconRow] { - entries.enumerated().map { index, entry in - VoiceLabLexiconRow( - id: index, - variant: entry.variant, - canonical: entry.canonical, - source: entry.source - ) - } + entries.enumerated().map { index, entry in + VoiceLabLexiconRow( + id: index, + variant: entry.variant, + canonical: entry.canonical, + source: entry.source + ) + } } /// Resolve the archived recording paired with an exact raw transcript. History @@ -98,469 +98,478 @@ func customLexiconRows(_ entries: [CsLexiconEntry]) -> [VoiceLabLexiconRow] { /// a correction from ever playing a different dictation merely because it was /// recorded nearby in time. func archivedAudioURL(configDir: String, rawText: String) -> URL? { - let root = URL(fileURLWithPath: configDir, isDirectory: true) - .appendingPathComponent("transcriptions", isDirectory: true) - guard let enumerator = FileManager.default.enumerator( - at: root, - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { return nil } - - var matches: [(URL, Date)] = [] - for case let transcriptURL as URL in enumerator { - guard transcriptURL.pathExtension == "txt", - let text = try? String(contentsOf: transcriptURL, encoding: .utf8), - text.trimmingCharacters(in: .whitespacesAndNewlines) - == rawText.trimmingCharacters(in: .whitespacesAndNewlines) - else { continue } - let date = (try? transcriptURL.resourceValues(forKeys: [.contentModificationDateKey]))? - .contentModificationDate ?? .distantPast - matches.append((transcriptURL, date)) + let root = URL(fileURLWithPath: configDir, isDirectory: true) + .appendingPathComponent("transcriptions", isDirectory: true) + guard + let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + else { return nil } + + var matches: [(URL, Date)] = [] + for case let transcriptURL as URL in enumerator { + guard transcriptURL.pathExtension == "txt", + let text = try? String(contentsOf: transcriptURL, encoding: .utf8), + text.trimmingCharacters(in: .whitespacesAndNewlines) + == rawText.trimmingCharacters(in: .whitespacesAndNewlines) + else { continue } + let date = + (try? transcriptURL.resourceValues(forKeys: [.contentModificationDateKey]))? + .contentModificationDate ?? .distantPast + matches.append((transcriptURL, date)) + } + + for (transcriptURL, _) in matches.sorted(by: { $0.1 > $1.1 }) { + let stem = transcriptURL.deletingPathExtension() + for ext in ["m4a", "wav", "flac"] { + let candidate = stem.appendingPathExtension(ext) + if FileManager.default.fileExists(atPath: candidate.path) { return candidate } } - - for (transcriptURL, _) in matches.sorted(by: { $0.1 > $1.1 }) { - let stem = transcriptURL.deletingPathExtension() - for ext in ["m4a", "wav", "flac"] { - let candidate = stem.appendingPathExtension(ext) - if FileManager.default.fileExists(atPath: candidate.path) { return candidate } - } - } - return nil + } + return nil } /// Honest Dictionary headline. /// Every custom lexicon variant→canonical is a **live rule** the engine applies. /// Correction provenance is a subset, not the only “real” count. func dictionaryHeadline(correctionsRecorded: Int, rulesLearned: Int) -> String { - "\(correctionsRecorded) corrections recorded · \(rulesLearned) rules in dictionary" + "\(correctionsRecorded) corrections recorded · \(rulesLearned) rules in dictionary" } func dictionarySubtitle( - correctionsRecorded: Int, - rulesLearned: Int, - taughtFromCorrections: Int, - totalEntries: Int + correctionsRecorded: Int, + rulesLearned: Int, + taughtFromCorrections: Int, + totalEntries: Int ) -> String { - if rulesLearned > 0 { - return "\(rulesLearned) live rules (variant→canonical) · \(taughtFromCorrections) with correction provenance · \(totalEntries) store rows." - } - if correctionsRecorded > 0 { - return "\(correctionsRecorded) corrections on disk · dictionary empty — press Teach to mine rules from the store." - } - return "Correction history and custom dictionary. Teach promotes corrections + proposed → live lexicon." + if rulesLearned > 0 { + return + "\(rulesLearned) live rules (variant→canonical) · \(taughtFromCorrections) with correction provenance · \(totalEntries) store rows." + } + if correctionsRecorded > 0 { + return + "\(correctionsRecorded) corrections on disk · dictionary empty — press Teach to mine rules from the store." + } + return + "Correction history and custom dictionary. Teach promotes corrections + proposed → live lexicon." } - /// NSSound plays independently of the view that started it — playback used to /// survive Previous/Next and even closing the Settings window, with no way to /// stop it (operator, 2026-08-09). The delegate flips the button back to Play /// when the file ends on its own. private final class VoiceLabPlaybackDelegate: NSObject, NSSoundDelegate { - var onFinish: (() -> Void)? - func sound(_ sound: NSSound, didFinishPlaying flag: Bool) { - DispatchQueue.main.async { self.onFinish?() } - } + var onFinish: (() -> Void)? + func sound(_ sound: NSSound, didFinishPlaying flag: Bool) { + DispatchQueue.main.async { self.onFinish?() } + } } struct VoiceLabPanel: View { - @ObservedObject var model: SettingsViewModel - @State private var editor = VoiceLabEditorState() - @State private var correctionIndex = 0 - @State private var lexiconIndex = 0 - @State private var playbackSound: NSSound? - @State private var playbackMessage: String? - @State private var playingRowID: String? - @State private var playbackDelegate = VoiceLabPlaybackDelegate() - - private var corrections: [VoiceLabCorrectionRow] { - qualityCorrectionRows(model.qualityRecords) - } - - /// Every flattened lexicon pair is a rule PostProcessor applies. - private var rulesLearnedCount: Int { - model.customLexiconEntries.count - } - - /// Subset taught from correction / proposed provenance (source=correction). - private var taughtFromCorrectionsCount: Int { - model.customLexiconEntries.lazy.filter { $0.source == "correction" }.count - } - - private var correctionsRecordedCount: Int { - corrections.count - } - - var body: some View { + @ObservedObject var model: SettingsViewModel + @State private var editor = VoiceLabEditorState() + @State private var correctionIndex = 0 + @State private var lexiconIndex = 0 + @State private var playbackSound: NSSound? + @State private var playbackMessage: String? + @State private var playingRowID: String? + @State private var playbackDelegate = VoiceLabPlaybackDelegate() + + private var corrections: [VoiceLabCorrectionRow] { + qualityCorrectionRows(model.qualityRecords) + } + + /// Every flattened lexicon pair is a rule PostProcessor applies. + private var rulesLearnedCount: Int { + model.customLexiconEntries.count + } + + /// Subset taught from correction / proposed provenance (source=correction). + private var taughtFromCorrectionsCount: Int { + model.customLexiconEntries.lazy.filter { $0.source == "correction" }.count + } + + private var correctionsRecordedCount: Int { + corrections.count + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 0) { - EyebrowLabel(text: "Settings · \(SettingsSection.voiceLab.title)") - Text(dictionaryHeadline( - correctionsRecorded: correctionsRecordedCount, - rulesLearned: rulesLearnedCount - )) - .font(CSFont.ui(26, .bold)) - .tracking(-0.5) - .foregroundStyle(CSColor.textHigh) - .padding(.top, 6) - Text(dictionarySubtitle( - correctionsRecorded: correctionsRecordedCount, - rulesLearned: rulesLearnedCount, - taughtFromCorrections: taughtFromCorrectionsCount, - totalEntries: model.customLexiconEntries.count - )) - .font(CSFont.ui(12.5)) - .foregroundStyle(CSColor.textMutedAlt) - .padding(.top, 8) - if let teachMsg = model.voiceLabTeachMessage { - Text(teachMsg) - .font(CSFont.mono(11, .medium)) - .foregroundStyle(CSColor.oliveLight) - .padding(.top, 8) - } - } - Spacer(minLength: 0) - HStack(spacing: 12) { - Button("Teach") { - model.teachDictionaryFromStore() - } - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - .csFocusRing(cornerRadius: 8) - .disabled(model.voiceLabTeachPending) - .accessibilityLabel("Teach dictionary from corrections and proposed rules") - Button("Refresh") { - model.refreshVoiceLab() - } - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - .csFocusRing(cornerRadius: 8) - .accessibilityLabel("Refresh \(SettingsSection.voiceLab.title) data") - } - } - - SettingsSectionLabel("Recent corrections · \(corrections.count)") - .padding(.top, 24) - correctionsSection - .padding(.top, 11) - - SettingsSectionLabel("Custom dictionary · \(model.customLexiconEntries.count)") - .padding(.top, 24) - lexiconSection - .padding(.top, 11) + EyebrowLabel(text: "Settings · \(SettingsSection.voiceLab.title)") + Text( + dictionaryHeadline( + correctionsRecorded: correctionsRecordedCount, + rulesLearned: rulesLearnedCount + ) + ) + .font(CSFont.ui(26, .bold)) + .tracking(-0.5) + .foregroundStyle(CSColor.textHigh) + .padding(.top, 6) + Text( + dictionarySubtitle( + correctionsRecorded: correctionsRecordedCount, + rulesLearned: rulesLearnedCount, + taughtFromCorrections: taughtFromCorrectionsCount, + totalEntries: model.customLexiconEntries.count + ) + ) + .font(CSFont.ui(12.5)) + .foregroundStyle(CSColor.textMutedAlt) + .padding(.top, 8) + if let teachMsg = model.voiceLabTeachMessage { + Text(teachMsg) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.oliveLight) + .padding(.top, 8) + } } - .padding(.horizontal, 28) - .padding(.vertical, 24) - // The sound belongs to the visible row: navigating away from the row - // or from the panel ends it. Without these, NSSound kept playing after - // the Settings window was closed. - .onChange(of: correctionIndex) { stopPlayback() } - .onDisappear { stopPlayback() } - } - - @ViewBuilder - private var correctionsSection: some View { - if let error = model.voiceLabReadError { - readError(error) - } else if corrections.isEmpty { - emptyState("No corrections yet — edit a transcript in the overlay so the engine can learn.") - } else { - VStack(spacing: 8) { - let safeIndex = min(correctionIndex, corrections.count - 1) - let row = corrections[safeIndex] - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - Text("ORIGINAL STT") - .font(CSFont.mono(10.5, .semibold)) - .foregroundStyle(row.isLowConfidence ? CSColor.terracottaLight : CSColor.oliveLight) - Text(row.isLowConfidence ? "LOW CONFIDENCE" : "CONFIDENCE DATA") - .font(CSFont.mono(9.5, .semibold)) - .foregroundStyle(row.isLowConfidence ? CSColor.terracottaLight : CSColor.textFaintAlt) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background( - Capsule().fill( - (row.isLowConfidence ? CSColor.terracottaLight : CSColor.oliveLight) - .opacity(0.12) - ) - ) - // Deferred-correction desk: sessions closed without an - // overlay edit land here as UNREVIEWED, awaiting Edit. - if row.action == "close-unreviewed" { - Text("UNREVIEWED") - .font(CSFont.mono(9.5, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(Capsule().fill(CSColor.chromeAccent.opacity(0.12))) - } - Spacer(minLength: 0) - Button { - togglePlayback(row) - } label: { - Label( - playingRowID == row.id ? "Stop" : "Play original", - systemImage: playingRowID == row.id ? "stop.fill" : "play.fill" - ) - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityLabel( - playingRowID == row.id - ? "Stop playing the original recording" - : "Play the original recording for this correction" - ) - } - Text(row.rawText) - .font(CSFont.ui(13, .medium)) - .foregroundStyle(CSColor.textHigh) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(11) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill( - (row.isLowConfidence ? CSColor.terracottaLight : CSColor.surfaceRaised(0.04)) - .opacity(row.isLowConfidence ? 0.12 : 1) - ) - ) - Text(row.confidenceSummary) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(row.isLowConfidence ? CSColor.terracottaLight : CSColor.textFaintAlt) - .textSelection(.enabled) - if let playbackMessage { - Text(playbackMessage) - .font(CSFont.ui(10.5)) - .foregroundStyle(CSColor.textMutedAlt) - } - VStack(alignment: .leading, spacing: 5) { - Text("DELIVERED AFTER FORMATTING") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.textFaintAlt) - Text(row.variant) - .font(CSFont.ui(12.5, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - if editor.correctionID == row.id { - VStack(alignment: .leading, spacing: 8) { - Text("CORRECTED ORIGINAL") - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - TextEditor(text: $editor.canonical) - .font(CSFont.ui(13)) - .scrollContentBackground(.hidden) - .frame(minHeight: 120, idealHeight: 180, maxHeight: 320) - .padding(8) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.chromeAccent.opacity(0.32), lineWidth: 1) - ) - .onExitCommand { editor.cancel() } - .accessibilityLabel("Correct the original transcript") - HStack { - Spacer() - Button("Cancel") { editor.cancel() } - Button("Save correction") { saveEdit(row) } - .disabled( - editor.canonical.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || model.voiceLabEditPending.contains(row.id) - ) - } - } - } else { - HStack(spacing: 7) { - Text("→") - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - Text(row.editedText) - .font(CSFont.ui(13, .semibold)) - .foregroundStyle(CSColor.textBody) - .textSelection(.enabled) - Spacer(minLength: 0) - Button("Edit") { editor.begin(row) } - .disabled(model.voiceLabEditPending.contains(row.id)) - .accessibilityLabel("Edit correction for \(row.variant)") - } - } - if model.voiceLabEditPending.contains(row.id) { - ProgressView() - .controlSize(.small) - .accessibilityLabel("Saving correction") - } - if let error = model.voiceLabEditErrors[row.id] { - Text("Save failed: \(error)") - .font(CSFont.ui(10.5)) - .foregroundStyle(CSColor.terracottaLight) - } - if let note = model.voiceLabEditNotes[row.id] { - Text(note) - .font(CSFont.ui(10.5)) - .foregroundStyle(CSColor.oliveLight) - .accessibilityLabel("Correction saved. \(note)") - } - HStack(spacing: 7) { - Text(row.action) - .foregroundStyle(CSColor.oliveLight) - Text("·") - Text("revision \(row.revision)") - Text("·") - Text(timestampLabel(row.timestampMs)) - } - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - .padding(14) - .background(card) - .overlay(cardBorder) - .accessibilityElement(children: .contain) - .accessibilityLabel( - "Heard \(row.variant). Current correction \(row.editedText). Revision \(row.revision)." - ) - HStack { - Button("Previous") { correctionIndex = max(0, safeIndex - 1) } - .disabled(safeIndex == 0) - Spacer() - Text("\(safeIndex + 1) of \(corrections.count)") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - Spacer() - Button("Next") { correctionIndex = min(corrections.count - 1, safeIndex + 1) } - .disabled(safeIndex == corrections.count - 1) - } - } + Spacer(minLength: 0) + HStack(spacing: 12) { + Button("Teach") { + model.teachDictionaryFromStore() + } + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + .csFocusRing(cornerRadius: 8) + .disabled(model.voiceLabTeachPending) + .accessibilityLabel("Teach dictionary from corrections and proposed rules") + Button("Refresh") { + model.refreshVoiceLab() + } + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + .csFocusRing(cornerRadius: 8) + .accessibilityLabel("Refresh \(SettingsSection.voiceLab.title) data") } - } + } - private func saveEdit(_ row: VoiceLabCorrectionRow) { - if model.finalizeVoiceLabCorrection(id: row.id, canonical: editor.canonical) { - editor.cancel() - } - } + SettingsSectionLabel("Recent corrections · \(corrections.count)") + .padding(.top, 24) + correctionsSection + .padding(.top, 11) - /// Play/stop toggle for one row. Playback stops on navigation and when the - /// panel disappears — the sound must never outlive the row it belongs to. - private func togglePlayback(_ row: VoiceLabCorrectionRow) { - if playingRowID == row.id { - stopPlayback() - return - } - playbackSound?.stop() - guard let url = archivedAudioURL(configDir: model.configDir, rawText: row.rawText), - let sound = NSSound(contentsOf: url, byReference: true) - else { - playingRowID = nil - playbackMessage = "Original audio is unavailable for this legacy correction." - return - } - playbackDelegate.onFinish = { stopPlayback() } - sound.delegate = playbackDelegate - playbackSound = sound - playingRowID = row.id - playbackMessage = "Playing \(url.lastPathComponent)" - sound.play() - } - - private func stopPlayback() { - playbackSound?.stop() - playbackSound = nil - playingRowID = nil - playbackMessage = nil + SettingsSectionLabel("Custom dictionary · \(model.customLexiconEntries.count)") + .padding(.top, 24) + lexiconSection + .padding(.top, 11) } - - @ViewBuilder - private var lexiconSection: some View { - if let error = model.voiceLabReadError { - readError(error) - } else if model.customLexiconEntries.isEmpty { - emptyState("The custom dictionary is empty — accepted overlay corrections will appear here.") - } else { - VStack(spacing: 8) { - let safeIndex = min(lexiconIndex, model.customLexiconEntries.count - 1) - let row = model.customLexiconEntries[safeIndex] - HStack(spacing: 10) { - Text(row.variant) - .font(CSFont.mono(11.5, .medium)) - .foregroundStyle(CSColor.textMutedAlt) - .textSelection(.enabled) - Text("→") - .font(CSFont.mono(11, .semibold)) - .foregroundStyle(CSColor.chromeAccent) - Text(row.canonical) - .font(CSFont.mono(11.5, .semibold)) - .foregroundStyle(CSColor.textBody) - .textSelection(.enabled) - Spacer(minLength: 0) - Text(row.source) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(card) - .overlay(cardBorder) - .accessibilityLabel("\(row.variant) to \(row.canonical), source \(row.source)") - HStack { - Button("Previous") { lexiconIndex = max(0, safeIndex - 1) } - .disabled(safeIndex == 0) - Spacer() - Text("\(safeIndex + 1) of \(model.customLexiconEntries.count)") - .font(CSFont.mono(10.5, .medium)) - .foregroundStyle(CSColor.textFaintAlt) - Spacer() - Button("Next") { - lexiconIndex = min(model.customLexiconEntries.count - 1, safeIndex + 1) - } - .disabled(safeIndex == model.customLexiconEntries.count - 1) - } + .padding(.horizontal, 28) + .padding(.vertical, 24) + // The sound belongs to the visible row: navigating away from the row + // or from the panel ends it. Without these, NSSound kept playing after + // the Settings window was closed. + .onChange(of: correctionIndex) { stopPlayback() } + .onDisappear { stopPlayback() } + } + + @ViewBuilder + private var correctionsSection: some View { + if let error = model.voiceLabReadError { + readError(error) + } else if corrections.isEmpty { + emptyState("No corrections yet — edit a transcript in the overlay so the engine can learn.") + } else { + VStack(spacing: 8) { + let safeIndex = min(correctionIndex, corrections.count - 1) + let row = corrections[safeIndex] + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text("ORIGINAL STT") + .font(CSFont.mono(10.5, .semibold)) + .foregroundStyle(row.isLowConfidence ? CSColor.terracottaLight : CSColor.oliveLight) + Text(row.isLowConfidence ? "LOW CONFIDENCE" : "CONFIDENCE DATA") + .font(CSFont.mono(9.5, .semibold)) + .foregroundStyle(row.isLowConfidence ? CSColor.terracottaLight : CSColor.textFaintAlt) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule().fill( + (row.isLowConfidence ? CSColor.terracottaLight : CSColor.oliveLight) + .opacity(0.12) + ) + ) + // Deferred-correction desk: sessions closed without an + // overlay edit land here as UNREVIEWED, awaiting Edit. + if row.action == "close-unreviewed" { + Text("UNREVIEWED") + .font(CSFont.mono(9.5, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(CSColor.chromeAccent.opacity(0.12))) } - } - } - - private func emptyState(_ message: String) -> some View { - Text(message) - .font(CSFont.ui(12.5)) - .lineSpacing(2) - .foregroundStyle(CSColor.textMutedAlt) + Spacer(minLength: 0) + Button { + togglePlayback(row) + } label: { + Label( + playingRowID == row.id ? "Stop" : "Play original", + systemImage: playingRowID == row.id ? "stop.fill" : "play.fill" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel( + playingRowID == row.id + ? "Stop playing the original recording" + : "Play the original recording for this correction" + ) + } + Text(row.rawText) + .font(CSFont.ui(13, .medium)) + .foregroundStyle(CSColor.textHigh) + .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - .padding(15) - .background(card) - .overlay(cardBorder) + .padding(11) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill( + (row.isLowConfidence ? CSColor.terracottaLight : CSColor.surfaceRaised(0.04)) + .opacity(row.isLowConfidence ? 0.12 : 1) + ) + ) + Text(row.confidenceSummary) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(row.isLowConfidence ? CSColor.terracottaLight : CSColor.textFaintAlt) + .textSelection(.enabled) + if let playbackMessage { + Text(playbackMessage) + .font(CSFont.ui(10.5)) + .foregroundStyle(CSColor.textMutedAlt) + } + VStack(alignment: .leading, spacing: 5) { + Text("DELIVERED AFTER FORMATTING") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.textFaintAlt) + Text(row.variant) + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + if editor.correctionID == row.id { + VStack(alignment: .leading, spacing: 8) { + Text("CORRECTED ORIGINAL") + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + TextEditor(text: $editor.canonical) + .font(CSFont.ui(13)) + .scrollContentBackground(.hidden) + .frame(minHeight: 120, idealHeight: 180, maxHeight: 320) + .padding(8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.chromeAccent.opacity(0.32), lineWidth: 1) + ) + .onExitCommand { editor.cancel() } + .accessibilityLabel("Correct the original transcript") + HStack { + Spacer() + Button("Cancel") { editor.cancel() } + Button("Save correction") { saveEdit(row) } + .disabled( + editor.canonical.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || model.voiceLabEditPending.contains(row.id) + ) + } + } + } else { + HStack(spacing: 7) { + Text("→") + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + Text(row.editedText) + .font(CSFont.ui(13, .semibold)) + .foregroundStyle(CSColor.textBody) + .textSelection(.enabled) + Spacer(minLength: 0) + Button("Edit") { editor.begin(row) } + .disabled(model.voiceLabEditPending.contains(row.id)) + .accessibilityLabel("Edit correction for \(row.variant)") + } + } + if model.voiceLabEditPending.contains(row.id) { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Saving correction") + } + if let error = model.voiceLabEditErrors[row.id] { + Text("Save failed: \(error)") + .font(CSFont.ui(10.5)) + .foregroundStyle(CSColor.terracottaLight) + } + if let note = model.voiceLabEditNotes[row.id] { + Text(note) + .font(CSFont.ui(10.5)) + .foregroundStyle(CSColor.oliveLight) + .accessibilityLabel("Correction saved. \(note)") + } + HStack(spacing: 7) { + Text(row.action) + .foregroundStyle(CSColor.oliveLight) + Text("·") + Text("revision \(row.revision)") + Text("·") + Text(timestampLabel(row.timestampMs)) + } + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + .padding(14) + .background(card) + .overlay(cardBorder) + .accessibilityElement(children: .contain) + .accessibilityLabel( + "Heard \(row.variant). Current correction \(row.editedText). Revision \(row.revision)." + ) + HStack { + Button("Previous") { correctionIndex = max(0, safeIndex - 1) } + .disabled(safeIndex == 0) + Spacer() + Text("\(safeIndex + 1) of \(corrections.count)") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + Spacer() + Button("Next") { correctionIndex = min(corrections.count - 1, safeIndex + 1) } + .disabled(safeIndex == corrections.count - 1) + } + } } + } - private func readError(_ error: String) -> some View { - Text("Live quality data is unavailable: \(error)") - .font(CSFont.ui(12.5)) - .foregroundStyle(CSColor.terracottaLight) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(15) - .background(card) - .overlay(cardBorder) + private func saveEdit(_ row: VoiceLabCorrectionRow) { + if model.finalizeVoiceLabCorrection(id: row.id, canonical: editor.canonical) { + editor.cancel() } - - private func timestampLabel(_ timestampMs: UInt64) -> String { - Date(timeIntervalSince1970: Double(timestampMs) / 1000.0) - .formatted(date: .abbreviated, time: .shortened) + } + + /// Play/stop toggle for one row. Playback stops on navigation and when the + /// panel disappears — the sound must never outlive the row it belongs to. + private func togglePlayback(_ row: VoiceLabCorrectionRow) { + if playingRowID == row.id { + stopPlayback() + return } - - private var card: some ShapeStyle { - CSColor.surfaceRaised(0.025) + playbackSound?.stop() + guard let url = archivedAudioURL(configDir: model.configDir, rawText: row.rawText), + let sound = NSSound(contentsOf: url, byReference: true) + else { + playingRowID = nil + playbackMessage = "Original audio is unavailable for this legacy correction." + return } - - private var cardBorder: some View { - RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) - .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + playbackDelegate.onFinish = { stopPlayback() } + sound.delegate = playbackDelegate + playbackSound = sound + playingRowID = row.id + playbackMessage = "Playing \(url.lastPathComponent)" + sound.play() + } + + private func stopPlayback() { + playbackSound?.stop() + playbackSound = nil + playingRowID = nil + playbackMessage = nil + } + + @ViewBuilder + private var lexiconSection: some View { + if let error = model.voiceLabReadError { + readError(error) + } else if model.customLexiconEntries.isEmpty { + emptyState("The custom dictionary is empty — accepted overlay corrections will appear here.") + } else { + VStack(spacing: 8) { + let safeIndex = min(lexiconIndex, model.customLexiconEntries.count - 1) + let row = model.customLexiconEntries[safeIndex] + HStack(spacing: 10) { + Text(row.variant) + .font(CSFont.mono(11.5, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .textSelection(.enabled) + Text("→") + .font(CSFont.mono(11, .semibold)) + .foregroundStyle(CSColor.chromeAccent) + Text(row.canonical) + .font(CSFont.mono(11.5, .semibold)) + .foregroundStyle(CSColor.textBody) + .textSelection(.enabled) + Spacer(minLength: 0) + Text(row.source) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(card) + .overlay(cardBorder) + .accessibilityLabel("\(row.variant) to \(row.canonical), source \(row.source)") + HStack { + Button("Previous") { lexiconIndex = max(0, safeIndex - 1) } + .disabled(safeIndex == 0) + Spacer() + Text("\(safeIndex + 1) of \(model.customLexiconEntries.count)") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaintAlt) + Spacer() + Button("Next") { + lexiconIndex = min(model.customLexiconEntries.count - 1, safeIndex + 1) + } + .disabled(safeIndex == model.customLexiconEntries.count - 1) + } + } } + } + + private func emptyState(_ message: String) -> some View { + Text(message) + .font(CSFont.ui(12.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(15) + .background(card) + .overlay(cardBorder) + } + + private func readError(_ error: String) -> some View { + Text("Live quality data is unavailable: \(error)") + .font(CSFont.ui(12.5)) + .foregroundStyle(CSColor.terracottaLight) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(15) + .background(card) + .overlay(cardBorder) + } + + private func timestampLabel(_ timestampMs: UInt64) -> String { + Date(timeIntervalSince1970: Double(timestampMs) / 1000.0) + .formatted(date: .abbreviated, time: .shortened) + } + + private var card: some ShapeStyle { + CSColor.surfaceRaised(0.025) + } + + private var cardBorder: some View { + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1) + } } #if DEBUG -#Preview("Settings — Dictionary") { + #Preview("Settings — Dictionary") { SettingsView(model: SettingsViewModel.preview(.voiceLab)) - .frame(width: 960, height: 720) -} + .frame(width: 960, height: 720) + } #endif diff --git a/macos/Codescribe/Screens/Tray/RealTrayEngine.swift b/macos/Codescribe/Screens/Tray/RealTrayEngine.swift index 3908188e..9b94a6a8 100644 --- a/macos/Codescribe/Screens/Tray/RealTrayEngine.swift +++ b/macos/Codescribe/Screens/Tray/RealTrayEngine.swift @@ -11,153 +11,155 @@ import Foundation // CodescribeDictation so tray + keyboard shortcuts share one RecordingController // and cannot open two independent overlays/recorders. final class RealTrayEngine: TrayEngine { - private let agent: CodescribeAgent - private let hotkeys: CodescribeHotkeys - private let config: CodescribeConfig - private let threads: CodescribeThreads - - init( - agent: CodescribeAgent = CodescribeAgent(), - hotkeys: CodescribeHotkeys = CodescribeHotkeys(), - config: CodescribeConfig = CodescribeConfig(), - threads: CodescribeThreads = CodescribeThreads() - ) { - self.agent = agent - self.hotkeys = hotkeys - self.config = config - self.threads = threads - } - - func isAgentAvailable() -> Bool { agent.isAvailable() } - - func isRecording() async -> Bool { await hotkeys.isRecording() } - - func startRecording(assistive: Bool) async throws { - if assistive { - try await hotkeys.startAssistiveRecording() - } else { - try await hotkeys.startRecording() - } - } - - func stopRecording() async throws { - try await hotkeys.stopRecording() - } - - func currentToggles() -> ( - showDockIcon: Bool, - overlayEnabled: Bool, - autoPasteEnabled: Bool, - autoFormatLevel: FormattingPolicyOption, - notesMode: Bool, - startInAssistive: Bool, - holdBadgeOption: HoldBadgeOption - )? { - let toggles = config.trayToggles() - guard let formatLevel = FormattingPolicyOption(rawValue: toggles.formattingLevel) else { - return nil - } - return ( - toggles.showDockIcon, - toggles.transcriptionOverlayEnabled, - toggles.autoPasteEnabled, - formatLevel, - toggles.notesModeEnabled, - toggles.startAssistive, - HoldBadgeOption( - indicatorEnabled: toggles.holdIndicator, - size: toggles.holdBadgeSize - ) - ) - } - - func setQuickToggle(_ toggle: TrayQuickToggle, enabled: Bool) { - try? config.updateConfig(key: toggle.configKey, value: enabled ? "1" : "0") - } - - func setAutoPasteEnabled(_ enabled: Bool) { - _ = try? config.setAutoPasteEnabled(enabled: enabled) - } - - func setAutoFormatLevel(_ level: FormattingPolicyOption) { - _ = try? config.setAutoFormatLevel(level: level.rawValue) - } - - func setHoldBadgeOption(_ option: HoldBadgeOption) -> Bool { - guard let size = option.size else { - return (try? config.updateConfig(key: "HOLD_INDICATOR", value: "0")) != nil - } - return (try? config.updateConfigMany(entries: [ - CsConfigEntry(key: "HOLD_INDICATOR", value: "1"), - CsConfigEntry(key: "HOLD_BADGE_SIZE", value: String(size)), - ])) != nil - } - - func setNotesMode(_ enabled: Bool) -> Bool { - // One atomic two-key write (both flags together): a failure leaves the - // config unchanged rather than half-set. Returns false on failure so the - // UI can avoid faking success. - (try? config.setNotesMode(enabled: enabled)) != nil - } - - func setStartInAssistive(_ enabled: Bool) -> Bool { - (try? config.updateConfig( - key: "TRAY_START_ASSISTIVE", - value: enabled ? "1" : "0" - )) != nil - } - - func latestHistoryPath() -> String? { - // Skip failure / no-speech markers: "copy / save last transcript" must land - // on the newest entry that actually carries copyable text, not a "failed" - // placeholder (mirrors Rust's `TranscriptKind::is_copyable_transcript`). - threads.recentHistory(limit: 32).first { $0.kind.isCopyableTranscript }?.path - } - - func latestTranscriptText() -> String? { - guard let path = latestHistoryPath() else { return nil } - return try? threads.readHistoryText(path: path) - } - - func recentTranscripts(limit: Int) -> [TrayTranscript] { - threads.recentHistory(limit: UInt32(limit)).map { entry in - TrayTranscript(path: entry.path, title: Self.historyTitle(entry)) - } - } - - func transcriptText(forPath path: String) -> String? { - try? threads.readHistoryText(path: path) - } - - /// "HH:mm · " label for a history entry; falls back to the file - /// name when the preview is empty. - private static func historyTitle(_ entry: CsHistoryEntry) -> String { - let date = Date(timeIntervalSince1970: TimeInterval(entry.timestampMs) / 1000) - let time = timeFormatter.string(from: date) - let preview = entry.preview.trimmingCharacters(in: .whitespacesAndNewlines) - let snippet = preview.isEmpty - ? (entry.path as NSString).lastPathComponent - : String(preview.prefix(32)) - return "\(time) · \(snippet)" - } - - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm" - return formatter - }() + private let agent: CodescribeAgent + private let hotkeys: CodescribeHotkeys + private let config: CodescribeConfig + private let threads: CodescribeThreads + + init( + agent: CodescribeAgent = CodescribeAgent(), + hotkeys: CodescribeHotkeys = CodescribeHotkeys(), + config: CodescribeConfig = CodescribeConfig(), + threads: CodescribeThreads = CodescribeThreads() + ) { + self.agent = agent + self.hotkeys = hotkeys + self.config = config + self.threads = threads + } + + func isAgentAvailable() -> Bool { agent.isAvailable() } + + func isRecording() async -> Bool { await hotkeys.isRecording() } + + func startRecording(assistive: Bool) async throws { + if assistive { + try await hotkeys.startAssistiveRecording() + } else { + try await hotkeys.startRecording() + } + } + + func stopRecording() async throws { + try await hotkeys.stopRecording() + } + + func currentToggles() -> ( + showDockIcon: Bool, + overlayEnabled: Bool, + autoPasteEnabled: Bool, + autoFormatLevel: FormattingPolicyOption, + notesMode: Bool, + startInAssistive: Bool, + holdBadgeOption: HoldBadgeOption + )? { + let toggles = config.trayToggles() + guard let formatLevel = FormattingPolicyOption(rawValue: toggles.formattingLevel) else { + return nil + } + return ( + toggles.showDockIcon, + toggles.transcriptionOverlayEnabled, + toggles.autoPasteEnabled, + formatLevel, + toggles.notesModeEnabled, + toggles.startAssistive, + HoldBadgeOption( + indicatorEnabled: toggles.holdIndicator, + size: toggles.holdBadgeSize + ) + ) + } + + func setQuickToggle(_ toggle: TrayQuickToggle, enabled: Bool) { + try? config.updateConfig(key: toggle.configKey, value: enabled ? "1" : "0") + } + + func setAutoPasteEnabled(_ enabled: Bool) { + _ = try? config.setAutoPasteEnabled(enabled: enabled) + } + + func setAutoFormatLevel(_ level: FormattingPolicyOption) { + _ = try? config.setAutoFormatLevel(level: level.rawValue) + } + + func setHoldBadgeOption(_ option: HoldBadgeOption) -> Bool { + guard let size = option.size else { + return (try? config.updateConfig(key: "HOLD_INDICATOR", value: "0")) != nil + } + return + (try? config.updateConfigMany(entries: [ + CsConfigEntry(key: "HOLD_INDICATOR", value: "1"), + CsConfigEntry(key: "HOLD_BADGE_SIZE", value: String(size)), + ])) != nil + } + + func setNotesMode(_ enabled: Bool) -> Bool { + // One atomic two-key write (both flags together): a failure leaves the + // config unchanged rather than half-set. Returns false on failure so the + // UI can avoid faking success. + (try? config.setNotesMode(enabled: enabled)) != nil + } + + func setStartInAssistive(_ enabled: Bool) -> Bool { + (try? config.updateConfig( + key: "TRAY_START_ASSISTIVE", + value: enabled ? "1" : "0" + )) != nil + } + + func latestHistoryPath() -> String? { + // Skip failure / no-speech markers: "copy / save last transcript" must land + // on the newest entry that actually carries copyable text, not a "failed" + // placeholder (mirrors Rust's `TranscriptKind::is_copyable_transcript`). + threads.recentHistory(limit: 32).first { $0.kind.isCopyableTranscript }?.path + } + + func latestTranscriptText() -> String? { + guard let path = latestHistoryPath() else { return nil } + return try? threads.readHistoryText(path: path) + } + + func recentTranscripts(limit: Int) -> [TrayTranscript] { + threads.recentHistory(limit: UInt32(limit)).map { entry in + TrayTranscript(path: entry.path, title: Self.historyTitle(entry)) + } + } + + func transcriptText(forPath path: String) -> String? { + try? threads.readHistoryText(path: path) + } + + /// "HH:mm · " label for a history entry; falls back to the file + /// name when the preview is empty. + private static func historyTitle(_ entry: CsHistoryEntry) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(entry.timestampMs) / 1000) + let time = timeFormatter.string(from: date) + let preview = entry.preview.trimmingCharacters(in: .whitespacesAndNewlines) + let snippet = + preview.isEmpty + ? (entry.path as NSString).lastPathComponent + : String(preview.prefix(32)) + return "\(time) · \(snippet)" + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() } extension CsTranscriptKind { - /// Mirrors `TranscriptKind::is_copyable_transcript` (core/state/history.rs): - /// true for entries that carry copyable transcript text, false for the - /// assistant interpretation and the failure / no-speech marker. - var isCopyableTranscript: Bool { - switch self { - case .raw, .cloud, .formattedTranscript, .formattingFailed: - return true - case .assistantInterpretation, .failed: - return false - } - } + /// Mirrors `TranscriptKind::is_copyable_transcript` (core/state/history.rs): + /// true for entries that carry copyable transcript text, false for the + /// assistant interpretation and the failure / no-speech marker. + var isCopyableTranscript: Bool { + switch self { + case .raw, .cloud, .formattedTranscript, .formattingFailed: + return true + case .assistantInterpretation, .failed: + return false + } + } } diff --git a/macos/Codescribe/Screens/Tray/TrayEngine.swift b/macos/Codescribe/Screens/Tray/TrayEngine.swift index aef69768..f7a2afa6 100644 --- a/macos/Codescribe/Screens/Tray/TrayEngine.swift +++ b/macos/Codescribe/Screens/Tray/TrayEngine.swift @@ -10,167 +10,175 @@ import Foundation /// Navigation intents the tray emits. App.swift binds each one to the action /// that actually opens the relevant window / scene / panel. enum TrayIntent { - /// Bring up the Agent Chat window and activate the app (tray / menu / summon). - case openChat - /// Passive voice-delivery reveal: create/order the chat window without - /// stealing focus. Used at TurnStarted and as end-of-turn fallback only. - case revealChat + /// Bring up the Agent Chat window and activate the app (tray / menu / summon). + case openChat + /// Passive voice-delivery reveal: create/order the chat window without + /// stealing focus. Used at TurnStarted and as end-of-turn fallback only. + case revealChat } /// The legacy fast config toggles surfaced in the tray, mapped to the core's /// router env keys consumed by `CodescribeConfig.updateConfig(key:value:)`. enum TrayQuickToggle { - case showDockIcon - case transcriptionOverlay - - var configKey: String { - switch self { - case .showDockIcon: return "SHOW_DOCK_ICON" - case .transcriptionOverlay: return "TRANSCRIPTION_OVERLAY_ENABLED" - } + case showDockIcon + case transcriptionOverlay + + var configKey: String { + switch self { + case .showDockIcon: return "SHOW_DOCK_ICON" + case .transcriptionOverlay: return "TRANSCRIPTION_OVERLAY_ENABLED" } + } } /// A recent transcript artifact surfaced in the tray's "Open history" submenu. /// `path` is the on-disk file (and the stable identity); `title` is a short /// display label (time + preview) built by the engine. struct TrayTranscript: Identifiable { - let path: String - let title: String - var id: String { path } + let path: String + let title: String + var id: String { path } } protocol TrayEngine: AnyObject { - /// True when the assistive LLM provider can be built (gates "Show Agent"). - func isAgentAvailable() -> Bool - - /// Live dictation state. Async because the core reads it behind its mutex. - func isRecording() async -> Bool - func startRecording(assistive: Bool) async throws - func stopRecording() async throws - - /// Current values for the tray's quick toggles, read from on-disk settings. - /// `nil` when settings cannot be loaded. - func currentToggles() -> ( - showDockIcon: Bool, - overlayEnabled: Bool, - autoPasteEnabled: Bool, - autoFormatLevel: FormattingPolicyOption, - notesMode: Bool, - startInAssistive: Bool, - holdBadgeOption: HoldBadgeOption - )? - func setQuickToggle(_ toggle: TrayQuickToggle, enabled: Bool) - /// Persist user-owned delivery/formatting policy. Callers always re-read - /// `currentToggles()` after these writes instead of assuming success. - func setAutoPasteEnabled(_ enabled: Bool) - func setAutoFormatLevel(_ level: FormattingPolicyOption) - func setHoldBadgeOption(_ option: HoldBadgeOption) -> Bool - /// Notes Mode is a two-key flag (quick-notes enabled + save-only) written as - /// one atomic op. Returns whether the write persisted, so the UI never fakes - /// success on a failed write. - func setNotesMode(_ enabled: Bool) -> Bool - func setStartInAssistive(_ enabled: Bool) -> Bool - - /// Path of the most recent transcript artifact, or `nil` when none exist. - func latestHistoryPath() -> String? - /// Full text of the most recent transcript, or `nil` when unavailable. - func latestTranscriptText() -> String? - - /// Up to `limit` most-recent transcript artifacts, newest first, for the - /// "Open history" submenu. Empty when none exist. - func recentTranscripts(limit: Int) -> [TrayTranscript] - /// Full text of the transcript artifact at `path`, or `nil` when unreadable. - func transcriptText(forPath path: String) -> String? + /// True when the assistive LLM provider can be built (gates "Show Agent"). + func isAgentAvailable() -> Bool + + /// Live dictation state. Async because the core reads it behind its mutex. + func isRecording() async -> Bool + func startRecording(assistive: Bool) async throws + func stopRecording() async throws + + /// Current values for the tray's quick toggles, read from on-disk settings. + /// `nil` when settings cannot be loaded. + func currentToggles() -> ( + showDockIcon: Bool, + overlayEnabled: Bool, + autoPasteEnabled: Bool, + autoFormatLevel: FormattingPolicyOption, + notesMode: Bool, + startInAssistive: Bool, + holdBadgeOption: HoldBadgeOption + )? + func setQuickToggle(_ toggle: TrayQuickToggle, enabled: Bool) + /// Persist user-owned delivery/formatting policy. Callers always re-read + /// `currentToggles()` after these writes instead of assuming success. + func setAutoPasteEnabled(_ enabled: Bool) + func setAutoFormatLevel(_ level: FormattingPolicyOption) + func setHoldBadgeOption(_ option: HoldBadgeOption) -> Bool + /// Notes Mode is a two-key flag (quick-notes enabled + save-only) written as + /// one atomic op. Returns whether the write persisted, so the UI never fakes + /// success on a failed write. + func setNotesMode(_ enabled: Bool) -> Bool + func setStartInAssistive(_ enabled: Bool) -> Bool + + /// Path of the most recent transcript artifact, or `nil` when none exist. + func latestHistoryPath() -> String? + /// Full text of the most recent transcript, or `nil` when unavailable. + func latestTranscriptText() -> String? + + /// Up to `limit` most-recent transcript artifacts, newest first, for the + /// "Open history" submenu. Empty when none exist. + func recentTranscripts(limit: Int) -> [TrayTranscript] + /// Full text of the transcript artifact at `path`, or `nil` when unreadable. + func transcriptText(forPath path: String) -> String? } // Standalone seed so the `#Preview` renders without the real core. final class MockTrayEngine: TrayEngine { - var recording: Bool - var agentAvailable: Bool - var showDockIcon: Bool - var overlayEnabled: Bool - var autoPasteEnabled: Bool - var autoFormatLevel: FormattingPolicyOption - var notesMode: Bool - var startInAssistive: Bool - var holdBadgeOption: HoldBadgeOption - var historyPath: String - var transcriptText: String - - init(recording: Bool = false, - agentAvailable: Bool = true, - showDockIcon: Bool = true, - overlayEnabled: Bool = false, - autoPasteEnabled: Bool = true, - autoFormatLevel: FormattingPolicyOption = .correction, - notesMode: Bool = false, - startInAssistive: Bool = false, - holdBadgeOption: HoldBadgeOption = .twelve, - historyPath: String = "/tmp/codescribe/history/2026-06-28-1422.md", - transcriptText: String = "Sample transcript.") { - self.recording = recording - self.agentAvailable = agentAvailable - self.showDockIcon = showDockIcon - self.overlayEnabled = overlayEnabled - self.autoPasteEnabled = autoPasteEnabled - self.autoFormatLevel = autoFormatLevel - self.notesMode = notesMode - self.startInAssistive = startInAssistive - self.holdBadgeOption = holdBadgeOption - self.historyPath = historyPath - self.transcriptText = transcriptText - } - - func isAgentAvailable() -> Bool { agentAvailable } - - func isRecording() async -> Bool { recording } - func startRecording(assistive: Bool) async throws { recording = true } - func stopRecording() async throws { recording = false } - - func currentToggles() -> ( - showDockIcon: Bool, - overlayEnabled: Bool, - autoPasteEnabled: Bool, - autoFormatLevel: FormattingPolicyOption, - notesMode: Bool, - startInAssistive: Bool, - holdBadgeOption: HoldBadgeOption - )? { - ( - showDockIcon, - overlayEnabled, - autoPasteEnabled, - autoFormatLevel, - notesMode, - startInAssistive, - holdBadgeOption - ) - } - - func setQuickToggle(_ toggle: TrayQuickToggle, enabled: Bool) { - switch toggle { - case .showDockIcon: showDockIcon = enabled - case .transcriptionOverlay: overlayEnabled = enabled - } - } - - func setAutoPasteEnabled(_ enabled: Bool) { autoPasteEnabled = enabled } - func setAutoFormatLevel(_ level: FormattingPolicyOption) { autoFormatLevel = level } - func setHoldBadgeOption(_ option: HoldBadgeOption) -> Bool { - holdBadgeOption = option - return true - } - - func setNotesMode(_ enabled: Bool) -> Bool { notesMode = enabled; return true } - func setStartInAssistive(_ enabled: Bool) -> Bool { startInAssistive = enabled; return true } - - func latestHistoryPath() -> String? { historyPath } - func latestTranscriptText() -> String? { transcriptText } - - func recentTranscripts(limit: Int) -> [TrayTranscript] { - [TrayTranscript(path: historyPath, title: "14:22 · \(transcriptText)")] + var recording: Bool + var agentAvailable: Bool + var showDockIcon: Bool + var overlayEnabled: Bool + var autoPasteEnabled: Bool + var autoFormatLevel: FormattingPolicyOption + var notesMode: Bool + var startInAssistive: Bool + var holdBadgeOption: HoldBadgeOption + var historyPath: String + var transcriptText: String + + init( + recording: Bool = false, + agentAvailable: Bool = true, + showDockIcon: Bool = true, + overlayEnabled: Bool = false, + autoPasteEnabled: Bool = true, + autoFormatLevel: FormattingPolicyOption = .correction, + notesMode: Bool = false, + startInAssistive: Bool = false, + holdBadgeOption: HoldBadgeOption = .twelve, + historyPath: String = "/tmp/codescribe/history/2026-06-28-1422.md", + transcriptText: String = "Sample transcript." + ) { + self.recording = recording + self.agentAvailable = agentAvailable + self.showDockIcon = showDockIcon + self.overlayEnabled = overlayEnabled + self.autoPasteEnabled = autoPasteEnabled + self.autoFormatLevel = autoFormatLevel + self.notesMode = notesMode + self.startInAssistive = startInAssistive + self.holdBadgeOption = holdBadgeOption + self.historyPath = historyPath + self.transcriptText = transcriptText + } + + func isAgentAvailable() -> Bool { agentAvailable } + + func isRecording() async -> Bool { recording } + func startRecording(assistive: Bool) async throws { recording = true } + func stopRecording() async throws { recording = false } + + func currentToggles() -> ( + showDockIcon: Bool, + overlayEnabled: Bool, + autoPasteEnabled: Bool, + autoFormatLevel: FormattingPolicyOption, + notesMode: Bool, + startInAssistive: Bool, + holdBadgeOption: HoldBadgeOption + )? { + ( + showDockIcon, + overlayEnabled, + autoPasteEnabled, + autoFormatLevel, + notesMode, + startInAssistive, + holdBadgeOption + ) + } + + func setQuickToggle(_ toggle: TrayQuickToggle, enabled: Bool) { + switch toggle { + case .showDockIcon: showDockIcon = enabled + case .transcriptionOverlay: overlayEnabled = enabled } - - func transcriptText(forPath path: String) -> String? { transcriptText } + } + + func setAutoPasteEnabled(_ enabled: Bool) { autoPasteEnabled = enabled } + func setAutoFormatLevel(_ level: FormattingPolicyOption) { autoFormatLevel = level } + func setHoldBadgeOption(_ option: HoldBadgeOption) -> Bool { + holdBadgeOption = option + return true + } + + func setNotesMode(_ enabled: Bool) -> Bool { + notesMode = enabled + return true + } + func setStartInAssistive(_ enabled: Bool) -> Bool { + startInAssistive = enabled + return true + } + + func latestHistoryPath() -> String? { historyPath } + func latestTranscriptText() -> String? { transcriptText } + + func recentTranscripts(limit: Int) -> [TrayTranscript] { + [TrayTranscript(path: historyPath, title: "14:22 · \(transcriptText)")] + } + + func transcriptText(forPath path: String) -> String? { transcriptText } } diff --git a/macos/Codescribe/Screens/Tray/TrayMenuView.swift b/macos/Codescribe/Screens/Tray/TrayMenuView.swift index 5912f08a..e16043ab 100644 --- a/macos/Codescribe/Screens/Tray/TrayMenuView.swift +++ b/macos/Codescribe/Screens/Tray/TrayMenuView.swift @@ -11,346 +11,346 @@ import SwiftUI // disclosure groups. Dictation toggle + quick config toggles are wired through // the composite TrayEngine. struct TrayMenuView: View { - @ObservedObject var viewModel: TrayViewModel - @ObservedObject var trayStatus: TrayStatusStore - // macOS 14+ action to open the app's Settings scene — replaces the fragile - // private `showSettingsWindow:` selector that stopped working on newer macOS. - @Environment(\.openSettings) private var openSettings - - var body: some View { - GlassPanel(cornerRadius: CSRadius.tray) { - VStack(spacing: 0) { - statusHeader - trayStatusRow - TrayDivider(top: 3, bottom: 5) - - primaryActions - - TrayDivider() - quickSettingsGroup - - notesGroup - diagnosticsGroup - - TrayDivider() - TrayRow(icon: .settings, title: "Settings…", shortcut: "⌘,") { - openSettings() - } - TrayRow(icon: .setupWizard, title: "Setup Wizard…") { viewModel.onOpenSetupWizard() } - TrayRow(icon: .refresh, title: "Check for Updates…") { - viewModel.onCheckForUpdates() - } - TrayRow(icon: .help, title: "Help") { viewModel.onHelp() } - TrayRow(icon: .info, title: "About") { viewModel.onAbout() } - - TrayDivider() - TrayRow( - icon: .power, - iconColor: CSColor.terracottaDeep, - title: "Quit codescribe", - titleColor: TrayRow.subnoteColor, - shortcut: "⌘Q" - ) { viewModel.onQuit() } - } - .padding(7) + @ObservedObject var viewModel: TrayViewModel + @ObservedObject var trayStatus: TrayStatusStore + // macOS 14+ action to open the app's Settings scene — replaces the fragile + // private `showSettingsWindow:` selector that stopped working on newer macOS. + @Environment(\.openSettings) private var openSettings + + var body: some View { + GlassPanel(cornerRadius: CSRadius.tray) { + VStack(spacing: 0) { + statusHeader + trayStatusRow + TrayDivider(top: 3, bottom: 5) + + primaryActions + + TrayDivider() + quickSettingsGroup + + notesGroup + diagnosticsGroup + + TrayDivider() + TrayRow(icon: .settings, title: "Settings…", shortcut: "⌘,") { + openSettings() } - .frame(width: 300) - .onAppear { viewModel.refreshStatus() } - .onDisappear { viewModel.collapseDisclosures() } - } - - // MARK: - Header (wordmark + runtime-bound status pill) - - private var statusHeader: some View { - HStack(spacing: 9) { - Wordmark(size: 14) - Spacer(minLength: 8) - // Separate view type on active vs idle/error (same rule as the overlay - // header): the animated pill exists only for live status phases. - if trayStatus.shouldRipple { - StatusPill( - text: trayStatus.compactLabel, - color: trayStatus.color, - rippling: true - ) - } else { - StaticStatusPill(text: trayStatus.compactLabel, color: trayStatus.color) - } + TrayRow(icon: .setupWizard, title: "Setup Wizard…") { viewModel.onOpenSetupWizard() } + TrayRow(icon: .refresh, title: "Check for Updates…") { + viewModel.onCheckForUpdates() } - .padding(.horizontal, 12) - .padding(.top, 11) - .padding(.bottom, 10) - } + TrayRow(icon: .help, title: "Help") { viewModel.onHelp() } + TrayRow(icon: .info, title: "About") { viewModel.onAbout() } - private var trayStatusRow: some View { - HStack(spacing: 7) { - CSIconView(icon: trayStatus.icon, size: 11, weight: .bold, color: trayStatus.color) - Text(trayStatus.status.menuLabel) - .font(CSFont.ui(12, .medium)) - .foregroundStyle(trayStatus.color) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(trayStatus.color.opacity(0.10)) + TrayDivider() + TrayRow( + icon: .power, + iconColor: CSColor.terracottaDeep, + title: "Quit codescribe", + titleColor: TrayRow.subnoteColor, + shortcut: "⌘Q" + ) { viewModel.onQuit() } + } + .padding(7) + } + .frame(width: 300) + .onAppear { viewModel.refreshStatus() } + .onDisappear { viewModel.collapseDisclosures() } + } + + // MARK: - Header (wordmark + runtime-bound status pill) + + private var statusHeader: some View { + HStack(spacing: 9) { + Wordmark(size: 14) + Spacer(minLength: 8) + // Separate view type on active vs idle/error (same rule as the overlay + // header): the animated pill exists only for live status phases. + if trayStatus.shouldRipple { + StatusPill( + text: trayStatus.compactLabel, + color: trayStatus.color, + rippling: true ) - .padding(.horizontal, 5) - .padding(.bottom, 3) + } else { + StaticStatusPill(text: trayStatus.compactLabel, color: trayStatus.color) + } } - - // MARK: - Primary actions - - private var primaryActions: some View { - VStack(spacing: 0) { - TrayRow( - icon: .agent, - title: "Show Agent", - titleColor: viewModel.agentAvailable ? CSColor.textBody : CSColor.textFaint, - titleWeight: .semibold, - shortcut: "⌥⌥", - shortcutColor: TrayRow.primaryShortcutColor, - style: .primary - ) { viewModel.onShowAgent() } - - TrayRow( - icon: viewModel.isRecording && !viewModel.isStartingDictation ? .stop : .record, - iconColor: recordingActionColor, - title: recordingActionTitle - ) { viewModel.toggleDictation() } - - historyGroup - TrayRow(icon: .copy, title: "Copy last transcript") { - viewModel.copyLastTranscript() - } - - // Permission-free "✓ Copied" confirmation after a history / last-transcript - // copy — reuses the Notes result banner row. - if let copyStatus = viewModel.copyStatus { - TrayNoteStatusRow(status: copyStatus) - .padding(.top, 2) - } - } - .animation(.easeOut(duration: 0.18), value: viewModel.copyStatus) + .padding(.horizontal, 12) + .padding(.top, 11) + .padding(.bottom, 10) + } + + private var trayStatusRow: some View { + HStack(spacing: 7) { + CSIconView(icon: trayStatus.icon, size: 11, weight: .bold, color: trayStatus.color) + Text(trayStatus.status.menuLabel) + .font(CSFont.ui(12, .medium)) + .foregroundStyle(trayStatus.color) + .lineLimit(1) + Spacer(minLength: 0) } - - // MARK: - History (nested disclosure → copy a recent transcript) - - private var historyGroup: some View { - VStack(spacing: 0) { - TrayRow( - icon: .history, - title: "Open history", - disclosureExpanded: viewModel.historyExpanded, - style: viewModel.historyExpanded ? .raised : .plain - ) { - withAnimation(TrayDisclosureChevron.animation) { viewModel.toggleHistory() } - } - - if viewModel.historyExpanded { - TrayDisclosureChildren { - if viewModel.historyItems.isEmpty { - TrayChildRow(title: "No transcripts yet") - } else { - ForEach(viewModel.historyItems) { item in - TrayChildRow(title: item.title) { - viewModel.copyTranscript(path: item.path) - } - } - } - TrayChildRow(title: "Open history folder") { - viewModel.openHistoryFolder() - } - } - } - } + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(trayStatus.color.opacity(0.10)) + ) + .padding(.horizontal, 5) + .padding(.bottom, 3) + } + + // MARK: - Primary actions + + private var primaryActions: some View { + VStack(spacing: 0) { + TrayRow( + icon: .agent, + title: "Show Agent", + titleColor: viewModel.agentAvailable ? CSColor.textBody : CSColor.textFaint, + titleWeight: .semibold, + shortcut: "⌥⌥", + shortcutColor: TrayRow.primaryShortcutColor, + style: .primary + ) { viewModel.onShowAgent() } + + TrayRow( + icon: viewModel.isRecording && !viewModel.isStartingDictation ? .stop : .record, + iconColor: recordingActionColor, + title: recordingActionTitle + ) { viewModel.toggleDictation() } + + historyGroup + TrayRow(icon: .copy, title: "Copy last transcript") { + viewModel.copyLastTranscript() + } + + // Permission-free "✓ Copied" confirmation after a history / last-transcript + // copy — reuses the Notes result banner row. + if let copyStatus = viewModel.copyStatus { + TrayNoteStatusRow(status: copyStatus) + .padding(.top, 2) + } } - - // MARK: - Quick config toggles (collapsed by default) - - /// Seven day-to-day toggles live under one disclosure so the cold-open tray - /// stays short: primary actions first, preferences on demand. - private var quickSettingsGroup: some View { - VStack(spacing: 0) { - TrayRow( - icon: .settings, - title: "Quick settings", - disclosureExpanded: viewModel.quickSettingsExpanded, - style: viewModel.quickSettingsExpanded ? .raised : .plain - ) { - withAnimation(TrayDisclosureChevron.animation) { - viewModel.quickSettingsExpanded.toggle() - } - } - - if viewModel.quickSettingsExpanded { - TrayDisclosureChildren { - quickToggles - } + .animation(.easeOut(duration: 0.18), value: viewModel.copyStatus) + } + + // MARK: - History (nested disclosure → copy a recent transcript) + + private var historyGroup: some View { + VStack(spacing: 0) { + TrayRow( + icon: .history, + title: "Open history", + disclosureExpanded: viewModel.historyExpanded, + style: viewModel.historyExpanded ? .raised : .plain + ) { + withAnimation(TrayDisclosureChevron.animation) { viewModel.toggleHistory() } + } + + if viewModel.historyExpanded { + TrayDisclosureChildren { + if viewModel.historyItems.isEmpty { + TrayChildRow(title: "No transcripts yet") + } else { + ForEach(viewModel.historyItems) { item in + TrayChildRow(title: item.title) { + viewModel.copyTranscript(path: item.path) + } } + } + TrayChildRow(title: "Open history folder") { + viewModel.openHistoryFolder() + } } + } } - - private var quickToggles: some View { - VStack(spacing: 0) { - toggleRow(icon: .dock, title: "Show Dock Icon", isOn: viewModel.showDockIcon) { - viewModel.setShowDockIcon($0) - } - toggleRow( - icon: .overlay, - title: "Transcription Overlay", - isOn: viewModel.overlayEnabled - ) { viewModel.setOverlayEnabled($0) } - autoPasteToggle - autoFormatMenu - holdBadgeMenu - toggleRow(icon: .notesMode, title: "Notes Mode", isOn: viewModel.notesModeEnabled) { - viewModel.setNotesMode($0) - } - toggleRow( - icon: .agent, - title: "Start in Assistive", - isOn: viewModel.startInAssistive, - onColor: CSColor.assistive - ) { viewModel.setStartInAssistive($0) } + } + + // MARK: - Quick config toggles (collapsed by default) + + /// Seven day-to-day toggles live under one disclosure so the cold-open tray + /// stays short: primary actions first, preferences on demand. + private var quickSettingsGroup: some View { + VStack(spacing: 0) { + TrayRow( + icon: .settings, + title: "Quick settings", + disclosureExpanded: viewModel.quickSettingsExpanded, + style: viewModel.quickSettingsExpanded ? .raised : .plain + ) { + withAnimation(TrayDisclosureChevron.animation) { + viewModel.quickSettingsExpanded.toggle() } - } + } - /// Auto Paste shares the exact baseline row (icon + trailing On/Off keycap) - /// with Show Dock Icon and Transcription Overlay — one visual grammar for - /// every quick toggle. TrayRow keeps the locked palette and geometry. - private var autoPasteToggle: some View { - toggleRow(icon: .send, title: "Auto Paste", isOn: viewModel.autoPasteEnabled) { - viewModel.setAutoPasteEnabled($0) + if viewModel.quickSettingsExpanded { + TrayDisclosureChildren { + quickToggles } + } } - - /// Auto Format is a cycling row in the same baseline grammar: each click - /// advances Off → Correction → Smart → Max → Off. The current level sits in - /// the trailing keycap slot, so nothing opens over the 300pt popover. - private var autoFormatMenu: some View { - TrayRow( - icon: .edit, - title: "Auto Format", - shortcut: viewModel.autoFormatLevel.visibleName, - shortcutColor: viewModel.autoFormatLevel == .off - ? CSColor.textFaintAlt : CSColor.oliveLight - ) { viewModel.setAutoFormatLevel(viewModel.autoFormatLevel.next) } - .accessibilityLabel("Auto Format") - .accessibilityValue(viewModel.autoFormatLevel.visibleName) - .accessibilityHint("Cycle automatic formatting level") + } + + private var quickToggles: some View { + VStack(spacing: 0) { + toggleRow(icon: .dock, title: "Show Dock Icon", isOn: viewModel.showDockIcon) { + viewModel.setShowDockIcon($0) + } + toggleRow( + icon: .overlay, + title: "Transcription Overlay", + isOn: viewModel.overlayEnabled + ) { viewModel.setOverlayEnabled($0) } + autoPasteToggle + autoFormatMenu + holdBadgeMenu + toggleRow(icon: .notesMode, title: "Notes Mode", isOn: viewModel.notesModeEnabled) { + viewModel.setNotesMode($0) + } + toggleRow( + icon: .agent, + title: "Start in Assistive", + isOn: viewModel.startInAssistive, + onColor: CSColor.assistive + ) { viewModel.setStartInAssistive($0) } } - - /// Pointer Indicator follows the same rolling-row grammar as Auto Format: - /// Off → 4px → 8px → 12px → Off, with the current value in the keycap. - private var holdBadgeMenu: some View { - TrayRow( - icon: .record, - title: "Pointer Indicator", - shortcut: viewModel.holdBadgeOption.visibleName, - shortcutColor: viewModel.holdBadgeOption == .off - ? CSColor.textFaintAlt : CSColor.oliveLight - ) { viewModel.setHoldBadgeOption(viewModel.holdBadgeOption.next) } - .accessibilityLabel("Pointer Indicator") - .accessibilityValue(viewModel.holdBadgeOption.visibleName) - .accessibilityHint("Cycle pointer recording indicator size") + } + + /// Auto Paste shares the exact baseline row (icon + trailing On/Off keycap) + /// with Show Dock Icon and Transcription Overlay — one visual grammar for + /// every quick toggle. TrayRow keeps the locked palette and geometry. + private var autoPasteToggle: some View { + toggleRow(icon: .send, title: "Auto Paste", isOn: viewModel.autoPasteEnabled) { + viewModel.setAutoPasteEnabled($0) } - - /// A checkbox-style row reusing `TrayRow`, with the on/off state shown as the - /// trailing keycap so it shares the locked palette and geometry. - private func toggleRow( - icon: CSIcon, - title: String, - isOn: Bool, - onColor: Color = CSColor.oliveLight, - set: @escaping (Bool) -> Void - ) -> some View { - TrayRow( - icon: icon, - title: title, - shortcut: isOn ? "On" : "Off", - shortcutColor: isOn ? onColor : CSColor.textFaintAlt - ) { set(!isOn) } + } + + /// Auto Format is a cycling row in the same baseline grammar: each click + /// advances Off → Correction → Smart → Max → Off. The current level sits in + /// the trailing keycap slot, so nothing opens over the 300pt popover. + private var autoFormatMenu: some View { + TrayRow( + icon: .edit, + title: "Auto Format", + shortcut: viewModel.autoFormatLevel.visibleName, + shortcutColor: viewModel.autoFormatLevel == .off + ? CSColor.textFaintAlt : CSColor.oliveLight + ) { viewModel.setAutoFormatLevel(viewModel.autoFormatLevel.next) } + .accessibilityLabel("Auto Format") + .accessibilityValue(viewModel.autoFormatLevel.visibleName) + .accessibilityHint("Cycle automatic formatting level") + } + + /// Pointer Indicator follows the same rolling-row grammar as Auto Format: + /// Off → 4px → 8px → 12px → Off, with the current value in the keycap. + private var holdBadgeMenu: some View { + TrayRow( + icon: .record, + title: "Pointer Indicator", + shortcut: viewModel.holdBadgeOption.visibleName, + shortcutColor: viewModel.holdBadgeOption == .off + ? CSColor.textFaintAlt : CSColor.oliveLight + ) { viewModel.setHoldBadgeOption(viewModel.holdBadgeOption.next) } + .accessibilityLabel("Pointer Indicator") + .accessibilityValue(viewModel.holdBadgeOption.visibleName) + .accessibilityHint("Cycle pointer recording indicator size") + } + + /// A checkbox-style row reusing `TrayRow`, with the on/off state shown as the + /// trailing keycap so it shares the locked palette and geometry. + private func toggleRow( + icon: CSIcon, + title: String, + isOn: Bool, + onColor: Color = CSColor.oliveLight, + set: @escaping (Bool) -> Void + ) -> some View { + TrayRow( + icon: icon, + title: title, + shortcut: isOn ? "On" : "Off", + shortcutColor: isOn ? onColor : CSColor.textFaintAlt + ) { set(!isOn) } + } + + private var recordingActionTitle: String { + if viewModel.isStartingDictation { return "Starting…" } + if viewModel.isRecording { + return trayStatus.status.assistive ? "Stop Assistive" : "Stop Dictation" } + return viewModel.startInAssistive ? "Start Assistive" : "Start Dictation" + } - private var recordingActionTitle: String { - if viewModel.isStartingDictation { return "Starting…" } - if viewModel.isRecording { - return trayStatus.status.assistive ? "Stop Assistive" : "Stop Dictation" - } - return viewModel.startInAssistive ? "Start Assistive" : "Start Dictation" + private var recordingActionColor: Color { + if viewModel.isStartingDictation { + return viewModel.startInAssistive ? CSColor.assistive : CSColor.terracotta } - - private var recordingActionColor: Color { - if viewModel.isStartingDictation { - return viewModel.startInAssistive ? CSColor.assistive : CSColor.terracotta - } - if viewModel.isRecording { - return trayStatus.status.assistive ? CSColor.assistive : CSColor.terracotta - } - return viewModel.startInAssistive ? CSColor.assistive : CSColor.oliveLight + if viewModel.isRecording { + return trayStatus.status.assistive ? CSColor.assistive : CSColor.terracotta } - - // MARK: - Notes (nested disclosure) - - private var notesGroup: some View { - VStack(spacing: 0) { - TrayRow( - icon: .notes, - title: "Notes", - disclosureExpanded: viewModel.notesExpanded, - style: viewModel.notesExpanded ? .raised : .plain - ) { - withAnimation(TrayDisclosureChevron.animation) { viewModel.notesExpanded.toggle() } - } - - if viewModel.notesExpanded { - TrayDisclosureChildren { - TrayChildRow(title: "Save last transcript") { - viewModel.onSaveLastTranscript() - } - TrayChildRow(title: "Save selection") { - viewModel.onSaveSelection() - } - TrayChildRow(title: "Open notes folder") { - viewModel.onOpenNotesFolder() - } - TrayChildRow(title: "Open today's note") { - viewModel.onOpenTodayNote() - } - if let status = viewModel.noteStatus { - TrayNoteStatusRow(status: status) - } - } - } + return viewModel.startInAssistive ? CSColor.assistive : CSColor.oliveLight + } + + // MARK: - Notes (nested disclosure) + + private var notesGroup: some View { + VStack(spacing: 0) { + TrayRow( + icon: .notes, + title: "Notes", + disclosureExpanded: viewModel.notesExpanded, + style: viewModel.notesExpanded ? .raised : .plain + ) { + withAnimation(TrayDisclosureChevron.animation) { viewModel.notesExpanded.toggle() } + } + + if viewModel.notesExpanded { + TrayDisclosureChildren { + TrayChildRow(title: "Save last transcript") { + viewModel.onSaveLastTranscript() + } + TrayChildRow(title: "Save selection") { + viewModel.onSaveSelection() + } + TrayChildRow(title: "Open notes folder") { + viewModel.onOpenNotesFolder() + } + TrayChildRow(title: "Open today's note") { + viewModel.onOpenTodayNote() + } + if let status = viewModel.noteStatus { + TrayNoteStatusRow(status: status) + } } + } } + } + + // MARK: - Diagnostics (nested disclosure) + + private var diagnosticsGroup: some View { + VStack(spacing: 0) { + TrayRow( + icon: .diagnostics, + title: "Diagnostics", + disclosureExpanded: viewModel.diagnosticsExpanded, + style: viewModel.diagnosticsExpanded ? .raised : .plain + ) { + withAnimation(TrayDisclosureChevron.animation) { + viewModel.diagnosticsExpanded.toggle() + } + } - // MARK: - Diagnostics (nested disclosure) - - private var diagnosticsGroup: some View { - VStack(spacing: 0) { - TrayRow( - icon: .diagnostics, - title: "Diagnostics", - disclosureExpanded: viewModel.diagnosticsExpanded, - style: viewModel.diagnosticsExpanded ? .raised : .plain - ) { - withAnimation(TrayDisclosureChevron.animation) { - viewModel.diagnosticsExpanded.toggle() - } - } - - if viewModel.diagnosticsExpanded { - TrayDisclosureChildren { - TrayChildRow(title: "Open log folder") { viewModel.onOpenLogFolder() } - TrayChildRow(title: "Copy debug info") { viewModel.onCopyDebugInfo() } - } - } + if viewModel.diagnosticsExpanded { + TrayDisclosureChildren { + TrayChildRow(title: "Open log folder") { viewModel.onOpenLogFolder() } + TrayChildRow(title: "Copy debug info") { viewModel.onCopyDebugInfo() } } + } } + } } // MARK: - Notes action result banner @@ -359,55 +359,59 @@ struct TrayMenuView: View { /// terracotta cross on failure — a permission-free, always-visible replacement /// for the OS notification that an accessory app can't guarantee. private struct TrayNoteStatusRow: View { - let status: TrayActionStatus - - private var isSuccess: Bool { status.kind == .success } - private var tint: Color { isSuccess ? CSColor.oliveLight : CSColor.terracotta } - - var body: some View { - HStack(spacing: 6) { - CSIconView(icon: isSuccess ? .success : .failure, size: 11, weight: .bold, color: tint) - Text(status.message) - .font(CSFont.ui(12, .medium)) - .foregroundStyle(tint) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(tint.opacity(0.10)) - ) - .transition(.opacity) + let status: TrayActionStatus + + private var isSuccess: Bool { status.kind == .success } + private var tint: Color { isSuccess ? CSColor.oliveLight : CSColor.terracotta } + + var body: some View { + HStack(spacing: 6) { + CSIconView(icon: isSuccess ? .success : .failure, size: 11, weight: .bold, color: tint) + Text(status.message) + .font(CSFont.ui(12, .medium)) + .foregroundStyle(tint) + .lineLimit(1) + Spacer(minLength: 0) } + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tint.opacity(0.10)) + ) + .transition(.opacity) + } } // MARK: - Previews (standalone, mock-seeded) #if DEBUG -#Preview("Tray · Idle") { + #Preview("Tray · Idle") { let vm = TrayViewModel(engine: MockTrayEngine(recording: false), isRecording: false) TrayMenuView(viewModel: vm, trayStatus: .preview()) - .padding(40) - .background(LinearGradient( - colors: [Color(hex: 0x15110E), Color(hex: 0x0B0C10), Color(hex: 0x0D1012)], - startPoint: .topLeading, endPoint: .bottomTrailing - )) - .onAppear { FontLoader.register() } -} + .padding(40) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), Color(hex: 0x0B0C10), Color(hex: 0x0D1012)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + ) + .onAppear { FontLoader.register() } + } -#Preview("Tray · Recording") { + #Preview("Tray · Recording") { let vm = TrayViewModel(engine: MockTrayEngine(recording: true), isRecording: true) TrayMenuView( - viewModel: vm, - trayStatus: .preview(kind: .listening, tone: .active, label: "Status: Recording...") + viewModel: vm, + trayStatus: .preview(kind: .listening, tone: .active, label: "Status: Recording...") ) - .padding(40) - .background(LinearGradient( - colors: [Color(hex: 0x15110E), Color(hex: 0x0B0C10), Color(hex: 0x0D1012)], - startPoint: .topLeading, endPoint: .bottomTrailing - )) - .onAppear { FontLoader.register() } -} + .padding(40) + .background( + LinearGradient( + colors: [Color(hex: 0x15110E), Color(hex: 0x0B0C10), Color(hex: 0x0D1012)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + ) + .onAppear { FontLoader.register() } + } #endif diff --git a/macos/Codescribe/Screens/Tray/TrayRow.swift b/macos/Codescribe/Screens/Tray/TrayRow.swift index 02cba6f1..be943b0c 100644 --- a/macos/Codescribe/Screens/Tray/TrayRow.swift +++ b/macos/Codescribe/Screens/Tray/TrayRow.swift @@ -5,153 +5,153 @@ import SwiftUI /// Two mock-only tints not present in the locked token palette. private enum TrayLocal { - /// Submenu child + Quit label (#c7cabf) — slightly muted body text. - static let subnote = Color(hex: 0xC7CABF) - /// Primary-row keycap follows the operator's system accent. - static var primaryShortcut: Color { CSColor.chromeAccent.opacity(0.78) } + /// Submenu child + Quit label (#c7cabf) — slightly muted body text. + static let subnote = Color(hex: 0xC7CABF) + /// Primary-row keycap follows the operator's system accent. + static var primaryShortcut: Color { CSColor.chromeAccent.opacity(0.78) } } enum TrayRowStyle { - case plain // transparent; subtle hover highlight - case primary // system accent tint + border (the ONE primary action) - case raised // surface-raised tint (an expanded disclosure parent) + case plain // transparent; subtle hover highlight + case primary // system accent tint + border (the ONE primary action) + case raised // surface-raised tint (an expanded disclosure parent) } /// The one disclosure idiom shared by every expandable tray row: a single /// glyph (`chevron.right`) pointing right when collapsed, rotated to point /// down when expanded — the standard macOS disclosure gesture. enum TrayDisclosureChevron { - static let icon: CSIcon = .chevronRight - static let animation = Animation.easeOut(duration: 0.18) - static func rotationDegrees(expanded: Bool) -> Double { expanded ? 90 : 0 } + static let icon: CSIcon = .chevronRight + static let animation = Animation.easeOut(duration: 0.18) + static func rotationDegrees(expanded: Bool) -> Double { expanded ? 90 : 0 } } /// A standard tray action row: icon · label · optional shortcut / chevron. struct TrayRow: View { - let icon: CSIcon - var iconColor: Color? = nil - let title: String - var titleColor: Color = CSColor.textBodyAlt - var titleWeight: Font.Weight = .medium - var shortcut: String? = nil - var shortcutColor: Color = CSColor.textFaintAlt - /// Expansion state of the disclosure group this row heads; `nil` for plain - /// action rows without a chevron. - var disclosureExpanded: Bool? = nil - var style: TrayRowStyle = .plain - var action: () -> Void = {} + let icon: CSIcon + var iconColor: Color? = nil + let title: String + var titleColor: Color = CSColor.textBodyAlt + var titleWeight: Font.Weight = .medium + var shortcut: String? = nil + var shortcutColor: Color = CSColor.textFaintAlt + /// Expansion state of the disclosure group this row heads; `nil` for plain + /// action rows without a chevron. + var disclosureExpanded: Bool? = nil + var style: TrayRowStyle = .plain + var action: () -> Void = {} - @State private var hovering = false + @State private var hovering = false - private var fillColor: Color { - switch style { - case .primary: return CSColor.chromeAccent.opacity(0.13) - case .raised: return CSColor.surfaceRaised(0.04) - case .plain: return hovering ? CSColor.surfaceRaised(0.05) : .clear - } + private var fillColor: Color { + switch style { + case .primary: return CSColor.chromeAccent.opacity(0.13) + case .raised: return CSColor.surfaceRaised(0.04) + case .plain: return hovering ? CSColor.surfaceRaised(0.05) : .clear } + } - private var borderColor: Color { - style == .primary ? CSColor.chromeAccent.opacity(0.24) : .clear - } + private var borderColor: Color { + style == .primary ? CSColor.chromeAccent.opacity(0.24) : .clear + } - var body: some View { - HStack(spacing: 11) { - CSIconView(icon: icon, size: 13, color: iconColor ?? titleColor) - .frame(width: 18) - Text(title) - .font(CSFont.ui(13, titleWeight)) - .foregroundStyle(titleColor) - .frame(maxWidth: .infinity, alignment: .leading) - if let shortcut { - Text(shortcut) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(shortcutColor) - } - if let expanded = disclosureExpanded { - CSIconView(icon: TrayDisclosureChevron.icon, size: 11, color: CSColor.textFaint) - .rotationEffect( - .degrees(TrayDisclosureChevron.rotationDegrees(expanded: expanded)) - ) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous).fill(fillColor) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(borderColor, lineWidth: 1) - ) - .contentShape(Rectangle()) - .onTapGesture(perform: action) - .onHover { hovering = $0 } + var body: some View { + HStack(spacing: 11) { + CSIconView(icon: icon, size: 13, color: iconColor ?? titleColor) + .frame(width: 18) + Text(title) + .font(CSFont.ui(13, titleWeight)) + .foregroundStyle(titleColor) + .frame(maxWidth: .infinity, alignment: .leading) + if let shortcut { + Text(shortcut) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(shortcutColor) + } + if let expanded = disclosureExpanded { + CSIconView(icon: TrayDisclosureChevron.icon, size: 11, color: CSColor.textFaint) + .rotationEffect( + .degrees(TrayDisclosureChevron.rotationDegrees(expanded: expanded)) + ) + } } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous).fill(fillColor) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(borderColor, lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: action) + .onHover { hovering = $0 } + } } /// A nested disclosure child row (smaller, with the left rail in the container). struct TrayChildRow: View { - let title: String - var suffix: String? = nil - var action: () -> Void = {} + let title: String + var suffix: String? = nil + var action: () -> Void = {} - @State private var hovering = false + @State private var hovering = false - var body: some View { - HStack(spacing: 5) { - Text(title) - .font(CSFont.ui(12, .medium)) - .foregroundStyle(TrayLocal.subnote) - if let suffix { - Text(suffix) - .font(CSFont.mono(10)) - .foregroundStyle(CSColor.textFaintAlt) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(hovering ? CSColor.surfaceRaised(0.05) : .clear) - ) - .contentShape(Rectangle()) - .onTapGesture(perform: action) - .onHover { hovering = $0 } + var body: some View { + HStack(spacing: 5) { + Text(title) + .font(CSFont.ui(12, .medium)) + .foregroundStyle(TrayLocal.subnote) + if let suffix { + Text(suffix) + .font(CSFont.mono(10)) + .foregroundStyle(CSColor.textFaintAlt) + } + Spacer(minLength: 0) } + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(hovering ? CSColor.surfaceRaised(0.05) : .clear) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: action) + .onHover { hovering = $0 } + } } /// Hairline group separator (transparent margins per the mock). struct TrayDivider: View { - var top: CGFloat = 5 - var bottom: CGFloat = 5 - var body: some View { - Rectangle() - .fill(CSColor.hairline(0.07)) - .frame(height: 1) - .padding(.horizontal, 6) - .padding(.top, top) - .padding(.bottom, bottom) - } + var top: CGFloat = 5 + var bottom: CGFloat = 5 + var body: some View { + Rectangle() + .fill(CSColor.hairline(0.07)) + .frame(height: 1) + .padding(.horizontal, 6) + .padding(.top, top) + .padding(.bottom, bottom) + } } /// Indented container for disclosure children: left rail + 14pt inset. struct TrayDisclosureChildren: View { - @ViewBuilder var content: Content - var body: some View { - VStack(spacing: 1) { content } - .padding(.leading, 14) - .overlay(alignment: .leading) { - Rectangle().fill(CSColor.hairline(0.08)).frame(width: 1) - } - .padding(.leading, 6) - .padding(.vertical, 2) - } + @ViewBuilder var content: Content + var body: some View { + VStack(spacing: 1) { content } + .padding(.leading, 14) + .overlay(alignment: .leading) { + Rectangle().fill(CSColor.hairline(0.08)).frame(width: 1) + } + .padding(.leading, 6) + .padding(.vertical, 2) + } } /// Expose the mock-only palette so it shares the brand's hex initializer. extension TrayRow { - static let subnoteColor = TrayLocal.subnote - static let primaryShortcutColor = TrayLocal.primaryShortcut + static let subnoteColor = TrayLocal.subnote + static let primaryShortcutColor = TrayLocal.primaryShortcut } diff --git a/macos/Codescribe/Screens/Tray/TrayStatusStore.swift b/macos/Codescribe/Screens/Tray/TrayStatusStore.swift index ff8c8bf6..7ed52d8d 100644 --- a/macos/Codescribe/Screens/Tray/TrayStatusStore.swift +++ b/macos/Codescribe/Screens/Tray/TrayStatusStore.swift @@ -8,202 +8,203 @@ import SwiftUI /// out so the dot stays legible over glyph strokes, and the dot fills the /// corner. enum TrayStatusDotIcon { - /// Dot diameter as a fraction of the glyph's smaller dimension. - static let dotFraction: CGFloat = 0.42 - /// Punched-out separation ring around the dot, in points. - static let ringWidth: CGFloat = 1.5 - - /// The dot's frame: flush with the glyph's bottom-right corner. - static func dotRect(in bounds: CGRect) -> CGRect { - let diameter = min(bounds.width, bounds.height) * dotFraction - return CGRect( - x: bounds.maxX - diameter, - y: bounds.minY, - width: diameter, - height: diameter - ) - } - - static func composite( - base: NSImage, - dot: NSColor, - glyphTint: NSColor? = nil - ) -> NSImage { - let image = NSImage(size: base.size, flipped: false) { rect in - base.draw(in: rect) - // Tint like a template image would; the draw-time handler makes - // label color follow the destination (menu bar) appearance. - (glyphTint ?? NSColor.labelColor).set() - rect.fill(using: .sourceAtop) - - let dotRect = dotRect(in: rect) - if let cg = NSGraphicsContext.current?.cgContext { - cg.saveGState() - cg.setBlendMode(.destinationOut) - cg.fillEllipse(in: dotRect.insetBy(dx: -ringWidth, dy: -ringWidth)) - cg.restoreGState() - } - dot.setFill() - NSBezierPath(ovalIn: dotRect).fill() - return true - } - image.isTemplate = false - return image + /// Dot diameter as a fraction of the glyph's smaller dimension. + static let dotFraction: CGFloat = 0.42 + /// Punched-out separation ring around the dot, in points. + static let ringWidth: CGFloat = 1.5 + + /// The dot's frame: flush with the glyph's bottom-right corner. + static func dotRect(in bounds: CGRect) -> CGRect { + let diameter = min(bounds.width, bounds.height) * dotFraction + return CGRect( + x: bounds.maxX - diameter, + y: bounds.minY, + width: diameter, + height: diameter + ) + } + + static func composite( + base: NSImage, + dot: NSColor, + glyphTint: NSColor? = nil + ) -> NSImage { + let image = NSImage(size: base.size, flipped: false) { rect in + base.draw(in: rect) + // Tint like a template image would; the draw-time handler makes + // label color follow the destination (menu bar) appearance. + (glyphTint ?? NSColor.labelColor).set() + rect.fill(using: .sourceAtop) + + let dotRect = dotRect(in: rect) + if let cg = NSGraphicsContext.current?.cgContext { + cg.saveGState() + cg.setBlendMode(.destinationOut) + cg.fillEllipse(in: dotRect.insetBy(dx: -ringWidth, dy: -ringWidth)) + cg.restoreGState() + } + dot.setFill() + NSBezierPath(ovalIn: dotRect).fill() + return true } + image.isTemplate = false + return image + } } @MainActor final class TrayStatusStore: ObservableObject { - @Published private(set) var status: CsTrayStatusPayload - - var onChange: ((CsTrayStatusPayload) -> Void)? - - private let bridge: CodescribeTrayStatus? - private var listener: TrayStatusListener? - private var lastAppliedGeneration: UInt64 - - init() { - let bridge = CodescribeTrayStatus() - let initialStatus = bridge.currentStatus() - self.bridge = bridge - self.status = initialStatus - self.lastAppliedGeneration = initialStatus.generation - - let listener = TrayStatusListener { [weak self] status in - self?.apply(status) - } - self.listener = listener - bridge.setListener(listener: listener) - } + @Published private(set) var status: CsTrayStatusPayload - private init(status: CsTrayStatusPayload) { - self.bridge = nil - self.status = status - self.lastAppliedGeneration = status.generation - } + var onChange: ((CsTrayStatusPayload) -> Void)? - private func apply(_ status: CsTrayStatusPayload) { - guard status.generation > lastAppliedGeneration else { return } - lastAppliedGeneration = status.generation - self.status = status - onChange?(status) - } + private let bridge: CodescribeTrayStatus? + private var listener: TrayStatusListener? + private var lastAppliedGeneration: UInt64 - var compactLabel: String { - status.menuLabel.replacingOccurrences(of: "Status: ", with: "") - } + init() { + let bridge = CodescribeTrayStatus() + let initialStatus = bridge.currentStatus() + self.bridge = bridge + self.status = initialStatus + self.lastAppliedGeneration = initialStatus.generation - var color: Color { - if status.indicatorMode == .assistive { - return CSColor.modeAgent - } - switch status.tone { - case .neutral: - return CSColor.oliveLight - case .active: - return status.indicatorMode == .processing - ? CSColor.modeProcessing - : CSColor.modeRecording - case .success: - return CSColor.oliveLight - case .warning: - return CSColor.modeProcessing - case .critical: - return CSColor.terracottaDeep - } + let listener = TrayStatusListener { [weak self] status in + self?.apply(status) } - - var icon: CSIcon { - switch status.kind { - case .starting: - return .more - case .idle: - return .success - case .listening, .processing: - return .mic - case .success: - return .success - case .error: - return .error - case .thermal: - return .warning - case .hotkeyConflict: - return .shortcuts - } + self.listener = listener + bridge.setListener(listener: listener) + } + + private init(status: CsTrayStatusPayload) { + self.bridge = nil + self.status = status + self.lastAppliedGeneration = status.generation + } + + private func apply(_ status: CsTrayStatusPayload) { + guard status.generation > lastAppliedGeneration else { return } + lastAppliedGeneration = status.generation + self.status = status + onChange?(status) + } + + var compactLabel: String { + status.menuLabel.replacingOccurrences(of: "Status: ", with: "") + } + + var color: Color { + if status.indicatorMode == .assistive { + return CSColor.modeAgent } - - var shouldRipple: Bool { - switch status.kind { - case .starting, .listening, .processing: - return true - case .idle, .success, .error, .thermal, .hotkeyConflict: - return false - } + switch status.tone { + case .neutral: + return CSColor.oliveLight + case .active: + return status.indicatorMode == .processing + ? CSColor.modeProcessing + : CSColor.modeRecording + case .success: + return CSColor.oliveLight + case .warning: + return CSColor.modeProcessing + case .critical: + return CSColor.terracottaDeep } - - /// Colored status dot composited into the (always-static) menu bar glyph's - /// bottom-right corner. The glyph never changes; only this dot signals the - /// mode, 1:1 with the Rust tray-status feed: green = ready (idle/success, - /// the locked palette's ready tone), red = recording, orange = processing, - /// purple = assistive — recording/processing/assistive hues mirror the - /// caret hold-badge (`app/os/hold_badge.rs`) so the tray and the cursor - /// speak one language. Processing is always orange, including agent - /// sessions. `nil` = no dot - /// (starting — the app is not ready yet). Warning states (error / thermal / - /// hotkey conflict) fall back to a system red / yellow attention dot — the - /// tooltip and menu status row carry the specifics. - var menuBarDotColor: Color? { - switch status.kind { - case .starting: - return nil - case .idle, .success: - return CSColor.modeReady - case .listening: - return status.indicatorMode == .assistive - ? CSColor.modeAgent - : CSColor.indicatorRecording - case .processing: - return CSColor.modeProcessing - case .error: - return .red - case .thermal, .hotkeyConflict: - return .yellow - } + } + + var icon: CSIcon { + switch status.kind { + case .starting: + return .more + case .idle: + return .success + case .listening, .processing: + return .mic + case .success: + return .success + case .error: + return .error + case .thermal: + return .warning + case .hotkeyConflict: + return .shortcuts } + } + + var shouldRipple: Bool { + switch status.kind { + case .starting, .listening, .processing: + return true + case .idle, .success, .error, .thermal, .hotkeyConflict: + return false + } + } + + /// Colored status dot composited into the (always-static) menu bar glyph's + /// bottom-right corner. The glyph never changes; only this dot signals the + /// mode, 1:1 with the Rust tray-status feed: green = ready (idle/success, + /// the locked palette's ready tone), red = recording, orange = processing, + /// purple = assistive — recording/processing/assistive hues mirror the + /// caret hold-badge (`app/os/hold_badge.rs`) so the tray and the cursor + /// speak one language. Processing is always orange, including agent + /// sessions. `nil` = no dot + /// (starting — the app is not ready yet). Warning states (error / thermal / + /// hotkey conflict) fall back to a system red / yellow attention dot — the + /// tooltip and menu status row carry the specifics. + var menuBarDotColor: Color? { + switch status.kind { + case .starting: + return nil + case .idle, .success: + return CSColor.modeReady + case .listening: + return status.indicatorMode == .assistive + ? CSColor.modeAgent + : CSColor.indicatorRecording + case .processing: + return CSColor.modeProcessing + case .error: + return .red + case .thermal, .hotkeyConflict: + return .yellow + } + } - #if DEBUG + #if DEBUG static func preview( - kind: CsTrayStatusKind = .idle, - tone: CsTrayStatusTone = .neutral, - indicatorMode: CsIndicatorMode = .hold, - assistive: Bool = false, - label: String = "Status: Idle" + kind: CsTrayStatusKind = .idle, + tone: CsTrayStatusTone = .neutral, + indicatorMode: CsIndicatorMode = .hold, + assistive: Bool = false, + label: String = "Status: Idle" ) -> TrayStatusStore { - TrayStatusStore(status: CsTrayStatusPayload( - kind: kind, - tone: tone, - indicatorMode: indicatorMode, - assistive: assistive, - tooltip: "Codescribe - \(label.replacingOccurrences(of: "Status: ", with: ""))", - menuLabel: label, - generation: 0 + TrayStatusStore( + status: CsTrayStatusPayload( + kind: kind, + tone: tone, + indicatorMode: indicatorMode, + assistive: assistive, + tooltip: "Codescribe - \(label.replacingOccurrences(of: "Status: ", with: ""))", + menuLabel: label, + generation: 0 )) } - #endif + #endif } final class TrayStatusListener: CsTrayStatusListener, @unchecked Sendable { - private let onStatus: @MainActor (CsTrayStatusPayload) -> Void + private let onStatus: @MainActor (CsTrayStatusPayload) -> Void - init(onStatus: @escaping @MainActor (CsTrayStatusPayload) -> Void) { - self.onStatus = onStatus - } + init(onStatus: @escaping @MainActor (CsTrayStatusPayload) -> Void) { + self.onStatus = onStatus + } - func onTrayStatus(status: CsTrayStatusPayload) { - DispatchQueue.main.async { - MainActor.assumeIsolated { - self.onStatus(status) - } - } + func onTrayStatus(status: CsTrayStatusPayload) { + DispatchQueue.main.async { + MainActor.assumeIsolated { + self.onStatus(status) + } } + } } diff --git a/macos/Codescribe/Screens/Tray/TrayViewModel.swift b/macos/Codescribe/Screens/Tray/TrayViewModel.swift index 4d0fc1ac..1c4a3df0 100644 --- a/macos/Codescribe/Screens/Tray/TrayViewModel.swift +++ b/macos/Codescribe/Screens/Tray/TrayViewModel.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI // Owns the tray's state + action routing. The view is dumb: it observes this. // @@ -9,322 +9,330 @@ import AppKit // to real window opens. Other app-level actions stay as injected closures. @MainActor final class TrayViewModel: ObservableObject { - // Runtime status (drives the pill + dictation row). - @Published var isRecording: Bool - @Published var isStartingDictation: Bool = false - @Published var agentAvailable: Bool = true - - // Quick config toggles (reflected on disk via the engine). - @Published var showDockIcon: Bool = true - @Published var overlayEnabled: Bool = true - @Published var autoPasteEnabled: Bool = true - @Published var autoFormatLevel: FormattingPolicyOption = .correction - @Published var notesModeEnabled: Bool = false - @Published var startInAssistive: Bool = false - @Published var holdBadgeOption: HoldBadgeOption = .twelve - - // Disclosure state for nested groups. Default ALL collapsed — the tray - // grew into a wall of toggles; open only what the user asks for. - // (Historical: Notes started expanded for the static mock; that made every - // cold open feel non-collapsed / "rozrośnięte".) - @Published var notesExpanded: Bool = false - @Published var diagnosticsExpanded: Bool = false - @Published var historyExpanded: Bool = false - @Published var quickSettingsExpanded: Bool = false - - // Transient result banner for the Notes actions ("Save selection" / "Save - // last transcript"). Rendered inside the still-open popover so the user gets - // an unmissable, permission-free confirmation even when the OS notification - // path is silent (accessory app / not-yet-granted). Auto-clears. - @Published private(set) var noteStatus: TrayActionStatus? - private var noteStatusClearTask: Task? - - // Transient "Copied" confirmation for the copy actions (history item / last - // transcript). Same permission-free banner pattern as `noteStatus`, kept - // separate so copy feedback shows next to the copy actions rather than in the - // Notes group. Auto-clears. - @Published private(set) var copyStatus: TrayActionStatus? - private var copyStatusClearTask: Task? - - // The 5 most recent transcripts, loaded when the History group is expanded - // (cached so re-renders don't re-hit disk). - @Published private(set) var historyItems: [TrayTranscript] = [] - - private let engine: TrayEngine? - private var holdBadgeObserver: NSObjectProtocol? - - // Navigation intents — bound by App.swift to the actual window/scene opens. - var onIntent: (TrayIntent) -> Void = { _ in } - var onDictationStartRequested: () -> Void = {} - - // App-level actions — injected by App.swift. Defaults are best-effort / no-op - // so the screen is fully interactive in isolation and in #Preview. - var onHelp: () -> Void = {} - var onAbout: () -> Void = {} - /// Sparkle user-initiated update check. Bound by App.swift to UpdaterService. - var onCheckForUpdates: () -> Void = {} - /// Re-open the first-run setup wizard. Bound by App.swift to the onboarding - /// window controller; a stable auxiliary-menu entry so setup is always - /// reachable — mid-onboarding (resume) or after completion (re-run). - var onOpenSetupWizard: () -> Void = {} - var onQuit: () -> Void = { NSApplication.shared.terminate(nil) } - - var onSaveLastTranscript: () -> Void = {} - var onSaveSelection: () -> Void = {} - var onOpenNotesFolder: () -> Void = {} - var onOpenTodayNote: () -> Void = {} - - var onOpenLogFolder: () -> Void = {} - var onCopyDebugInfo: () -> Void = {} - - init(engine: TrayEngine? = nil, isRecording: Bool = false) { - self.engine = engine - self.isRecording = isRecording - // K4: Settings writes arrive on the bus; reload tray badge display. - holdBadgeObserver = NotificationCenter.default.addObserver( - forName: ConfigChangeBus.holdBadgeDidChange, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.reloadHoldBadgeFromDisk() - } - } + // Runtime status (drives the pill + dictation row). + @Published var isRecording: Bool + @Published var isStartingDictation: Bool = false + @Published var agentAvailable: Bool = true + + // Quick config toggles (reflected on disk via the engine). + @Published var showDockIcon: Bool = true + @Published var overlayEnabled: Bool = true + @Published var autoPasteEnabled: Bool = true + @Published var autoFormatLevel: FormattingPolicyOption = .correction + @Published var notesModeEnabled: Bool = false + @Published var startInAssistive: Bool = false + @Published var holdBadgeOption: HoldBadgeOption = .twelve + + // Disclosure state for nested groups. Default ALL collapsed — the tray + // grew into a wall of toggles; open only what the user asks for. + // (Historical: Notes started expanded for the static mock; that made every + // cold open feel non-collapsed / "rozrośnięte".) + @Published var notesExpanded: Bool = false + @Published var diagnosticsExpanded: Bool = false + @Published var historyExpanded: Bool = false + @Published var quickSettingsExpanded: Bool = false + + // Transient result banner for the Notes actions ("Save selection" / "Save + // last transcript"). Rendered inside the still-open popover so the user gets + // an unmissable, permission-free confirmation even when the OS notification + // path is silent (accessory app / not-yet-granted). Auto-clears. + @Published private(set) var noteStatus: TrayActionStatus? + private var noteStatusClearTask: Task? + + // Transient "Copied" confirmation for the copy actions (history item / last + // transcript). Same permission-free banner pattern as `noteStatus`, kept + // separate so copy feedback shows next to the copy actions rather than in the + // Notes group. Auto-clears. + @Published private(set) var copyStatus: TrayActionStatus? + private var copyStatusClearTask: Task? + + // The 5 most recent transcripts, loaded when the History group is expanded + // (cached so re-renders don't re-hit disk). + @Published private(set) var historyItems: [TrayTranscript] = [] + + private let engine: TrayEngine? + private var holdBadgeObserver: NSObjectProtocol? + + // Navigation intents — bound by App.swift to the actual window/scene opens. + var onIntent: (TrayIntent) -> Void = { _ in } + var onDictationStartRequested: () -> Void = {} + + // App-level actions — injected by App.swift. Defaults are best-effort / no-op + // so the screen is fully interactive in isolation and in #Preview. + var onHelp: () -> Void = {} + var onAbout: () -> Void = {} + /// Sparkle user-initiated update check. Bound by App.swift to UpdaterService. + var onCheckForUpdates: () -> Void = {} + /// Re-open the first-run setup wizard. Bound by App.swift to the onboarding + /// window controller; a stable auxiliary-menu entry so setup is always + /// reachable — mid-onboarding (resume) or after completion (re-run). + var onOpenSetupWizard: () -> Void = {} + var onQuit: () -> Void = { NSApplication.shared.terminate(nil) } + + var onSaveLastTranscript: () -> Void = {} + var onSaveSelection: () -> Void = {} + var onOpenNotesFolder: () -> Void = {} + var onOpenTodayNote: () -> Void = {} + + var onOpenLogFolder: () -> Void = {} + var onCopyDebugInfo: () -> Void = {} + + init(engine: TrayEngine? = nil, isRecording: Bool = false) { + self.engine = engine + self.isRecording = isRecording + // K4: Settings writes arrive on the bus; reload tray badge display. + holdBadgeObserver = NotificationCenter.default.addObserver( + forName: ConfigChangeBus.holdBadgeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.reloadHoldBadgeFromDisk() + } } + } - deinit { - if let holdBadgeObserver { - NotificationCenter.default.removeObserver(holdBadgeObserver) - } + deinit { + if let holdBadgeObserver { + NotificationCenter.default.removeObserver(holdBadgeObserver) } - - // MARK: - Navigation intents - - func onShowAgent() { onIntent(.openChat) } - - /// A popover is a short-lived surface. Do not carry an expanded wall of - /// history/settings into the operator's next tray visit. - func collapseDisclosures() { - notesExpanded = false - diagnosticsExpanded = false - historyExpanded = false - quickSettingsExpanded = false + } + + // MARK: - Navigation intents + + func onShowAgent() { onIntent(.openChat) } + + /// A popover is a short-lived surface. Do not carry an expanded wall of + /// history/settings into the operator's next tray visit. + func collapseDisclosures() { + notesExpanded = false + diagnosticsExpanded = false + historyExpanded = false + quickSettingsExpanded = false + } + + // MARK: - Derived status (mock copy + palette) + + /// Olive "Idle" when stopped, terracotta "Recording" when live. + var statusText: String { + if isStartingDictation { return "Starting" } + return isRecording ? "Recording" : "Idle" + } + var statusColor: Color { + (isRecording || isStartingDictation) ? CSColor.terracotta : CSColor.oliveLight + } + + /// Pull prompt-free runtime flags from the engine (call on appear). + func refreshStatus() { + guard let engine else { return } + if let toggles = engine.currentToggles() { + showDockIcon = toggles.showDockIcon + overlayEnabled = toggles.overlayEnabled + autoPasteEnabled = toggles.autoPasteEnabled + autoFormatLevel = toggles.autoFormatLevel + notesModeEnabled = toggles.notesMode + startInAssistive = toggles.startInAssistive + holdBadgeOption = toggles.holdBadgeOption } - - // MARK: - Derived status (mock copy + palette) - - /// Olive "Idle" when stopped, terracotta "Recording" when live. - var statusText: String { - if isStartingDictation { return "Starting" } - return isRecording ? "Recording" : "Idle" + Task { [weak self] in + guard let self else { return } + self.isRecording = await engine.isRecording() } - var statusColor: Color { (isRecording || isStartingDictation) ? CSColor.terracotta : CSColor.oliveLight } - - /// Pull prompt-free runtime flags from the engine (call on appear). - func refreshStatus() { - guard let engine else { return } - if let toggles = engine.currentToggles() { - showDockIcon = toggles.showDockIcon - overlayEnabled = toggles.overlayEnabled - autoPasteEnabled = toggles.autoPasteEnabled - autoFormatLevel = toggles.autoFormatLevel - notesModeEnabled = toggles.notesMode - startInAssistive = toggles.startInAssistive - holdBadgeOption = toggles.holdBadgeOption - } - Task { [weak self] in - guard let self else { return } - self.isRecording = await engine.isRecording() - } + } + + // MARK: - Dictation toggle + + /// Flip the dictation session, then reconcile against the engine's truth. + func toggleDictation() { + // Ignore re-entry while a start is still in flight: a second tap would + // read the optimistic `isRecording = true` as `wasRecording` and fire a + // stop before the start's Task resolves, ordering stop before start. + // `isStartingDictation` is cleared unconditionally once that Task finishes. + guard !isStartingDictation else { return } + guard let engine else { + isRecording.toggle() + return } - - // MARK: - Dictation toggle - - /// Flip the dictation session, then reconcile against the engine's truth. - func toggleDictation() { - // Ignore re-entry while a start is still in flight: a second tap would - // read the optimistic `isRecording = true` as `wasRecording` and fire a - // stop before the start's Task resolves, ordering stop before start. - // `isStartingDictation` is cleared unconditionally once that Task finishes. - guard !isStartingDictation else { return } - guard let engine else { isRecording.toggle(); return } - let wasRecording = isRecording - let shouldStartAssistive = startInAssistive - if !wasRecording { - isStartingDictation = true - isRecording = true - onDictationStartRequested() - } - Task { [weak self] in - guard let self else { return } - do { - if wasRecording { try await engine.stopRecording() } - else { try await engine.startRecording(assistive: shouldStartAssistive) } - } catch { - // Swallow: the reconcile below reflects the real session state. - } - self.isStartingDictation = false - self.isRecording = await engine.isRecording() - } - } - - // MARK: - Quick config toggles - - func setShowDockIcon(_ enabled: Bool) { - showDockIcon = enabled - engine?.setQuickToggle(.showDockIcon, enabled: enabled) - // Persisting the flag isn't enough: the app launches as an accessory - // (LSUIElement), so flip the activation policy to actually show/hide the - // Dock icon at runtime. - NSApp.setActivationPolicy(enabled ? .regular : .accessory) + let wasRecording = isRecording + let shouldStartAssistive = startInAssistive + if !wasRecording { + isStartingDictation = true + isRecording = true + onDictationStartRequested() } - - func setOverlayEnabled(_ enabled: Bool) { - guard let engine else { - overlayEnabled = enabled - return + Task { [weak self] in + guard let self else { return } + do { + if wasRecording { + try await engine.stopRecording() + } else { + try await engine.startRecording(assistive: shouldStartAssistive) } - engine.setQuickToggle(.transcriptionOverlay, enabled: enabled) - refreshStatus() + } catch { + // Swallow: the reconcile below reflects the real session state. + } + self.isStartingDictation = false + self.isRecording = await engine.isRecording() } - - /// Persisted delivery policy. Re-read the complete tray snapshot after the - /// write so a rejected save never leaves an optimistic switch behind. - func setAutoPasteEnabled(_ enabled: Bool) { - guard let engine else { - autoPasteEnabled = enabled - return - } - engine.setAutoPasteEnabled(enabled) - refreshStatus() + } + + // MARK: - Quick config toggles + + func setShowDockIcon(_ enabled: Bool) { + showDockIcon = enabled + engine?.setQuickToggle(.showDockIcon, enabled: enabled) + // Persisting the flag isn't enough: the app launches as an accessory + // (LSUIElement), so flip the activation policy to actually show/hide the + // Dock icon at runtime. + NSApp.setActivationPolicy(enabled ? .regular : .accessory) + } + + func setOverlayEnabled(_ enabled: Bool) { + guard let engine else { + overlayEnabled = enabled + return } - - /// Persist one of the four normalized formatting IDs, then reconcile with - /// prompt-free settings truth even when the bridge rejects the write. - func setAutoFormatLevel(_ level: FormattingPolicyOption) { - guard let engine else { - autoFormatLevel = level - return - } - engine.setAutoFormatLevel(level) - refreshStatus() + engine.setQuickToggle(.transcriptionOverlay, enabled: enabled) + refreshStatus() + } + + /// Persisted delivery policy. Re-read the complete tray snapshot after the + /// write so a rejected save never leaves an optimistic switch behind. + func setAutoPasteEnabled(_ enabled: Bool) { + guard let engine else { + autoPasteEnabled = enabled + return } - - /// K3: persists immediately; next badge show uses the new size. - /// K4: posts bus so Settings reflects the tray cycle without reopen. - func setHoldBadgeOption(_ option: HoldBadgeOption) { - guard let engine else { - holdBadgeOption = option - ConfigChangeBus.postHoldBadgeChanged() - return - } - if engine.setHoldBadgeOption(option) { - holdBadgeOption = option - ConfigChangeBus.postHoldBadgeChanged() - } else { - refreshStatus() - } + engine.setAutoPasteEnabled(enabled) + refreshStatus() + } + + /// Persist one of the four normalized formatting IDs, then reconcile with + /// prompt-free settings truth even when the bridge rejects the write. + func setAutoFormatLevel(_ level: FormattingPolicyOption) { + guard let engine else { + autoFormatLevel = level + return } - - /// Peer-surface reload after Settings wrote HOLD_BADGE_SIZE / HOLD_INDICATOR. - func reloadHoldBadgeFromDisk() { - refreshStatus() + engine.setAutoFormatLevel(level) + refreshStatus() + } + + /// K3: persists immediately; next badge show uses the new size. + /// K4: posts bus so Settings reflects the tray cycle without reopen. + func setHoldBadgeOption(_ option: HoldBadgeOption) { + guard let engine else { + holdBadgeOption = option + ConfigChangeBus.postHoldBadgeChanged() + return } - - /// Notes Mode: dictation → daily note (no paste). Distinct from normal - /// dictation, which pastes at the cursor. Only reflect the new state if the - /// two-key write actually persisted — otherwise re-sync to on-disk truth so - /// the toggle never shows a state the config doesn't hold. - func setNotesMode(_ enabled: Bool) { - guard let engine else { - notesModeEnabled = enabled - return - } - if engine.setNotesMode(enabled) { - notesModeEnabled = enabled - } else { - refreshStatus() - } + if engine.setHoldBadgeOption(option) { + holdBadgeOption = option + ConfigChangeBus.postHoldBadgeChanged() + } else { + refreshStatus() } - - /// UI-initiated recording lane. Keyboard shortcuts keep their own bindings. - func setStartInAssistive(_ enabled: Bool) { - guard let engine else { - startInAssistive = enabled - return - } - if engine.setStartInAssistive(enabled) { - startInAssistive = enabled - } else { - refreshStatus() - } + } + + /// Peer-surface reload after Settings wrote HOLD_BADGE_SIZE / HOLD_INDICATOR. + func reloadHoldBadgeFromDisk() { + refreshStatus() + } + + /// Notes Mode: dictation → daily note (no paste). Distinct from normal + /// dictation, which pastes at the cursor. Only reflect the new state if the + /// two-key write actually persisted — otherwise re-sync to on-disk truth so + /// the toggle never shows a state the config doesn't hold. + func setNotesMode(_ enabled: Bool) { + guard let engine else { + notesModeEnabled = enabled + return } - - // MARK: - History actions (route through the engine seam) - - /// Toggle the "Open history" disclosure, loading the 5 most recent - /// transcripts from the engine the moment it opens. - func toggleHistory() { - historyExpanded.toggle() - if historyExpanded { - historyItems = engine?.recentTranscripts(limit: 5) ?? [] - } + if engine.setNotesMode(enabled) { + notesModeEnabled = enabled + } else { + refreshStatus() } + } - /// Copy a chosen recent transcript's full text to the system pasteboard. - func copyTranscript(path: String) { - guard let text = engine?.transcriptText(forPath: path) else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - showCopyStatus("Copied") + /// UI-initiated recording lane. Keyboard shortcuts keep their own bindings. + func setStartInAssistive(_ enabled: Bool) { + guard let engine else { + startInAssistive = enabled + return } - - /// Reveal the folder holding the most recent transcript in Finder. - func openHistoryFolder() { - guard let path = engine?.latestHistoryPath() else { return } - let dir = (path as NSString).deletingLastPathComponent - NSWorkspace.shared.open(URL(fileURLWithPath: dir)) + if engine.setStartInAssistive(enabled) { + startInAssistive = enabled + } else { + refreshStatus() } + } - /// Copy the most recent transcript's text to the system pasteboard. - func copyLastTranscript() { - guard let text = engine?.latestTranscriptText() else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - showCopyStatus("Copied") - } + // MARK: - History actions (route through the engine seam) - /// Flash a transient "Copied" banner beside the copy actions, then auto-clear - /// it (cancelling any in-flight clear so rapid copies keep the newest banner). - private func showCopyStatus(_ message: String) { - copyStatus = TrayActionStatus(kind: .success, message: message) - copyStatusClearTask?.cancel() - copyStatusClearTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 4_000_000_000) - guard !Task.isCancelled else { return } - self?.copyStatus = nil - } + /// Toggle the "Open history" disclosure, loading the 5 most recent + /// transcripts from the engine the moment it opens. + func toggleHistory() { + historyExpanded.toggle() + if historyExpanded { + historyItems = engine?.recentTranscripts(limit: 5) ?? [] } - - // MARK: - Notes action feedback - - /// Surface the outcome of a Notes action in the popover and auto-clear it a - /// few seconds later. Cancels any in-flight clear so back-to-back actions - /// don't wipe the newest banner early. - func showNoteStatus(_ status: TrayActionStatus) { - noteStatus = status - noteStatusClearTask?.cancel() - noteStatusClearTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 4_000_000_000) - guard !Task.isCancelled else { return } - self?.noteStatus = nil - } + } + + /// Copy a chosen recent transcript's full text to the system pasteboard. + func copyTranscript(path: String) { + guard let text = engine?.transcriptText(forPath: path) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + showCopyStatus("Copied") + } + + /// Reveal the folder holding the most recent transcript in Finder. + func openHistoryFolder() { + guard let path = engine?.latestHistoryPath() else { return } + let dir = (path as NSString).deletingLastPathComponent + NSWorkspace.shared.open(URL(fileURLWithPath: dir)) + } + + /// Copy the most recent transcript's text to the system pasteboard. + func copyLastTranscript() { + guard let text = engine?.latestTranscriptText() else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + showCopyStatus("Copied") + } + + /// Flash a transient "Copied" banner beside the copy actions, then auto-clear + /// it (cancelling any in-flight clear so rapid copies keep the newest banner). + private func showCopyStatus(_ message: String) { + copyStatus = TrayActionStatus(kind: .success, message: message) + copyStatusClearTask?.cancel() + copyStatusClearTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 4_000_000_000) + guard !Task.isCancelled else { return } + self?.copyStatus = nil + } + } + + // MARK: - Notes action feedback + + /// Surface the outcome of a Notes action in the popover and auto-clear it a + /// few seconds later. Cancels any in-flight clear so back-to-back actions + /// don't wipe the newest banner early. + func showNoteStatus(_ status: TrayActionStatus) { + noteStatus = status + noteStatusClearTask?.cancel() + noteStatusClearTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 4_000_000_000) + guard !Task.isCancelled else { return } + self?.noteStatus = nil } + } } /// Outcome of a tray Notes action, shown as a transient banner row. struct TrayActionStatus: Equatable { - enum Kind { case success, failure } - let kind: Kind - let message: String + enum Kind { case success, failure } + let kind: Kind + let message: String } diff --git a/macos/Codescribe/Services/ActivationPing.swift b/macos/Codescribe/Services/ActivationPing.swift index 42d248ef..dcf80b68 100644 --- a/macos/Codescribe/Services/ActivationPing.swift +++ b/macos/Codescribe/Services/ActivationPing.swift @@ -5,36 +5,36 @@ import OSLog /// domain stays empty and the feature is inert even if a local preference is /// toggled. Tests and development probes inject an explicit domain. struct ActivationPingConfiguration: Equatable { - static let production = ActivationPingConfiguration( - endpoint: URL(string: "https://plausible.io/api/event")!, - domain: "" - ) + static let production = ActivationPingConfiguration( + endpoint: URL(string: "https://plausible.io/api/event")!, + domain: "" + ) - let endpoint: URL - let domain: String + let endpoint: URL + let domain: String - var isEnabled: Bool { - !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } + var isEnabled: Bool { + !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } } protocol ActivationPingTransport { - func data(for request: URLRequest) async throws -> (Data, URLResponse) + func data(for request: URLRequest) async throws -> (Data, URLResponse) } struct URLSessionActivationPingTransport: ActivationPingTransport { - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - try await URLSession.shared.data(for: request) - } + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await URLSession.shared.data(for: request) + } } enum ActivationPingOutcome: Equatable { - case notConsented - case disabled - case alreadySent - case inFlight - case sent - case failed + case notConsented + case disabled + case alreadySent + case inFlight + case sent + case failed } /// Sends one content-free activation event after a successful dictation. @@ -44,85 +44,86 @@ enum ActivationPingOutcome: Equatable { /// UserDefaults; consent defaults to false when the key is absent. @MainActor final class ActivationPing { - static let optInDefaultsKey = "analytics.activationPing.optIn.v1" - static let sentDefaultsKey = "analytics.firstSuccessfulDictation.sent.v1" - static let shared = ActivationPing() + static let optInDefaultsKey = "analytics.activationPing.optIn.v1" + static let sentDefaultsKey = "analytics.firstSuccessfulDictation.sent.v1" + static let shared = ActivationPing() - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "activation-analytics" - ) + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "activation-analytics" + ) - private let configuration: ActivationPingConfiguration - private let defaults: UserDefaults - private let transport: ActivationPingTransport - private let appVersion: () -> String - private let operatingSystem: () -> String - private var sendInFlight = false + private let configuration: ActivationPingConfiguration + private let defaults: UserDefaults + private let transport: ActivationPingTransport + private let appVersion: () -> String + private let operatingSystem: () -> String + private var sendInFlight = false - init( - configuration: ActivationPingConfiguration = .production, - defaults: UserDefaults = .standard, - transport: ActivationPingTransport = URLSessionActivationPingTransport(), - appVersion: @escaping () -> String = { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - ?? "unknown" - }, - operatingSystem: @escaping () -> String = { - ProcessInfo.processInfo.operatingSystemVersionString - } - ) { - self.configuration = configuration - self.defaults = defaults - self.transport = transport - self.appVersion = appVersion - self.operatingSystem = operatingSystem + init( + configuration: ActivationPingConfiguration = .production, + defaults: UserDefaults = .standard, + transport: ActivationPingTransport = URLSessionActivationPingTransport(), + appVersion: @escaping () -> String = { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "unknown" + }, + operatingSystem: @escaping () -> String = { + ProcessInfo.processInfo.operatingSystemVersionString } + ) { + self.configuration = configuration + self.defaults = defaults + self.transport = transport + self.appVersion = appVersion + self.operatingSystem = operatingSystem + } - func recordFirstSuccessfulDictation() async -> ActivationPingOutcome { - guard defaults.bool(forKey: Self.optInDefaultsKey) else { return .notConsented } - guard configuration.isEnabled else { return .disabled } - guard !defaults.bool(forKey: Self.sentDefaultsKey) else { return .alreadySent } - guard !sendInFlight else { return .inFlight } + func recordFirstSuccessfulDictation() async -> ActivationPingOutcome { + guard defaults.bool(forKey: Self.optInDefaultsKey) else { return .notConsented } + guard configuration.isEnabled else { return .disabled } + guard !defaults.bool(forKey: Self.sentDefaultsKey) else { return .alreadySent } + guard !sendInFlight else { return .inFlight } - sendInFlight = true - defer { sendInFlight = false } + sendInFlight = true + defer { sendInFlight = false } - do { - let request = try makeRequest() - let (_, response) = try await transport.data(for: request) - guard let http = response as? HTTPURLResponse, - (200..<300).contains(http.statusCode) else { - Self.logger.error("activation ping rejected by analytics endpoint") - return .failed - } - defaults.set(true, forKey: Self.sentDefaultsKey) - Self.logger.info("first successful dictation activation ping sent") - return .sent - } catch { - Self.logger.error( - "activation ping failed: \(error.localizedDescription, privacy: .public)" - ) - return .failed - } + do { + let request = try makeRequest() + let (_, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) + else { + Self.logger.error("activation ping rejected by analytics endpoint") + return .failed + } + defaults.set(true, forKey: Self.sentDefaultsKey) + Self.logger.info("first successful dictation activation ping sent") + return .sent + } catch { + Self.logger.error( + "activation ping failed: \(error.localizedDescription, privacy: .public)" + ) + return .failed } + } - private func makeRequest() throws -> URLRequest { - let version = appVersion() - let payload: [String: Any] = [ - "name": "first_successful_dictation", - "url": "https://\(configuration.domain)/app/activation", - "domain": configuration.domain, - "props": [ - "app_version": version, - "os": operatingSystem(), - ], - ] - var request = URLRequest(url: configuration.endpoint) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Codescribe/\(version)", forHTTPHeaderField: "User-Agent") - request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) - return request - } + private func makeRequest() throws -> URLRequest { + let version = appVersion() + let payload: [String: Any] = [ + "name": "first_successful_dictation", + "url": "https://\(configuration.domain)/app/activation", + "domain": configuration.domain, + "props": [ + "app_version": version, + "os": operatingSystem(), + ], + ] + var request = URLRequest(url: configuration.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Codescribe/\(version)", forHTTPHeaderField: "User-Agent") + request.httpBody = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + return request + } } diff --git a/macos/Codescribe/Services/LicenseService.swift b/macos/Codescribe/Services/LicenseService.swift index fb134eda..68bbeb82 100644 --- a/macos/Codescribe/Services/LicenseService.swift +++ b/macos/Codescribe/Services/LicenseService.swift @@ -2,191 +2,229 @@ import Foundation import Security protocol LicenseKeychainStoring { - func load() throws -> Data? - func save(_ data: Data) throws - func delete() throws + func load() throws -> Data? + func save(_ data: Data) throws + func delete() throws } private struct PersistedLicense: Codable { - let key: String - let lastOnlineValidation: Int64 + let key: String + let lastOnlineValidation: Int64 } struct SystemLicenseKeychain: LicenseKeychainStoring { - static let service = "com.vetcoders.codescribe.license" - private static let account = "license" - - private let service: String - private let account: String - - init(service: String = Self.service, account: String = Self.account) { - self.service = service - self.account = account + static let service = "com.vetcoders.codescribe.license" + private static let account = "license" + + private let service: String + private let account: String + + init(service: String = Self.service, account: String = Self.account) { + self.service = service + self.account = account + } + + func load() throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw LicenseKeychainError(status) } + return result as? Data + } + + func save(_ data: Data) throws { + let key: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let update = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(key as CFDictionary, update as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw LicenseKeychainError(updateStatus) } - - func load() throws -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess else { throw LicenseKeychainError(status) } - return result as? Data - } - - func save(_ data: Data) throws { - let key: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let update = [kSecValueData as String: data] - let updateStatus = SecItemUpdate(key as CFDictionary, update as CFDictionary) - if updateStatus == errSecSuccess { return } - guard updateStatus == errSecItemNotFound else { - throw LicenseKeychainError(updateStatus) - } - var add = key - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - // A signed license is an entitlement, not an authentication secret. It - // must load unattended at app launch, so biometric user-presence access - // control would break the local-first restore contract. - let addStatus = SecItemAdd(add as CFDictionary, nil) // nosemgrep: swift.biometrics-and-auth.missing-user-auth.keychain-without-user-auth - guard addStatus == errSecSuccess else { throw LicenseKeychainError(addStatus) } - } - - func delete() throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw LicenseKeychainError(status) - } + var add = key + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + // A signed license is an entitlement, not an authentication secret. It + // must load unattended at app launch, so biometric user-presence access + // control would break the local-first restore contract. + // nosemgrep: swift.biometrics-and-auth.missing-user-auth.keychain-without-user-auth + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw LicenseKeychainError(addStatus) } + } + + func delete() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw LicenseKeychainError(status) } + } } struct LicenseKeychainError: LocalizedError { - let status: OSStatus + let status: OSStatus - init(_ status: OSStatus) { self.status = status } + init(_ status: OSStatus) { self.status = status } - var errorDescription: String? { - SecCopyErrorMessageString(status, nil) as String? - ?? "Keychain error \(status)" - } + var errorDescription: String? { + SecCopyErrorMessageString(status, nil) as String? + ?? "Keychain error \(status)" + } +} + +/// Keep the independent Swift license store on the same no-Keychain contract as +/// the Rust secret bundle. Xcode launches XCTest inside the app host before +/// `XCTestCase` is necessarily visible to lifecycle code, but it installs these +/// process markers before `App.body` evaluates `LicenseService.shared`. +/// A relocated data directory or generic CI flag is not proof of a test host +/// and must not discard persisted license truth. +func licenseKeychainDisabledByEnvironment( + _ environment: [String: String] = ProcessInfo.processInfo.environment +) -> Bool { + environment["CODESCRIBE_DISABLE_KEYCHAIN"] != nil + || environment["XCTestConfigurationFilePath"] != nil + || environment["XCTestSessionIdentifier"] != nil + || environment["XCTestBundlePath"] != nil } @MainActor final class LicenseService: ObservableObject { - static let shared = LicenseService(keychain: SystemLicenseKeychain(), autoload: true) - static let preview = LicenseService(keychain: nil, autoload: false) - - @Published private(set) var status: CsLicenseStatus = .unlicensed - @Published private(set) var lastError: String? - - var canUseAgentic: Bool { status.agenticEntitled } - var agenticBlockMessage: String { - "Agentic requires a license. Basic dictation remains free." + static let shared: LicenseService = { + let keychain: LicenseKeychainStoring? = + licenseKeychainDisabledByEnvironment() + ? nil + : SystemLicenseKeychain() + return LicenseService(keychain: keychain, autoload: true) + }() + static let preview = LicenseService(keychain: nil, autoload: false) + + @Published private(set) var status: CsLicenseStatus = .unlicensed + @Published private(set) var lastError: String? + + /// Core's contract (`LicenseStatus::allows_agentic`) is explicit: the SKU + /// answers "was this key ever entitled", never "is the entitlement current" — + /// callers must combine it with the evaluated state. Gating on the SKU alone + /// made every activated key a lifetime unlock because `updates_until` was + /// computed by core and then ignored here. + var canUseAgentic: Bool { + guard status.agenticEntitled else { return false } + switch status.state { + case .active, .graceOffline: return true + case .unlicensed, .expiredUpdates: return false } - - private let keychain: LicenseKeychainStoring? - private let now: () -> Date - private let activateBridge: (String, Int64) throws -> CsLicenseStatus - private let statusBridge: (String?, Int64?, Int64) throws -> CsLicenseStatus - - init( - keychain: LicenseKeychainStoring?, - autoload: Bool, - now: @escaping () -> Date = Date.init, - activateBridge: @escaping (String, Int64) throws -> CsLicenseStatus = { - try licenseActivate(key: $0, nowUnixSeconds: $1) - }, - statusBridge: @escaping (String?, Int64?, Int64) throws -> CsLicenseStatus = { - try licenseStatus( - key: $0, - lastOnlineValidationUnixSeconds: $1, - nowUnixSeconds: $2 - ) - } - ) { - self.keychain = keychain - self.now = now - self.activateBridge = activateBridge - self.statusBridge = statusBridge - if autoload { refresh() } + } + var agenticBlockMessage: String { + status.state == .expiredUpdates + ? "Your license period ended. Renew to keep using Agentic — Basic dictation remains free." + : "Agentic requires a license. Basic dictation remains free." + } + + private let keychain: LicenseKeychainStoring? + private let now: () -> Date + private let activateBridge: (String, Int64) throws -> CsLicenseStatus + private let statusBridge: (String?, Int64?, Int64) throws -> CsLicenseStatus + + init( + keychain: LicenseKeychainStoring?, + autoload: Bool, + now: @escaping () -> Date = Date.init, + activateBridge: @escaping (String, Int64) throws -> CsLicenseStatus = { + try licenseActivate(key: $0, nowUnixSeconds: $1) + }, + statusBridge: @escaping (String?, Int64?, Int64) throws -> CsLicenseStatus = { + try licenseStatus( + key: $0, + lastOnlineValidationUnixSeconds: $1, + nowUnixSeconds: $2 + ) } - - func refresh() { - do { - let stored = try keychain?.load().map { try JSONDecoder().decode(PersistedLicense.self, from: $0) } - status = try statusBridge( - stored?.key, - stored?.lastOnlineValidation, - Int64(now().timeIntervalSince1970) - ) - lastError = nil - } catch { - status = .unlicensed - lastError = String(describing: error) - } + ) { + self.keychain = keychain + self.now = now + self.activateBridge = activateBridge + self.statusBridge = statusBridge + if autoload { refresh() } + } + + func refresh() { + do { + let stored = try keychain?.load().map { + try JSONDecoder().decode(PersistedLicense.self, from: $0) + } + status = try statusBridge( + stored?.key, + stored?.lastOnlineValidation, + Int64(now().timeIntervalSince1970) + ) + lastError = nil + } catch { + status = .unlicensed + lastError = String(describing: error) } - - @discardableResult - func activate(_ rawKey: String) -> Bool { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { - lastError = "Enter a CSK1 license key." - return false - } - do { - let timestamp = Int64(now().timeIntervalSince1970) - let validated = try activateBridge(key, timestamp) - let persisted = try JSONEncoder().encode(PersistedLicense( - key: key, - lastOnlineValidation: timestamp - )) - try keychain?.save(persisted) - status = validated - lastError = nil - return true - } catch { - status = .unlicensed - lastError = String(describing: error) - return false - } + } + + @discardableResult + func activate(_ rawKey: String) -> Bool { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { + lastError = "Enter a CSK1 license key." + return false } - - func removeLicense() { - do { - try keychain?.delete() - status = .unlicensed - lastError = nil - } catch { - lastError = String(describing: error) - } + do { + let timestamp = Int64(now().timeIntervalSince1970) + let validated = try activateBridge(key, timestamp) + let persisted = try JSONEncoder().encode( + PersistedLicense( + key: key, + lastOnlineValidation: timestamp + )) + try keychain?.save(persisted) + status = validated + lastError = nil + return true + } catch { + status = .unlicensed + lastError = String(describing: error) + return false } + } + + func removeLicense() { + do { + try keychain?.delete() + status = .unlicensed + lastError = nil + } catch { + lastError = String(describing: error) + } + } } extension CsLicenseStatus { - static var unlicensed: Self { - Self( - state: .unlicensed, - daysLeft: nil, - sku: nil, - emailHash: nil, - issued: nil, - updatesUntil: nil, - seatLimit: nil, - agenticEntitled: false - ) - } + static var unlicensed: Self { + Self( + state: .unlicensed, + daysLeft: nil, + sku: nil, + emailHash: nil, + issued: nil, + updatesUntil: nil, + seatLimit: nil, + agenticEntitled: false + ) + } } diff --git a/macos/Codescribe/Services/UpdaterService.swift b/macos/Codescribe/Services/UpdaterService.swift index 2d7072f9..6156800a 100644 --- a/macos/Codescribe/Services/UpdaterService.swift +++ b/macos/Codescribe/Services/UpdaterService.swift @@ -17,52 +17,52 @@ import Sparkle // accept an unsigned one. @MainActor final class UpdaterService: ObservableObject { - private static let log = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", - category: "updater" - ) + private static let log = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.vetcoders.codescribe", + category: "updater" + ) - /// False while a check is already in flight or the updater has not started. - @Published private(set) var canCheckForUpdates = false + /// False while a check is already in flight or the updater has not started. + @Published private(set) var canCheckForUpdates = false - private let controller: SPUStandardUpdaterController - private var cancellable: AnyCancellable? + private let controller: SPUStandardUpdaterController + private var cancellable: AnyCancellable? - init() { - controller = SPUStandardUpdaterController( - startingUpdater: true, - updaterDelegate: nil, - userDriverDelegate: nil - ) - cancellable = controller.updater.publisher(for: \.canCheckForUpdates) - .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.canCheckForUpdates = $0 } + init() { + controller = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + cancellable = controller.updater.publisher(for: \.canCheckForUpdates) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.canCheckForUpdates = $0 } - // E2E verifier hook: a scripted upgrade test can't click the tray, so an - // env flag triggers the user-initiated check shortly after launch. Not a - // user-facing surface; release builds without the flag are unaffected. - if ProcessInfo.processInfo.environment["CODESCRIBE_SPARKLE_CHECK_AT_LAUNCH"] == "1" { - Self.log.info("sparkle check-at-launch hook armed") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.checkForUpdates() - } - } - // Headless variant for the automated N-1→N verifier: a background check - // (paired with the SUAutomaticallyUpdate defaults override) downloads, - // verifies the EdDSA signature, and stages the install without any UI. - if ProcessInfo.processInfo.environment["CODESCRIBE_SPARKLE_BACKGROUND_CHECK"] == "1" { - Self.log.info("sparkle background-check hook armed") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.controller.updater.checkForUpdatesInBackground() - } - } + // E2E verifier hook: a scripted upgrade test can't click the tray, so an + // env flag triggers the user-initiated check shortly after launch. Not a + // user-facing surface; release builds without the flag are unaffected. + if ProcessInfo.processInfo.environment["CODESCRIBE_SPARKLE_CHECK_AT_LAUNCH"] == "1" { + Self.log.info("sparkle check-at-launch hook armed") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.checkForUpdates() + } } - - /// User-initiated check ("Check for Updates…" in the tray). The app is an - /// LSUIElement accessory, so activate first — otherwise Sparkle's update - /// window can appear behind the frontmost app. - func checkForUpdates() { - NSApp.activate(ignoringOtherApps: true) - controller.checkForUpdates(nil) + // Headless variant for the automated N-1→N verifier: a background check + // (paired with the SUAutomaticallyUpdate defaults override) downloads, + // verifies the EdDSA signature, and stages the install without any UI. + if ProcessInfo.processInfo.environment["CODESCRIBE_SPARKLE_BACKGROUND_CHECK"] == "1" { + Self.log.info("sparkle background-check hook armed") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.controller.updater.checkForUpdatesInBackground() + } } + } + + /// User-initiated check ("Check for Updates…" in the tray). The app is an + /// LSUIElement accessory, so activate first — otherwise Sparkle's update + /// window can appear behind the frontmost app. + func checkForUpdates() { + NSApp.activate(ignoringOtherApps: true) + controller.checkForUpdates(nil) + } } diff --git a/macos/CodescribeTests/ActivationPingTests.swift b/macos/CodescribeTests/ActivationPingTests.swift index 294c773a..68b07ec2 100644 --- a/macos/CodescribeTests/ActivationPingTests.swift +++ b/macos/CodescribeTests/ActivationPingTests.swift @@ -1,106 +1,107 @@ import Foundation import XCTest + @testable import Codescribe private final class ActivationPingTransportSpy: ActivationPingTransport { - private(set) var requests: [URLRequest] = [] - var statusCode = 202 + private(set) var requests: [URLRequest] = [] + var statusCode = 202 - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - requests.append(request) - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: "HTTP/1.1", - headerFields: nil - )! - return (Data(), response) - } + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (Data(), response) + } } @MainActor final class ActivationPingTests: XCTestCase { - private var defaults: UserDefaults! - private var suiteName: String! + private var defaults: UserDefaults! + private var suiteName: String! - override func setUp() { - super.setUp() - suiteName = "ActivationPingTests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName) - defaults.removePersistentDomain(forName: suiteName) - } + override func setUp() { + super.setUp() + suiteName = "ActivationPingTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } - override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - defaults = nil - suiteName = nil - super.tearDown() - } + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } - func testOptInDefaultsOffAndProducesZeroNetworkRequests() async { - let transport = ActivationPingTransportSpy() - let ping = makePing(transport: transport) + func testOptInDefaultsOffAndProducesZeroNetworkRequests() async { + let transport = ActivationPingTransportSpy() + let ping = makePing(transport: transport) - XCTAssertFalse(defaults.bool(forKey: ActivationPing.optInDefaultsKey)) - let outcome = await ping.recordFirstSuccessfulDictation() - XCTAssertEqual(outcome, .notConsented) - XCTAssertTrue(transport.requests.isEmpty) - } + XCTAssertFalse(defaults.bool(forKey: ActivationPing.optInDefaultsKey)) + let outcome = await ping.recordFirstSuccessfulDictation() + XCTAssertEqual(outcome, .notConsented) + XCTAssertTrue(transport.requests.isEmpty) + } - func testOptInSendsExactlyOneContentFreeEventAndPersistsReceipt() async throws { - defaults.set(true, forKey: ActivationPing.optInDefaultsKey) - let transport = ActivationPingTransportSpy() - let ping = makePing(transport: transport) + func testOptInSendsExactlyOneContentFreeEventAndPersistsReceipt() async throws { + defaults.set(true, forKey: ActivationPing.optInDefaultsKey) + let transport = ActivationPingTransportSpy() + let ping = makePing(transport: transport) - let firstOutcome = await ping.recordFirstSuccessfulDictation() - let secondOutcome = await ping.recordFirstSuccessfulDictation() - XCTAssertEqual(firstOutcome, .sent) - XCTAssertEqual(secondOutcome, .alreadySent) - XCTAssertEqual(transport.requests.count, 1) - XCTAssertTrue(defaults.bool(forKey: ActivationPing.sentDefaultsKey)) + let firstOutcome = await ping.recordFirstSuccessfulDictation() + let secondOutcome = await ping.recordFirstSuccessfulDictation() + XCTAssertEqual(firstOutcome, .sent) + XCTAssertEqual(secondOutcome, .alreadySent) + XCTAssertEqual(transport.requests.count, 1) + XCTAssertTrue(defaults.bool(forKey: ActivationPing.sentDefaultsKey)) - let request = try XCTUnwrap(transport.requests.first) - XCTAssertEqual(request.url?.absoluteString, "https://plausible.test/api/event") - XCTAssertEqual(request.httpMethod, "POST") - let body = try XCTUnwrap(request.httpBody) - let object = try XCTUnwrap( - JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - XCTAssertEqual(object["name"] as? String, "first_successful_dictation") - XCTAssertEqual(object["domain"] as? String, "dev.codescribe.test") - XCTAssertEqual(object["url"] as? String, "https://dev.codescribe.test/app/activation") - XCTAssertEqual( - object["props"] as? [String: String], - ["app_version": "9.9.9-test", "os": "macOS test"] - ) - XCTAssertNil(object["text"]) - XCTAssertFalse(String(decoding: body, as: UTF8.self).contains("transcript")) - } + let request = try XCTUnwrap(transport.requests.first) + XCTAssertEqual(request.url?.absoluteString, "https://plausible.test/api/event") + XCTAssertEqual(request.httpMethod, "POST") + let body = try XCTUnwrap(request.httpBody) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + XCTAssertEqual(object["name"] as? String, "first_successful_dictation") + XCTAssertEqual(object["domain"] as? String, "dev.codescribe.test") + XCTAssertEqual(object["url"] as? String, "https://dev.codescribe.test/app/activation") + XCTAssertEqual( + object["props"] as? [String: String], + ["app_version": "9.9.9-test", "os": "macOS test"] + ) + XCTAssertNil(object["text"]) + XCTAssertFalse(String(decoding: body, as: UTF8.self).contains("transcript")) + } - func testMissingB3DomainKeepsNetworkDisabledEvenAfterOptIn() async { - defaults.set(true, forKey: ActivationPing.optInDefaultsKey) - let transport = ActivationPingTransportSpy() - let ping = ActivationPing( - configuration: .production, - defaults: defaults, - transport: transport - ) + func testMissingB3DomainKeepsNetworkDisabledEvenAfterOptIn() async { + defaults.set(true, forKey: ActivationPing.optInDefaultsKey) + let transport = ActivationPingTransportSpy() + let ping = ActivationPing( + configuration: .production, + defaults: defaults, + transport: transport + ) - let outcome = await ping.recordFirstSuccessfulDictation() - XCTAssertEqual(outcome, .disabled) - XCTAssertTrue(transport.requests.isEmpty) - } + let outcome = await ping.recordFirstSuccessfulDictation() + XCTAssertEqual(outcome, .disabled) + XCTAssertTrue(transport.requests.isEmpty) + } - private func makePing(transport: ActivationPingTransportSpy) -> ActivationPing { - ActivationPing( - configuration: ActivationPingConfiguration( - endpoint: URL(string: "https://plausible.test/api/event")!, - domain: "dev.codescribe.test" - ), - defaults: defaults, - transport: transport, - appVersion: { "9.9.9-test" }, - operatingSystem: { "macOS test" } - ) - } + private func makePing(transport: ActivationPingTransportSpy) -> ActivationPing { + ActivationPing( + configuration: ActivationPingConfiguration( + endpoint: URL(string: "https://plausible.test/api/event")!, + domain: "dev.codescribe.test" + ), + defaults: defaults, + transport: transport, + appVersion: { "9.9.9-test" }, + operatingSystem: { "macOS test" } + ) + } } diff --git a/macos/CodescribeTests/AgentChatCancellationTests.swift b/macos/CodescribeTests/AgentChatCancellationTests.swift index 14ccaa51..27e9cae6 100644 --- a/macos/CodescribeTests/AgentChatCancellationTests.swift +++ b/macos/CodescribeTests/AgentChatCancellationTests.swift @@ -1,364 +1,367 @@ import Foundation import XCTest + @testable import Codescribe @MainActor final class AgentChatCancellationTests: XCTestCase { - private enum CancellationEvent: Equatable { - case swiftTaskCancelled - case rustCancel(String) + private enum CancellationEvent: Equatable { + case swiftTaskCancelled + case rustCancel(String) + } + + private final class LockedState: @unchecked Sendable { + private let lock = NSLock() + private var storedEvents: [CancellationEvent] = [] + private var storedContinuation: CheckedContinuation? + private var storedCallCount = 0 + + var events: [CancellationEvent] { + lock.withLock { storedEvents } } - private final class LockedState: @unchecked Sendable { - private let lock = NSLock() - private var storedEvents: [CancellationEvent] = [] - private var storedContinuation: CheckedContinuation? - private var storedCallCount = 0 - - var events: [CancellationEvent] { - lock.withLock { storedEvents } - } - - var callCount: Int { - lock.withLock { storedCallCount } - } - - func nextCall() -> Int { - lock.withLock { - storedCallCount += 1 - return storedCallCount - } - } - - func suspend(with continuation: CheckedContinuation) { - lock.withLock { storedContinuation = continuation } - } - - func record(_ event: CancellationEvent) { - lock.withLock { storedEvents.append(event) } - } - - func cancelSuspendedCall() { - let continuation = lock.withLock { () -> CheckedContinuation? in - defer { storedContinuation = nil } - return storedContinuation - } - continuation?.resume(throwing: CancellationError()) - } + var callCount: Int { + lock.withLock { storedCallCount } } - private final class SpyEngine: AgentChatEngine { - let firstStreamStarted: XCTestExpectation - let emitPartialAndTool: Bool - let state = LockedState() - var approvalHandler: (@MainActor (PendingToolApproval) -> Void)? - var resolvedApprovals: [(PendingToolApproval, Bool)] = [] - var rememberedApprovals: [Bool] = [] - - init(firstStreamStarted: XCTestExpectation, emitPartialAndTool: Bool = false) { - self.firstStreamStarted = firstStreamStarted - self.emitPartialAndTool = emitPartialAndTool - } - - func isAvailable() -> Bool { true } - func availabilityDetail() -> String? { nil } - func generateThreadTitle(_ text: String) async throws -> String? { nil } - - func streamReply( - _ text: String, - threadId: String, - attachmentPaths: [String], - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (String, String) -> Void, - onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void - ) async throws -> String { - let call = state.nextCall() - if call > 1 { - await onDelta("Recovered") - return "Recovered" - } - - if emitPartialAndTool { - await onDelta("Partial answer") - await onToolExecuting("slow-side-effect", "call-1") - } - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - state.suspend(with: continuation) - firstStreamStarted.fulfill() - } - } onCancel: { - self.state.record(.swiftTaskCancelled) - } - } - - func cancelReply(threadId: String) -> Bool { - state.record(.rustCancel(threadId)) - state.cancelSuspendedCall() - return true - } - - func installToolApprovalHandler( - _ handler: @escaping @MainActor (PendingToolApproval) -> Void - ) { - approvalHandler = handler - } + func nextCall() -> Int { + lock.withLock { + storedCallCount += 1 + return storedCallCount + } + } - func resolveToolApproval( - _ request: PendingToolApproval, approved: Bool, remember: Bool - ) -> Bool { - resolvedApprovals.append((request, approved)) - rememberedApprovals.append(remember) - return true - } + func suspend(with continuation: CheckedContinuation) { + lock.withLock { storedContinuation = continuation } + } - @MainActor - func emitApproval(_ request: PendingToolApproval) { - approvalHandler?(request) - } + func record(_ event: CancellationEvent) { + lock.withLock { storedEvents.append(event) } } - private final class VoiceCancelSpy: VoiceTurnCancelling { - private(set) var threadIDs: [String] = [] - var acknowledges = true + func cancelSuspendedCall() { + let continuation = lock.withLock { () -> CheckedContinuation? in + defer { storedContinuation = nil } + return storedContinuation + } + continuation?.resume(throwing: CancellationError()) + } + } + + private final class SpyEngine: AgentChatEngine { + let firstStreamStarted: XCTestExpectation + let emitPartialAndTool: Bool + let state = LockedState() + var approvalHandler: (@MainActor (PendingToolApproval) -> Void)? + var resolvedApprovals: [(PendingToolApproval, Bool)] = [] + var rememberedApprovals: [Bool] = [] + + init(firstStreamStarted: XCTestExpectation, emitPartialAndTool: Bool = false) { + self.firstStreamStarted = firstStreamStarted + self.emitPartialAndTool = emitPartialAndTool + } - func cancelVoiceTurn(threadId: String) -> Bool { - threadIDs.append(threadId) - return acknowledges + func isAvailable() -> Bool { true } + func availabilityDetail() -> String? { nil } + func generateThreadTitle(_ text: String) async throws -> String? { nil } + + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (String, String) -> Void, + onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void + ) async throws -> String { + let call = state.nextCall() + if call > 1 { + await onDelta("Recovered") + return "Recovered" + } + + if emitPartialAndTool { + await onDelta("Partial answer") + await onToolExecuting("slow-side-effect", "call-1") + } + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + state.suspend(with: continuation) + firstStreamStarted.fulfill() } + } onCancel: { + self.state.record(.swiftTaskCancelled) + } } - func testThinkingStopCancelsSwiftBeforeExactRustThreadAndIsIdempotent() async throws { - let started = expectation(description: "first composer stream started") - let engine = SpyEngine(firstStreamStarted: started) - let store = makeStore(engine: engine, backendID: "backend-thread-42") - store.draft = "start thinking" - - store.send() - await fulfillment(of: [started], timeout: 1) - - XCTAssertEqual(store.activeComposerTurn?.phase, .thinking) - XCTAssertEqual(store.activeComposerTurn?.backendThreadID, "backend-thread-42") - - store.stopActiveTurn() - XCTAssertEqual(store.activeComposerTurn?.phase, .cancelling) - store.stopActiveTurn() - - await waitUntil { store.activeComposerTurn == nil } - - XCTAssertEqual( - engine.state.events, - [.swiftTaskCancelled, .rustCancel("backend-thread-42")] - ) - let assistant = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) - XCTAssertEqual(assistant.text, "Stopped") - XCTAssertTrue(assistant.wasStopped) - XCTAssertFalse(assistant.isThinking) - XCTAssertFalse(assistant.isStreaming) - XCTAssertFalse(store.isThinking) - XCTAssertFalse(store.isStreaming) + func cancelReply(threadId: String) -> Bool { + state.record(.rustCancel(threadId)) + state.cancelSuspendedCall() + return true } - func testStreamingStopPreservesPartialCancelsToolAndNextSendRecovers() async throws { - let started = expectation(description: "stream emitted partial text and slow tool") - let engine = SpyEngine(firstStreamStarted: started, emitPartialAndTool: true) - let store = makeStore(engine: engine, backendID: "backend-recovery") - store.draft = "stream this" - - store.send() - await fulfillment(of: [started], timeout: 1) - - XCTAssertEqual(store.activeComposerTurn?.phase, .streaming) - XCTAssertTrue(store.isStreaming) - - store.stopActiveTurn() - await waitUntil { store.activeComposerTurn == nil } - - let stoppedAssistant = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) - XCTAssertEqual(stoppedAssistant.text, "Partial answer") - XCTAssertTrue(stoppedAssistant.wasStopped) - XCTAssertFalse(stoppedAssistant.isThinking) - XCTAssertFalse(stoppedAssistant.isStreaming) - - let tool = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .tool }) - let line = try XCTUnwrap(tool.toolLines.first) - XCTAssertEqual(line.state, .cancelled) - XCTAssertEqual(line.verb, "stopped") - XCTAssertTrue(tool.toolTitle.contains("stopped")) - - store.draft = "send again" - store.send() - await waitUntil { engine.state.callCount == 2 && store.activeComposerTurn == nil } - - let recovered = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) - XCTAssertEqual(recovered.text, "Recovered") - XCTAssertFalse(recovered.wasStopped) - XCTAssertFalse(recovered.isThinking) - XCTAssertFalse(recovered.isStreaming) + func installToolApprovalHandler( + _ handler: @escaping @MainActor (PendingToolApproval) -> Void + ) { + approvalHandler = handler } - func testApprovalUiResumesExactPendingCall() { - let engine = SpyEngine( - firstStreamStarted: XCTestExpectation(description: "unused stream") - ) - let store = makeStore(engine: engine, backendID: "backend-approval") - let exact = PendingToolApproval( - callID: "call-exact", - sessionID: "session-exact", - threadID: "backend-approval", - tool: "mcp__desktop-commander__write_file", - server: "desktop-commander", - risk: "mutating", - summary: "write file", - command: nil, - cwd: nil, - paths: ["/workspace/file"] - ) - let other = PendingToolApproval( - callID: "call-other", - sessionID: "session-other", - threadID: "other-thread", - tool: "mcp__desktop-commander__write_file", - server: "desktop-commander", - risk: "mutating", - summary: "write other file", - command: nil, - cwd: nil, - paths: ["/workspace/other"] - ) - - engine.emitApproval(other) - engine.emitApproval(exact) - XCTAssertEqual(store.currentToolApprovals, [exact]) - - store.resolveToolApproval(exact, approved: true) - XCTAssertEqual(engine.resolvedApprovals.count, 1) - XCTAssertEqual(engine.resolvedApprovals.first?.0, exact) - XCTAssertEqual(engine.resolvedApprovals.first?.1, true) - // Default resolve is allow-once: remember must stay false unless the - // operator explicitly presses "Zawsze zezwalaj". - XCTAssertEqual(engine.rememberedApprovals, [false]) - XCTAssertTrue(store.currentToolApprovals.isEmpty) - XCTAssertEqual(store.pendingToolApprovals, [other]) - - store.resolveToolApproval(other, approved: true, remember: true) - XCTAssertEqual(engine.rememberedApprovals, [false, true]) - XCTAssertTrue(store.pendingToolApprovals.isEmpty) + func resolveToolApproval( + _ request: PendingToolApproval, approved: Bool, remember: Bool + ) -> Bool { + resolvedApprovals.append((request, approved)) + rememberedApprovals.append(remember) + return true } - func testComposerActionProjectsThinkingStreamingAndCancelling() { - XCTAssertEqual( - ComposerActionVisualState.resolve(canSend: false, activePhase: nil), - .send(enabled: false) - ) - XCTAssertEqual( - ComposerActionVisualState.resolve(canSend: true, activePhase: nil), - .send(enabled: true) - ) - XCTAssertEqual( - ComposerActionVisualState.resolve(canSend: false, activePhase: .thinking), - .stop - ) - XCTAssertEqual( - ComposerActionVisualState.resolve(canSend: false, activePhase: .streaming), - .stop - ) - XCTAssertEqual( - ComposerActionVisualState.resolve(canSend: true, activePhase: .cancelling), - .stopping - ) - XCTAssertFalse(ComposerActionVisualState.stopping.isEnabled) - XCTAssertEqual(ComposerActionVisualState.stop.accessibilityLabel, "Stop response") - XCTAssertEqual(ComposerActionAccessibility.identifier, "agent-composer-primary-action") + @MainActor + func emitApproval(_ request: PendingToolApproval) { + approvalHandler?(request) } + } - func testComposerSendUsesSystemCircleAndSharedControlGeometry() { - guard case .sf(let symbolName) = ComposerActionVisualState.send(enabled: true).icon.backend else { - return XCTFail("Composer send action must use an SF Symbol") - } + private final class VoiceCancelSpy: VoiceTurnCancelling { + private(set) var threadIDs: [String] = [] + var acknowledges = true - XCTAssertEqual(symbolName, "arrow.up.circle.fill") - XCTAssertEqual(ComposerControlMetrics.glyphSize, 15) - XCTAssertEqual(ComposerControlMetrics.hitTargetSize, 22) + func cancelVoiceTurn(threadId: String) -> Bool { + threadIDs.append(threadId) + return acknowledges } - - func testVoiceStopRoutesOnlyToVoiceAdapterPreservesPartialAndRecovers() throws { - // Construct directly rather than registering an XCTest expectation: the - // stronger routing assertion is that the composer engine records no - // events/calls, and registered-but-unwaited expectations fail XCTest. - let composerStarted = XCTestExpectation(description: "unused composer start") - let engine = SpyEngine(firstStreamStarted: composerStarted) - let voice = VoiceCancelSpy() - let store = makeStore( - engine: engine, - backendID: "voice-thread-42", - voiceTurnCanceller: voice - ) - - store.ingestVoiceTurn(threadId: "voice-thread-42", userText: "voice request") - store.ingestVoiceDelta("Partial voice answer") - store.ingestVoiceToolExecuting(name: "slow-side-effect", id: "voice-call-1") - - XCTAssertEqual(store.selectedComposerTurnPhase, .streaming) - store.stopActiveTurn() - store.stopActiveTurn() - - XCTAssertEqual(voice.threadIDs, ["voice-thread-42"]) - XCTAssertTrue(engine.state.events.isEmpty, "voice Stop must not call composer cancellation") - XCTAssertEqual(store.voiceTurnPhase, .cancelling) - - // A queued delta after Stop cannot repaint the cancelling bubble. - store.ingestVoiceDelta(" late") - store.ingestVoiceCancelled(threadId: "voice-thread-42") - store.ingestVoiceDone() // a duplicate successful terminal is ignored - - let stopped = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) - XCTAssertEqual(stopped.text, "Partial voice answer") - XCTAssertTrue(stopped.wasStopped) - XCTAssertFalse(stopped.isThinking) - XCTAssertFalse(stopped.isStreaming) - let stoppedTool = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .tool }) - XCTAssertEqual(stoppedTool.toolLines.first?.state, .cancelled) - XCTAssertEqual(stoppedTool.toolLines.first?.verb, "stopped") - XCTAssertNil(store.voiceTurnPhase) - - store.ingestVoiceTurn(threadId: "voice-thread-42", userText: "try again") - store.ingestVoiceDelta("Recovered voice turn") - store.ingestVoiceDone() - - let recovered = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) - XCTAssertEqual(recovered.text, "Recovered voice turn") - XCTAssertFalse(recovered.wasStopped) - XCTAssertNil(store.voiceTurnPhase) + } + + func testThinkingStopCancelsSwiftBeforeExactRustThreadAndIsIdempotent() async throws { + let started = expectation(description: "first composer stream started") + let engine = SpyEngine(firstStreamStarted: started) + let store = makeStore(engine: engine, backendID: "backend-thread-42") + store.draft = "start thinking" + + store.send() + await fulfillment(of: [started], timeout: 1) + + XCTAssertEqual(store.activeComposerTurn?.phase, .thinking) + XCTAssertEqual(store.activeComposerTurn?.backendThreadID, "backend-thread-42") + + store.stopActiveTurn() + XCTAssertEqual(store.activeComposerTurn?.phase, .cancelling) + store.stopActiveTurn() + + await waitUntil { store.activeComposerTurn == nil } + + XCTAssertEqual( + engine.state.events, + [.swiftTaskCancelled, .rustCancel("backend-thread-42")] + ) + let assistant = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) + XCTAssertEqual(assistant.text, "Stopped") + XCTAssertTrue(assistant.wasStopped) + XCTAssertFalse(assistant.isThinking) + XCTAssertFalse(assistant.isStreaming) + XCTAssertFalse(store.isThinking) + XCTAssertFalse(store.isStreaming) + } + + func testStreamingStopPreservesPartialCancelsToolAndNextSendRecovers() async throws { + let started = expectation(description: "stream emitted partial text and slow tool") + let engine = SpyEngine(firstStreamStarted: started, emitPartialAndTool: true) + let store = makeStore(engine: engine, backendID: "backend-recovery") + store.draft = "stream this" + + store.send() + await fulfillment(of: [started], timeout: 1) + + XCTAssertEqual(store.activeComposerTurn?.phase, .streaming) + XCTAssertTrue(store.isStreaming) + + store.stopActiveTurn() + await waitUntil { store.activeComposerTurn == nil } + + let stoppedAssistant = try XCTUnwrap( + store.currentThread?.messages.last { $0.role == .assistant }) + XCTAssertEqual(stoppedAssistant.text, "Partial answer") + XCTAssertTrue(stoppedAssistant.wasStopped) + XCTAssertFalse(stoppedAssistant.isThinking) + XCTAssertFalse(stoppedAssistant.isStreaming) + + let tool = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .tool }) + let line = try XCTUnwrap(tool.toolLines.first) + XCTAssertEqual(line.state, .cancelled) + XCTAssertEqual(line.verb, "stopped") + XCTAssertTrue(tool.toolTitle.contains("stopped")) + + store.draft = "send again" + store.send() + await waitUntil { engine.state.callCount == 2 && store.activeComposerTurn == nil } + + let recovered = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) + XCTAssertEqual(recovered.text, "Recovered") + XCTAssertFalse(recovered.wasStopped) + XCTAssertFalse(recovered.isThinking) + XCTAssertFalse(recovered.isStreaming) + } + + func testApprovalUiResumesExactPendingCall() { + let engine = SpyEngine( + firstStreamStarted: XCTestExpectation(description: "unused stream") + ) + let store = makeStore(engine: engine, backendID: "backend-approval") + let exact = PendingToolApproval( + callID: "call-exact", + sessionID: "session-exact", + threadID: "backend-approval", + tool: "mcp__desktop-commander__write_file", + server: "desktop-commander", + risk: "mutating", + summary: "write file", + command: nil, + cwd: nil, + paths: ["/workspace/file"] + ) + let other = PendingToolApproval( + callID: "call-other", + sessionID: "session-other", + threadID: "other-thread", + tool: "mcp__desktop-commander__write_file", + server: "desktop-commander", + risk: "mutating", + summary: "write other file", + command: nil, + cwd: nil, + paths: ["/workspace/other"] + ) + + engine.emitApproval(other) + engine.emitApproval(exact) + XCTAssertEqual(store.currentToolApprovals, [exact]) + + store.resolveToolApproval(exact, approved: true) + XCTAssertEqual(engine.resolvedApprovals.count, 1) + XCTAssertEqual(engine.resolvedApprovals.first?.0, exact) + XCTAssertEqual(engine.resolvedApprovals.first?.1, true) + // Default resolve is allow-once: remember must stay false unless the + // operator explicitly presses "Zawsze zezwalaj". + XCTAssertEqual(engine.rememberedApprovals, [false]) + XCTAssertTrue(store.currentToolApprovals.isEmpty) + XCTAssertEqual(store.pendingToolApprovals, [other]) + + store.resolveToolApproval(other, approved: true, remember: true) + XCTAssertEqual(engine.rememberedApprovals, [false, true]) + XCTAssertTrue(store.pendingToolApprovals.isEmpty) + } + + func testComposerActionProjectsThinkingStreamingAndCancelling() { + XCTAssertEqual( + ComposerActionVisualState.resolve(canSend: false, activePhase: nil), + .send(enabled: false) + ) + XCTAssertEqual( + ComposerActionVisualState.resolve(canSend: true, activePhase: nil), + .send(enabled: true) + ) + XCTAssertEqual( + ComposerActionVisualState.resolve(canSend: false, activePhase: .thinking), + .stop + ) + XCTAssertEqual( + ComposerActionVisualState.resolve(canSend: false, activePhase: .streaming), + .stop + ) + XCTAssertEqual( + ComposerActionVisualState.resolve(canSend: true, activePhase: .cancelling), + .stopping + ) + XCTAssertFalse(ComposerActionVisualState.stopping.isEnabled) + XCTAssertEqual(ComposerActionVisualState.stop.accessibilityLabel, "Stop response") + XCTAssertEqual(ComposerActionAccessibility.identifier, "agent-composer-primary-action") + } + + func testComposerSendUsesSystemCircleAndSharedControlGeometry() { + guard case .sf(let symbolName) = ComposerActionVisualState.send(enabled: true).icon.backend + else { + return XCTFail("Composer send action must use an SF Symbol") } - private func makeStore( - engine: AgentChatEngine, - backendID: String, - voiceTurnCanceller: VoiceTurnCancelling? = nil - ) -> AgentChatStore { - var thread = ChatThread(title: "Cancellation", meta: "now") - thread.backendId = backendID - thread.messagesLoaded = true - return AgentChatStore( - engine: engine, - threads: [thread], - voiceTurnCanceller: voiceTurnCanceller - ) - } - - private func waitUntil( - timeout: Duration = .seconds(1), - _ condition: @escaping @MainActor () -> Bool - ) async { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - while clock.now < deadline { - if condition() { return } - try? await Task.sleep(for: .milliseconds(5)) - } - XCTFail("Timed out waiting for cancellation state") + XCTAssertEqual(symbolName, "arrow.up.circle.fill") + XCTAssertEqual(ComposerControlMetrics.glyphSize, 15) + XCTAssertEqual(ComposerControlMetrics.hitTargetSize, 22) + } + + func testVoiceStopRoutesOnlyToVoiceAdapterPreservesPartialAndRecovers() throws { + // Construct directly rather than registering an XCTest expectation: the + // stronger routing assertion is that the composer engine records no + // events/calls, and registered-but-unwaited expectations fail XCTest. + let composerStarted = XCTestExpectation(description: "unused composer start") + let engine = SpyEngine(firstStreamStarted: composerStarted) + let voice = VoiceCancelSpy() + let store = makeStore( + engine: engine, + backendID: "voice-thread-42", + voiceTurnCanceller: voice + ) + + store.ingestVoiceTurn(threadId: "voice-thread-42", userText: "voice request") + store.ingestVoiceDelta("Partial voice answer") + store.ingestVoiceToolExecuting(name: "slow-side-effect", id: "voice-call-1") + + XCTAssertEqual(store.selectedComposerTurnPhase, .streaming) + store.stopActiveTurn() + store.stopActiveTurn() + + XCTAssertEqual(voice.threadIDs, ["voice-thread-42"]) + XCTAssertTrue(engine.state.events.isEmpty, "voice Stop must not call composer cancellation") + XCTAssertEqual(store.voiceTurnPhase, .cancelling) + + // A queued delta after Stop cannot repaint the cancelling bubble. + store.ingestVoiceDelta(" late") + store.ingestVoiceCancelled(threadId: "voice-thread-42") + store.ingestVoiceDone() // a duplicate successful terminal is ignored + + let stopped = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) + XCTAssertEqual(stopped.text, "Partial voice answer") + XCTAssertTrue(stopped.wasStopped) + XCTAssertFalse(stopped.isThinking) + XCTAssertFalse(stopped.isStreaming) + let stoppedTool = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .tool }) + XCTAssertEqual(stoppedTool.toolLines.first?.state, .cancelled) + XCTAssertEqual(stoppedTool.toolLines.first?.verb, "stopped") + XCTAssertNil(store.voiceTurnPhase) + + store.ingestVoiceTurn(threadId: "voice-thread-42", userText: "try again") + store.ingestVoiceDelta("Recovered voice turn") + store.ingestVoiceDone() + + let recovered = try XCTUnwrap(store.currentThread?.messages.last { $0.role == .assistant }) + XCTAssertEqual(recovered.text, "Recovered voice turn") + XCTAssertFalse(recovered.wasStopped) + XCTAssertNil(store.voiceTurnPhase) + } + + private func makeStore( + engine: AgentChatEngine, + backendID: String, + voiceTurnCanceller: VoiceTurnCancelling? = nil + ) -> AgentChatStore { + var thread = ChatThread(title: "Cancellation", meta: "now") + thread.backendId = backendID + thread.messagesLoaded = true + return AgentChatStore( + engine: engine, + threads: [thread], + voiceTurnCanceller: voiceTurnCanceller + ) + } + + private func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @escaping @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return } + try? await Task.sleep(for: .milliseconds(5)) } + XCTFail("Timed out waiting for cancellation state") + } } diff --git a/macos/CodescribeTests/AgentChatQueueTests.swift b/macos/CodescribeTests/AgentChatQueueTests.swift index f356d704..a186d09f 100644 --- a/macos/CodescribeTests/AgentChatQueueTests.swift +++ b/macos/CodescribeTests/AgentChatQueueTests.swift @@ -8,282 +8,284 @@ import XCTest /// survive an app death (restart replays what never reached a terminal). @MainActor final class AgentChatQueueTests: XCTestCase { - private final class GatedState: @unchecked Sendable { - private let lock = NSLock() - private var continuations: [CheckedContinuation] = [] - private var storedStarts: [(text: String, threadId: String)] = [] - - var starts: [(text: String, threadId: String)] { - lock.withLock { storedStarts } - } - - func recordStart(text: String, threadId: String) { - lock.withLock { storedStarts.append((text, threadId)) } - } - - func suspend(_ continuation: CheckedContinuation) { - lock.withLock { continuations.append(continuation) } - } - - func finishNext(_ result: String) { - let continuation = lock.withLock { - continuations.isEmpty ? nil : continuations.removeFirst() - } - continuation?.resume(returning: result) - } - - func failNext(_ error: Error) { - let continuation = lock.withLock { - continuations.isEmpty ? nil : continuations.removeFirst() - } - continuation?.resume(throwing: error) - } - } + private final class GatedState: @unchecked Sendable { + private let lock = NSLock() + private var continuations: [CheckedContinuation] = [] + private var storedStarts: [(text: String, threadId: String)] = [] - /// Engine whose streams stay open until the test releases them, so a turn - /// can be held "active" while more messages are accepted. - private final class GatedEngine: AgentChatEngine { - let state = GatedState() - - func isAvailable() -> Bool { true } - func availabilityDetail() -> String? { nil } - func generateThreadTitle(_ text: String) async throws -> String? { nil } - - func streamReply( - _ text: String, - threadId: String, - attachmentPaths: [String], - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (String, String) -> Void, - onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void - ) async throws -> String { - state.recordStart(text: text, threadId: threadId) - return try await withCheckedThrowingContinuation { state.suspend($0) } - } - - func cancelReply(threadId: String) -> Bool { - state.failNext(CancellationError()) - return true - } - - func installToolApprovalHandler( - _ handler: @escaping @MainActor (PendingToolApproval) -> Void - ) {} - - func resolveToolApproval( - _ request: PendingToolApproval, approved: Bool, remember: Bool - ) -> Bool { true } + var starts: [(text: String, threadId: String)] { + lock.withLock { storedStarts } } - private final class StubProvider: ChatThreadsProviding { - func listThreads() -> [ChatThread] { [] } - func searchThreads(query: String) -> [ChatThread] { [] } - func loadMessages(backendId: String) -> [ChatMessage] { [] } - func deleteThread(backendId: String) -> Bool { true } - func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } - func renameThread(backendId: String, title: String) -> Bool { true } - func setGeneratedTitle(backendId: String, title: String) -> Bool { true } - func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } - func generateThreadId() -> String { "t_\(UUID().uuidString)" } + func recordStart(text: String, threadId: String) { + lock.withLock { storedStarts.append((text, threadId)) } } - override func setUp() { - super.setUp() - UserDefaults.standard.removeObject(forKey: AgentChatStore.acceptedTurnsDefaultsKey) + func suspend(_ continuation: CheckedContinuation) { + lock.withLock { continuations.append(continuation) } } - override func tearDown() { - UserDefaults.standard.removeObject(forKey: AgentChatStore.acceptedTurnsDefaultsKey) - super.tearDown() + func finishNext(_ result: String) { + let continuation = lock.withLock { + continuations.isEmpty ? nil : continuations.removeFirst() + } + continuation?.resume(returning: result) } - private func waitUntil( - timeout: TimeInterval = 2, - _ message: String = "condition not met in time", - _ condition: () -> Bool - ) async { - let deadline = Date().addingTimeInterval(timeout) - while !condition(), Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - XCTAssertTrue(condition(), message) + func failNext(_ error: Error) { + let continuation = lock.withLock { + continuations.isEmpty ? nil : continuations.removeFirst() + } + continuation?.resume(throwing: error) } - - private func sendMessage(_ text: String, in store: AgentChatStore) { - store.draft = text - store.send() + } + + /// Engine whose streams stay open until the test releases them, so a turn + /// can be held "active" while more messages are accepted. + private final class GatedEngine: AgentChatEngine { + let state = GatedState() + + func isAvailable() -> Bool { true } + func availabilityDetail() -> String? { nil } + func generateThreadTitle(_ text: String) async throws -> String? { nil } + + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (String, String) -> Void, + onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void + ) async throws -> String { + state.recordStart(text: text, threadId: threadId) + return try await withCheckedThrowingContinuation { state.suspend($0) } } - func testMessagesAcceptedDuringActiveTurnExecuteFIFO() async { - let engine = GatedEngine() - let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) - - sendMessage("first", in: store) - await waitUntil("first turn should start") { engine.state.starts.count == 1 } - - sendMessage("second", in: store) - sendMessage("third", in: store) - sendMessage("fourth", in: store) - XCTAssertEqual( - store.queuedTurns.map(\.text), ["second", "third", "fourth"], - "messages accepted mid-turn must queue FIFO, not vanish" - ) - - engine.state.finishNext("r1") - await waitUntil("second turn should start after first terminal") { - engine.state.starts.count == 2 - } - XCTAssertEqual(engine.state.starts[1].text, "second") - - engine.state.finishNext("r2") - await waitUntil("third turn should start") { engine.state.starts.count == 3 } - XCTAssertEqual(engine.state.starts[2].text, "third") - - engine.state.finishNext("r3") - await waitUntil("fourth turn should start") { engine.state.starts.count == 4 } - XCTAssertEqual(engine.state.starts[3].text, "fourth") - - engine.state.finishNext("r4") - await waitUntil("queue should drain") { store.queuedTurns.isEmpty } - // All four ran on the same backend thread — the queue is thread-bound. - XCTAssertEqual(Set(engine.state.starts.map(\.threadId)).count, 1) + func cancelReply(threadId: String) -> Bool { + state.failNext(CancellationError()) + return true } - func testCancellingActiveTurnKeepsAndContinuesQueue() async { - let engine = GatedEngine() - let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + func installToolApprovalHandler( + _ handler: @escaping @MainActor (PendingToolApproval) -> Void + ) {} + + func resolveToolApproval( + _ request: PendingToolApproval, approved: Bool, remember: Bool + ) -> Bool { true } + } + + private final class StubProvider: ChatThreadsProviding { + func listThreads() -> [ChatThread] { [] } + func searchThreads(query: String) -> [ChatThread] { [] } + func loadMessages(backendId: String) -> [ChatMessage] { [] } + func deleteThread(backendId: String) -> Bool { true } + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } + func renameThread(backendId: String, title: String) -> Bool { true } + func setGeneratedTitle(backendId: String, title: String) -> Bool { true } + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } + func generateThreadId() -> String { "t_\(UUID().uuidString)" } + } + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: AgentChatStore.acceptedTurnsDefaultsKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: AgentChatStore.acceptedTurnsDefaultsKey) + super.tearDown() + } + + private func waitUntil( + timeout: TimeInterval = 2, + _ message: String = "condition not met in time", + _ condition: () -> Bool + ) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTAssertTrue(condition(), message) + } + + private func sendMessage(_ text: String, in store: AgentChatStore) { + store.draft = text + store.send() + } + + func testMessagesAcceptedDuringActiveTurnExecuteFIFO() async { + let engine = GatedEngine() + let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + + sendMessage("first", in: store) + await waitUntil("first turn should start") { engine.state.starts.count == 1 } + + sendMessage("second", in: store) + sendMessage("third", in: store) + sendMessage("fourth", in: store) + XCTAssertEqual( + store.queuedTurns.map(\.text), ["second", "third", "fourth"], + "messages accepted mid-turn must queue FIFO, not vanish" + ) + + engine.state.finishNext("r1") + await waitUntil("second turn should start after first terminal") { + engine.state.starts.count == 2 + } + XCTAssertEqual(engine.state.starts[1].text, "second") - sendMessage("first", in: store) - await waitUntil("first turn should start") { engine.state.starts.count == 1 } - sendMessage("second", in: store) - XCTAssertEqual(store.queuedTurns.map(\.text), ["second"]) + engine.state.finishNext("r2") + await waitUntil("third turn should start") { engine.state.starts.count == 3 } + XCTAssertEqual(engine.state.starts[2].text, "third") - store.stopActiveTurn() + engine.state.finishNext("r3") + await waitUntil("fourth turn should start") { engine.state.starts.count == 4 } + XCTAssertEqual(engine.state.starts[3].text, "fourth") - await waitUntil("queue must continue after a user Stop") { - engine.state.starts.count == 2 - } - XCTAssertEqual(engine.state.starts[1].text, "second") - engine.state.finishNext("r2") - await waitUntil("queue should drain") { store.queuedTurns.isEmpty } - } + engine.state.finishNext("r4") + await waitUntil("queue should drain") { store.queuedTurns.isEmpty } + // All four ran on the same backend thread — the queue is thread-bound. + XCTAssertEqual(Set(engine.state.starts.map(\.threadId)).count, 1) + } - func testProviderErrorDoesNotStrandQueuedMessages() async { - let engine = GatedEngine() - let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + func testCancellingActiveTurnKeepsAndContinuesQueue() async { + let engine = GatedEngine() + let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) - sendMessage("first", in: store) - await waitUntil("first turn should start") { engine.state.starts.count == 1 } - sendMessage("second", in: store) + sendMessage("first", in: store) + await waitUntil("first turn should start") { engine.state.starts.count == 1 } + sendMessage("second", in: store) + XCTAssertEqual(store.queuedTurns.map(\.text), ["second"]) - engine.state.failNext(NSError(domain: "test", code: 1)) + store.stopActiveTurn() - await waitUntil("queue must continue after a provider error") { - engine.state.starts.count == 2 - } - XCTAssertEqual(engine.state.starts[1].text, "second") - engine.state.finishNext("r2") + await waitUntil("queue must continue after a user Stop") { + engine.state.starts.count == 2 } + XCTAssertEqual(engine.state.starts[1].text, "second") + engine.state.finishNext("r2") + await waitUntil("queue should drain") { store.queuedTurns.isEmpty } + } - func testCancelQueuedMessageBeforeDispatch() async { - let engine = GatedEngine() - let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) - - sendMessage("first", in: store) - await waitUntil("first turn should start") { engine.state.starts.count == 1 } - sendMessage("doomed", in: store) - guard let queuedID = store.queuedTurns.first?.id else { - return XCTFail("expected a queued turn") - } - - store.cancelQueuedTurn(queuedID) - XCTAssertTrue(store.queuedTurns.isEmpty) - - engine.state.finishNext("r1") - try? await Task.sleep(nanoseconds: 100_000_000) - XCTAssertEqual( - engine.state.starts.count, 1, - "a cancelled queued message must never dispatch" - ) - // Its durable record is gone too — a restart cannot resurrect it. - let sidecarCount = UserDefaults.standard - .data(forKey: AgentChatStore.acceptedTurnsDefaultsKey) - .flatMap { try? JSONDecoder().decode([SidecarProbe].self, from: $0) }? - .count ?? 0 - XCTAssertEqual(sidecarCount, 0, "no accepted-turn records may survive the terminals") - } + func testProviderErrorDoesNotStrandQueuedMessages() async { + let engine = GatedEngine() + let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + + sendMessage("first", in: store) + await waitUntil("first turn should start") { engine.state.starts.count == 1 } + sendMessage("second", in: store) - func testEditingQueuedMessageUpdatesDispatchAndDurableCopy() async { - let engine = GatedEngine() - let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) - - sendMessage("first", in: store) - await waitUntil("first turn should start") { engine.state.starts.count == 1 } - sendMessage("old queued wording", in: store) - let queuedID = try! XCTUnwrap(store.queuedTurns.first?.id) - - XCTAssertTrue(store.editQueuedTurn(queuedID, text: " corrected queued wording ")) - XCTAssertEqual(store.queuedTurns.first?.text, "corrected queued wording") - let durableTexts = UserDefaults.standard - .data(forKey: AgentChatStore.acceptedTurnsDefaultsKey) - .flatMap { try? JSONDecoder().decode([SidecarTextProbe].self, from: $0) }? - .map(\.text) ?? [] - XCTAssertTrue(durableTexts.contains("corrected queued wording")) - XCTAssertFalse(durableTexts.contains("old queued wording")) - - engine.state.finishNext("r1") - await waitUntil("edited turn should dispatch") { engine.state.starts.count == 2 } - XCTAssertEqual(engine.state.starts[1].text, "corrected queued wording") - engine.state.finishNext("r2") + engine.state.failNext(NSError(domain: "test", code: 1)) + + await waitUntil("queue must continue after a provider error") { + engine.state.starts.count == 2 + } + XCTAssertEqual(engine.state.starts[1].text, "second") + engine.state.finishNext("r2") + } + + func testCancelQueuedMessageBeforeDispatch() async { + let engine = GatedEngine() + let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + + sendMessage("first", in: store) + await waitUntil("first turn should start") { engine.state.starts.count == 1 } + sendMessage("doomed", in: store) + guard let queuedID = store.queuedTurns.first?.id else { + return XCTFail("expected a queued turn") } - func testComposerHistoryIncludesQueuedMessagesInFIFOOrder() async { - let engine = GatedEngine() - let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) - let threadID = try! XCTUnwrap(store.selectedThreadID) - - sendMessage("first", in: store) - await waitUntil("first turn should start") { engine.state.starts.count == 1 } - sendMessage("second", in: store) - sendMessage("third", in: store) - - XCTAssertEqual(store.composerHistory(in: threadID), ["first", "second", "third"]) - engine.state.finishNext("r1") - await waitUntil("second should start") { engine.state.starts.count == 2 } - engine.state.finishNext("r2") - await waitUntil("third should start") { engine.state.starts.count == 3 } - engine.state.finishNext("r3") + store.cancelQueuedTurn(queuedID) + XCTAssertTrue(store.queuedTurns.isEmpty) + + engine.state.finishNext("r1") + try? await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual( + engine.state.starts.count, 1, + "a cancelled queued message must never dispatch" + ) + // Its durable record is gone too — a restart cannot resurrect it. + let sidecarCount = + UserDefaults.standard + .data(forKey: AgentChatStore.acceptedTurnsDefaultsKey) + .flatMap { try? JSONDecoder().decode([SidecarProbe].self, from: $0) }? + .count ?? 0 + XCTAssertEqual(sidecarCount, 0, "no accepted-turn records may survive the terminals") + } + + func testEditingQueuedMessageUpdatesDispatchAndDurableCopy() async { + let engine = GatedEngine() + let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + + sendMessage("first", in: store) + await waitUntil("first turn should start") { engine.state.starts.count == 1 } + sendMessage("old queued wording", in: store) + let queuedID = try! XCTUnwrap(store.queuedTurns.first?.id) + + XCTAssertTrue(store.editQueuedTurn(queuedID, text: " corrected queued wording ")) + XCTAssertEqual(store.queuedTurns.first?.text, "corrected queued wording") + let durableTexts = + UserDefaults.standard + .data(forKey: AgentChatStore.acceptedTurnsDefaultsKey) + .flatMap { try? JSONDecoder().decode([SidecarTextProbe].self, from: $0) }? + .map(\.text) ?? [] + XCTAssertTrue(durableTexts.contains("corrected queued wording")) + XCTAssertFalse(durableTexts.contains("old queued wording")) + + engine.state.finishNext("r1") + await waitUntil("edited turn should dispatch") { engine.state.starts.count == 2 } + XCTAssertEqual(engine.state.starts[1].text, "corrected queued wording") + engine.state.finishNext("r2") + } + + func testComposerHistoryIncludesQueuedMessagesInFIFOOrder() async { + let engine = GatedEngine() + let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + let threadID = try! XCTUnwrap(store.selectedThreadID) + + sendMessage("first", in: store) + await waitUntil("first turn should start") { engine.state.starts.count == 1 } + sendMessage("second", in: store) + sendMessage("third", in: store) + + XCTAssertEqual(store.composerHistory(in: threadID), ["first", "second", "third"]) + engine.state.finishNext("r1") + await waitUntil("second should start") { engine.state.starts.count == 2 } + engine.state.finishNext("r2") + await waitUntil("third should start") { engine.state.starts.count == 3 } + engine.state.finishNext("r3") + } + + /// Simulated app death: the first store accepts two messages (one running, + /// one queued) and never reaches a terminal. A fresh store — same defaults — + /// must replay both, in order. + func testRestartReplaysAcceptedMessagesThatNeverReachedATerminal() async { + let engine = GatedEngine() + let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) + sendMessage("interrupted", in: store) + await waitUntil("first turn should start") { engine.state.starts.count == 1 } + sendMessage("queued-survivor", in: store) + XCTAssertEqual(store.queuedTurns.map(\.text), ["queued-survivor"]) + // No terminal, no cleanup — the durable sidecar still holds both. + + let relaunchEngine = GatedEngine() + let relaunched = AgentChatStore(engine: relaunchEngine, threadsProvider: StubProvider()) + + await waitUntil("restart must replay the interrupted message first") { + relaunchEngine.state.starts.count == 1 } + XCTAssertEqual(relaunchEngine.state.starts[0].text, "interrupted") + XCTAssertEqual(relaunched.queuedTurns.map(\.text), ["queued-survivor"]) - /// Simulated app death: the first store accepts two messages (one running, - /// one queued) and never reaches a terminal. A fresh store — same defaults — - /// must replay both, in order. - func testRestartReplaysAcceptedMessagesThatNeverReachedATerminal() async { - let engine = GatedEngine() - let store = AgentChatStore(engine: engine, threadsProvider: StubProvider()) - sendMessage("interrupted", in: store) - await waitUntil("first turn should start") { engine.state.starts.count == 1 } - sendMessage("queued-survivor", in: store) - XCTAssertEqual(store.queuedTurns.map(\.text), ["queued-survivor"]) - // No terminal, no cleanup — the durable sidecar still holds both. - - let relaunchEngine = GatedEngine() - let relaunched = AgentChatStore(engine: relaunchEngine, threadsProvider: StubProvider()) - - await waitUntil("restart must replay the interrupted message first") { - relaunchEngine.state.starts.count == 1 - } - XCTAssertEqual(relaunchEngine.state.starts[0].text, "interrupted") - XCTAssertEqual(relaunched.queuedTurns.map(\.text), ["queued-survivor"]) - - relaunchEngine.state.finishNext("r1") - await waitUntil("the queued survivor follows FIFO") { - relaunchEngine.state.starts.count == 2 - } - XCTAssertEqual(relaunchEngine.state.starts[1].text, "queued-survivor") - relaunchEngine.state.finishNext("r2") + relaunchEngine.state.finishNext("r1") + await waitUntil("the queued survivor follows FIFO") { + relaunchEngine.state.starts.count == 2 } + XCTAssertEqual(relaunchEngine.state.starts[1].text, "queued-survivor") + relaunchEngine.state.finishNext("r2") + } } /// Decoding helper for the sidecar-empty assertion: any element shape counts. diff --git a/macos/CodescribeTests/AgentSummonTests.swift b/macos/CodescribeTests/AgentSummonTests.swift index ac071662..cc91320f 100644 --- a/macos/CodescribeTests/AgentSummonTests.swift +++ b/macos/CodescribeTests/AgentSummonTests.swift @@ -1,107 +1,108 @@ import AppKit import XCTest + @testable import Codescribe @MainActor final class AgentSummonTests: XCTestCase { - private final class SpyEngine: AgentChatEngine { - private(set) var streamCalls = 0 - private(set) var cancelCalls = 0 - - func isAvailable() -> Bool { true } - func availabilityDetail() -> String? { nil } - func generateThreadTitle(_ text: String) async throws -> String? { nil } - - func streamReply( - _ text: String, - threadId: String, - attachmentPaths: [String], - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (String, String) -> Void, - onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void - ) async throws -> String { - streamCalls += 1 - return "unexpected" - } - - func cancelReply(threadId: String) -> Bool { - cancelCalls += 1 - return false - } - } + private final class SpyEngine: AgentChatEngine { + private(set) var streamCalls = 0 + private(set) var cancelCalls = 0 + + func isAvailable() -> Bool { true } + func availabilityDetail() -> String? { nil } + func generateThreadTitle(_ text: String) async throws -> String? { nil } - func testRepeatedSummonPreservesThreadDraftAttachmentsAndIdleState() { - var first = ChatThread(title: "First", meta: "now") - first.messages = [ChatMessage(role: .you, timestamp: "now", text: "existing")] - let second = ChatThread(title: "Second", meta: "now") - let engine = SpyEngine() - let store = AgentChatStore(engine: engine, threads: [first, second]) - store.select(second.id) - store.draft = "unsent draft" - store.addAttachments([URL(fileURLWithPath: "/tmp/staged-agent-summon.png")]) - - let window = NSWindow() - var presentedWindows: [ObjectIdentifier] = [] - let action = AgentSummonAction(store: store) { - presentedWindows.append(ObjectIdentifier(window)) - } - - let threadIDs = store.threads.map(\.id) - let messageCounts = store.threads.map { $0.messages.count } - let attachmentIDs = store.pendingAttachments.map(\.id) - action.perform() - action.perform() - - XCTAssertEqual(presentedWindows.count, 2) - XCTAssertEqual(Set(presentedWindows).count, 1, "the presenter must reuse one Agent window") - XCTAssertEqual(store.composerFocusRequest, 2) - XCTAssertEqual(store.threads.map(\.id), threadIDs) - XCTAssertEqual(store.threads.map { $0.messages.count }, messageCounts) - XCTAssertEqual(store.selectedThreadID, second.id) - XCTAssertEqual(store.draft, "unsent draft") - XCTAssertEqual(store.pendingAttachments.map(\.id), attachmentIDs) - XCTAssertFalse(store.isThinking) - XCTAssertFalse(store.isStreaming) - XCTAssertEqual(engine.streamCalls, 0) - XCTAssertEqual(engine.cancelCalls, 0) + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (String, String) -> Void, + onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void + ) async throws -> String { + streamCalls += 1 + return "unexpected" } - func testForeignCallbackDeliversExactlyOneMainActorAction() async { - let delivered = expectation(description: "show Agent action") - delivered.expectedFulfillmentCount = 1 - let listener = AgentAppActionListener { - delivered.fulfill() - } + func cancelReply(threadId: String) -> Bool { + cancelCalls += 1 + return false + } + } - listener.onShowAgent() + func testRepeatedSummonPreservesThreadDraftAttachmentsAndIdleState() { + var first = ChatThread(title: "First", meta: "now") + first.messages = [ChatMessage(role: .you, timestamp: "now", text: "existing")] + let second = ChatThread(title: "Second", meta: "now") + let engine = SpyEngine() + let store = AgentChatStore(engine: engine, threads: [first, second]) + store.select(second.id) + store.draft = "unsent draft" + store.addAttachments([URL(fileURLWithPath: "/tmp/staged-agent-summon.png")]) - await fulfillment(of: [delivered], timeout: 1.0) + let window = NSWindow() + var presentedWindows: [ObjectIdentifier] = [] + let action = AgentSummonAction(store: store) { + presentedWindows.append(ObjectIdentifier(window)) } - func testAssistiveCallbackFrontsExistingAgentAndRoutesCaptureCommand() async { - let delivered = expectation(description: "Agent capture action") - let listener = AgentAppActionListener( - summonAgent: {}, - captureAgent: { command in - XCTAssertEqual(command, .toggle) - delivered.fulfill() - } - ) + let threadIDs = store.threads.map(\.id) + let messageCounts = store.threads.map { $0.messages.count } + let attachmentIDs = store.pendingAttachments.map(\.id) + action.perform() + action.perform() - listener.onAgentCapture(command: .toggle) + XCTAssertEqual(presentedWindows.count, 2) + XCTAssertEqual(Set(presentedWindows).count, 1, "the presenter must reuse one Agent window") + XCTAssertEqual(store.composerFocusRequest, 2) + XCTAssertEqual(store.threads.map(\.id), threadIDs) + XCTAssertEqual(store.threads.map { $0.messages.count }, messageCounts) + XCTAssertEqual(store.selectedThreadID, second.id) + XCTAssertEqual(store.draft, "unsent draft") + XCTAssertEqual(store.pendingAttachments.map(\.id), attachmentIDs) + XCTAssertFalse(store.isThinking) + XCTAssertFalse(store.isStreaming) + XCTAssertEqual(engine.streamCalls, 0) + XCTAssertEqual(engine.cancelCalls, 0) + } - await fulfillment(of: [delivered], timeout: 1.0) + func testForeignCallbackDeliversExactlyOneMainActorAction() async { + let delivered = expectation(description: "show Agent action") + delivered.expectedFulfillmentCount = 1 + let listener = AgentAppActionListener { + delivered.fulfill() } - func testAgentPinMapsToFloatingAndNormalWindowLevels() { - XCTAssertEqual( - AgentWindowLevelPolicy.level(isPinned: true).rawValue, - NSWindow.Level.floating.rawValue - ) - XCTAssertEqual( - AgentWindowLevelPolicy.level(isPinned: false).rawValue, - NSWindow.Level.normal.rawValue - ) - } + listener.onShowAgent() + + await fulfillment(of: [delivered], timeout: 1.0) + } + + func testAssistiveCallbackFrontsExistingAgentAndRoutesCaptureCommand() async { + let delivered = expectation(description: "Agent capture action") + let listener = AgentAppActionListener( + summonAgent: {}, + captureAgent: { command in + XCTAssertEqual(command, .toggle) + delivered.fulfill() + } + ) + + listener.onAgentCapture(command: .toggle) + + await fulfillment(of: [delivered], timeout: 1.0) + } + + func testAgentPinMapsToFloatingAndNormalWindowLevels() { + XCTAssertEqual( + AgentWindowLevelPolicy.level(isPinned: true).rawValue, + NSWindow.Level.floating.rawValue + ) + XCTAssertEqual( + AgentWindowLevelPolicy.level(isPinned: false).rawValue, + NSWindow.Level.normal.rawValue + ) + } } diff --git a/macos/CodescribeTests/AgentThreadContinuityTests.swift b/macos/CodescribeTests/AgentThreadContinuityTests.swift index 5ee6271f..04599634 100644 --- a/macos/CodescribeTests/AgentThreadContinuityTests.swift +++ b/macos/CodescribeTests/AgentThreadContinuityTests.swift @@ -10,131 +10,134 @@ import XCTest /// `ingestVoiceTurn` and the completion refresh in `replaceThreads`. @MainActor final class AgentThreadContinuityTests: XCTestCase { - private final class StubThreadsProvider: ChatThreadsProviding { - var rows: [(id: String, title: String)] - - init(_ rows: [(id: String, title: String)]) { - self.rows = rows - } - - func listThreads() -> [ChatThread] { - rows.map { row in - var thread = ChatThread(title: row.title, meta: "now") - thread.backendId = row.id - thread.messagesLoaded = true - thread.messages = [ - ChatMessage(role: .assistant, timestamp: "earlier", text: "\(row.id) history") - ] - return thread - } - } - - func searchThreads(query: String) -> [ChatThread] { listThreads() } - func loadMessages(backendId: String) -> [ChatMessage] { [] } - func deleteThread(backendId: String) -> Bool { true } - func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } - func renameThread(backendId: String, title: String) -> Bool { true } - func setGeneratedTitle(backendId: String, title: String) -> Bool { true } - func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } - func generateThreadId() -> String { "t_generated" } - } - - private func thread(_ backendID: String, in store: AgentChatStore) -> ChatThread? { - store.threads.first { $0.backendId == backendID } - } - - func testActivationAppendsToCurrentlyOpenMatchingThreadWithoutChangingSelection() { - let provider = StubThreadsProvider([ - ("t_active", "Active"), - ("t_other", "Other"), - ]) - let store = AgentChatStore(threadsProvider: provider) - let activeID = store.selectedThreadID - - store.ingestVoiceTurn(threadId: "t_active", userText: "continue here") - store.ingestVoiceDelta("answer") - - XCTAssertEqual(store.selectedThreadID, activeID) - XCTAssertEqual(store.currentThread?.backendId, "t_active") - XCTAssertTrue(store.currentThread?.messages.contains { message in - message.role == .you && message.text == "continue here" - } == true) - XCTAssertEqual(store.currentThread?.messages.last?.text, "answer") - } - - func testGenerationForBackgroundThreadNeverMovesThreadOrVisibleMessages() { - let provider = StubThreadsProvider([ - ("t_visible", "Visible"), - ("t_background", "Background"), - ]) - let store = AgentChatStore(threadsProvider: provider) - let visibleID = store.selectedThreadID - let visibleMessageIDs = store.currentThread?.messages.map(\.id) - - store.ingestVoiceTurn(threadId: "t_background", userText: "background request") - store.ingestVoiceDelta("background response") - - XCTAssertEqual(store.selectedThreadID, visibleID, "turn start must not move selection") - XCTAssertEqual(store.currentThread?.messages.map(\.id), visibleMessageIDs) - XCTAssertEqual(thread("t_background", in: store)?.messages.last?.text, "background response") - - store.ingestVoiceDone() - ThreadsChangeBus.postThreadsChanged() + private final class StubThreadsProvider: ChatThreadsProviding { + var rows: [(id: String, title: String)] - XCTAssertEqual(store.selectedThreadID, visibleID, "terminal refresh and bus must preserve selection") - XCTAssertEqual(store.currentThread?.backendId, "t_visible") - XCTAssertEqual(store.currentThread?.messages.map(\.id), visibleMessageIDs) + init(_ rows: [(id: String, title: String)]) { + self.rows = rows } - func testVoiceTurnAdoptsSelectedEmptyDraftInsteadOfMintingParallelThread() { - let provider = StubThreadsProvider([("t_history", "History")]) - let store = AgentChatStore(threadsProvider: provider) - store.newThread() - let draftID = store.selectedThreadID - let threadCount = store.threads.count - - store.ingestVoiceTurn(threadId: "t_voice_session", userText: "Ze względu na fakt że…") - store.ingestVoiceDelta("odpowiedź") - - XCTAssertEqual(store.selectedThreadID, draftID, "voice turn must land in the open draft") - XCTAssertEqual(store.currentThread?.backendId, "t_voice_session") - XCTAssertEqual(store.threads.count, threadCount, "no parallel thread may be minted") - XCTAssertTrue(store.currentThread?.messages.contains { message in - message.role == .you && message.text.contains("Ze względu") - } == true) - XCTAssertEqual(store.currentThread?.messages.last?.text, "odpowiedź") - XCTAssertNotEqual(store.currentThread?.title, "New thread", "adopted draft takes a real title") + func listThreads() -> [ChatThread] { + rows.map { row in + var thread = ChatThread(title: row.title, meta: "now") + thread.backendId = row.id + thread.messagesLoaded = true + thread.messages = [ + ChatMessage(role: .assistant, timestamp: "earlier", text: "\(row.id) history") + ] + return thread + } } - func testVoiceTurnWithUnknownIdStillMintsThreadWhenSelectionIsBound() { - let provider = StubThreadsProvider([("t_history", "History")]) - let store = AgentChatStore(threadsProvider: provider) - let selectedID = store.selectedThreadID - let threadCount = store.threads.count - - store.ingestVoiceTurn(threadId: "t_new_session", userText: "fresh voice") - - XCTAssertEqual(store.selectedThreadID, selectedID, "selection must not move") - XCTAssertEqual(store.threads.count, threadCount + 1) - XCTAssertEqual(thread("t_new_session", in: store)?.messages.first?.role, .you) - } - - func testOnlyExplicitNewThreadActionSelectsFreshThread() { - let provider = StubThreadsProvider([ - ("t_first", "First"), - ("t_second", "Second"), - ]) - let store = AgentChatStore(threadsProvider: provider) - let secondID = thread("t_second", in: store)!.id - store.select(secondID) - - store.ingestVoiceTurn(threadId: "t_first", userText: "do not switch") - XCTAssertEqual(store.selectedThreadID, secondID) - - store.newThread() - - XCTAssertNotEqual(store.selectedThreadID, secondID) - XCTAssertNil(store.currentThread?.backendId) - XCTAssertEqual(store.currentThread?.title, "New thread") - } + func searchThreads(query: String) -> [ChatThread] { listThreads() } + func loadMessages(backendId: String) -> [ChatMessage] { [] } + func deleteThread(backendId: String) -> Bool { true } + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } + func renameThread(backendId: String, title: String) -> Bool { true } + func setGeneratedTitle(backendId: String, title: String) -> Bool { true } + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } + func generateThreadId() -> String { "t_generated" } + } + + private func thread(_ backendID: String, in store: AgentChatStore) -> ChatThread? { + store.threads.first { $0.backendId == backendID } + } + + func testActivationAppendsToCurrentlyOpenMatchingThreadWithoutChangingSelection() { + let provider = StubThreadsProvider([ + ("t_active", "Active"), + ("t_other", "Other"), + ]) + let store = AgentChatStore(threadsProvider: provider) + let activeID = store.selectedThreadID + + store.ingestVoiceTurn(threadId: "t_active", userText: "continue here") + store.ingestVoiceDelta("answer") + + XCTAssertEqual(store.selectedThreadID, activeID) + XCTAssertEqual(store.currentThread?.backendId, "t_active") + XCTAssertTrue( + store.currentThread?.messages.contains { message in + message.role == .you && message.text == "continue here" + } == true) + XCTAssertEqual(store.currentThread?.messages.last?.text, "answer") + } + + func testGenerationForBackgroundThreadNeverMovesThreadOrVisibleMessages() { + let provider = StubThreadsProvider([ + ("t_visible", "Visible"), + ("t_background", "Background"), + ]) + let store = AgentChatStore(threadsProvider: provider) + let visibleID = store.selectedThreadID + let visibleMessageIDs = store.currentThread?.messages.map(\.id) + + store.ingestVoiceTurn(threadId: "t_background", userText: "background request") + store.ingestVoiceDelta("background response") + + XCTAssertEqual(store.selectedThreadID, visibleID, "turn start must not move selection") + XCTAssertEqual(store.currentThread?.messages.map(\.id), visibleMessageIDs) + XCTAssertEqual(thread("t_background", in: store)?.messages.last?.text, "background response") + + store.ingestVoiceDone() + ThreadsChangeBus.postThreadsChanged() + + XCTAssertEqual( + store.selectedThreadID, visibleID, "terminal refresh and bus must preserve selection") + XCTAssertEqual(store.currentThread?.backendId, "t_visible") + XCTAssertEqual(store.currentThread?.messages.map(\.id), visibleMessageIDs) + } + + func testVoiceTurnAdoptsSelectedEmptyDraftInsteadOfMintingParallelThread() { + let provider = StubThreadsProvider([("t_history", "History")]) + let store = AgentChatStore(threadsProvider: provider) + store.newThread() + let draftID = store.selectedThreadID + let threadCount = store.threads.count + + store.ingestVoiceTurn(threadId: "t_voice_session", userText: "Ze względu na fakt że…") + store.ingestVoiceDelta("odpowiedź") + + XCTAssertEqual(store.selectedThreadID, draftID, "voice turn must land in the open draft") + XCTAssertEqual(store.currentThread?.backendId, "t_voice_session") + XCTAssertEqual(store.threads.count, threadCount, "no parallel thread may be minted") + XCTAssertTrue( + store.currentThread?.messages.contains { message in + message.role == .you && message.text.contains("Ze względu") + } == true) + XCTAssertEqual(store.currentThread?.messages.last?.text, "odpowiedź") + XCTAssertNotEqual(store.currentThread?.title, "New thread", "adopted draft takes a real title") + } + + func testVoiceTurnWithUnknownIdStillMintsThreadWhenSelectionIsBound() { + let provider = StubThreadsProvider([("t_history", "History")]) + let store = AgentChatStore(threadsProvider: provider) + let selectedID = store.selectedThreadID + let threadCount = store.threads.count + + store.ingestVoiceTurn(threadId: "t_new_session", userText: "fresh voice") + + XCTAssertEqual(store.selectedThreadID, selectedID, "selection must not move") + XCTAssertEqual(store.threads.count, threadCount + 1) + XCTAssertEqual(thread("t_new_session", in: store)?.messages.first?.role, .you) + } + + func testOnlyExplicitNewThreadActionSelectsFreshThread() { + let provider = StubThreadsProvider([ + ("t_first", "First"), + ("t_second", "Second"), + ]) + let store = AgentChatStore(threadsProvider: provider) + let secondID = thread("t_second", in: store)!.id + store.select(secondID) + + store.ingestVoiceTurn(threadId: "t_first", userText: "do not switch") + XCTAssertEqual(store.selectedThreadID, secondID) + + store.newThread() + + XCTAssertNotEqual(store.selectedThreadID, secondID) + XCTAssertNil(store.currentThread?.backendId) + XCTAssertEqual(store.currentThread?.title, "New thread") + } } diff --git a/macos/CodescribeTests/AgentThreadTitleTests.swift b/macos/CodescribeTests/AgentThreadTitleTests.swift index 99905e49..ac062610 100644 --- a/macos/CodescribeTests/AgentThreadTitleTests.swift +++ b/macos/CodescribeTests/AgentThreadTitleTests.swift @@ -1,790 +1,812 @@ import Foundation import XCTest + @testable import Codescribe @MainActor final class AgentThreadTitleTests: XCTestCase { - private enum FakeError: Error { - case failed - } - - private struct StreamCall: Equatable { - let text: String - let threadID: String - let attachmentPaths: [String] - } - - private final class ControllableEngine: AgentChatEngine { - private let lock = NSLock() - private var storedTitleCalls: [String] = [] - private var storedStreamCalls: [StreamCall] = [] - private var titleContinuation: CheckedContinuation? - private var streamContinuation: CheckedContinuation? - - let unavailableDetail: String? - - init(unavailableDetail: String? = nil) { - self.unavailableDetail = unavailableDetail - } - - var titleCalls: [String] { lock.withLock { storedTitleCalls } } - var streamCalls: [StreamCall] { lock.withLock { storedStreamCalls } } - - func isAvailable() -> Bool { unavailableDetail == nil } - func availabilityDetail() -> String? { unavailableDetail } - - func generateThreadTitle(_ text: String) async throws -> String? { - try await withCheckedThrowingContinuation { continuation in - lock.withLock { - storedTitleCalls.append(text) - titleContinuation = continuation - } - } - } - - func streamReply( - _ text: String, - threadId: String, - attachmentPaths: [String], - onDelta: @escaping @MainActor (String) -> Void, - onReasoning: @escaping @MainActor (String) -> Void, - onToolExecuting: @escaping @MainActor (String, String) -> Void, - onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void - ) async throws -> String { - try await withCheckedThrowingContinuation { continuation in - lock.withLock { - storedStreamCalls.append( - StreamCall(text: text, threadID: threadId, attachmentPaths: attachmentPaths) - ) - streamContinuation = continuation - } - } - } - - func cancelReply(threadId: String) -> Bool { - completeStream(.failure(CancellationError())) - return true - } - - func completeTitle(_ result: Result) { - let continuation = lock.withLock { () -> CheckedContinuation? in - defer { titleContinuation = nil } - return titleContinuation - } - continuation?.resume(with: result) - } - - func completeStream(_ result: Result) { - let continuation = lock.withLock { () -> CheckedContinuation? in - defer { streamContinuation = nil } - return streamContinuation - } - continuation?.resume(with: result) - } - } - - private final class TitleThreadsProvider: ChatThreadsProviding { - enum Event: Equatable { - case list - case generated(String) - case renamed(String) - case deleted - } - - let backendID: String - private(set) var events: [Event] = [] - private(set) var persistedTitle: String - var threadExists = false - var isCustom = false - var forceGeneratedFailure = false - - init(backendID: String = "title-thread-1", title: String = "Heuristic slug") { - self.backendID = backendID - self.persistedTitle = title - } - - func markFirstTurnPersisted() { - threadExists = true - } - - func listThreads() -> [ChatThread] { - events.append(.list) - guard threadExists else { return [] } - var thread = ChatThread(title: persistedTitle, meta: "now") - thread.backendId = backendID - thread.messagesLoaded = true - return [thread] - } - - func searchThreads(query: String) -> [ChatThread] { listThreads() } - func loadMessages(backendId: String) -> [ChatMessage] { [] } - - func deleteThread(backendId: String) -> Bool { - events.append(.deleted) - guard threadExists else { return false } - threadExists = false - return true - } - - func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { threadExists } - - func renameThread(backendId: String, title: String) -> Bool { - events.append(.renamed(title)) - guard threadExists else { return false } - persistedTitle = title - isCustom = true - return true - } - - func setGeneratedTitle(backendId: String, title: String) -> Bool { - events.append(.generated(title)) - guard threadExists, !isCustom, !forceGeneratedFailure else { return false } - persistedTitle = title - return true - } - - func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } - func generateThreadId() -> String { backendID } - } - - private final class VoiceCancelStub: VoiceTurnCancelling { - func cancelVoiceTurn(threadId: String) -> Bool { true } - } - - func testFirstTextTurnLaunchesExactlyOneIndependentTitleRequest() async throws { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "Plan the rename race" - - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - - XCTAssertEqual(engine.titleCalls, ["Plan the rename race"]) - XCTAssertEqual( - engine.streamCalls, - [StreamCall(text: "Plan the rename race", threadID: provider.backendID, attachmentPaths: [])] - ) - - engine.completeTitle(.success(nil)) - provider.markFirstTurnPersisted() - engine.completeStream(.success("Assistant reply")) - await waitUntil { store.activeComposerTurn == nil } - - XCTAssertEqual(engine.titleCalls.count, 1) - XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistant reply") - } - - func testSubsequentAttachmentOnlyAndUnavailableTurnsDoNotLaunchTitle() async { - do { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - var thread = ChatThread(title: "Existing", meta: "now") - thread.messages = [ChatMessage(role: .you, timestamp: "now", text: "first")] - let store = makeStore(engine: engine, provider: provider, thread: thread) - store.draft = "second" - store.send() - await waitUntil { engine.streamCalls.count == 1 } - XCTAssertTrue(engine.titleCalls.isEmpty) - provider.markFirstTurnPersisted() - engine.completeStream(.success("done")) - await waitUntil { store.activeComposerTurn == nil } - } - - do { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.addAttachments([URL(fileURLWithPath: "/tmp/title-attachment.png")]) - store.send() - await waitUntil { engine.streamCalls.count == 1 } - XCTAssertTrue(engine.titleCalls.isEmpty) - XCTAssertEqual(engine.streamCalls.first?.attachmentPaths, ["/tmp/title-attachment.png"]) - provider.markFirstTurnPersisted() - engine.completeStream(.success("saw image")) - await waitUntil { store.activeComposerTurn == nil } - } - - do { - let engine = ControllableEngine(unavailableDetail: "Assistive lane unavailable") - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "first" - store.send() - await waitUntil { store.activeComposerTurn == nil } - XCTAssertTrue(engine.titleCalls.isEmpty) - XCTAssertTrue(engine.streamCalls.isEmpty) - XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistive lane unavailable") - } - } - - func testEarlyTitleUpdatesOriginalThreadWithoutChangingSelection() async throws { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let first = ChatThread(title: "Heuristic slug", meta: "now") - let second = ChatThread(title: "Other thread", meta: "now") - let store = AgentChatStore(engine: engine, threadsProvider: provider, threads: [first, second]) - store.draft = "first request" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - store.select(second.id) - - engine.completeTitle(.success("Race-proof Swift titles")) - await waitUntil { store.threads.first(where: { $0.id == first.id })?.title == "Race-proof Swift titles" } - - XCTAssertEqual(store.selectedThreadID, second.id) - XCTAssertEqual(store.threads.first(where: { $0.id == first.id })?.title, "Race-proof Swift titles") - - provider.markFirstTurnPersisted() - engine.completeStream(.success("done")) - await waitUntil { store.activeComposerTurn == nil } - } + private enum FakeError: Error { + case failed + } - func testMissingFirstPersistRetriesExactlyOnceBeforeRefresh() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "build title state" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - - engine.completeTitle(.success("Title state machine")) - await waitUntil { provider.events.filter { $0 == .generated("Title state machine") }.count == 1 } - XCTAssertEqual(store.currentThread?.title, "Title state machine") - - provider.markFirstTurnPersisted() - engine.completeStream(.success("done")) - await waitUntil { store.activeComposerTurn == nil } - - let generated = provider.events.filter { $0 == .generated("Title state machine") } - XCTAssertEqual(generated.count, 2, "one immediate attempt plus one post-stream retry") - XCTAssertEqual(provider.persistedTitle, "Title state machine") - XCTAssertEqual(store.currentThread?.title, "Title state machine", "refresh must not flash the heuristic slug back") - let lastGenerated = provider.events.lastIndex(of: .generated("Title state machine")) - let refresh = provider.events.lastIndex(of: .list) - XCTAssertNotNil(lastGenerated) - XCTAssertNotNil(refresh) - XCTAssertLessThan(lastGenerated!, refresh!, "queued title must flush before refresh") - } + private struct StreamCall: Equatable { + let text: String + let threadID: String + let attachmentPaths: [String] + } - func testLateTitlePersistsDirectlyWithoutRetry() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "late title" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - - provider.markFirstTurnPersisted() - engine.completeStream(.success("done")) - await waitUntil { store.activeComposerTurn == nil } - engine.completeTitle(.success("Late direct title")) - await waitUntil { store.currentThread?.title == "Late direct title" } - - XCTAssertEqual(provider.events.filter { $0 == .generated("Late direct title") }.count, 1) - XCTAssertEqual(provider.persistedTitle, "Late direct title") - } + private final class ControllableEngine: AgentChatEngine { + private let lock = NSLock() + private var storedTitleCalls: [String] = [] + private var storedStreamCalls: [StreamCall] = [] + private var titleContinuation: CheckedContinuation? + private var streamContinuation: CheckedContinuation? - func testManualRenameBeforeGenerationQueuesCustomAndDiscardsGenerated() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "rename before generation" - store.send() - // Deliberately rename before the send task gets a scheduling turn. The - // synchronous first-turn state must already own the missing-file race. - let thread = store.currentThread! - store.rename(thread, to: "My durable title") - XCTAssertEqual(store.currentThread?.title, "My durable title") - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - engine.completeTitle(.success("Generated loser")) - await Task.yield() - - provider.markFirstTurnPersisted() - engine.completeStream(.success("done")) - await waitUntil { store.activeComposerTurn == nil } - - XCTAssertEqual(provider.persistedTitle, "My durable title") - XCTAssertEqual(store.currentThread?.title, "My durable title") - XCTAssertTrue(provider.events.filter { $0 == .generated("Generated loser") }.isEmpty) - XCTAssertEqual(provider.events.filter { $0 == .renamed("My durable title") }.count, 2) - } + let unavailableDetail: String? - func testManualRenameAfterEarlyGenerationDiscardsQueuedGeneratedRetry() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "generation first" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - - engine.completeTitle(.success("Generated first")) - await waitUntil { store.currentThread?.title == "Generated first" } - store.rename(store.currentThread!, to: "Custom after generation") - - provider.markFirstTurnPersisted() - engine.completeStream(.success("done")) - await waitUntil { store.activeComposerTurn == nil } - - XCTAssertEqual(provider.events.filter { $0 == .generated("Generated first") }.count, 1) - XCTAssertEqual(provider.persistedTitle, "Custom after generation") - XCTAssertEqual(store.currentThread?.title, "Custom after generation") - let customFlush = provider.events.lastIndex(of: .renamed("Custom after generation")) - let refresh = provider.events.lastIndex(of: .list) - XCTAssertLessThan(customFlush!, refresh!) + init(unavailableDetail: String? = nil) { + self.unavailableDetail = unavailableDetail } - func testManualRenameAfterLateGeneratedPersistWins() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "late generated then rename" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - - provider.markFirstTurnPersisted() - engine.completeStream(.success("done")) - await waitUntil { store.activeComposerTurn == nil } - engine.completeTitle(.success("Generated persisted")) - await waitUntil { store.currentThread?.title == "Generated persisted" } + var titleCalls: [String] { lock.withLock { storedTitleCalls } } + var streamCalls: [StreamCall] { lock.withLock { storedStreamCalls } } - store.rename(store.currentThread!, to: "User final title") - - XCTAssertEqual(provider.persistedTitle, "User final title") - XCTAssertEqual(store.currentThread?.title, "User final title") - } - - func testDeleteBeforeTitleCompletionDiscardsLateResultAndNeverReselectsThread() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - let deletedID = store.currentThread!.id - store.draft = "delete this first turn" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - - store.delete(store.currentThread!) - await waitUntil { store.activeComposerTurn == nil } - let replacementID = store.selectedThreadID - engine.completeTitle(.success("Resurrected title")) - await Task.yield() - - XCTAssertFalse(store.threads.contains { $0.id == deletedID }) - XCTAssertNotEqual(replacementID, deletedID) - XCTAssertEqual(store.selectedThreadID, replacementID) - XCTAssertTrue(provider.events.filter { $0 == .generated("Resurrected title") }.isEmpty) - XCTAssertFalse(store.threads.contains { $0.title == "Resurrected title" }) - } + func isAvailable() -> Bool { unavailableDetail == nil } + func availabilityDetail() -> String? { unavailableDetail } - func testNilEmptyThrowAndPersistenceFailureLeaveFallbackAndAssistantUntouched() async { - await assertGenerationFallback(.success(nil)) - await assertGenerationFallback(.success(" \n")) - await assertGenerationFallback(.failure(FakeError.failed)) - - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - provider.forceGeneratedFailure = true - let store = makeStore(engine: engine, provider: provider) - store.draft = "persistence failure" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - provider.markFirstTurnPersisted() - engine.completeStream(.success("Assistant survives")) - await waitUntil { store.activeComposerTurn == nil } - engine.completeTitle(.success("Cannot persist")) - await waitUntil { provider.events.contains(.generated("Cannot persist")) } - - XCTAssertEqual(store.currentThread?.title, "Heuristic slug") - XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistant survives") - XCTAssertEqual(provider.events.filter { $0 == .generated("Cannot persist") }.count, 1) - } - - func testComposerAndAssistiveRejectDelimiterOnlyGeneratedTitles() async { - do { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "keyboard title fallback" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - - engine.completeTitle(.success("<<<")) - provider.markFirstTurnPersisted() - engine.completeStream(.success("Keyboard reply")) - await waitUntil { store.activeComposerTurn == nil } - - XCTAssertEqual(store.currentThread?.title, "Heuristic slug") - XCTAssertTrue(provider.events.allSatisfy { - if case .generated = $0 { return false } - return true - }) + func generateThreadTitle(_ text: String) async throws -> String? { + try await withCheckedThrowingContinuation { continuation in + lock.withLock { + storedTitleCalls.append(text) + titleContinuation = continuation } - - do { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.ingestVoiceTurn( - threadId: provider.backendID, - userText: voiceWire(instruction: "assistive title fallback") - ) - await waitUntil { engine.titleCalls.count == 1 } - - engine.completeTitle(.success("<<<")) - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - await waitUntil { provider.events.contains(.list) } - - XCTAssertEqual(store.currentThread?.title, "Heuristic slug") - XCTAssertTrue(provider.events.allSatisfy { - if case .generated = $0 { return false } - return true - }) - XCTAssertTrue(engine.streamCalls.isEmpty) + } + } + + func streamReply( + _ text: String, + threadId: String, + attachmentPaths: [String], + onDelta: @escaping @MainActor (String) -> Void, + onReasoning: @escaping @MainActor (String) -> Void, + onToolExecuting: @escaping @MainActor (String, String) -> Void, + onToolResult: @escaping @MainActor (String, String, Bool, String) -> Void + ) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + lock.withLock { + storedStreamCalls.append( + StreamCall(text: text, threadID: threadId, attachmentPaths: attachmentPaths) + ) + streamContinuation = continuation } + } } - // MARK: - Title marker strip (bucket markers never reach a derived title) - - func testNormalizedStripsContextMarkerAndRejoinsSplitWord() { - // Incident input, verbatim: the capture landed mid-word and the overlay - // space-padded the marker inside "mnie". - XCTAssertEqual( - ThreadTitlePolicy.normalized( - "Chciałbym Ci przedstawić taką jedną rzecz, która mn {selection_1} ie bardzo drażni..." - ), - "Chciałbym Ci przedstawić taką jedną rzecz, która mnie bardzo drażni..." - ) + func cancelReply(threadId: String) -> Bool { + completeStream(.failure(CancellationError())) + return true } - func testNormalizedStripsWordBoundaryMarkersWithSingleSpace() { - XCTAssertEqual(ThreadTitlePolicy.normalized("say {selection_1} then"), "say then") - XCTAssertEqual(ThreadTitlePolicy.normalized("look {image_1} here"), "look here") - XCTAssertEqual( - ThreadTitlePolicy.normalized("stack {selection_1} {selection_2} them"), - "stack them" - ) - XCTAssertEqual( - ThreadTitlePolicy.normalized("{selection_1} leading and trailing {image_2}"), - "leading and trailing" - ) - XCTAssertNil(ThreadTitlePolicy.normalized("{selection_1}"), "a marker-only line is not a title") - XCTAssertEqual( - ThreadTitlePolicy.normalized("keep {selection_} literal"), - "keep {selection_} literal", - "index-less braces are not bucket markers" - ) + func completeTitle(_ result: Result) { + let continuation = lock.withLock { () -> CheckedContinuation? in + defer { titleContinuation = nil } + return titleContinuation + } + continuation?.resume(with: result) } - func testNormalizedGluesUnpaddedMidWordMarkersLosslessly() { - // The overlay inserts mid-word markers without padding, so adjacency - // itself signals the split — vowels no longer matter. - XCTAssertEqual( - ThreadTitlePolicy.normalized("która mn{selection_1}ie bardzo drażni"), - "która mnie bardzo drażni" - ) - XCTAssertEqual( - ThreadTitlePolicy.normalized("bard{selection_1}zo lubię pieguski"), - "bardzo lubię pieguski" - ) + func completeStream(_ result: Result) { + let continuation = lock.withLock { () -> CheckedContinuation? in + defer { streamContinuation = nil } + return streamContinuation + } + continuation?.resume(with: result) } + } - func testNormalizedMarkerStripStillClipsAtLimit() { - let padding = String(repeating: "x", count: 100) - let title = ThreadTitlePolicy.normalized("mn {selection_1} ie \(padding)") - XCTAssertEqual(title?.count, 72) - XCTAssertEqual(title?.hasPrefix("mnie x"), true) - XCTAssertEqual(title?.contains("selection"), false) + private final class TitleThreadsProvider: ChatThreadsProviding { + enum Event: Equatable { + case list + case generated(String) + case renamed(String) + case deleted } - private func assertGenerationFallback(_ outcome: Result) async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.draft = "fallback title" - store.send() - await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } - engine.completeTitle(outcome) - provider.markFirstTurnPersisted() - engine.completeStream(.success("Assistant survives")) - await waitUntil { store.activeComposerTurn == nil } - - XCTAssertEqual(store.currentThread?.title, "Heuristic slug") - XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistant survives") - XCTAssertTrue(provider.events.filter { - if case .generated = $0 { return true } - return false - }.isEmpty) - } - - // MARK: - Voice parity (same coordinator; the core owns the conversational turn) - - /// Byte-for-byte assistive wire skeleton (`build_assistive_input`, missing - /// selection variant) — proves the title request uses the PRESENTED spoken - /// instruction, never the wire prompt. - private func voiceWire(instruction: String) -> String { - "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n\(instruction)\n>\n\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n" - } - - func testFirstVoiceExchangeLaunchesExactlyOneTitleRequestFromPresentedText() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - - store.ingestVoiceTurn( - threadId: provider.backendID, - userText: voiceWire(instruction: "Plan the voice rename race") - ) - await waitUntil { engine.titleCalls.count == 1 } - - XCTAssertEqual(engine.titleCalls, ["Plan the voice rename race"], "title must use presented text, not the wire skeleton") - XCTAssertTrue(engine.streamCalls.isEmpty, "voice title must never re-send the conversation") - - store.ingestVoiceDelta("Assistant reply") - engine.completeTitle(.success(nil)) - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - await waitUntil { provider.events.contains(.list) } - - XCTAssertEqual(engine.titleCalls.count, 1) - XCTAssertTrue(engine.streamCalls.isEmpty) - } - - func testSecondVoiceExchangeLaunchesNoAdditionalTitleRequest() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "first voice ask") - await waitUntil { engine.titleCalls.count == 1 } - engine.completeTitle(.success("Voice thread title")) - await waitUntil { provider.events.filter { $0 == .generated("Voice thread title") }.count == 1 } - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - await waitUntil { provider.persistedTitle == "Voice thread title" } - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "second voice ask") - store.ingestVoiceDelta("more") - store.ingestVoiceDone() - await Task.yield() - - XCTAssertEqual(engine.titleCalls.count, 1, "later exchanges on the same backend thread launch no title request") - XCTAssertTrue(engine.streamCalls.isEmpty) - } - - func testVoiceEarlyTitleQueuesAndFlushesAfterCorePersistenceOnDone() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice early title") - selectVoiceThread(provider.backendID, in: store) - await waitUntil { engine.titleCalls.count == 1 } - - engine.completeTitle(.success("Voice title state")) - await waitUntil { provider.events.filter { $0 == .generated("Voice title state") }.count == 1 } - XCTAssertEqual(store.currentThread?.title, "Voice title state") - XCTAssertFalse(provider.threadExists, "a queued title must not create the missing thread") - - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - await waitUntil { provider.persistedTitle == "Voice title state" } - - XCTAssertEqual(provider.events.filter { $0 == .generated("Voice title state") }.count, 2, - "one immediate attempt plus one post-persistence retry") - let lastGenerated = provider.events.lastIndex(of: .generated("Voice title state")) - let refresh = provider.events.lastIndex(of: .list) - XCTAssertNotNil(lastGenerated) - XCTAssertNotNil(refresh) - XCTAssertLessThan(lastGenerated!, refresh!, "queued voice title must flush before the rail refresh") - XCTAssertEqual(store.currentThread?.title, "Voice title state") - XCTAssertEqual(store.currentThread?.backendId, provider.backendID, "selection stays on the same backend thread") - } - - func testVoiceLateTitlePersistsDirectlyWithoutRetry() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice late title") - selectVoiceThread(provider.backendID, in: store) - await waitUntil { engine.titleCalls.count == 1 } - - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - engine.completeTitle(.success("Late voice title")) - await waitUntil { store.currentThread?.title == "Late voice title" } - - XCTAssertEqual(provider.events.filter { $0 == .generated("Late voice title") }.count, 1) - XCTAssertEqual(provider.persistedTitle, "Late voice title") - XCTAssertEqual(store.currentThread?.backendId, provider.backendID) - } - - func testVoiceNilBlankThrowAndPersistenceFailurePreserveFallbackTitle() async { - await assertVoiceGenerationFallback(.success(nil)) - await assertVoiceGenerationFallback(.success(" \n")) - await assertVoiceGenerationFallback(.failure(FakeError.failed)) - - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - provider.forceGeneratedFailure = true - let store = makeStore(engine: engine, provider: provider) - store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice persistence failure") - selectVoiceThread(provider.backendID, in: store) - await waitUntil { engine.titleCalls.count == 1 } - engine.completeTitle(.success("Cannot persist")) - await waitUntil { provider.events.filter { $0 == .generated("Cannot persist") }.count == 1 } - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - await waitUntil { store.currentThread?.title == "Heuristic slug" } - - XCTAssertEqual(provider.events.filter { $0 == .generated("Cannot persist") }.count, 2) - XCTAssertEqual(provider.persistedTitle, "Heuristic slug") - } - - func testVoiceManualRenameBeforeCompletionWinsPermanently() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice rename before generation") - selectVoiceThread(provider.backendID, in: store) - await waitUntil { engine.titleCalls.count == 1 } - - store.rename(store.currentThread!, to: "My durable voice title") - XCTAssertEqual(store.currentThread?.title, "My durable voice title") - - engine.completeTitle(.success("Generated loser")) - await Task.yield() - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - await waitUntil { provider.persistedTitle == "My durable voice title" } - - XCTAssertEqual(store.currentThread?.title, "My durable voice title") - XCTAssertTrue(provider.events.filter { $0 == .generated("Generated loser") }.isEmpty) - XCTAssertTrue(provider.isCustom) - } - - func testVoiceManualRenameAfterGeneratedTitleWinsPermanently() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice late generated then rename") - selectVoiceThread(provider.backendID, in: store) - await waitUntil { engine.titleCalls.count == 1 } - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - engine.completeTitle(.success("Voice generated persisted")) - await waitUntil { store.currentThread?.title == "Voice generated persisted" } - - store.rename(store.currentThread!, to: "User final voice title") - - XCTAssertEqual(provider.persistedTitle, "User final voice title") - XCTAssertEqual(store.currentThread?.title, "User final voice title") - } - - func testVoiceDeleteBeforeTitleCompletionDiscardsLateResultAndNeverRecreatesThread() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "delete this voice turn") - selectVoiceThread(provider.backendID, in: store) - await waitUntil { engine.titleCalls.count == 1 } - let deletedID = store.currentThread!.id - - store.delete(store.currentThread!) - engine.completeTitle(.success("Voice resurrected title")) - await Task.yield() - store.ingestVoiceDone() // late terminal for the deleted turn must no-op - await Task.yield() - - XCTAssertFalse(store.threads.contains { $0.id == deletedID }) - XCTAssertFalse(provider.threadExists, "a deleted first voice turn must never come back to disk") - XCTAssertTrue(provider.events.filter { $0 == .generated("Voice resurrected title") }.isEmpty) - XCTAssertFalse(store.threads.contains { $0.title == "Voice resurrected title" }) - } - - func testVoiceStopBeforeTitleCompletionPreservesFallbackAndPersistsNothing() async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let canceller = VoiceCancelStub() // the store holds it weakly - let store = AgentChatStore( - engine: engine, - threadsProvider: provider, - threads: [ChatThread(title: "Heuristic slug", meta: "now")], - voiceTurnCanceller: canceller - ) - defer { _ = canceller } - - store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice cancel race") - selectVoiceThread(provider.backendID, in: store) - store.ingestVoiceDelta("Partial voice answer") - await waitUntil { engine.titleCalls.count == 1 } - - store.stopActiveTurn() - store.ingestVoiceCancelled(threadId: provider.backendID) - engine.completeTitle(.success("Cancelled winner")) - await waitUntil { provider.events.contains(.generated("Cancelled winner")) } - - XCTAssertEqual(store.currentThread?.title, "voice cancel race", - "late title on a cancelled turn cannot persist; the fallback heuristic returns") - XCTAssertFalse(provider.threadExists, "a cancelled voice turn must not create the thread") - XCTAssertEqual(store.currentThread?.messages.last?.wasStopped, true) - XCTAssertTrue(engine.streamCalls.isEmpty) - } - - private func assertVoiceGenerationFallback(_ outcome: Result) async { - let engine = ControllableEngine() - let provider = TitleThreadsProvider() - let store = makeStore(engine: engine, provider: provider) - store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice fallback title") - selectVoiceThread(provider.backendID, in: store) - await waitUntil { engine.titleCalls.count == 1 } - engine.completeTitle(outcome) - provider.markFirstTurnPersisted() - store.ingestVoiceDone() - await waitUntil { store.currentThread?.title == "Heuristic slug" } - - XCTAssertTrue(engine.streamCalls.isEmpty) - XCTAssertTrue(provider.events.filter { - if case .generated = $0 { return true } - return false - }.isEmpty) - } - - private func selectVoiceThread( - _ backendID: String, - in store: AgentChatStore, - file: StaticString = #filePath, - line: UInt = #line - ) { - guard let threadID = store.threads.first(where: { $0.backendId == backendID })?.id else { - XCTFail("Expected a local voice thread for \(backendID)", file: file, line: line) - return - } - store.select(threadID) - } - - private func makeStore( - engine: ControllableEngine, - provider: TitleThreadsProvider, - thread: ChatThread = ChatThread(title: "Heuristic slug", meta: "now") - ) -> AgentChatStore { - AgentChatStore(engine: engine, threadsProvider: provider, threads: [thread]) - } - - private func waitUntil( - timeout: Duration = .seconds(1), - _ condition: @escaping @MainActor () -> Bool - ) async { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - while clock.now < deadline { - if condition() { return } - try? await Task.sleep(for: .milliseconds(5)) - } - XCTFail("Timed out waiting for deterministic title state") - } + let backendID: String + private(set) var events: [Event] = [] + private(set) var persistedTitle: String + var threadExists = false + var isCustom = false + var forceGeneratedFailure = false + + init(backendID: String = "title-thread-1", title: String = "Heuristic slug") { + self.backendID = backendID + self.persistedTitle = title + } + + func markFirstTurnPersisted() { + threadExists = true + } + + func listThreads() -> [ChatThread] { + events.append(.list) + guard threadExists else { return [] } + var thread = ChatThread(title: persistedTitle, meta: "now") + thread.backendId = backendID + thread.messagesLoaded = true + return [thread] + } + + func searchThreads(query: String) -> [ChatThread] { listThreads() } + func loadMessages(backendId: String) -> [ChatMessage] { [] } + + func deleteThread(backendId: String) -> Bool { + events.append(.deleted) + guard threadExists else { return false } + threadExists = false + return true + } + + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { threadExists } + + func renameThread(backendId: String, title: String) -> Bool { + events.append(.renamed(title)) + guard threadExists else { return false } + persistedTitle = title + isCustom = true + return true + } + + func setGeneratedTitle(backendId: String, title: String) -> Bool { + events.append(.generated(title)) + guard threadExists, !isCustom, !forceGeneratedFailure else { return false } + persistedTitle = title + return true + } + + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } + func generateThreadId() -> String { backendID } + } + + private final class VoiceCancelStub: VoiceTurnCancelling { + func cancelVoiceTurn(threadId: String) -> Bool { true } + } + + func testFirstTextTurnLaunchesExactlyOneIndependentTitleRequest() async throws { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "Plan the rename race" + + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + + XCTAssertEqual(engine.titleCalls, ["Plan the rename race"]) + XCTAssertEqual( + engine.streamCalls, + [StreamCall(text: "Plan the rename race", threadID: provider.backendID, attachmentPaths: [])] + ) + + engine.completeTitle(.success(nil)) + provider.markFirstTurnPersisted() + engine.completeStream(.success("Assistant reply")) + await waitUntil { store.activeComposerTurn == nil } + + XCTAssertEqual(engine.titleCalls.count, 1) + XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistant reply") + } + + func testSubsequentAttachmentOnlyAndUnavailableTurnsDoNotLaunchTitle() async { + do { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + var thread = ChatThread(title: "Existing", meta: "now") + thread.messages = [ChatMessage(role: .you, timestamp: "now", text: "first")] + let store = makeStore(engine: engine, provider: provider, thread: thread) + store.draft = "second" + store.send() + await waitUntil { engine.streamCalls.count == 1 } + XCTAssertTrue(engine.titleCalls.isEmpty) + provider.markFirstTurnPersisted() + engine.completeStream(.success("done")) + await waitUntil { store.activeComposerTurn == nil } + } + + do { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.addAttachments([URL(fileURLWithPath: "/tmp/title-attachment.png")]) + store.send() + await waitUntil { engine.streamCalls.count == 1 } + XCTAssertTrue(engine.titleCalls.isEmpty) + XCTAssertEqual(engine.streamCalls.first?.attachmentPaths, ["/tmp/title-attachment.png"]) + provider.markFirstTurnPersisted() + engine.completeStream(.success("saw image")) + await waitUntil { store.activeComposerTurn == nil } + } + + do { + let engine = ControllableEngine(unavailableDetail: "Assistive lane unavailable") + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "first" + store.send() + await waitUntil { store.activeComposerTurn == nil } + XCTAssertTrue(engine.titleCalls.isEmpty) + XCTAssertTrue(engine.streamCalls.isEmpty) + XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistive lane unavailable") + } + } + + func testEarlyTitleUpdatesOriginalThreadWithoutChangingSelection() async throws { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let first = ChatThread(title: "Heuristic slug", meta: "now") + let second = ChatThread(title: "Other thread", meta: "now") + let store = AgentChatStore(engine: engine, threadsProvider: provider, threads: [first, second]) + store.draft = "first request" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + store.select(second.id) + + engine.completeTitle(.success("Race-proof Swift titles")) + await waitUntil { + store.threads.first(where: { $0.id == first.id })?.title == "Race-proof Swift titles" + } + + XCTAssertEqual(store.selectedThreadID, second.id) + XCTAssertEqual( + store.threads.first(where: { $0.id == first.id })?.title, "Race-proof Swift titles") + + provider.markFirstTurnPersisted() + engine.completeStream(.success("done")) + await waitUntil { store.activeComposerTurn == nil } + } + + func testMissingFirstPersistRetriesExactlyOnceBeforeRefresh() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "build title state" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + + engine.completeTitle(.success("Title state machine")) + await waitUntil { + provider.events.filter { $0 == .generated("Title state machine") }.count == 1 + } + XCTAssertEqual(store.currentThread?.title, "Title state machine") + + provider.markFirstTurnPersisted() + engine.completeStream(.success("done")) + await waitUntil { store.activeComposerTurn == nil } + + let generated = provider.events.filter { $0 == .generated("Title state machine") } + XCTAssertEqual(generated.count, 2, "one immediate attempt plus one post-stream retry") + XCTAssertEqual(provider.persistedTitle, "Title state machine") + XCTAssertEqual( + store.currentThread?.title, "Title state machine", + "refresh must not flash the heuristic slug back") + let lastGenerated = provider.events.lastIndex(of: .generated("Title state machine")) + let refresh = provider.events.lastIndex(of: .list) + XCTAssertNotNil(lastGenerated) + XCTAssertNotNil(refresh) + XCTAssertLessThan(lastGenerated!, refresh!, "queued title must flush before refresh") + } + + func testLateTitlePersistsDirectlyWithoutRetry() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "late title" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + + provider.markFirstTurnPersisted() + engine.completeStream(.success("done")) + await waitUntil { store.activeComposerTurn == nil } + engine.completeTitle(.success("Late direct title")) + await waitUntil { store.currentThread?.title == "Late direct title" } + + XCTAssertEqual(provider.events.filter { $0 == .generated("Late direct title") }.count, 1) + XCTAssertEqual(provider.persistedTitle, "Late direct title") + } + + func testManualRenameBeforeGenerationQueuesCustomAndDiscardsGenerated() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "rename before generation" + store.send() + // Deliberately rename before the send task gets a scheduling turn. The + // synchronous first-turn state must already own the missing-file race. + let thread = store.currentThread! + store.rename(thread, to: "My durable title") + XCTAssertEqual(store.currentThread?.title, "My durable title") + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + engine.completeTitle(.success("Generated loser")) + await Task.yield() + + provider.markFirstTurnPersisted() + engine.completeStream(.success("done")) + await waitUntil { store.activeComposerTurn == nil } + + XCTAssertEqual(provider.persistedTitle, "My durable title") + XCTAssertEqual(store.currentThread?.title, "My durable title") + XCTAssertTrue(provider.events.filter { $0 == .generated("Generated loser") }.isEmpty) + XCTAssertEqual(provider.events.filter { $0 == .renamed("My durable title") }.count, 2) + } + + func testManualRenameAfterEarlyGenerationDiscardsQueuedGeneratedRetry() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "generation first" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + + engine.completeTitle(.success("Generated first")) + await waitUntil { store.currentThread?.title == "Generated first" } + store.rename(store.currentThread!, to: "Custom after generation") + + provider.markFirstTurnPersisted() + engine.completeStream(.success("done")) + await waitUntil { store.activeComposerTurn == nil } + + XCTAssertEqual(provider.events.filter { $0 == .generated("Generated first") }.count, 1) + XCTAssertEqual(provider.persistedTitle, "Custom after generation") + XCTAssertEqual(store.currentThread?.title, "Custom after generation") + let customFlush = provider.events.lastIndex(of: .renamed("Custom after generation")) + let refresh = provider.events.lastIndex(of: .list) + XCTAssertLessThan(customFlush!, refresh!) + } + + func testManualRenameAfterLateGeneratedPersistWins() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "late generated then rename" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + + provider.markFirstTurnPersisted() + engine.completeStream(.success("done")) + await waitUntil { store.activeComposerTurn == nil } + engine.completeTitle(.success("Generated persisted")) + await waitUntil { store.currentThread?.title == "Generated persisted" } + + store.rename(store.currentThread!, to: "User final title") + + XCTAssertEqual(provider.persistedTitle, "User final title") + XCTAssertEqual(store.currentThread?.title, "User final title") + } + + func testDeleteBeforeTitleCompletionDiscardsLateResultAndNeverReselectsThread() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + let deletedID = store.currentThread!.id + store.draft = "delete this first turn" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + + store.delete(store.currentThread!) + await waitUntil { store.activeComposerTurn == nil } + let replacementID = store.selectedThreadID + engine.completeTitle(.success("Resurrected title")) + await Task.yield() + + XCTAssertFalse(store.threads.contains { $0.id == deletedID }) + XCTAssertNotEqual(replacementID, deletedID) + XCTAssertEqual(store.selectedThreadID, replacementID) + XCTAssertTrue(provider.events.filter { $0 == .generated("Resurrected title") }.isEmpty) + XCTAssertFalse(store.threads.contains { $0.title == "Resurrected title" }) + } + + func testNilEmptyThrowAndPersistenceFailureLeaveFallbackAndAssistantUntouched() async { + await assertGenerationFallback(.success(nil)) + await assertGenerationFallback(.success(" \n")) + await assertGenerationFallback(.failure(FakeError.failed)) + + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + provider.forceGeneratedFailure = true + let store = makeStore(engine: engine, provider: provider) + store.draft = "persistence failure" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + provider.markFirstTurnPersisted() + engine.completeStream(.success("Assistant survives")) + await waitUntil { store.activeComposerTurn == nil } + engine.completeTitle(.success("Cannot persist")) + await waitUntil { provider.events.contains(.generated("Cannot persist")) } + + XCTAssertEqual(store.currentThread?.title, "Heuristic slug") + XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistant survives") + XCTAssertEqual(provider.events.filter { $0 == .generated("Cannot persist") }.count, 1) + } + + func testComposerAndAssistiveRejectDelimiterOnlyGeneratedTitles() async { + do { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "keyboard title fallback" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + + engine.completeTitle(.success("<<<")) + provider.markFirstTurnPersisted() + engine.completeStream(.success("Keyboard reply")) + await waitUntil { store.activeComposerTurn == nil } + + XCTAssertEqual(store.currentThread?.title, "Heuristic slug") + XCTAssertTrue( + provider.events.allSatisfy { + if case .generated = $0 { return false } + return true + }) + } + + do { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.ingestVoiceTurn( + threadId: provider.backendID, + userText: voiceWire(instruction: "assistive title fallback") + ) + await waitUntil { engine.titleCalls.count == 1 } + + engine.completeTitle(.success("<<<")) + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + await waitUntil { provider.events.contains(.list) } + + XCTAssertEqual(store.currentThread?.title, "Heuristic slug") + XCTAssertTrue( + provider.events.allSatisfy { + if case .generated = $0 { return false } + return true + }) + XCTAssertTrue(engine.streamCalls.isEmpty) + } + } + + // MARK: - Title marker strip (bucket markers never reach a derived title) + + func testNormalizedStripsContextMarkerAndRejoinsSplitWord() { + // Incident input, verbatim: the capture landed mid-word and the overlay + // space-padded the marker inside "mnie". + XCTAssertEqual( + ThreadTitlePolicy.normalized( + "Chciałbym Ci przedstawić taką jedną rzecz, która mn {selection_1} ie bardzo drażni..." + ), + "Chciałbym Ci przedstawić taką jedną rzecz, która mnie bardzo drażni..." + ) + } + + func testNormalizedStripsWordBoundaryMarkersWithSingleSpace() { + XCTAssertEqual(ThreadTitlePolicy.normalized("say {selection_1} then"), "say then") + XCTAssertEqual(ThreadTitlePolicy.normalized("look {image_1} here"), "look here") + XCTAssertEqual( + ThreadTitlePolicy.normalized("stack {selection_1} {selection_2} them"), + "stack them" + ) + XCTAssertEqual( + ThreadTitlePolicy.normalized("{selection_1} leading and trailing {image_2}"), + "leading and trailing" + ) + XCTAssertNil(ThreadTitlePolicy.normalized("{selection_1}"), "a marker-only line is not a title") + XCTAssertEqual( + ThreadTitlePolicy.normalized("keep {selection_} literal"), + "keep {selection_} literal", + "index-less braces are not bucket markers" + ) + } + + func testNormalizedGluesUnpaddedMidWordMarkersLosslessly() { + // The overlay inserts mid-word markers without padding, so adjacency + // itself signals the split — vowels no longer matter. + XCTAssertEqual( + ThreadTitlePolicy.normalized("która mn{selection_1}ie bardzo drażni"), + "która mnie bardzo drażni" + ) + XCTAssertEqual( + ThreadTitlePolicy.normalized("bard{selection_1}zo lubię pieguski"), + "bardzo lubię pieguski" + ) + } + + func testNormalizedMarkerStripStillClipsAtLimit() { + let padding = String(repeating: "x", count: 100) + let title = ThreadTitlePolicy.normalized("mn {selection_1} ie \(padding)") + XCTAssertEqual(title?.count, 72) + XCTAssertEqual(title?.hasPrefix("mnie x"), true) + XCTAssertEqual(title?.contains("selection"), false) + } + + private func assertGenerationFallback(_ outcome: Result) async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.draft = "fallback title" + store.send() + await waitUntil { engine.titleCalls.count == 1 && engine.streamCalls.count == 1 } + engine.completeTitle(outcome) + provider.markFirstTurnPersisted() + engine.completeStream(.success("Assistant survives")) + await waitUntil { store.activeComposerTurn == nil } + + XCTAssertEqual(store.currentThread?.title, "Heuristic slug") + XCTAssertEqual(store.currentThread?.messages.last?.text, "Assistant survives") + XCTAssertTrue( + provider.events.filter { + if case .generated = $0 { return true } + return false + }.isEmpty) + } + + // MARK: - Voice parity (same coordinator; the core owns the conversational turn) + + /// Byte-for-byte assistive wire skeleton (`build_assistive_input`, missing + /// selection variant) — proves the title request uses the PRESENTED spoken + /// instruction, never the wire prompt. + private func voiceWire(instruction: String) -> String { + "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n\(instruction)\n>\n\nZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n" + } + + func testFirstVoiceExchangeLaunchesExactlyOneTitleRequestFromPresentedText() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + + store.ingestVoiceTurn( + threadId: provider.backendID, + userText: voiceWire(instruction: "Plan the voice rename race") + ) + await waitUntil { engine.titleCalls.count == 1 } + + XCTAssertEqual( + engine.titleCalls, ["Plan the voice rename race"], + "title must use presented text, not the wire skeleton") + XCTAssertTrue(engine.streamCalls.isEmpty, "voice title must never re-send the conversation") + + store.ingestVoiceDelta("Assistant reply") + engine.completeTitle(.success(nil)) + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + await waitUntil { provider.events.contains(.list) } + + XCTAssertEqual(engine.titleCalls.count, 1) + XCTAssertTrue(engine.streamCalls.isEmpty) + } + + func testSecondVoiceExchangeLaunchesNoAdditionalTitleRequest() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + + store.ingestVoiceTurn(threadId: provider.backendID, userText: "first voice ask") + await waitUntil { engine.titleCalls.count == 1 } + engine.completeTitle(.success("Voice thread title")) + await waitUntil { provider.events.filter { $0 == .generated("Voice thread title") }.count == 1 } + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + await waitUntil { provider.persistedTitle == "Voice thread title" } + + store.ingestVoiceTurn(threadId: provider.backendID, userText: "second voice ask") + store.ingestVoiceDelta("more") + store.ingestVoiceDone() + await Task.yield() + + XCTAssertEqual( + engine.titleCalls.count, 1, + "later exchanges on the same backend thread launch no title request") + XCTAssertTrue(engine.streamCalls.isEmpty) + } + + func testVoiceEarlyTitleQueuesAndFlushesAfterCorePersistenceOnDone() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + + store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice early title") + selectVoiceThread(provider.backendID, in: store) + await waitUntil { engine.titleCalls.count == 1 } + + engine.completeTitle(.success("Voice title state")) + await waitUntil { provider.events.filter { $0 == .generated("Voice title state") }.count == 1 } + XCTAssertEqual(store.currentThread?.title, "Voice title state") + XCTAssertFalse(provider.threadExists, "a queued title must not create the missing thread") + + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + await waitUntil { provider.persistedTitle == "Voice title state" } + + XCTAssertEqual( + provider.events.filter { $0 == .generated("Voice title state") }.count, 2, + "one immediate attempt plus one post-persistence retry") + let lastGenerated = provider.events.lastIndex(of: .generated("Voice title state")) + let refresh = provider.events.lastIndex(of: .list) + XCTAssertNotNil(lastGenerated) + XCTAssertNotNil(refresh) + XCTAssertLessThan( + lastGenerated!, refresh!, "queued voice title must flush before the rail refresh") + XCTAssertEqual(store.currentThread?.title, "Voice title state") + XCTAssertEqual( + store.currentThread?.backendId, provider.backendID, + "selection stays on the same backend thread") + } + + func testVoiceLateTitlePersistsDirectlyWithoutRetry() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + + store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice late title") + selectVoiceThread(provider.backendID, in: store) + await waitUntil { engine.titleCalls.count == 1 } + + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + engine.completeTitle(.success("Late voice title")) + await waitUntil { store.currentThread?.title == "Late voice title" } + + XCTAssertEqual(provider.events.filter { $0 == .generated("Late voice title") }.count, 1) + XCTAssertEqual(provider.persistedTitle, "Late voice title") + XCTAssertEqual(store.currentThread?.backendId, provider.backendID) + } + + func testVoiceNilBlankThrowAndPersistenceFailurePreserveFallbackTitle() async { + await assertVoiceGenerationFallback(.success(nil)) + await assertVoiceGenerationFallback(.success(" \n")) + await assertVoiceGenerationFallback(.failure(FakeError.failed)) + + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + provider.forceGeneratedFailure = true + let store = makeStore(engine: engine, provider: provider) + store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice persistence failure") + selectVoiceThread(provider.backendID, in: store) + await waitUntil { engine.titleCalls.count == 1 } + engine.completeTitle(.success("Cannot persist")) + await waitUntil { provider.events.filter { $0 == .generated("Cannot persist") }.count == 1 } + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + await waitUntil { store.currentThread?.title == "Heuristic slug" } + + XCTAssertEqual(provider.events.filter { $0 == .generated("Cannot persist") }.count, 2) + XCTAssertEqual(provider.persistedTitle, "Heuristic slug") + } + + func testVoiceManualRenameBeforeCompletionWinsPermanently() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + + store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice rename before generation") + selectVoiceThread(provider.backendID, in: store) + await waitUntil { engine.titleCalls.count == 1 } + + store.rename(store.currentThread!, to: "My durable voice title") + XCTAssertEqual(store.currentThread?.title, "My durable voice title") + + engine.completeTitle(.success("Generated loser")) + await Task.yield() + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + await waitUntil { provider.persistedTitle == "My durable voice title" } + + XCTAssertEqual(store.currentThread?.title, "My durable voice title") + XCTAssertTrue(provider.events.filter { $0 == .generated("Generated loser") }.isEmpty) + XCTAssertTrue(provider.isCustom) + } + + func testVoiceManualRenameAfterGeneratedTitleWinsPermanently() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + + store.ingestVoiceTurn( + threadId: provider.backendID, userText: "voice late generated then rename") + selectVoiceThread(provider.backendID, in: store) + await waitUntil { engine.titleCalls.count == 1 } + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + engine.completeTitle(.success("Voice generated persisted")) + await waitUntil { store.currentThread?.title == "Voice generated persisted" } + + store.rename(store.currentThread!, to: "User final voice title") + + XCTAssertEqual(provider.persistedTitle, "User final voice title") + XCTAssertEqual(store.currentThread?.title, "User final voice title") + } + + func testVoiceDeleteBeforeTitleCompletionDiscardsLateResultAndNeverRecreatesThread() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + + store.ingestVoiceTurn(threadId: provider.backendID, userText: "delete this voice turn") + selectVoiceThread(provider.backendID, in: store) + await waitUntil { engine.titleCalls.count == 1 } + let deletedID = store.currentThread!.id + + store.delete(store.currentThread!) + engine.completeTitle(.success("Voice resurrected title")) + await Task.yield() + store.ingestVoiceDone() // late terminal for the deleted turn must no-op + await Task.yield() + + XCTAssertFalse(store.threads.contains { $0.id == deletedID }) + XCTAssertFalse(provider.threadExists, "a deleted first voice turn must never come back to disk") + XCTAssertTrue(provider.events.filter { $0 == .generated("Voice resurrected title") }.isEmpty) + XCTAssertFalse(store.threads.contains { $0.title == "Voice resurrected title" }) + } + + func testVoiceStopBeforeTitleCompletionPreservesFallbackAndPersistsNothing() async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let canceller = VoiceCancelStub() // the store holds it weakly + let store = AgentChatStore( + engine: engine, + threadsProvider: provider, + threads: [ChatThread(title: "Heuristic slug", meta: "now")], + voiceTurnCanceller: canceller + ) + defer { _ = canceller } + + store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice cancel race") + selectVoiceThread(provider.backendID, in: store) + store.ingestVoiceDelta("Partial voice answer") + await waitUntil { engine.titleCalls.count == 1 } + + store.stopActiveTurn() + store.ingestVoiceCancelled(threadId: provider.backendID) + engine.completeTitle(.success("Cancelled winner")) + await waitUntil { provider.events.contains(.generated("Cancelled winner")) } + + XCTAssertEqual( + store.currentThread?.title, "voice cancel race", + "late title on a cancelled turn cannot persist; the fallback heuristic returns") + XCTAssertFalse(provider.threadExists, "a cancelled voice turn must not create the thread") + XCTAssertEqual(store.currentThread?.messages.last?.wasStopped, true) + XCTAssertTrue(engine.streamCalls.isEmpty) + } + + private func assertVoiceGenerationFallback(_ outcome: Result) async { + let engine = ControllableEngine() + let provider = TitleThreadsProvider() + let store = makeStore(engine: engine, provider: provider) + store.ingestVoiceTurn(threadId: provider.backendID, userText: "voice fallback title") + selectVoiceThread(provider.backendID, in: store) + await waitUntil { engine.titleCalls.count == 1 } + engine.completeTitle(outcome) + provider.markFirstTurnPersisted() + store.ingestVoiceDone() + await waitUntil { store.currentThread?.title == "Heuristic slug" } + + XCTAssertTrue(engine.streamCalls.isEmpty) + XCTAssertTrue( + provider.events.filter { + if case .generated = $0 { return true } + return false + }.isEmpty) + } + + private func selectVoiceThread( + _ backendID: String, + in store: AgentChatStore, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let threadID = store.threads.first(where: { $0.backendId == backendID })?.id else { + XCTFail("Expected a local voice thread for \(backendID)", file: file, line: line) + return + } + store.select(threadID) + } + + private func makeStore( + engine: ControllableEngine, + provider: TitleThreadsProvider, + thread: ChatThread = ChatThread(title: "Heuristic slug", meta: "now") + ) -> AgentChatStore { + AgentChatStore(engine: engine, threadsProvider: provider, threads: [thread]) + } + + private func waitUntil( + timeout: Duration = .seconds(1), + _ condition: @escaping @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return } + try? await Task.sleep(for: .milliseconds(5)) + } + XCTFail("Timed out waiting for deterministic title state") + } } diff --git a/macos/CodescribeTests/AssistivePromptParserTests.swift b/macos/CodescribeTests/AssistivePromptParserTests.swift index 247a0707..11d847c9 100644 --- a/macos/CodescribeTests/AssistivePromptParserTests.swift +++ b/macos/CodescribeTests/AssistivePromptParserTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import Codescribe /// U17 chat-presentation-truth: the You-bubble shows the spoken instruction, @@ -8,401 +9,404 @@ import XCTest /// persisted history on load. final class AssistivePromptParserTests: XCTestCase { - // MARK: - Wire builders (byte-for-byte mirror of build_assistive_input) - - /// Legacy Polish dialect — threads persisted before the EN label rename. - private func wire( - instruction: String, - selection: String? = nil, - app: String? = nil - ) -> String { - var out = "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n\(instruction)\n>\n\n" - if let selection { - out += "ZAZNACZONY_TEKST:\n<<<\n\(selection)\n>\n" - } else { - out += "ZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n" - } - if let app { - out += "\nKONTEKST:\n- frontmost_app: \(app)\n" - } - return out - } - - /// Canonical English dialect — current `build_assistive_input` output. - private func englishWire( - instruction: String, - selection: String? = nil, - selectionCarriedInContextTags: Bool = false, - app: String? = nil - ) -> String { - var out = "USER_INSTRUCTION:\n<<<\n\(instruction)\n>\n\n" - if let selection { - out += "SELECTED_TEXT:\n<<<\n\(selection)\n>\n" - } else if selectionCarriedInContextTags { - out += "SELECTED_TEXT: carried in .\n" - } else { - out += "SELECTED_TEXT: no selection available.\n" - } - if let app { - out += "\nCONTEXT:\n- frontmost_app: \(app)\n" - } - return out - } - - // MARK: - Skeleton variants - - func testParsesSelectionAndContextVariant() { - let parts = AssistivePromptParser.parse( - wire(instruction: "popraw ten akapit", selection: "stary tekst do poprawy", app: "Safari") - ) - XCTAssertEqual(parts?.instruction, "popraw ten akapit") - XCTAssertEqual(parts?.selectedText, "stary tekst do poprawy") - XCTAssertEqual(parts?.frontmostApp, "Safari") - } - - func testParsesSelectionWithoutContextVariant() { - let parts = AssistivePromptParser.parse( - wire(instruction: "przetłumacz to", selection: "hello world") - ) - XCTAssertEqual(parts?.instruction, "przetłumacz to") - XCTAssertEqual(parts?.selectedText, "hello world") - XCTAssertNil(parts?.frontmostApp) - } - - func testParsesMissingSelectionWithContextVariant() { - let parts = AssistivePromptParser.parse( - wire(instruction: "napisz krótkie podsumowanie dnia", app: "Ghostty") - ) - XCTAssertEqual(parts?.instruction, "napisz krótkie podsumowanie dnia") - XCTAssertNil(parts?.selectedText) - XCTAssertEqual(parts?.frontmostApp, "Ghostty") - } - - func testParsesMissingSelectionWithoutContextVariant() { - let parts = AssistivePromptParser.parse(wire(instruction: "co słychać")) - XCTAssertEqual(parts?.instruction, "co słychać") - XCTAssertNil(parts?.selectedText) - XCTAssertNil(parts?.frontmostApp) - } - - // MARK: - Canonical English dialect (current wires) - - func testParsesEnglishSelectionAndContextVariant() { - let parts = AssistivePromptParser.parse( - englishWire(instruction: "popraw ten akapit", selection: "stary tekst", app: "Safari") - ) - XCTAssertEqual(parts?.instruction, "popraw ten akapit") - XCTAssertEqual(parts?.selectedText, "stary tekst") - XCTAssertEqual(parts?.frontmostApp, "Safari") - } - - func testParsesEnglishMissingSelectionVariants() { - let missing = AssistivePromptParser.parse( - englishWire(instruction: "summarize the day", app: "Ghostty") - ) - XCTAssertEqual(missing?.instruction, "summarize the day") - XCTAssertNil(missing?.selectedText) - XCTAssertEqual(missing?.frontmostApp, "Ghostty") - - // Bucket-carried selections live in tags appended - // after the skeleton; the header line itself parses like "missing". - let carried = AssistivePromptParser.parse( - englishWire(instruction: "compare all three", selectionCarriedInContextTags: true) - ) - XCTAssertEqual(carried?.instruction, "compare all three") - XCTAssertNil(carried?.selectedText) - XCTAssertNil(carried?.frontmostApp) - } - - func testParsesCarriedSelectionVariantWithLiveCount() { - // The carried-line suffix carries an honest selection count, so the - // parser matches by prefix and consumes through the end of the line. - let parts = AssistivePromptParser.parse( - "USER_INSTRUCTION:\n<<<\npieguski przede wszystkim\n>\n\n" - + "SELECTED_TEXT: carried in (3 selections).\n" - + "\nCONTEXT:\n- frontmost_app: iTerm2\n" - ) - XCTAssertEqual(parts?.instruction, "pieguski przede wszystkim") - XCTAssertNil(parts?.selectedText) - XCTAssertEqual(parts?.frontmostApp, "iTerm2") - - let singular = AssistivePromptParser.parse( - "USER_INSTRUCTION:\n<<<\njedno zaznaczenie\n>\n\n" - + "SELECTED_TEXT: carried in (1 selection).\n" - ) - XCTAssertEqual(singular?.instruction, "jedno zaznaczenie") - XCTAssertNil(singular?.selectedText) - XCTAssertNil(singular?.frontmostApp) - } - - // MARK: - Multiline payloads - - func testMultilineInstructionAndSelectionSurviveIntact() { - let instruction = "pierwsza myśl\n\ndruga myśl po pauzie" - let selection = "linia 1\nlinia 2\n\nlinia 4 z > znakiem" - let parts = AssistivePromptParser.parse( - wire(instruction: instruction, selection: selection, app: "Xcode") - ) - XCTAssertEqual(parts?.instruction, instruction) - XCTAssertEqual(parts?.selectedText, selection) - XCTAssertEqual(parts?.frontmostApp, "Xcode") - } - - // MARK: - Non-skeleton text passes through - - func testPlainComposerTextIsNotParsed() { - XCTAssertNil(AssistivePromptParser.parse("just a normal chat message")) - XCTAssertNil(AssistivePromptParser.parse("mention of INSTRUKCJA_UŻYTKOWNIKA: mid-text")) - XCTAssertNil(AssistivePromptParser.parse("")) - } - - func testHeaderWithoutSelectionSectionSalvagesInstruction() { - // Truncated wire (header + instruction, no selection section) used to - // stay raw and dump the skeleton into the You bubble — that collapsed - // the Agent window on June-era restores (R1). Salvage the spoken text. - let parts = AssistivePromptParser.parse("INSTRUKCJA_UŻYTKOWNIKA:\n<<<\ncoś\n>\n") - XCTAssertEqual(parts?.instruction, "coś") - XCTAssertNil(parts?.selectedText) - XCTAssertNil(parts?.frontmostApp) - } - - // MARK: - June-era / gpt-5.5 tolerant seams (R1) - - /// Operator screenshot class: instruction heredoc close (`>`) missing - /// between spoken text and ZAZNACZONY_TEKST, with a huge selection body. - private func juneEraWireMissingClose( - instruction: String, - selection: String, - app: String? = "Xcode" - ) -> String { - var out = "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n\(instruction)\n\n" - out += "ZAZNACZONY_TEKST:\n<<<\n\(selection)\n>\n" - if let app { - out += "\nKONTEKST:\n- frontmost_app: \(app)\n" - } - return out - } - - func testParsesJuneEraWireMissingHeredocClose() { - let selection = String(repeating: "long unbreakable_token_fragment)\" ", count: 200) - let raw = juneEraWireMissingClose( - instruction: "No wiesz co, spróbujesz jeszcze raz?", - selection: selection - ) - let parts = AssistivePromptParser.parse(raw) - XCTAssertEqual(parts?.instruction, "No wiesz co, spróbujesz jeszcze raz?") - XCTAssertEqual(parts?.selectedText, selection.trimmingCharacters(in: .whitespacesAndNewlines)) - XCTAssertEqual(parts?.frontmostApp, "Xcode") - } - - func testPresentedJuneEraWireNeverShowsSkeletonInBubble() { - let selection = """ - vibecrafted workflow claude --prompt "$(cat <<'PROMPT' - Masz do zrobienia audit... - PROMPT)" - """ - let raw = juneEraWireMissingClose( - instruction: "Spróbujesz jeszcze raz?", - selection: selection - ) - let presented = AssistivePromptParser.presented( - ChatMessage(role: .you, timestamp: "13:03", text: raw) - ) - XCTAssertEqual(presented.text, "Spróbujesz jeszcze raz?") - XCTAssertEqual(presented.wireText, raw) - XCTAssertFalse(presented.text.contains("INSTRUKCJA_UŻYTKOWNIKA")) - XCTAssertFalse(presented.text.contains("ZAZNACZONY_TEKST")) - XCTAssertTrue(presented.contextSelection?.contains("vibecrafted") == true) - // Display text stays a short spoken instruction — not the wall of wire. - XCTAssertLessThan(presented.text.utf8.count, 200) - } - - func testParsesEnglishWireMissingHeredocClose() { - let raw = "USER_INSTRUCTION:\n<<<\ntry again\n\nSELECTED_TEXT:\n<<<\npasted body\n>\n\nCONTEXT:\n- frontmost_app: Safari\n" - let parts = AssistivePromptParser.parse(raw) - XCTAssertEqual(parts?.instruction, "try again") - XCTAssertEqual(parts?.selectedText, "pasted body") - XCTAssertEqual(parts?.frontmostApp, "Safari") + // MARK: - Wire builders (byte-for-byte mirror of build_assistive_input) + + /// Legacy Polish dialect — threads persisted before the EN label rename. + private func wire( + instruction: String, + selection: String? = nil, + app: String? = nil + ) -> String { + var out = "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n\(instruction)\n>\n\n" + if let selection { + out += "ZAZNACZONY_TEKST:\n<<<\n\(selection)\n>\n" + } else { + out += "ZAZNACZONY_TEKST: brak dostępnego zaznaczenia.\n" } - - func testOpenEndedSelectionStillSalvagesInstruction() { - // Selection heredoc never closed — still must not dump the skeleton. - let raw = "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\nkontynuuj\n>\n\nZAZNACZONY_TEKST:\n<<<\nunclosed selection body without close" - let parts = AssistivePromptParser.parse(raw) - XCTAssertEqual(parts?.instruction, "kontynuuj") - XCTAssertEqual(parts?.selectedText, "unclosed selection body without close") + if let app { + out += "\nKONTEKST:\n- frontmost_app: \(app)\n" } - - // MARK: - Message presentation (display/wire split) - - func testPresentedRewritesUserSkeletonMessage() { - let raw = wire(instruction: "zrób listę zakupów", selection: "mleko, chleb", app: "Notes") - let message = ChatMessage(role: .you, timestamp: "10:00", text: raw) - - let presented = AssistivePromptParser.presented(message) - - XCTAssertEqual(presented.text, "zrób listę zakupów") - XCTAssertEqual(presented.wireText, raw) - XCTAssertEqual(presented.contextSelection, "mleko, chleb") - XCTAssertEqual(presented.contextApp, "Notes") + return out + } + + /// Canonical English dialect — current `build_assistive_input` output. + private func englishWire( + instruction: String, + selection: String? = nil, + selectionCarriedInContextTags: Bool = false, + app: String? = nil + ) -> String { + var out = "USER_INSTRUCTION:\n<<<\n\(instruction)\n>\n\n" + if let selection { + out += "SELECTED_TEXT:\n<<<\n\(selection)\n>\n" + } else if selectionCarriedInContextTags { + out += "SELECTED_TEXT: carried in .\n" + } else { + out += "SELECTED_TEXT: no selection available.\n" } - - func testPresentedLeavesPlainUserMessageUntouched() { - let message = ChatMessage(role: .you, timestamp: "10:00", text: "plain composer text") - let presented = AssistivePromptParser.presented(message) - XCTAssertEqual(presented.text, "plain composer text") - XCTAssertNil(presented.wireText) - XCTAssertNil(presented.contextSelection) - XCTAssertNil(presented.contextApp) + if let app { + out += "\nCONTEXT:\n- frontmost_app: \(app)\n" } - - func testPresentedLeavesAssistantMessageUntouched() { - let raw = wire(instruction: "echo of the skeleton in a reply") - let message = ChatMessage(role: .assistant, timestamp: "10:00", text: raw) - let presented = AssistivePromptParser.presented(message) - XCTAssertEqual(presented.text, raw) - XCTAssertNil(presented.wireText) - } - - // MARK: - Restore path (persisted threads render clean) - - @MainActor - func testRestoredThreadMessagesRenderCleanFromWire() { - let raw = wire(instruction: "przeczytaj tego maila", selection: "Dear team…", app: "Mail") - let provider = StubThreadsProvider( - thread: { - var thread = ChatThread(title: "restored", meta: "yesterday") - thread.backendId = "t_restore" - return thread - }(), - messages: [ - ChatMessage(role: .you, timestamp: "09:00", text: raw), - ChatMessage(role: .assistant, timestamp: "09:01", text: "Sure — summary follows."), - ] - ) - - let store = AgentChatStore(threadsProvider: provider) - - let messages = store.threads.first { $0.backendId == "t_restore" }?.messages ?? [] - XCTAssertEqual(messages.count, 2) - XCTAssertEqual(messages.first?.text, "przeczytaj tego maila") - XCTAssertEqual(messages.first?.wireText, raw) - XCTAssertEqual(messages.first?.contextSelection, "Dear team…") - XCTAssertEqual(messages.first?.contextApp, "Mail") - // The assistant turn is untouched by the rewrite. - XCTAssertEqual(messages.last?.text, "Sure — summary follows.") - XCTAssertNil(messages.last?.wireText) - } - - @MainActor - func testLiveVoiceTurnIngestsDisplayNotWire() { - let raw = wire(instruction: "odpowiedz po polsku", selection: "some english text", app: "Slack") - let store = AgentChatStore(threads: []) - - store.ingestVoiceTurn(threadId: "t_live", userText: raw) - - let thread = store.threads.first { $0.backendId == "t_live" } - let you = thread?.messages.first { $0.role == .you } - XCTAssertEqual(you?.text, "odpowiedz po polsku") - XCTAssertEqual(you?.wireText, raw) - XCTAssertEqual(you?.contextSelection, "some english text") - XCTAssertEqual(you?.contextApp, "Slack") - // The thread title comes from the spoken instruction, not the skeleton. - XCTAssertEqual(thread?.title, "odpowiedz po polsku") - } - - /// V4: hydrating a June-era legacy session and then re-selecting a live - /// thread must not leave skeleton dump text in the live view. - @MainActor - func testSelectingLegacyThenLiveDoesNotPoisonLiveMessages() { - let legacyWire = juneEraWireMissingClose( - instruction: "Cześć. Kim jesteś?", - selection: String(repeating: "legacy_prompt_body)\" ", count: 400) - ) - let liveYou = ChatMessage(role: .you, timestamp: "14:00", text: "live plain message") - let liveAssistant = ChatMessage( - role: .assistant, timestamp: "14:01", text: "live assistant reply" - ) - let legacyYou = ChatMessage(role: .you, timestamp: "13:00", text: legacyWire) - let legacyAssistant = ChatMessage( - role: .assistant, timestamp: "13:01", text: "legacy assistant reply" - ) - - var liveThread = ChatThread(title: "live", meta: "now") - liveThread.backendId = "t_live" - var legacyThread = ChatThread(title: "legacy", meta: "Jun 15") - legacyThread.backendId = "t_legacy" - - let provider = MultiThreadStubProvider(messagesByBackendId: [ - "t_live": [liveYou, liveAssistant], - "t_legacy": [legacyYou, legacyAssistant], - ], threads: [liveThread, legacyThread]) - - let store = AgentChatStore(threadsProvider: provider) - // Prefer live first if the store auto-selects the first row. - let liveID = store.threads.first { $0.backendId == "t_live" }!.id - let legacyID = store.threads.first { $0.backendId == "t_legacy" }!.id - - store.select(legacyID) - let legacyMessages = store.threads.first { $0.id == legacyID }?.messages ?? [] - XCTAssertEqual(legacyMessages.first?.text, "Cześć. Kim jesteś?") - XCTAssertFalse(legacyMessages.first?.text.contains("INSTRUKCJA") == true) - XCTAssertNotNil(legacyMessages.first?.wireText) - - store.select(liveID) - let liveMessages = store.threads.first { $0.id == liveID }?.messages ?? [] - XCTAssertEqual(liveMessages.count, 2) - XCTAssertEqual(liveMessages.first?.text, "live plain message") - XCTAssertNil(liveMessages.first?.wireText) - XCTAssertEqual(liveMessages.last?.text, "live assistant reply") - // Live view must not pick up the legacy wire body. - XCTAssertFalse(liveMessages.contains { $0.text.contains("INSTRUKCJA") }) - XCTAssertFalse(liveMessages.contains { $0.text.contains("legacy_prompt_body") }) + return out + } + + // MARK: - Skeleton variants + + func testParsesSelectionAndContextVariant() { + let parts = AssistivePromptParser.parse( + wire(instruction: "popraw ten akapit", selection: "stary tekst do poprawy", app: "Safari") + ) + XCTAssertEqual(parts?.instruction, "popraw ten akapit") + XCTAssertEqual(parts?.selectedText, "stary tekst do poprawy") + XCTAssertEqual(parts?.frontmostApp, "Safari") + } + + func testParsesSelectionWithoutContextVariant() { + let parts = AssistivePromptParser.parse( + wire(instruction: "przetłumacz to", selection: "hello world") + ) + XCTAssertEqual(parts?.instruction, "przetłumacz to") + XCTAssertEqual(parts?.selectedText, "hello world") + XCTAssertNil(parts?.frontmostApp) + } + + func testParsesMissingSelectionWithContextVariant() { + let parts = AssistivePromptParser.parse( + wire(instruction: "napisz krótkie podsumowanie dnia", app: "Ghostty") + ) + XCTAssertEqual(parts?.instruction, "napisz krótkie podsumowanie dnia") + XCTAssertNil(parts?.selectedText) + XCTAssertEqual(parts?.frontmostApp, "Ghostty") + } + + func testParsesMissingSelectionWithoutContextVariant() { + let parts = AssistivePromptParser.parse(wire(instruction: "co słychać")) + XCTAssertEqual(parts?.instruction, "co słychać") + XCTAssertNil(parts?.selectedText) + XCTAssertNil(parts?.frontmostApp) + } + + // MARK: - Canonical English dialect (current wires) + + func testParsesEnglishSelectionAndContextVariant() { + let parts = AssistivePromptParser.parse( + englishWire(instruction: "popraw ten akapit", selection: "stary tekst", app: "Safari") + ) + XCTAssertEqual(parts?.instruction, "popraw ten akapit") + XCTAssertEqual(parts?.selectedText, "stary tekst") + XCTAssertEqual(parts?.frontmostApp, "Safari") + } + + func testParsesEnglishMissingSelectionVariants() { + let missing = AssistivePromptParser.parse( + englishWire(instruction: "summarize the day", app: "Ghostty") + ) + XCTAssertEqual(missing?.instruction, "summarize the day") + XCTAssertNil(missing?.selectedText) + XCTAssertEqual(missing?.frontmostApp, "Ghostty") + + // Bucket-carried selections live in tags appended + // after the skeleton; the header line itself parses like "missing". + let carried = AssistivePromptParser.parse( + englishWire(instruction: "compare all three", selectionCarriedInContextTags: true) + ) + XCTAssertEqual(carried?.instruction, "compare all three") + XCTAssertNil(carried?.selectedText) + XCTAssertNil(carried?.frontmostApp) + } + + func testParsesCarriedSelectionVariantWithLiveCount() { + // The carried-line suffix carries an honest selection count, so the + // parser matches by prefix and consumes through the end of the line. + let parts = AssistivePromptParser.parse( + "USER_INSTRUCTION:\n<<<\npieguski przede wszystkim\n>\n\n" + + "SELECTED_TEXT: carried in (3 selections).\n" + + "\nCONTEXT:\n- frontmost_app: iTerm2\n" + ) + XCTAssertEqual(parts?.instruction, "pieguski przede wszystkim") + XCTAssertNil(parts?.selectedText) + XCTAssertEqual(parts?.frontmostApp, "iTerm2") + + let singular = AssistivePromptParser.parse( + "USER_INSTRUCTION:\n<<<\njedno zaznaczenie\n>\n\n" + + "SELECTED_TEXT: carried in (1 selection).\n" + ) + XCTAssertEqual(singular?.instruction, "jedno zaznaczenie") + XCTAssertNil(singular?.selectedText) + XCTAssertNil(singular?.frontmostApp) + } + + // MARK: - Multiline payloads + + func testMultilineInstructionAndSelectionSurviveIntact() { + let instruction = "pierwsza myśl\n\ndruga myśl po pauzie" + let selection = "linia 1\nlinia 2\n\nlinia 4 z > znakiem" + let parts = AssistivePromptParser.parse( + wire(instruction: instruction, selection: selection, app: "Xcode") + ) + XCTAssertEqual(parts?.instruction, instruction) + XCTAssertEqual(parts?.selectedText, selection) + XCTAssertEqual(parts?.frontmostApp, "Xcode") + } + + // MARK: - Non-skeleton text passes through + + func testPlainComposerTextIsNotParsed() { + XCTAssertNil(AssistivePromptParser.parse("just a normal chat message")) + XCTAssertNil(AssistivePromptParser.parse("mention of INSTRUKCJA_UŻYTKOWNIKA: mid-text")) + XCTAssertNil(AssistivePromptParser.parse("")) + } + + func testHeaderWithoutSelectionSectionSalvagesInstruction() { + // Truncated wire (header + instruction, no selection section) used to + // stay raw and dump the skeleton into the You bubble — that collapsed + // the Agent window on June-era restores (R1). Salvage the spoken text. + let parts = AssistivePromptParser.parse("INSTRUKCJA_UŻYTKOWNIKA:\n<<<\ncoś\n>\n") + XCTAssertEqual(parts?.instruction, "coś") + XCTAssertNil(parts?.selectedText) + XCTAssertNil(parts?.frontmostApp) + } + + // MARK: - June-era / gpt-5.5 tolerant seams (R1) + + /// Operator screenshot class: instruction heredoc close (`>`) missing + /// between spoken text and ZAZNACZONY_TEKST, with a huge selection body. + private func juneEraWireMissingClose( + instruction: String, + selection: String, + app: String? = "Xcode" + ) -> String { + var out = "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n\(instruction)\n\n" + out += "ZAZNACZONY_TEKST:\n<<<\n\(selection)\n>\n" + if let app { + out += "\nKONTEKST:\n- frontmost_app: \(app)\n" } + return out + } + + func testParsesJuneEraWireMissingHeredocClose() { + let selection = String(repeating: "long unbreakable_token_fragment)\" ", count: 200) + let raw = juneEraWireMissingClose( + instruction: "No wiesz co, spróbujesz jeszcze raz?", + selection: selection + ) + let parts = AssistivePromptParser.parse(raw) + XCTAssertEqual(parts?.instruction, "No wiesz co, spróbujesz jeszcze raz?") + XCTAssertEqual(parts?.selectedText, selection.trimmingCharacters(in: .whitespacesAndNewlines)) + XCTAssertEqual(parts?.frontmostApp, "Xcode") + } + + func testPresentedJuneEraWireNeverShowsSkeletonInBubble() { + let selection = """ + vibecrafted workflow claude --prompt "$(cat <<'PROMPT' + Masz do zrobienia audit... + PROMPT)" + """ + let raw = juneEraWireMissingClose( + instruction: "Spróbujesz jeszcze raz?", + selection: selection + ) + let presented = AssistivePromptParser.presented( + ChatMessage(role: .you, timestamp: "13:03", text: raw) + ) + XCTAssertEqual(presented.text, "Spróbujesz jeszcze raz?") + XCTAssertEqual(presented.wireText, raw) + XCTAssertFalse(presented.text.contains("INSTRUKCJA_UŻYTKOWNIKA")) + XCTAssertFalse(presented.text.contains("ZAZNACZONY_TEKST")) + XCTAssertTrue(presented.contextSelection?.contains("vibecrafted") == true) + // Display text stays a short spoken instruction — not the wall of wire. + XCTAssertLessThan(presented.text.utf8.count, 200) + } + + func testParsesEnglishWireMissingHeredocClose() { + let raw = + "USER_INSTRUCTION:\n<<<\ntry again\n\nSELECTED_TEXT:\n<<<\npasted body\n>\n\nCONTEXT:\n- frontmost_app: Safari\n" + let parts = AssistivePromptParser.parse(raw) + XCTAssertEqual(parts?.instruction, "try again") + XCTAssertEqual(parts?.selectedText, "pasted body") + XCTAssertEqual(parts?.frontmostApp, "Safari") + } + + func testOpenEndedSelectionStillSalvagesInstruction() { + // Selection heredoc never closed — still must not dump the skeleton. + let raw = + "INSTRUKCJA_UŻYTKOWNIKA:\n<<<\nkontynuuj\n>\n\nZAZNACZONY_TEKST:\n<<<\nunclosed selection body without close" + let parts = AssistivePromptParser.parse(raw) + XCTAssertEqual(parts?.instruction, "kontynuuj") + XCTAssertEqual(parts?.selectedText, "unclosed selection body without close") + } + + // MARK: - Message presentation (display/wire split) + + func testPresentedRewritesUserSkeletonMessage() { + let raw = wire(instruction: "zrób listę zakupów", selection: "mleko, chleb", app: "Notes") + let message = ChatMessage(role: .you, timestamp: "10:00", text: raw) + + let presented = AssistivePromptParser.presented(message) + + XCTAssertEqual(presented.text, "zrób listę zakupów") + XCTAssertEqual(presented.wireText, raw) + XCTAssertEqual(presented.contextSelection, "mleko, chleb") + XCTAssertEqual(presented.contextApp, "Notes") + } + + func testPresentedLeavesPlainUserMessageUntouched() { + let message = ChatMessage(role: .you, timestamp: "10:00", text: "plain composer text") + let presented = AssistivePromptParser.presented(message) + XCTAssertEqual(presented.text, "plain composer text") + XCTAssertNil(presented.wireText) + XCTAssertNil(presented.contextSelection) + XCTAssertNil(presented.contextApp) + } + + func testPresentedLeavesAssistantMessageUntouched() { + let raw = wire(instruction: "echo of the skeleton in a reply") + let message = ChatMessage(role: .assistant, timestamp: "10:00", text: raw) + let presented = AssistivePromptParser.presented(message) + XCTAssertEqual(presented.text, raw) + XCTAssertNil(presented.wireText) + } + + // MARK: - Restore path (persisted threads render clean) + + @MainActor + func testRestoredThreadMessagesRenderCleanFromWire() { + let raw = wire(instruction: "przeczytaj tego maila", selection: "Dear team…", app: "Mail") + let provider = StubThreadsProvider( + thread: { + var thread = ChatThread(title: "restored", meta: "yesterday") + thread.backendId = "t_restore" + return thread + }(), + messages: [ + ChatMessage(role: .you, timestamp: "09:00", text: raw), + ChatMessage(role: .assistant, timestamp: "09:01", text: "Sure — summary follows."), + ] + ) + + let store = AgentChatStore(threadsProvider: provider) + + let messages = store.threads.first { $0.backendId == "t_restore" }?.messages ?? [] + XCTAssertEqual(messages.count, 2) + XCTAssertEqual(messages.first?.text, "przeczytaj tego maila") + XCTAssertEqual(messages.first?.wireText, raw) + XCTAssertEqual(messages.first?.contextSelection, "Dear team…") + XCTAssertEqual(messages.first?.contextApp, "Mail") + // The assistant turn is untouched by the rewrite. + XCTAssertEqual(messages.last?.text, "Sure — summary follows.") + XCTAssertNil(messages.last?.wireText) + } + + @MainActor + func testLiveVoiceTurnIngestsDisplayNotWire() { + let raw = wire(instruction: "odpowiedz po polsku", selection: "some english text", app: "Slack") + let store = AgentChatStore(threads: []) + + store.ingestVoiceTurn(threadId: "t_live", userText: raw) + + let thread = store.threads.first { $0.backendId == "t_live" } + let you = thread?.messages.first { $0.role == .you } + XCTAssertEqual(you?.text, "odpowiedz po polsku") + XCTAssertEqual(you?.wireText, raw) + XCTAssertEqual(you?.contextSelection, "some english text") + XCTAssertEqual(you?.contextApp, "Slack") + // The thread title comes from the spoken instruction, not the skeleton. + XCTAssertEqual(thread?.title, "odpowiedz po polsku") + } + + /// V4: hydrating a June-era legacy session and then re-selecting a live + /// thread must not leave skeleton dump text in the live view. + @MainActor + func testSelectingLegacyThenLiveDoesNotPoisonLiveMessages() { + let legacyWire = juneEraWireMissingClose( + instruction: "Cześć. Kim jesteś?", + selection: String(repeating: "legacy_prompt_body)\" ", count: 400) + ) + let liveYou = ChatMessage(role: .you, timestamp: "14:00", text: "live plain message") + let liveAssistant = ChatMessage( + role: .assistant, timestamp: "14:01", text: "live assistant reply" + ) + let legacyYou = ChatMessage(role: .you, timestamp: "13:00", text: legacyWire) + let legacyAssistant = ChatMessage( + role: .assistant, timestamp: "13:01", text: "legacy assistant reply" + ) + + var liveThread = ChatThread(title: "live", meta: "now") + liveThread.backendId = "t_live" + var legacyThread = ChatThread(title: "legacy", meta: "Jun 15") + legacyThread.backendId = "t_legacy" + + let provider = MultiThreadStubProvider( + messagesByBackendId: [ + "t_live": [liveYou, liveAssistant], + "t_legacy": [legacyYou, legacyAssistant], + ], threads: [liveThread, legacyThread]) + + let store = AgentChatStore(threadsProvider: provider) + // Prefer live first if the store auto-selects the first row. + let liveID = store.threads.first { $0.backendId == "t_live" }!.id + let legacyID = store.threads.first { $0.backendId == "t_legacy" }!.id + + store.select(legacyID) + let legacyMessages = store.threads.first { $0.id == legacyID }?.messages ?? [] + XCTAssertEqual(legacyMessages.first?.text, "Cześć. Kim jesteś?") + XCTAssertFalse(legacyMessages.first?.text.contains("INSTRUKCJA") == true) + XCTAssertNotNil(legacyMessages.first?.wireText) + + store.select(liveID) + let liveMessages = store.threads.first { $0.id == liveID }?.messages ?? [] + XCTAssertEqual(liveMessages.count, 2) + XCTAssertEqual(liveMessages.first?.text, "live plain message") + XCTAssertNil(liveMessages.first?.wireText) + XCTAssertEqual(liveMessages.last?.text, "live assistant reply") + // Live view must not pick up the legacy wire body. + XCTAssertFalse(liveMessages.contains { $0.text.contains("INSTRUKCJA") }) + XCTAssertFalse(liveMessages.contains { $0.text.contains("legacy_prompt_body") }) + } } /// Provider with per-backend message tables so select(legacy) → select(live) /// exercises the real lazy-load path without sharing one message array. private final class MultiThreadStubProvider: ChatThreadsProviding { - private let messagesByBackendId: [String: [ChatMessage]] - private let threads: [ChatThread] - - init(messagesByBackendId: [String: [ChatMessage]], threads: [ChatThread]) { - self.messagesByBackendId = messagesByBackendId - self.threads = threads - } - - func listThreads() -> [ChatThread] { threads } - func searchThreads(query: String) -> [ChatThread] { threads } - func loadMessages(backendId: String) -> [ChatMessage] { - messagesByBackendId[backendId] ?? [] - } - func deleteThread(backendId: String) -> Bool { true } - func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } - func renameThread(backendId: String, title: String) -> Bool { true } - func setGeneratedTitle(backendId: String, title: String) -> Bool { true } - func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } - func generateThreadId() -> String { "t_generated" } + private let messagesByBackendId: [String: [ChatMessage]] + private let threads: [ChatThread] + + init(messagesByBackendId: [String: [ChatMessage]], threads: [ChatThread]) { + self.messagesByBackendId = messagesByBackendId + self.threads = threads + } + + func listThreads() -> [ChatThread] { threads } + func searchThreads(query: String) -> [ChatThread] { threads } + func loadMessages(backendId: String) -> [ChatMessage] { + messagesByBackendId[backendId] ?? [] + } + func deleteThread(backendId: String) -> Bool { true } + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } + func renameThread(backendId: String, title: String) -> Bool { true } + func setGeneratedTitle(backendId: String, title: String) -> Bool { true } + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } + func generateThreadId() -> String { "t_generated" } } /// Minimal threads provider: one persisted thread whose messages carry the wire /// skeleton, standing in for ThreadStore JSON written before the display split. private final class StubThreadsProvider: ChatThreadsProviding { - private let thread: ChatThread - private let messages: [ChatMessage] - - init(thread: ChatThread, messages: [ChatMessage]) { - self.thread = thread - self.messages = messages - } - - func listThreads() -> [ChatThread] { [thread] } - func searchThreads(query: String) -> [ChatThread] { [thread] } - func loadMessages(backendId: String) -> [ChatMessage] { messages } - func deleteThread(backendId: String) -> Bool { true } - func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } - func renameThread(backendId: String, title: String) -> Bool { true } - func setGeneratedTitle(backendId: String, title: String) -> Bool { true } - func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } - func generateThreadId() -> String { "t_generated" } + private let thread: ChatThread + private let messages: [ChatMessage] + + init(thread: ChatThread, messages: [ChatMessage]) { + self.thread = thread + self.messages = messages + } + + func listThreads() -> [ChatThread] { [thread] } + func searchThreads(query: String) -> [ChatThread] { [thread] } + func loadMessages(backendId: String) -> [ChatMessage] { messages } + func deleteThread(backendId: String) -> Bool { true } + func setThreadFavorite(backendId: String, isFavorite: Bool) -> Bool { true } + func renameThread(backendId: String, title: String) -> Bool { true } + func setGeneratedTitle(backendId: String, title: String) -> Bool { true } + func exportThreadMarkdown(backendId: String, assistantOnly: Bool) -> String? { nil } + func generateThreadId() -> String { "t_generated" } } diff --git a/macos/CodescribeTests/AudioPanelTests.swift b/macos/CodescribeTests/AudioPanelTests.swift index cbba022e..384df92a 100644 --- a/macos/CodescribeTests/AudioPanelTests.swift +++ b/macos/CodescribeTests/AudioPanelTests.swift @@ -1,143 +1,147 @@ import XCTest + @testable import Codescribe @MainActor final class AudioPanelTests: XCTestCase { - func testSelectedInputWritesPromotedKeyAndSurvivesSettingsRoundTrip() { - var writes: [(String, String)] = [] - let liveSnapshot = CsAudioInputSnapshot( - devices: ["MacBook Pro Microphone", "USB Studio Mic"], - configuredDevice: nil, - runtimeDevice: "MacBook Pro Microphone", - configuredDeviceAvailable: true, - fallbackToDefault: false, - runtimeConfigurationMatches: true - ) - let writer = MockSettingsEngine( - audioSnapshot: liveSnapshot, - updateConfigObserver: { key, value in writes.append((key, value)) } - ) - let firstLaunch = SettingsViewModel( - engine: writer, - permissionProbe: MockPermissionProbe(.allGranted) - ) - - firstLaunch.setAudioInputDevice("USB Studio Mic") - - XCTAssertEqual(writes.map(\.0), ["AUDIO_INPUT_DEVICE"]) - XCTAssertEqual(writes.map(\.1), ["USB Studio Mic"]) - - var persisted = CsSettings.sample - persisted.audioInputDevice = "USB Studio Mic" - let restartedSnapshot = CsAudioInputSnapshot( - devices: liveSnapshot.devices, - configuredDevice: "USB Studio Mic", - runtimeDevice: "USB Studio Mic", - configuredDeviceAvailable: true, - fallbackToDefault: false, - runtimeConfigurationMatches: true - ) - let reader = MockSettingsEngine(settings: persisted, audioSnapshot: restartedSnapshot) - let restarted = SettingsViewModel( - engine: reader, - permissionProbe: MockPermissionProbe(.allGranted) - ) - - restarted.refreshAudioInput() - - XCTAssertEqual(reader.loadSettings().audioInputDevice, "USB Studio Mic") - XCTAssertEqual(restarted.audioInput.runtimeDevice, "USB Studio Mic") - } - - func testUnavailableConfiguredDeviceShowsExplicitFallbackWithoutPanic() { - let snapshot = CsAudioInputSnapshot( - devices: ["MacBook Pro Microphone"], - configuredDevice: "Unplugged USB Mic", - runtimeDevice: "MacBook Pro Microphone", - configuredDeviceAvailable: false, - fallbackToDefault: true, - runtimeConfigurationMatches: true - ) - - XCTAssertEqual( - audioInputDisplayState(snapshot), - AudioInputDisplayState( - tone: .fallback, - title: "Using system fallback: MacBook Pro Microphone", - detail: "Unplugged USB Mic is unavailable. Recording continues on the live default input." - ) - ) - - let noHardware = CsAudioInputSnapshot( - devices: [], - configuredDevice: "Unplugged USB Mic", - runtimeDevice: nil, - configuredDeviceAvailable: false, - fallbackToDefault: true, - runtimeConfigurationMatches: true - ) - XCTAssertEqual(audioInputDisplayState(noHardware).tone, .unavailable) - } - - func testSavedDeviceNeverMasqueradesAsTheCurrentRuntimeInput() { - let snapshot = CsAudioInputSnapshot( - devices: ["MacBook Pro Microphone", "USB Studio Mic"], - configuredDevice: "USB Studio Mic", - runtimeDevice: "MacBook Pro Microphone", - configuredDeviceAvailable: true, - fallbackToDefault: false, - runtimeConfigurationMatches: false - ) - - XCTAssertEqual( - audioInputDisplayState(snapshot), - AudioInputDisplayState( - tone: .fallback, - title: "Currently using: MacBook Pro Microphone", - detail: "Saved: USB Studio Mic. Restart Codescribe to apply it; an explicit AUDIO_INPUT_DEVICE launch override can keep a different runtime input active." - ) - ) - } - - func testResetUsesDedicatedUnsetContractNotEmptyStringWrite() { - var resetCalls = 0 - var writes: [(String, String)] = [] - var selected = CsSettings.sample - selected.audioInputDevice = "USB Studio Mic" - let engine = MockSettingsEngine( - settings: selected, - resetAudioInputDeviceObserver: { resetCalls += 1 }, - updateConfigObserver: { key, value in writes.append((key, value)) } - ) - let model = SettingsViewModel( - engine: engine, - permissionProbe: MockPermissionProbe(.allGranted) - ) - - model.resetAudioInputDevice() - - XCTAssertEqual(resetCalls, 1) - XCTAssertTrue(writes.isEmpty, "reset must not route an empty device string") - } - - // Hands-free silence (TOGGLE_SILENCE_SEC) is Dictation-owned; its write - // contract is asserted in SettingsTruthTests. Audio owns only hardware - // selection and sound feedback. - func testAudioKnobsWriteOnlyLiveRuntimeConfigKeys() { - var writes: [(String, String)] = [] - let model = SettingsViewModel( - engine: MockSettingsEngine( - updateConfigObserver: { key, value in writes.append((key, value)) } - ), - permissionProbe: MockPermissionProbe(.allGranted) - ) - - model.setSoundFeedbackEnabled(false) - model.setSoundVolume(0.4) - - XCTAssertEqual(writes.map(\.0), [ - "BEEP_ON_START", "SOUND_VOLUME", - ]) - XCTAssertEqual(writes.map(\.1), ["0", "0.40"]) - } + func testSelectedInputWritesPromotedKeyAndSurvivesSettingsRoundTrip() { + var writes: [(String, String)] = [] + let liveSnapshot = CsAudioInputSnapshot( + devices: ["MacBook Pro Microphone", "USB Studio Mic"], + configuredDevice: nil, + runtimeDevice: "MacBook Pro Microphone", + configuredDeviceAvailable: true, + fallbackToDefault: false, + runtimeConfigurationMatches: true + ) + let writer = MockSettingsEngine( + audioSnapshot: liveSnapshot, + updateConfigObserver: { key, value in writes.append((key, value)) } + ) + let firstLaunch = SettingsViewModel( + engine: writer, + permissionProbe: MockPermissionProbe(.allGranted) + ) + + firstLaunch.setAudioInputDevice("USB Studio Mic") + + XCTAssertEqual(writes.map(\.0), ["AUDIO_INPUT_DEVICE"]) + XCTAssertEqual(writes.map(\.1), ["USB Studio Mic"]) + + var persisted = CsSettings.sample + persisted.audioInputDevice = "USB Studio Mic" + let restartedSnapshot = CsAudioInputSnapshot( + devices: liveSnapshot.devices, + configuredDevice: "USB Studio Mic", + runtimeDevice: "USB Studio Mic", + configuredDeviceAvailable: true, + fallbackToDefault: false, + runtimeConfigurationMatches: true + ) + let reader = MockSettingsEngine(settings: persisted, audioSnapshot: restartedSnapshot) + let restarted = SettingsViewModel( + engine: reader, + permissionProbe: MockPermissionProbe(.allGranted) + ) + + restarted.refreshAudioInput() + + XCTAssertEqual(reader.loadSettings().audioInputDevice, "USB Studio Mic") + XCTAssertEqual(restarted.audioInput.runtimeDevice, "USB Studio Mic") + } + + func testUnavailableConfiguredDeviceShowsExplicitFallbackWithoutPanic() { + let snapshot = CsAudioInputSnapshot( + devices: ["MacBook Pro Microphone"], + configuredDevice: "Unplugged USB Mic", + runtimeDevice: "MacBook Pro Microphone", + configuredDeviceAvailable: false, + fallbackToDefault: true, + runtimeConfigurationMatches: true + ) + + XCTAssertEqual( + audioInputDisplayState(snapshot), + AudioInputDisplayState( + tone: .fallback, + title: "Using system fallback: MacBook Pro Microphone", + detail: "Unplugged USB Mic is unavailable. Recording continues on the live default input." + ) + ) + + let noHardware = CsAudioInputSnapshot( + devices: [], + configuredDevice: "Unplugged USB Mic", + runtimeDevice: nil, + configuredDeviceAvailable: false, + fallbackToDefault: true, + runtimeConfigurationMatches: true + ) + XCTAssertEqual(audioInputDisplayState(noHardware).tone, .unavailable) + } + + func testSavedDeviceNeverMasqueradesAsTheCurrentRuntimeInput() { + let snapshot = CsAudioInputSnapshot( + devices: ["MacBook Pro Microphone", "USB Studio Mic"], + configuredDevice: "USB Studio Mic", + runtimeDevice: "MacBook Pro Microphone", + configuredDeviceAvailable: true, + fallbackToDefault: false, + runtimeConfigurationMatches: false + ) + + XCTAssertEqual( + audioInputDisplayState(snapshot), + AudioInputDisplayState( + tone: .fallback, + title: "Currently using: MacBook Pro Microphone", + detail: + "Saved: USB Studio Mic. Restart Codescribe to apply it; an explicit AUDIO_INPUT_DEVICE launch override can keep a different runtime input active." + ) + ) + } + + func testResetUsesDedicatedUnsetContractNotEmptyStringWrite() { + var resetCalls = 0 + var writes: [(String, String)] = [] + var selected = CsSettings.sample + selected.audioInputDevice = "USB Studio Mic" + let engine = MockSettingsEngine( + settings: selected, + resetAudioInputDeviceObserver: { resetCalls += 1 }, + updateConfigObserver: { key, value in writes.append((key, value)) } + ) + let model = SettingsViewModel( + engine: engine, + permissionProbe: MockPermissionProbe(.allGranted) + ) + + model.resetAudioInputDevice() + + XCTAssertEqual(resetCalls, 1) + XCTAssertTrue(writes.isEmpty, "reset must not route an empty device string") + } + + // Hands-free silence (TOGGLE_SILENCE_SEC) is Engine-owned epoch lifecycle; + // its write contract is asserted in SettingsTruthTests. Audio owns only + // hardware selection and sound feedback. + func testAudioKnobsWriteOnlyLiveRuntimeConfigKeys() { + var writes: [(String, String)] = [] + let model = SettingsViewModel( + engine: MockSettingsEngine( + updateConfigObserver: { key, value in writes.append((key, value)) } + ), + permissionProbe: MockPermissionProbe(.allGranted) + ) + + model.setSoundFeedbackEnabled(false) + model.setSoundVolume(0.4) + + XCTAssertEqual( + writes.map(\.0), + [ + "BEEP_ON_START", "SOUND_VOLUME", + ]) + XCTAssertEqual(writes.map(\.1), ["0", "0.40"]) + } } diff --git a/macos/CodescribeTests/ChatLayoutPolicyTests.swift b/macos/CodescribeTests/ChatLayoutPolicyTests.swift index dfcdb767..70c7a70b 100644 --- a/macos/CodescribeTests/ChatLayoutPolicyTests.swift +++ b/macos/CodescribeTests/ChatLayoutPolicyTests.swift @@ -1,118 +1,119 @@ import XCTest + @testable import Codescribe final class ChatLayoutPolicyTests: XCTestCase { - func testContentWidthSubtractsListPadding() { - XCTAssertEqual(ChatLayoutPolicy.contentWidth(for: 800), 760) - XCTAssertEqual(ChatLayoutPolicy.contentWidth(for: 0), 0) - } - - func testYouBubbleTracksContainerWithoutFixed760Cap() { - // Narrow: floor at minimumReadable. - XCTAssertEqual( - ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 320, mode: .wide), - ChatLayoutPolicy.minimumReadable - ) - - // Mid column (~960): You stays chat-style (~72% of usable), which can be - // *narrower* than the old fixed 760 cap — that is intentional. - let mid = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 960, mode: .wide) - let usableMid = ChatLayoutPolicy.contentWidth(for: 960) - XCTAssertEqual(mid, usableMid * ChatWidthMode.wide.youFraction, accuracy: 0.5) - XCTAssertLessThan(mid, usableMid) - - // Wider viewport: proportional You exceeds the old 760 hard cap. - let expanded = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 1200, mode: .wide) - XCTAssertGreaterThan(expanded, 760) - - // Ultrawide still never exceeds usable column. - let wide = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 1800, mode: .wide) - XCTAssertLessThanOrEqual(wide, ChatLayoutPolicy.contentWidth(for: 1800)) - } - - func testLeadingColumnFillsUsableWidthUntilProseCap() { - let laptop = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: 960, mode: .wide) - XCTAssertEqual(laptop, ChatLayoutPolicy.contentWidth(for: 960), accuracy: 0.5) - XCTAssertGreaterThan(laptop, 900) // old assistant hard cap - - let ultrawide = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: 2200, mode: .wide) - XCTAssertEqual(ultrawide, ChatWidthMode.wide.proseComfortCap) - } - - func testWidthModesOrderComfortableThenWideThenFull() { - let container: CGFloat = 1400 - let comfortable = ChatLayoutPolicy.leadingColumnMaxWidth( - containerWidth: container, - mode: .comfortable - ) - let wide = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: container, mode: .wide) - let full = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: container, mode: .full) - XCTAssertLessThan(comfortable, wide) - XCTAssertLessThan(wide, full) - XCTAssertEqual(full, ChatLayoutPolicy.contentWidth(for: container), accuracy: 0.5) - } - - func testYouBubbleModeFractionsScale() { - let container: CGFloat = 1200 - let c = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .comfortable) - let w = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .wide) - let f = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .full) - XCTAssertLessThan(c, w) - XCTAssertLessThan(w, f) - } - - func testResolveUnknownWidthModeFallsBackToWide() { - XCTAssertEqual(ChatWidthMode.resolve("nope"), .wide) - XCTAssertEqual(ChatWidthMode.resolve(ChatWidthMode.full.rawValue), .full) - } - - func testDefaultsKeyIsStableForAppStorage() { - XCTAssertEqual(ChatLayoutPolicy.defaultsKey, "codescribe.chatWidthMode") - } - - // MARK: - R1 window-collapse clamps - - func testDocumentWidthNeverExceedsContainer() { - XCTAssertEqual(ChatLayoutPolicy.documentWidth(for: 800), 800) - XCTAssertEqual(ChatLayoutPolicy.documentWidth(for: 320), 320) - // Zero / unknown geometry falls back to the readable minimum so a - // first-layout pass never hands children an unbounded width. - XCTAssertEqual(ChatLayoutPolicy.documentWidth(for: 0), ChatLayoutPolicy.minimumReadable) - } - - func testYouAndLeadingCapsStayInsideDocument() { - let container: CGFloat = 900 - let document = ChatLayoutPolicy.documentWidth(for: container) - let you = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .wide) - let leading = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: container, mode: .wide) - XCTAssertLessThanOrEqual(you, document) - XCTAssertLessThanOrEqual(leading, document) - XCTAssertLessThanOrEqual(you, ChatLayoutPolicy.contentWidth(for: container)) - XCTAssertLessThanOrEqual(leading, ChatLayoutPolicy.contentWidth(for: container)) - } - - func testOversizedSelectionDispositionMatchesBubblePolicy() { - // Context-chip selections share the same cap as bubble bodies so a - // 100k pasted selection cannot expand the You turn unboundedly. - let huge = String(repeating: "token)\" ", count: 20_000) - XCTAssertTrue(OversizedBubblePolicy.isOversized(huge)) - XCTAssertFalse( - OversizedBubblePolicy.disposition(utf8Count: huge.utf8.count) - .sharesListSelectionOverlay - ) - } - - func testAttachmentMissingPathDoesNotClaimLocalFile() { - let missing = MessageAttachment(name: "scan.png", url: nil, type: "image/png") - XCTAssertNil(missing.url) - XCTAssertEqual(missing.name, "scan.png") - XCTAssertEqual(missing.type, "image/png") - } - - func testAttachmentWithPathKeepsMetadataForPreview() { - let url = URL(fileURLWithPath: "/tmp/codescribe-preview-fixture.png") - let att = MessageAttachment(name: "codescribe-preview-fixture.png", url: url) - XCTAssertEqual(att.url, url) - XCTAssertEqual(att.type, "image/png") - } + func testContentWidthSubtractsListPadding() { + XCTAssertEqual(ChatLayoutPolicy.contentWidth(for: 800), 760) + XCTAssertEqual(ChatLayoutPolicy.contentWidth(for: 0), 0) + } + + func testYouBubbleTracksContainerWithoutFixed760Cap() { + // Narrow: floor at minimumReadable. + XCTAssertEqual( + ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 320, mode: .wide), + ChatLayoutPolicy.minimumReadable + ) + + // Mid column (~960): You stays chat-style (~72% of usable), which can be + // *narrower* than the old fixed 760 cap — that is intentional. + let mid = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 960, mode: .wide) + let usableMid = ChatLayoutPolicy.contentWidth(for: 960) + XCTAssertEqual(mid, usableMid * ChatWidthMode.wide.youFraction, accuracy: 0.5) + XCTAssertLessThan(mid, usableMid) + + // Wider viewport: proportional You exceeds the old 760 hard cap. + let expanded = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 1200, mode: .wide) + XCTAssertGreaterThan(expanded, 760) + + // Ultrawide still never exceeds usable column. + let wide = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: 1800, mode: .wide) + XCTAssertLessThanOrEqual(wide, ChatLayoutPolicy.contentWidth(for: 1800)) + } + + func testLeadingColumnFillsUsableWidthUntilProseCap() { + let laptop = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: 960, mode: .wide) + XCTAssertEqual(laptop, ChatLayoutPolicy.contentWidth(for: 960), accuracy: 0.5) + XCTAssertGreaterThan(laptop, 900) // old assistant hard cap + + let ultrawide = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: 2200, mode: .wide) + XCTAssertEqual(ultrawide, ChatWidthMode.wide.proseComfortCap) + } + + func testWidthModesOrderComfortableThenWideThenFull() { + let container: CGFloat = 1400 + let comfortable = ChatLayoutPolicy.leadingColumnMaxWidth( + containerWidth: container, + mode: .comfortable + ) + let wide = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: container, mode: .wide) + let full = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: container, mode: .full) + XCTAssertLessThan(comfortable, wide) + XCTAssertLessThan(wide, full) + XCTAssertEqual(full, ChatLayoutPolicy.contentWidth(for: container), accuracy: 0.5) + } + + func testYouBubbleModeFractionsScale() { + let container: CGFloat = 1200 + let c = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .comfortable) + let w = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .wide) + let f = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .full) + XCTAssertLessThan(c, w) + XCTAssertLessThan(w, f) + } + + func testResolveUnknownWidthModeFallsBackToWide() { + XCTAssertEqual(ChatWidthMode.resolve("nope"), .wide) + XCTAssertEqual(ChatWidthMode.resolve(ChatWidthMode.full.rawValue), .full) + } + + func testDefaultsKeyIsStableForAppStorage() { + XCTAssertEqual(ChatLayoutPolicy.defaultsKey, "codescribe.chatWidthMode") + } + + // MARK: - R1 window-collapse clamps + + func testDocumentWidthNeverExceedsContainer() { + XCTAssertEqual(ChatLayoutPolicy.documentWidth(for: 800), 800) + XCTAssertEqual(ChatLayoutPolicy.documentWidth(for: 320), 320) + // Zero / unknown geometry falls back to the readable minimum so a + // first-layout pass never hands children an unbounded width. + XCTAssertEqual(ChatLayoutPolicy.documentWidth(for: 0), ChatLayoutPolicy.minimumReadable) + } + + func testYouAndLeadingCapsStayInsideDocument() { + let container: CGFloat = 900 + let document = ChatLayoutPolicy.documentWidth(for: container) + let you = ChatLayoutPolicy.youBubbleMaxWidth(containerWidth: container, mode: .wide) + let leading = ChatLayoutPolicy.leadingColumnMaxWidth(containerWidth: container, mode: .wide) + XCTAssertLessThanOrEqual(you, document) + XCTAssertLessThanOrEqual(leading, document) + XCTAssertLessThanOrEqual(you, ChatLayoutPolicy.contentWidth(for: container)) + XCTAssertLessThanOrEqual(leading, ChatLayoutPolicy.contentWidth(for: container)) + } + + func testOversizedSelectionDispositionMatchesBubblePolicy() { + // Context-chip selections share the same cap as bubble bodies so a + // 100k pasted selection cannot expand the You turn unboundedly. + let huge = String(repeating: "token)\" ", count: 20_000) + XCTAssertTrue(OversizedBubblePolicy.isOversized(huge)) + XCTAssertFalse( + OversizedBubblePolicy.disposition(utf8Count: huge.utf8.count) + .sharesListSelectionOverlay + ) + } + + func testAttachmentMissingPathDoesNotClaimLocalFile() { + let missing = MessageAttachment(name: "scan.png", url: nil, type: "image/png") + XCTAssertNil(missing.url) + XCTAssertEqual(missing.name, "scan.png") + XCTAssertEqual(missing.type, "image/png") + } + + func testAttachmentWithPathKeepsMetadataForPreview() { + let url = URL(fileURLWithPath: "/tmp/codescribe-preview-fixture.png") + let att = MessageAttachment(name: "codescribe-preview-fixture.png", url: url) + XCTAssertEqual(att.url, url) + XCTAssertEqual(att.type, "image/png") + } } diff --git a/macos/CodescribeTests/ChatRenderCostTests.swift b/macos/CodescribeTests/ChatRenderCostTests.swift index 469d5b3c..8f1625be 100644 --- a/macos/CodescribeTests/ChatRenderCostTests.swift +++ b/macos/CodescribeTests/ChatRenderCostTests.swift @@ -1,5 +1,6 @@ import SwiftUI import XCTest + @testable import Codescribe /// Render-cost measurement for the chat stream hot path (bolączka #3: beachball @@ -19,243 +20,247 @@ import XCTest /// current on identical fixtures), not absolute release timings. @MainActor final class ChatRenderCostTests: XCTestCase { - private let ticks = 200 + private let ticks = 200 - // MARK: Tool inspect presentation (P0-4 residual) + // MARK: Tool inspect presentation (P0-4 residual) - func testToolInspectPayloadRequiresSummaryCallIdOrDuration() { - XCTAssertFalse( - ToolInspectPresentation.hasInspectPayload(reason: nil, callID: nil, durationMs: nil) - ) - XCTAssertFalse( - ToolInspectPresentation.hasInspectPayload(reason: " ", callID: "", durationMs: nil) - ) - XCTAssertTrue( - ToolInspectPresentation.hasInspectPayload(reason: "ok", callID: nil, durationMs: nil) - ) - XCTAssertTrue( - ToolInspectPresentation.hasInspectPayload(reason: nil, callID: "call_1", durationMs: nil) - ) - XCTAssertTrue( - ToolInspectPresentation.hasInspectPayload(reason: nil, callID: nil, durationMs: 12) - ) - } + func testToolInspectPayloadRequiresSummaryCallIdOrDuration() { + XCTAssertFalse( + ToolInspectPresentation.hasInspectPayload(reason: nil, callID: nil, durationMs: nil) + ) + XCTAssertFalse( + ToolInspectPresentation.hasInspectPayload(reason: " ", callID: "", durationMs: nil) + ) + XCTAssertTrue( + ToolInspectPresentation.hasInspectPayload(reason: "ok", callID: nil, durationMs: nil) + ) + XCTAssertTrue( + ToolInspectPresentation.hasInspectPayload(reason: nil, callID: "call_1", durationMs: nil) + ) + XCTAssertTrue( + ToolInspectPresentation.hasInspectPayload(reason: nil, callID: nil, durationMs: 12) + ) + } - func testToolInspectDurationAndTechnicalCopy() { - XCTAssertEqual(ToolInspectPresentation.durationLabel(ms: 42), "42 ms") - XCTAssertEqual(ToolInspectPresentation.durationLabel(ms: 1500), "1.5 s") - XCTAssertEqual(ToolInspectPresentation.durationLabel(ms: 12_000), "12 s") - XCTAssertNil(ToolInspectPresentation.durationLabel(ms: nil)) - - let line = ToolLine( - callID: "tc_9", - verb: "ran", - detail: "read_file", - state: .succeeded, - reason: "318 lines", - durationMs: 42 - ) - XCTAssertTrue(line.hasInspectPayload) - let copy = line.technicalCopyText - XCTAssertTrue(copy.contains("tool: read_file")) - XCTAssertTrue(copy.contains("status: succeeded")) - XCTAssertTrue(copy.contains("duration: 42 ms")) - XCTAssertTrue(copy.contains("call_id: tc_9")) - XCTAssertTrue(copy.contains("summary: 318 lines")) - XCTAssertEqual(ToolInspectPresentation.statusLabel(for: .failed), "failed") - } + func testToolInspectDurationAndTechnicalCopy() { + XCTAssertEqual(ToolInspectPresentation.durationLabel(ms: 42), "42 ms") + XCTAssertEqual(ToolInspectPresentation.durationLabel(ms: 1500), "1.5 s") + XCTAssertEqual(ToolInspectPresentation.durationLabel(ms: 12_000), "12 s") + XCTAssertNil(ToolInspectPresentation.durationLabel(ms: nil)) + let line = ToolLine( + callID: "tc_9", + verb: "ran", + detail: "read_file", + state: .succeeded, + reason: "318 lines", + durationMs: 42 + ) + XCTAssertTrue(line.hasInspectPayload) + let copy = line.technicalCopyText + XCTAssertTrue(copy.contains("tool: read_file")) + XCTAssertTrue(copy.contains("status: succeeded")) + XCTAssertTrue(copy.contains("duration: 42 ms")) + XCTAssertTrue(copy.contains("call_id: tc_9")) + XCTAssertTrue(copy.contains("summary: 318 lines")) + XCTAssertEqual(ToolInspectPresentation.statusLabel(for: .failed), "failed") + } - // MARK: Fixtures + // MARK: Fixtures - /// Markdown-shaped assistant text grown to ~`chars` bytes — the 20k stream. - private func streamedMarkdown(chars: Int) -> String { - let unit = """ - ## Analysis pass + /// Markdown-shaped assistant text grown to ~`chars` bytes — the 20k stream. + private func streamedMarkdown(chars: Int) -> String { + let unit = """ + ## Analysis pass - The `EventBus` re-emits on retry, and the store subscribes twice on \ - remount. A minimal patch gates the emit on a settled flag and de-dupes \ - the listener registration on remount. + The `EventBus` re-emits on retry, and the store subscribes twice on \ + remount. A minimal patch gates the emit on a settled flag and de-dupes \ + the listener registration on remount. - - check `events/bus.ts` for the retry loop - - check `ui/store.ts` for the double subscribe - - add a regression test for the double fire + - check `events/bus.ts` for the retry loop + - check `ui/store.ts` for the double subscribe + - add a regression test for the double fire - ```ts - bus.on("retry", () => emitOnce(event)) - ``` + ```ts + bus.on("retry", () => emitOnce(event)) + ``` - """ - var out = "" - out.reserveCapacity(chars + unit.utf8.count) - while out.utf8.count < chars { out += unit } - return out - } + """ + var out = "" + out.reserveCapacity(chars + unit.utf8.count) + while out.utf8.count < chars { out += unit } + return out + } - /// Plain prose grown to ~`chars` bytes — the 100k paste into a You turn. - private func pastedProse(chars: Int) -> String { - let unit = "Pasted log line with some detail about the failing request and its retry budget. " - var out = "" - out.reserveCapacity(chars + unit.utf8.count) - while out.utf8.count < chars { out += unit } - return out - } + /// Plain prose grown to ~`chars` bytes — the 100k paste into a You turn. + private func pastedProse(chars: Int) -> String { + let unit = "Pasted log line with some detail about the failing request and its retry budget. " + var out = "" + out.reserveCapacity(chars + unit.utf8.count) + while out.utf8.count < chars { out += unit } + return out + } - /// A realistic tail during a stream: small You turn, a tool row, and the - /// growing assistant bubble. `pasted` adds a 100k You turn into the window. - private func streamingThread(streamChars: Int, pastedChars: Int? = nil) -> [ChatMessage] { - var messages: [ChatMessage] = [] - if let pastedChars { - messages.append(ChatMessage(role: .you, timestamp: "now", text: pastedProse(chars: pastedChars))) - } - messages.append(ChatMessage(role: .you, timestamp: "now", text: "where do we double-dispatch events?")) - var tool = ChatMessage(role: .tool, timestamp: "now", text: "") - tool.toolLines = [ - ToolLine(verb: "grep", detail: "events/bus.ts · ui/store.ts"), - ToolLine(verb: "read", detail: "2 files · 318 lines"), - ToolLine(verb: "tool", detail: "regression-test", state: .running), - ] - messages.append(tool) - var assistant = ChatMessage(role: .assistant, timestamp: "now", - text: streamedMarkdown(chars: streamChars)) - assistant.isStreaming = true - messages.append(assistant) - return messages + /// A realistic tail during a stream: small You turn, a tool row, and the + /// growing assistant bubble. `pasted` adds a 100k You turn into the window. + private func streamingThread(streamChars: Int, pastedChars: Int? = nil) -> [ChatMessage] { + var messages: [ChatMessage] = [] + if let pastedChars { + messages.append( + ChatMessage(role: .you, timestamp: "now", text: pastedProse(chars: pastedChars))) } + messages.append( + ChatMessage(role: .you, timestamp: "now", text: "where do we double-dispatch events?")) + var tool = ChatMessage(role: .tool, timestamp: "now", text: "") + tool.toolLines = [ + ToolLine(verb: "grep", detail: "events/bus.ts · ui/store.ts"), + ToolLine(verb: "read", detail: "2 files · 318 lines"), + ToolLine(verb: "tool", detail: "regression-test", state: .running), + ] + messages.append(tool) + var assistant = ChatMessage( + role: .assistant, timestamp: "now", + text: streamedMarkdown(chars: streamChars)) + assistant.isStreaming = true + messages.append(assistant) + return messages + } - // MARK: Legacy per-tick work (pre-U5, reproduced verbatim for the baseline) + // MARK: Legacy per-tick work (pre-U5, reproduced verbatim for the baseline) - /// The old `MessageList.lastSignature`: suffix(5), grapheme `count` on every - /// text (O(n) walk — 100k steps per tick on a big paste), tool-line string - /// concat with `detail` and `reason` per line. - private func legacySignature(_ messages: [ChatMessage]) -> String { - messages.suffix(5).map { message in - let tools = message.toolLines.map { line in - "\(line.id)-\(line.state)-\(line.detail)-\(line.reason?.count ?? 0)" - }.joined(separator: ",") - return "\(message.id)-\(message.text.count)-\(message.reasoning.count)-\(tools)" - }.joined(separator: "|") - } + /// The old `MessageList.lastSignature`: suffix(5), grapheme `count` on every + /// text (O(n) walk — 100k steps per tick on a big paste), tool-line string + /// concat with `detail` and `reason` per line. + private func legacySignature(_ messages: [ChatMessage]) -> String { + messages.suffix(5).map { message in + let tools = message.toolLines.map { line in + "\(line.id)-\(line.state)-\(line.detail)-\(line.reason?.count ?? 0)" + }.joined(separator: ",") + return "\(message.id)-\(message.text.count)-\(message.reasoning.count)-\(tools)" + }.joined(separator: "|") + } - /// The old streaming-bubble body eval: `MarkdownText.body` re-runs - /// `MDBlock.parse` on the whole grown text every delta, then materializes an - /// AttributedString per prose block. - private func legacyMarkdownTick(_ text: String) -> Int { - let blocks = MDBlock.parse(text) - var materialized = 0 - for block in blocks { - if case let .paragraph(body) = block { - _ = MarkdownText.inlineAttributed( - body, fontSize: 14, baseFont: CSFont.ui(14), - baseColor: CSColor.textBodyAlt - ) - materialized += 1 - } - } - return blocks.count + materialized + /// The old streaming-bubble body eval: `MarkdownText.body` re-runs + /// `MDBlock.parse` on the whole grown text every delta, then materializes an + /// AttributedString per prose block. + private func legacyMarkdownTick(_ text: String) -> Int { + let blocks = MDBlock.parse(text) + var materialized = 0 + for block in blocks { + if case .paragraph(let body) = block { + _ = MarkdownText.inlineAttributed( + body, fontSize: 14, baseFont: CSFont.ui(14), + baseColor: CSColor.textBodyAlt + ) + materialized += 1 + } } + return blocks.count + materialized + } - // MARK: Timing harness + // MARK: Timing harness - private func perTickMicros(_ label: String, body: () -> Int) -> Double { - var sink = 0 - let start = CFAbsoluteTimeGetCurrent() - for _ in 0.. Int) -> Double { + var sink = 0 + let start = CFAbsoluteTimeGetCurrent() + for _ in 0..