diff --git a/.aiassistant/rules/all-the-code-is-OURS.md b/.aiassistant/rules/all-the-code-is-OURS.md index 91c32635..c9f5c047 100644 --- a/.aiassistant/rules/all-the-code-is-OURS.md +++ b/.aiassistant/rules/all-the-code-is-OURS.md @@ -19,7 +19,7 @@ przyjmujesz zasadę: "All the code is ours" konfigurację lub (w ostateczności!) dostarcz jasny, udokumentowany workaround. -„All the code is ours” — traktuj całe repo VetCoders jak własne i wspólne: jeśli coś nie działa (lint/testy/build/UX), +„All the code is ours” — traktuj całe repo vetcoders jak własne i wspólne: jeśli coś nie działa (lint/testy/build/UX), nie szukaj winnego ani „czyjego to plik”, tylko bierz ownership, zdiagnozuj przyczynę, napraw to minimalnie i trwale, dodaj test zabezpieczający i doprowadź projekt do zielonego stanu. Pamiętaj: kodujemy dla lekarzy weterynarii — stabilność, przewidywalność i brak regresji są ważniejsze niż wymówki i skróty. diff --git a/.env.debug.example b/.env.debug.example index a4841c72..7a68a816 100644 --- a/.env.debug.example +++ b/.env.debug.example @@ -1,6 +1,6 @@ -# CodeScribe Env Debug / Developer (exhaustive) +# Codescribe Env Debug / Developer (exhaustive) # Copy to ~/.codescribe/.env or point via CODESCRIBE_ENV_PATH -# This file lists ALL known env vars used by CodeScribe (runtime + build + tests). +# This file lists ALL known env vars used by Codescribe (runtime + build + tests). # Values shown are safe defaults/examples. Remove what you don't need. # ============================================================================= @@ -25,7 +25,7 @@ # CODESCRIBE_MODEL_PATH=/path/to/model # Override embedded model LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 # Local model name (if multiple) WHISPER_MODEL=mlx-community/whisper-large-v3-mlx # External HF model name -WHISPER_LANGUAGE=pl # pl | en (auto maps to pl) +WHISPER_LANGUAGE=auto # auto | pl | en USE_LOCAL_STT=1 # 1=prefer local, 0=cloud only # CODESCRIBE_MODEL_DIR=... # Set by build.rs (internal) diff --git a/.env.example b/.env.example index 072010b3..7e583cad 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# CodeScribe — OPTIONAL power-user overrides (~/.codescribe/.env) +# Codescribe — OPTIONAL power-user overrides (~/.codescribe/.env) # # You do NOT need this file. settings.json (managed by the GUI) is the source of # truth and can express every setting below. This file is only a power-user @@ -19,10 +19,13 @@ # USE_LOCAL_STT=1 # LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 # WHISPER_MODEL=whisper-large-v3-turbo-mlx-q8 -# WHISPER_LANGUAGE=pl +# WHISPER_LANGUAGE=auto # CODESCRIBE_TOGGLE_FINAL_PASS=1 # 0 = restore preview-only toggle stop path -# CODESCRIBE_WHISPER_INITIAL_PROMPT=CodeScribe, LibraxisAI, Docker, GitHub +# CODESCRIBE_LAYERED_TRANSCRIPTION=off # off | phase1 | phase2 | phase3 | phase4 +# CODESCRIBE_WHISPER_INITIAL_PROMPT=Codescribe, LibraxisAI, Docker, GitHub +# CODESCRIBE_STT_INITIAL_PROMPT_ENABLED=0 # 1 = opt in to runtime lexicon initial_prompt # CODESCRIBE_MODEL_PATH=/path/to/whisper/model # runtime fallback / dev override +# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # unload Whisper from GPU/host memory after N s idle (0 = never) # ============================================================================= # CLOUD STT (OPTIONAL) @@ -76,7 +79,7 @@ # ============================================================================= # QUICK_NOTES_ENABLED=0 # QUICK_NOTES_SAVE_ONLY=0 -# CODESCRIBE_NOTES_DIR=~/Git/vista/notes +# CODESCRIBE_NOTES_DIR=~/Documents/notes # ============================================================================= # AI FORMATTING / ASSISTIVE @@ -89,6 +92,7 @@ # AI_MAX_TOKENS=1200 # AI_ASSISTIVE_MAX_TOKENS=2000 # AGENT_ENTER_SENDS=1 +# AGENT_WORKSPACE_ROOTS=~/Git # colon-separated roots the agent's list_projects tool scans # Shared LLM defaults (endpoints/models are config; keys live in Keychain) # LLM_ENDPOINT=https://api.openai.com/v1/responses @@ -109,11 +113,13 @@ # Assistive overrides # LLM_ASSISTIVE_ENDPOINT=https://api.openai.com/v1/responses +# LLM_ASSISTIVE_PROVIDER=openai-responses # openai-responses | anthropic-messages # LLM_ASSISTIVE_MODEL=gpt-5.5 # LLM_ASSISTIVE_TEMPERATURE=0.3 # Formatting overrides # LLM_FORMATTING_ENDPOINT=https://api.openai.com/v1/responses +# LLM_FORMATTING_PROVIDER=openai-responses # openai-responses | anthropic-messages # LLM_FORMATTING_MODEL=gpt-4.1 # LLM_FORMATTING_TEMPERATURE=0.1 @@ -145,9 +151,18 @@ # CODESCRIBE_STREAM_NOVELTY=0.20 # CODESCRIBE_STREAM_DISABLE_EMBEDDINGS=1 # CODESCRIBE_STREAM_FORCE_EMBEDDINGS=1 +# CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS=300 # unload MiniLM embedder from GPU/host memory after N s idle (0 = never) # CODESCRIBE_STREAM_LEXICON=1 # CODESCRIBE_STREAM_LOG=1 # CODESCRIBE_STREAM_LOG_PATH=/tmp/codescribe_stream.log +# CODESCRIBE_STT_THERMAL_GOVERNOR_ENABLED=1 +# CODESCRIBE_STT_MIN_INFER_INTERVAL_MS=90 +# CODESCRIBE_STT_COMMIT_MIN_INTERVAL_MS=180 +# CODESCRIBE_STT_REFINE_MIN_INTERVAL_MS=240 +# CODESCRIBE_STT_THERMAL_NOMINAL_MULT=0.0 +# CODESCRIBE_STT_THERMAL_FAIR_MULT=1.0 +# CODESCRIBE_STT_THERMAL_SERIOUS_MULT=2.0 +# CODESCRIBE_STT_THERMAL_CRITICAL_MULT=4.0 # ============================================================================= # VAD (SILERO) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 33b07bbf..db29b9c1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,6 @@ --- name: Bug report -about: Report a reproducible defect in CodeScribe +about: Report a reproducible defect in codescribe title: "[bug] " labels: ["bug", "triage"] assignees: [] @@ -12,7 +12,7 @@ Describe the defect in one or two sentences. ## Environment -- CodeScribe version: +- Codescribe version: - macOS version: - Device (Apple Silicon model): - Install mode (`make install`, `.app`, other): diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 05da52ab..cf1e75bd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,7 @@ What changed, and why? ## User Impact -What does this improve or prevent for a real CodeScribe user? +What does this improve or prevent for a real codescribe user? ## Runtime Impact diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index aed49778..222380b3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -22,11 +22,11 @@ jobs: name: Build landing artifact runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/configure-pages@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 with: enablement: true - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: docs/landing @@ -39,4 +39,4 @@ jobs: needs: build steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efcb76b5..47d634d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,11 +18,20 @@ jobs: name: Build, notarize, and publish DMG runs-on: macos-14 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.tag_name || github.ref }} - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + + - name: Install XcodeGen + # scripts/build-app.sh preflights `require xcodegen` — the app's + # .xcodeproj is generated, not committed, so the release runner needs it + # or the DMG build dies before xcodebuild. (cargo, uniffi-bindgen and + # xcodebuild are already provided: rust-toolchain installs cargo, + # uniffi-bindgen is built from `cargo build -p codescribe-ffi`, and + # xcodebuild ships with the macOS runner image.) + run: brew install xcodegen - name: Validate signing secrets env: @@ -62,7 +71,7 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} run: | - xcrun notarytool store-credentials "CodeScribeNotary" \ + xcrun notarytool store-credentials "CodescribeNotary" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_SPECIFIC_PASSWORD" @@ -71,12 +80,12 @@ jobs: env: CODESCRIBE_CODESIGN_IDENTITY: ${{ secrets.CODESCRIBE_CODESIGN_IDENTITY }} CODESCRIBE_BUNDLE_ID: ${{ vars.CODESCRIBE_BUNDLE_ID || 'com.vetcoders.codescribe' }} - NOTARY_PROFILE: CodeScribeNotary + NOTARY_PROFILE: CodescribeNotary run: make release-dmgs - name: Publish GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ inputs.tag_name || github.ref_name }} - files: CodeScribe_*.dmg + files: Codescribe_*.dmg generate_release_notes: true diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 200db953..a41623b0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,10 +1,10 @@ # Rust Code Quality CI -# Created by M&K (c)2026 VetCoders +# Created by Vetcoders (c)2026 # -# NOTE: Full build requires macOS with Swift 6.0 (CoreML, Metal). -# GitHub runners have Swift 5.10, so we only run format checks here. -# Full quality gate (clippy, tests) runs locally via: make check -# Or via pre-commit hooks: make hooks +# Runs on self-hosted macOS runners (Swift 6.0 / CoreML / Metal available). +# format: cargo fmt --check +# quality: cargo clippy -D warnings + cargo test (workspace). +# Full local gate (incl. real-API / heavy e2e tests) still via: make check name: Rust Quality @@ -24,24 +24,38 @@ env: jobs: format: name: Format Check - runs-on: self-hosted + runs-on: [self-hosted, macOS] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: components: rustfmt - name: cargo fmt --check run: cargo fmt --all -- --check - # NOTE: clippy and check disabled - requires macOS + Swift 6.0 - # Run locally: make lint - # - # To enable full CI, use a self-hosted macOS runner: - # quality: - # runs-on: [self-hosted, macos, arm64] - # steps: - # - uses: actions/checkout@v4 - # - run: cargo clippy -- -D warnings - # - run: cargo test --lib + quality: + name: Clippy + Tests + runs-on: [self-hosted, macOS] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + components: clippy + + - name: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings + + # Runs the default (non-#[ignore]) workspace test set. + # CODESCRIBE_DISABLE_KEYCHAIN=1: keychain-backed tests must bypass the + # macOS Keychain (no interactive unlock / signed host on the runner) — + # see core/config/keychain.rs and the Makefile `test` target. + # #[ignore]-gated tests (real LLM APIs, Whisper/STT models, audio devices, + # live SSE) are intentionally NOT run here — they need secrets/hardware + # absent on CI and stay behind `make test` / `make test-e2e-real` locally. + - name: cargo test --workspace + env: + CODESCRIBE_DISABLE_KEYCHAIN: "1" + run: cargo test --workspace diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index a35beb88..c90e8df7 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -16,7 +16,7 @@ jobs: runs-on: self-hosted steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install Semgrep shell: bash diff --git a/.gitignore b/.gitignore index 6bc51210..eddee443 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,7 @@ dist/ *.app/ target-noembed/ -# Cargo lockfile (per latest GitHub security recommendations for libraries/apps) -Cargo.lock +# Cargo.lock is tracked for reproducible app builds and cargo-audit evidence. __pycache__/ *.pyc .venv/ @@ -65,7 +64,7 @@ nohup.out.backup *codex* /agents -# CodeScribe +# Codescribe .codescribe/state/ codescribe_memory_*.md codescribe_memory_*.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ee2cee0..ce849a9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,8 @@ -# Pre-commit hooks for CodeScribe (Pure Rust) -# Install: pipx install pre-commit && pre-commit install --hook-type pre-commit --hook-type pre-push +# Pre-commit hooks for Codescribe (Pure Rust) +# Install: pipx install pre-commit && make hooks # Run: pre-commit run --all-files # -# Created by M&K (c)2026 VetCoders +# Created by Vetcoders (c)2026 minimum_pre_commit_version: "3.5.0" @@ -62,3 +62,12 @@ repos: language: system pass_filenames: false stages: [pre-push] + + # Commit provenance (commit-msg stage) + - repo: local + hooks: + - id: commit-msg-provenance + name: commit message provenance + entry: scripts/commit-msg-provenance.sh + language: system + stages: [commit-msg] diff --git a/.prettierignore b/.prettierignore index 95993c99..5a6863b9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,7 +18,7 @@ **/.venv/** **/.DS_Store **/claude_memory_*.md -**/CodeScribe_memory_*.md +**/Codescribe_memory_*.md **/node_modules/** **/target/** **/target-noembed/** diff --git a/CHANGELOG.md b/CHANGELOG.md index f84d7bac..7bc0bcf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **API key liveness probe in Settings → Keys** (PR #50) — per-key **Test** button runs a background probe and shows a result chip (`Key OK` / `Invalid key` / `No credits (check billing)` / `Network error` / `Not set` / `Unsupported`). LLM keys are probed with a minimal generation request rather than an auth-only endpoint, so exhausted billing (`insufficient_quota`) is distinguished from an invalid key. +- **Guided MCP onboarding + reset app data** (PR #52) — fresh installs with no `mcp.json` now get a short explainer with **Set up MCP servers** (deep-links into Settings → Engine) and **Skip for now**, instead of a dead-end wall; Settings → Engine gains a danger-zone **Reset app data…** action with a two-step destructive confirmation and an opt-in checkbox to also remove API keys from the Keychain. + ### Changed - **Public release hygiene** — release packaging, repository metadata, and public-facing docs are being aligned for a current `v0.12.x` public release. - **Dual DMG release variants** — release automation now builds a standard notarized DMG with embedded Silero + embedder and runtime Whisper cache/download, plus a `_full` notarized DMG with Whisper embedded. +- **Memory footprint** — idle RAM cut from ~5 GB (peak ~10 GB) to ~0.8 GB. The Whisper and MiniLM embedder models now unload from GPU/host memory after a period of inactivity and reload transparently on next use (`CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS`, `CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS`, default 300s, 0 disables). + +### Fixed + +- **Legacy fallback threads persisted on AI failure** (PR #51) — when the AI runtime is unavailable, the legacy assistive fallback no longer persists a conversation thread for `Failed`/`Skipped` attempts (previously created repeated "AI Failed" junk threads cluttering the history rail); `Applied`/`AiNoop` outcomes are still persisted as before. +- **MCP setup prompt never appeared** (PR #53) — `probe_mcp_status` now reports a `configured` flag so onboarding can tell "no `mcp.json`" apart from "servers configured," fixing the guided MCP setup prompt from PR #52 that was dead code because the row-count check was always true. +- **MCP setup deep-link did nothing** (PR #54) — the onboarding "Set up MCP servers" action now opens Settings via the SwiftUI `openSettings` environment action instead of a dead AppKit `showSettingsWindow:` selector that has no responder in this accessory (LSUIElement) app. +- **Silero VAD reload leak** — the Silero ONNX session is now compiled once and shared process-wide instead of being rebuilt per recording (which leaked native ORT memory over long sessions). +- **Allocator retention** — freed transient buffers are returned to the OS after each recording (`malloc_zone_pressure_relief` on macOS) instead of inflating the resident footprint across a session. ## [0.12.2] - 2026-06-22 @@ -55,13 +69,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Stdio MCP tool bridge** — CodeScribe can load configured MCP tools and report MCP status honestly. +- **Stdio MCP tool bridge** — Codescribe can load configured MCP tools and report MCP status honestly. - **Thread search agent tool** — assistant tooling can search saved thread history. - **Creator taxonomy shell and preview timing presets** — settings gained richer controls for creator workflows and live-preview cadence. ### Changed -- **License** — relicensed the public CodeScribe release surface from Apache-2.0 to FSL-1.1-ALv2 to support public availability while protecting against commercial repackaging; each version converts to Apache-2.0 after 2 years. +- **License** — relicensed the public codescribe release surface from Apache-2.0 to FSL-1.1-ALv2 to support public availability while protecting against commercial repackaging; each version converts to Apache-2.0 after 2 years. - **Voice chat UI** — modernized drawer rows, preserved raw markdown bubbles by default, and reduced streaming render cost. - **UI module shape** — decomposed large settings, voice chat, onboarding, overlay, pipeline, hotkey, and shared-helper surfaces into responsibility modules. @@ -106,7 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **TOCTOU lock in onboarding** — replaced check-then-create file lock with `flock(2)` to prevent racing first-run setups across simultaneously launched CodeScribe instances. +- **TOCTOU lock in onboarding** — replaced check-then-create file lock with `flock(2)` to prevent racing first-run setups across simultaneously launched codescribe instances. - **NSGlassEffectView retain balance** — UI overlay now autoreleases the glass effect view to balance its explicit retain on construction; prevents a steady leak of glass overlays under heavy use on macOS 26+. - **ObjC release contract on overlay teardown** — completed the `release` pairing for all overlay subviews so teardown does not leak under ARC-incompatible call paths. @@ -116,18 +130,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Typed transcription flags + toggle adjudication** ([091dd67](https://github.com/VetCoders/CodeScribe/commit/091dd67)) — `TranscriptionConfidenceFlag` enum extended; `Vec` confidence flags converted to typed `Vec` across `RecordingTruthVerdict` boundary. Toggle mode now adjudicates session truth via the same final-pass pipeline as hold mode (no more 80% speech loss in long toggle sessions). Closes Marbles_truth_plan **L9** + research **Q10/LIE A/Q7**. -- **`final-pass` env toggle for runtime experimentation** ([42a09e7](https://github.com/VetCoders/CodeScribe/commit/42a09e7)) — `CODESCRIBE_LOCAL_STT_FINAL_PASS=0|1` (default `1`) lets ops disable the saved-WAV adjudicator without rebuild. `Vec::contains` cleanup on flag iteration. -- **Centralized env handling + embedded-Whisper documentation** ([fb30db2](https://github.com/VetCoders/CodeScribe/commit/fb30db2)) — env-var loading consolidated in one path; README + `.env.example` updated to declare embedded-first Whisper as canonical and `CODESCRIBE_NO_EMBED=1` as opt-out. +- **Typed transcription flags + toggle adjudication** ([091dd67](https://github.com/vetcoders/codescribe/commit/091dd67)) — `TranscriptionConfidenceFlag` enum extended; `Vec` confidence flags converted to typed `Vec` across `RecordingTruthVerdict` boundary. Toggle mode now adjudicates session truth via the same final-pass pipeline as hold mode (no more 80% speech loss in long toggle sessions). Closes Marbles_truth_plan **L9** + research **Q10/LIE A/Q7**. +- **`final-pass` env toggle for runtime experimentation** ([42a09e7](https://github.com/vetcoders/codescribe/commit/42a09e7)) — `CODESCRIBE_LOCAL_STT_FINAL_PASS=0|1` (default `1`) lets ops disable the saved-WAV adjudicator without rebuild. `Vec::contains` cleanup on flag iteration. +- **Centralized env handling + embedded-Whisper documentation** ([fb30db2](https://github.com/vetcoders/codescribe/commit/fb30db2)) — env-var loading consolidated in one path; README + `.env.example` updated to declare embedded-first Whisper as canonical and `CODESCRIBE_NO_EMBED=1` as opt-out. ### Changed -- **Config loader rewrite** ([0a9bd99](https://github.com/VetCoders/CodeScribe/commit/0a9bd99)) — `core/config/{loader,migrate,mod}.rs` substantively refactored to enforce priority `settings.json > promoted env > defaults`. Lays infrastructure for upcoming Settings Creator. **Test parity** (verified `0.9.3`): both `test_load_prefers_settings_json_over_promoted_env_file_values` and `test_runtime_env_does_not_persist_into_settings_during_migration` pass on this commit. The L1 marble that flagged them was already converged by `0a9bd99` (inject_file_env_for_runtime skips promoted keys) and `43d67d1` (migrate_if_needed early-returns when `.env` snapshot is absent or empty); the CHANGELOG-as-shipped lagged the actual fix state. Functional impact: none. +- **Config loader rewrite** ([0a9bd99](https://github.com/vetcoders/codescribe/commit/0a9bd99)) — `core/config/{loader,migrate,mod}.rs` substantively refactored to enforce priority `settings.json > promoted env > defaults`. Lays infrastructure for upcoming Settings Creator. **Test parity** (verified `0.9.3`): both `test_load_prefers_settings_json_over_promoted_env_file_values` and `test_runtime_env_does_not_persist_into_settings_during_migration` pass on this commit. The L1 marble that flagged them was already converged by `0a9bd99` (inject_file_env_for_runtime skips promoted keys) and `43d67d1` (migrate_if_needed early-returns when `.env` snapshot is absent or empty); the CHANGELOG-as-shipped lagged the actual fix state. Functional impact: none. - **Sort + collapsible match hygiene** (clippy) — `sort_by(|a,b| b.x.cmp(&a.x))` → `sort_by_key(|b| std::cmp::Reverse(b.x))` across `core/agent/thread_index.rs`, `core/quality/qube_daemon.rs`, `app/ui/shared/helpers.rs`, `app/ui/voice_chat/api.rs`. Collapsible `match` → guard pattern in `core/agent/thread_index.rs`, `app/controller/helpers.rs`, `app/ui/voice_chat/api.rs`. Zero behavior change, idiomatic Rust 2024. ### Fixed -- **Short-text formatting truth guard** ([ab9a9c6](https://github.com/VetCoders/CodeScribe/commit/ab9a9c6) — L1 marble) — non-assistive AI formatting now hard-skips only inputs `<10` chars; `AiNoop` detection narrowed to whitespace-only echoes. Punctuation and capitalization changes are preserved as legitimate formatting work. Short `FormattedTranscript` outputs in the 10–23 char band re-entered the controller quality gate (previously bypassed). Closes regression in `e2e_prompts_and_history`. +- **Short-text formatting truth guard** ([ab9a9c6](https://github.com/vetcoders/codescribe/commit/ab9a9c6) — L1 marble) — non-assistive AI formatting now hard-skips only inputs `<10` chars; `AiNoop` detection narrowed to whitespace-only echoes. Punctuation and capitalization changes are preserved as legitimate formatting work. Short `FormattedTranscript` outputs in the 10–23 char band re-entered the controller quality gate (previously bypassed). Closes regression in `e2e_prompts_and_history`. ### Internal @@ -140,11 +154,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Silero VAD embedded path** ([8b0e278](https://github.com/VetCoders/CodeScribe/commit/8b0e278)) — Silero ONNX model was embedded in the binary via `include_bytes!`, but runtime called `Session::commit_from_file(path)` against `~/.codescribe/models/silero_vad.onnx` which doesn't exist on fresh machines. Result: every recording on freshly-installed `0.9.0` DMG returned `vad_no_speech_detected`, regardless of audio content. Fix: new `SileroVad::new_embedded(config)` and `AccumulatingVad::with_config_embedded` use `Session::builder().commit_from_memory(embedded::MODEL)` (ort 2.0.0-rc.11 API). `core/audio/chunker.rs::init_silero_vad` rewired to embedded path; legacy `SileroVad::new(model_path, ...)` kept as dev/test override only. Verified empirically against Dragon `Sesja 1` recording (53-char Polish transcript with 57% speech detected vs prior 0% speech under `0.9.0`). +- **Silero VAD embedded path** ([8b0e278](https://github.com/vetcoders/codescribe/commit/8b0e278)) — Silero ONNX model was embedded in the binary via `include_bytes!`, but runtime called `Session::commit_from_file(path)` against `~/.codescribe/models/silero_vad.onnx` which doesn't exist on fresh machines. Result: every recording on freshly-installed `0.9.0` DMG returned `vad_no_speech_detected`, regardless of audio content. Fix: new `SileroVad::new_embedded(config)` and `AccumulatingVad::with_config_embedded` use `Session::builder().commit_from_memory(embedded::MODEL)` (ort 2.0.0-rc.11 API). `core/audio/chunker.rs::init_silero_vad` rewired to embedded path; legacy `SileroVad::new(model_path, ...)` kept as dev/test override only. Verified empirically against a real-device `Sesja 1` recording (53-char Polish transcript with 57% speech detected vs prior 0% speech under `0.9.0`). ### Changed -- **Slim DMG via build-pipeline split** — `Makefile` target `release` split into `release-codescribe` (embedded Whisper + MiniLM + Silero) and `release-qube` (`CODESCRIBE_NO_EMBED=1`, isolated `target-noembed/` directory). `qube-daemon` and `qube-report` binaries shrank from ~1.3 GB each (each had its own `include_bytes!()` baked-in models — Cargo doesn't deduplicate `__DATA` segments across workspace binaries) to **24 MB each**, resolving runtime models from HF cache instead. Bundle dropped from **4.0 GB → 1.4 GB**, signed+notarized DMG from **3.7 GB → 1.2 GB** (~67% reduction). `qube-*` binaries continue to function as VetCoders-internal CLI tools without per-binary model embedding overhead. +- **Slim DMG via build-pipeline split** — `Makefile` target `release` split into `release-codescribe` (embedded Whisper + MiniLM + Silero) and `release-qube` (`CODESCRIBE_NO_EMBED=1`, isolated `target-noembed/` directory). `qube-daemon` and `qube-report` binaries shrank from ~1.3 GB each (each had its own `include_bytes!()` baked-in models — Cargo doesn't deduplicate `__DATA` segments across workspace binaries) to **24 MB each**, resolving runtime models from HF cache instead. Bundle dropped from **4.0 GB → 1.4 GB**, signed+notarized DMG from **3.7 GB → 1.2 GB** (~67% reduction). `qube-*` binaries continue to function as vetcoders-internal CLI tools without per-binary model embedding overhead. - **`.gitignore`** — added `target-noembed/` (build-pipeline-split workspace artifact directory). ### Internal @@ -332,63 +346,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Native desktop UI (Tauri + Leptos)** – Introduced the (now legacy) Tauri frontend with a - three-tab interface (Voice Lab, Teacher, Settings). ([a275ae8](https://github.com/VetCoders/CodeScribe/commit/a275ae8), - [7aa0754](https://github.com/VetCoders/CodeScribe/commit/7aa0754)) + three-tab interface (Voice Lab, Teacher, Settings). ([a275ae8](https://github.com/vetcoders/codescribe/commit/a275ae8), + [7aa0754](https://github.com/vetcoders/codescribe/commit/7aa0754)) - **Pure Rust local Whisper STT (Metal GPU)** – Added local Whisper inference via `candle-transformers` (Metal acceleration), with long-audio chunking + language detection. - ([268f5d0](https://github.com/VetCoders/CodeScribe/commit/268f5d0), - [69ed294](https://github.com/VetCoders/CodeScribe/commit/69ed294)) + ([268f5d0](https://github.com/vetcoders/codescribe/commit/268f5d0), + [69ed294](https://github.com/vetcoders/codescribe/commit/69ed294)) - **Whisper decoding controls** – Added `DecodingParams` (mlx_whisper-compatible) including - n-gram blocking and streaming callback support. ([69574fb](https://github.com/VetCoders/CodeScribe/commit/69574fb), - [cc0d8aa](https://github.com/VetCoders/CodeScribe/commit/cc0d8aa)) + n-gram blocking and streaming callback support. ([69574fb](https://github.com/vetcoders/codescribe/commit/69574fb), + [cc0d8aa](https://github.com/vetcoders/codescribe/commit/cc0d8aa)) - **CLI transcription + E2E pipeline tests** – Added file transcription flows and a comprehensive - end-to-end pipeline test suite. ([d7bdb4b](https://github.com/VetCoders/CodeScribe/commit/d7bdb4b), - [d46c62c](https://github.com/VetCoders/CodeScribe/commit/d46c62c)) -- **Config convenience** – Added `--config` flag to open/create the config file. ([535270c](https://github.com/VetCoders/CodeScribe/commit/535270c)) + end-to-end pipeline test suite. ([d7bdb4b](https://github.com/vetcoders/codescribe/commit/d7bdb4b), + [d46c62c](https://github.com/vetcoders/codescribe/commit/d46c62c)) +- **Config convenience** – Added `--config` flag to open/create the config file. ([535270c](https://github.com/vetcoders/codescribe/commit/535270c)) - **UX updates** – Added badge modes + Dock icon behavior and tightened environment/API key - requirements. ([7946c17](https://github.com/VetCoders/CodeScribe/commit/7946c17)) + requirements. ([7946c17](https://github.com/vetcoders/codescribe/commit/7946c17)) ### Changed - **License** – Switched the project license to Apache 2.0 and added release scripts/docs. - ([e0e7ec1](https://github.com/VetCoders/CodeScribe/commit/e0e7ec1)) + ([e0e7ec1](https://github.com/vetcoders/codescribe/commit/e0e7ec1)) - **Backend architecture** – Removed the Python backend and updated the Rust CI pipeline to match. - ([5c65481](https://github.com/VetCoders/CodeScribe/commit/5c65481)) + ([5c65481](https://github.com/vetcoders/codescribe/commit/5c65481)) - **AI formatting pipeline** – Improved configuration, workflows, and Harmony support; refined - formatting behavior and defaults. ([e11400c](https://github.com/VetCoders/CodeScribe/commit/e11400c), - [8a3157f](https://github.com/VetCoders/CodeScribe/commit/8a3157f), - [d46c62c](https://github.com/VetCoders/CodeScribe/commit/d46c62c)) + formatting behavior and defaults. ([e11400c](https://github.com/vetcoders/codescribe/commit/e11400c), + [8a3157f](https://github.com/vetcoders/codescribe/commit/8a3157f), + [d46c62c](https://github.com/vetcoders/codescribe/commit/d46c62c)) - **Tray menu + local STT integration** – Refactored tray menu plumbing while integrating the local - Whisper engine and improving related behavior. ([16021b1](https://github.com/VetCoders/CodeScribe/commit/16021b1)) + Whisper engine and improving related behavior. ([16021b1](https://github.com/vetcoders/codescribe/commit/16021b1)) - **Local model packaging/loading** – Bundled a default model and updated model loading logic. - ([13378fe](https://github.com/VetCoders/CodeScribe/commit/13378fe)) + ([13378fe](https://github.com/vetcoders/codescribe/commit/13378fe)) - **Cloud/STT provider work** – Refactored lab assets and migrated cloud provider integration. - ([8392cb9](https://github.com/VetCoders/CodeScribe/commit/8392cb9)) + ([8392cb9](https://github.com/vetcoders/codescribe/commit/8392cb9)) - **Configuration consolidation** – Deduplicated configuration to a single source of truth. - ([217a336](https://github.com/VetCoders/CodeScribe/commit/217a336)) + ([217a336](https://github.com/vetcoders/codescribe/commit/217a336)) - **Error handling/refactors** – Refactored Whisper engine imports and adopted `anyhow`. - ([b9ac5d9](https://github.com/VetCoders/CodeScribe/commit/b9ac5d9)) + ([b9ac5d9](https://github.com/vetcoders/codescribe/commit/b9ac5d9)) - **Repository maintenance** – Restructured the repo and added conversation session tracking. - ([07fe69f](https://github.com/VetCoders/CodeScribe/commit/07fe69f)) + ([07fe69f](https://github.com/vetcoders/codescribe/commit/07fe69f)) - **Developer ergonomics** – Applied `cargo fmt`-driven formatting fixes. - ([f8e04ef](https://github.com/VetCoders/CodeScribe/commit/f8e04ef)) + ([f8e04ef](https://github.com/vetcoders/codescribe/commit/f8e04ef)) ### Fixed - **Stability** – Handled poisoned mutexes via `into_inner()` fallback to avoid cascading failures - after panics. ([b7591ab](https://github.com/VetCoders/CodeScribe/commit/b7591ab)) + after panics. ([b7591ab](https://github.com/vetcoders/codescribe/commit/b7591ab)) - **Backend cleanup** – Ensured backend processes are killed on all known ports. - ([417b002](https://github.com/VetCoders/CodeScribe/commit/417b002)) + ([417b002](https://github.com/vetcoders/codescribe/commit/417b002)) ### Removed - **Cleanup** – Removed unused and deprecated code to keep the build clean. - ([68469dc](https://github.com/VetCoders/CodeScribe/commit/68469dc)) + ([68469dc](https://github.com/vetcoders/codescribe/commit/68469dc)) ### Changed (Internal) - **Foundations** – Landed the initial Rust-based architecture groundwork. - ([5a17c3a](https://github.com/VetCoders/CodeScribe/commit/5a17c3a)) + ([5a17c3a](https://github.com/vetcoders/codescribe/commit/5a17c3a)) ## v0.4.3 – 2025-11-21 @@ -408,19 +422,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CI & types** – Type checking and CI improvements. - **Menu robustness** – Tray menu stability fixes. -[unreleased]: https://github.com/VetCoders/CodeScribe/compare/v0.12.2...HEAD -[0.12.2]: https://github.com/VetCoders/CodeScribe/compare/v0.12.1...v0.12.2 -[0.12.1]: https://github.com/VetCoders/CodeScribe/compare/v0.12.0...v0.12.1 -[0.12.0]: https://github.com/VetCoders/CodeScribe/compare/v0.11.2...v0.12.0 -[0.11.2]: https://github.com/VetCoders/CodeScribe/compare/v0.10.0...v0.11.2 -[0.10.0]: https://github.com/VetCoders/CodeScribe/compare/v0.9.2...v0.10.0 -[v0.9.2]: https://github.com/VetCoders/CodeScribe/compare/v0.9.1...v0.9.2 -[v0.9.1]: https://github.com/VetCoders/CodeScribe/compare/v0.9.0...v0.9.1 -[v0.9.0]: https://github.com/VetCoders/CodeScribe/compare/v0.8.0...v0.9.0 -[v0.7.14]: https://github.com/VetCoders/CodeScribe/compare/v0.7.2-dev...v0.7.14 -[v0.7.2-dev]: https://github.com/VetCoders/CodeScribe/compare/v0.7.0...v0.7.2-dev -[v0.7.0]: https://github.com/VetCoders/CodeScribe/compare/v0.6.3...v0.7.0 -[v0.6.3]: https://github.com/VetCoders/CodeScribe/compare/v0.6.2...v0.6.3 -[v0.6.2]: https://github.com/VetCoders/CodeScribe/compare/v0.6.1...v0.6.2 -[v0.6.1]: https://github.com/VetCoders/CodeScribe/compare/v0.6.0...v0.6.1 -[v0.6.0]: https://github.com/VetCoders/CodeScribe/compare/19e05ad...v0.6.0 +[unreleased]: https://github.com/vetcoders/codescribe/compare/v0.12.2...HEAD +[0.12.2]: https://github.com/vetcoders/codescribe/compare/v0.12.1...v0.12.2 +[0.12.1]: https://github.com/vetcoders/codescribe/compare/v0.12.0...v0.12.1 +[0.12.0]: https://github.com/vetcoders/codescribe/compare/v0.11.2...v0.12.0 +[0.11.2]: https://github.com/vetcoders/codescribe/compare/v0.10.0...v0.11.2 +[0.10.0]: https://github.com/vetcoders/codescribe/compare/v0.9.2...v0.10.0 +[v0.9.2]: https://github.com/vetcoders/codescribe/compare/v0.9.1...v0.9.2 +[v0.9.1]: https://github.com/vetcoders/codescribe/compare/v0.9.0...v0.9.1 +[v0.9.0]: https://github.com/vetcoders/codescribe/compare/v0.8.0...v0.9.0 +[v0.7.14]: https://github.com/vetcoders/codescribe/compare/v0.7.2-dev...v0.7.14 +[v0.7.2-dev]: https://github.com/vetcoders/codescribe/compare/v0.7.0...v0.7.2-dev +[v0.7.0]: https://github.com/vetcoders/codescribe/compare/v0.6.3...v0.7.0 +[v0.6.3]: https://github.com/vetcoders/codescribe/compare/v0.6.2...v0.6.3 +[v0.6.2]: https://github.com/vetcoders/codescribe/compare/v0.6.1...v0.6.2 +[v0.6.1]: https://github.com/vetcoders/codescribe/compare/v0.6.0...v0.6.1 +[v0.6.0]: https://github.com/vetcoders/codescribe/compare/19e05ad...v0.6.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index d954e5ff..2daf9895 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,6 +1,6 @@ # Code of Conduct -CodeScribe is maintained by VetCoders for people building practical macOS voice tools. We want issues, discussions, and pull requests to stay useful, direct, and respectful. +Codescribe is maintained by vetcoders for people building practical macOS voice tools. We want issues, discussions, and pull requests to stay useful, direct, and respectful. ## Expected Behavior diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ec0d2ae..38f663cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to CodeScribe +# Contributing to codescribe -CodeScribe is a native macOS Rust application for dictation and assistive voice workflows. Contributions should be based on the current macOS runtime surface and the documented release path. +Codescribe is a native macOS Rust application for dictation and assistive voice workflows. Contributions should be based on the current macOS runtime surface and the documented release path. ## Ground Rules @@ -12,26 +12,34 @@ CodeScribe is a native macOS Rust application for dictation and assistive voice ## Local Setup -1. Install Rust (toolchain new enough for edition 2024). -2. Clone the repo. -3. Copy or create your local config when needed: +Prerequisites (macOS): + +1. Rust toolchain new enough for edition 2024 (https://rustup.rs). +2. Xcode (the full app, not just the Command Line Tools). Finalise it once with + `sudo xcodebuild -runFirstLaunch` — a freshly installed Xcode fails to load its + build plug-ins until you do. +3. `xcodegen` — the SwiftUI app's `.xcodeproj` is generated, not checked in: + `brew install xcodegen`. + +Then: + +1. Clone the repo. +2. Copy or create your local config when needed: ```bash cp .env.example ~/.codescribe/.env ``` -4. Build or install: +3. Build the macOS app: ```bash - make build - make install + make install-app # build + install the SwiftUI app + # or: make app # build only, without installing ``` Useful commands: ```bash -make build -make install -make install-app -codescribe --version -codescribe --config +make build # cargo build (core + FFI bridge) +make app # build the app: cargo -> uniffi-bindgen -> xcodegen -> xcodebuild +make install-app # build and install the app ``` ## Required Local Gates diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..1f1b1593 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6630 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.118", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "candle-core" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091" +dependencies = [ + "byteorder", + "candle-metal-kernels", + "candle-ug", + "float8", + "gemm 0.19.0", + "half", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "objc2-foundation", + "objc2-metal", + "rand 0.9.4", + "rand_distr", + "rayon", + "safetensors 0.7.0", + "thiserror 2.0.18", + "yoke 0.8.3", + "zip 7.2.0", +] + +[[package]] +name = "candle-metal-kernels" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fdfe9d06de16ce49961e49084e5b79a75a9bdf157246e7c7b6328e87a7aa25d" +dependencies = [ + "half", + "objc2", + "objc2-foundation", + "objc2-metal", + "once_cell", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "candle-nn" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045fa9e7aef8567d209a27d56b692f60b96f4d0569f4c3011f8ca6715c65e03" +dependencies = [ + "candle-core", + "candle-metal-kernels", + "half", + "libc", + "num-traits", + "objc2-metal", + "rayon", + "safetensors 0.7.0", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "candle-transformers" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b538ec4aa807c416a2ddd3621044888f188827862e2a6fcacba4738e89795d01" +dependencies = [ + "byteorder", + "candle-core", + "candle-nn", + "fancy-regex", + "num-traits", + "rand 0.9.4", + "rayon", + "serde", + "serde_json", + "serde_plain", + "tracing", +] + +[[package]] +name = "candle-ug" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22d62be69068bf58987a45f690612739d8d2ea1bf508c1b87dc6815a019575d" +dependencies = [ + "ug", + "ug-metal", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codescribe" +version = "0.12.2" +dependencies = [ + "anyhow", + "arboard", + "async-trait", + "base64 0.22.1", + "block2", + "chrono", + "clap", + "codescribe-core", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "directories", + "dispatch", + "futures-util", + "hound", + "image", + "lazy_static", + "libc", + "mockito", + "muda", + "objc", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "reqwest", + "serde", + "serde_json", + "serial_test", + "tao", + "tempfile", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "tray-icon", + "uuid", +] + +[[package]] +name = "codescribe-core" +version = "0.12.2" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "candle-core", + "candle-nn", + "candle-transformers", + "cap-std", + "chrono", + "cpal", + "deunicode", + "directories", + "dirs", + "dotenvy", + "flate2", + "futures-util", + "hound", + "indicatif", + "lazy_static", + "mockito", + "moshi", + "ndarray", + "ndarray-npy", + "ort", + "rand 0.8.6", + "regex", + "reqwest", + "security-framework", + "serde", + "serde_json", + "serial_test", + "sha2", + "shellexpand", + "symphonia", + "tempfile", + "tiny_http", + "tokenizers", + "tokio", + "tokio-tungstenite", + "tracing", + "unicode-segmentation", + "uuid", + "zip 0.6.6", +] + +[[package]] +name = "codescribe-ffi" +version = "0.12.2" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codescribe", + "codescribe-core", + "crossbeam-channel", + "serde_json", + "serial_test", + "tokio", + "tracing", + "uniffi", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.118", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float8" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" +dependencies = [ + "half", + "num-traits", + "rand 0.9.4", + "rand_distr", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "gemm" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab96b703d31950f1aeddded248bc95543c9efc7ac9c4a21fda8703a83ee35451" +dependencies = [ + "dyn-stack", + "gemm-c32 0.18.2", + "gemm-c64 0.18.2", + "gemm-common 0.18.2", + "gemm-f16 0.18.2", + "gemm-f32 0.18.2", + "gemm-f64 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32 0.19.0", + "gemm-c64 0.19.0", + "gemm-common 0.19.0", + "gemm-f16 0.19.0", + "gemm-f32 0.19.0", + "gemm-f64 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6db9fd9f40421d00eea9dd0770045a5603b8d684654816637732463f4073847" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcad8a3d35a43758330b635d02edad980c1e143dc2f21e6fd25f9e4eada8edf" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a352d4a69cbe938b9e2a9cb7a3a63b7e72f9349174a2752a558a8a563510d0f3" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.21.5", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp 0.22.3", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff95ae3259432f3c3410eaa919033cd03791d81cebd18018393dc147952e109" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "gemm-f32 0.18.2", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "gemm-f32 0.19.0", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8d3d4385393304f407392f754cd2dc4b315d05063f62cf09f47b58de276864" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b2a4f76ce4b8b16eadc11ccf2e083252d8237c1b589558a49b0183545015bd" +dependencies = [ + "dyn-stack", + "gemm-common 0.18.2", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common 0.19.0", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "rand 0.9.4", + "rand_distr", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke 0.8.3", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke 0.8.3", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", + "stable_deref_trait", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "moshi" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5153be1c62a5f77bf31797931c5df6f155ad2df2f95c6f241720132046ed2259" +dependencies = [ + "candle-core", + "candle-nn", + "candle-transformers", + "rayon", + "serde", + "tracing", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndarray-npy" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e8a348bca0075000d999d750420d74434fd0d3e0993b456554f885e7657a11" +dependencies = [ + "byteorder", + "ndarray", + "num-traits", + "py_literal", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ort" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulp" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b86df24f0a7ddd5e4b95c94fc9ed8a98f1ca94d3b01bdce2824097e7835907" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "py_literal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" +dependencies = [ + "num-bigint", + "num-complex", + "num-traits", + "pest", + "pest_derive", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safetensors" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44560c11236a6130a46ce36c836a62936dc81ebf8c36a37947423571be0e55b6" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags 2.13.0", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "indicatif", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "ug" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b761acf8af3494640d826a8609e2265e19778fb43306c7f15379c78c9b05b0" +dependencies = [ + "gemm 0.18.2", + "half", + "libloading 0.8.9", + "memmap2", + "num", + "num-traits", + "num_cpus", + "rayon", + "safetensors 0.4.5", + "serde", + "thiserror 1.0.69", + "tracing", + "yoke 0.7.5", +] + +[[package]] +name = "ug-metal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7adf545a99a086d362efc739e7cf4317c18cbeda22706000fd434d70ea3d95" +dependencies = [ + "half", + "metal", + "objc", + "serde", + "thiserror 1.0.69", + "ug", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "uniffi" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c866f627c3f04c3df068b68bb2d725492caaa539dd313e2a9d26bb85b1a32f4e" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8ca600167641ebe7c8ba9254af40492dda3397c528cc3b2f511bd23e8541a5" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck 0.5.0", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7a5a038ebffe8f4cf91416b154ef3c2468b18e828b7009e01b1b99938089f9" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c2a6f93e7b73726e2015696ece25ca0ac5a5f1cf8d6a7ab5214dd0a01d2edf" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "uniffi_macros" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c6309fc36c7992afc03bc0c5b059c656bccbef3f2a4bc362980017f8936141" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a138823392dba19b0aa494872689f97d0ee157de5852e2bec157ce6de9cdc22" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c27c4b515d25f8e53cc918e238c39a79c3144a40eaf2e51c4a7958973422c29" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0adacdd848aeed7af4f5af7d2f621d5e82531325d405e29463482becfdeafca" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive 0.8.2", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke 0.8.3", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke 0.8.3", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 8d544438..e5a6ce98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,18 @@ [workspace] resolver = "2" -members = [".", "core"] +members = [".", "core", "bridge"] [workspace.package] version = "0.12.2" edition = "2024" -authors = ["VetCoders "] +authors = ["Vetcoders "] [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" async-trait = "0.1" base64 = "0.22" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "test-util"] } anyhow = "1" tracing = "0.1" @@ -20,19 +20,15 @@ tracing = "0.1" name = "codescribe" version = "0.12.2" edition = "2024" -description = "Speech-to-text tray app for macOS - Pure Rust" -authors = ["VetCoders "] +description = "Speech-to-text for macOS — SwiftUI front-end over a Rust engine (UniFFI bridge)" +authors = ["Vetcoders "] license = "FSL-1.1-ALv2" -repository = "https://github.com/VetCoders/CodeScribe" -homepage = "https://vetcoders.github.io/CodeScribe/" -documentation = "https://vetcoders.github.io/CodeScribe/quickstart/" +repository = "https://github.com/vetcoders/codescribe" +homepage = "https://vetcoders.github.io/codescribe/" +documentation = "https://vetcoders.github.io/codescribe/quickstart/" keywords = ["speech-to-text", "whisper", "macos", "dictation", "audio"] categories = ["command-line-utilities", "multimedia::audio"] -[[bin]] -name = "codescribe" -path = "bin/codescribe.rs" - [[bin]] name = "qube-report" path = "bin/qube_report.rs" @@ -44,16 +40,12 @@ path = "bin/qube_daemon.rs" [lib] path = "app/lib.rs" -[features] -default = ["liquid_glass"] -liquid_glass = [] - [dependencies] # Core library codescribe_core = { package = "codescribe-core", path = "core" } # Async runtime -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "test-util"] } # System tray tray-icon = "0.21" @@ -126,7 +118,7 @@ objc2-core-foundation = "0.3" # Legacy objc for runtime declarations (ClassDecl) and msg_send! macro objc = "0.2" # ObjC blocks for callback-based permission APIs (e.g. AVCaptureDevice requestAccess) -block = "0.1" +block2 = "0.6" # Grand Central Dispatch for main thread operations dispatch = "0.2" diff --git a/LICENSE b/LICENSE index 88dd8f1a..221aded9 100644 --- a/LICENSE +++ b/LICENSE @@ -6,7 +6,7 @@ FSL-1.1-ALv2 ## Notice -Copyright 2024-2026 Maciej Gad & VetCoders +Copyright 2024-2026 Vetcoders ## Terms and Conditions diff --git a/Makefile b/Makefile index 260969fc..38b5112d 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ -# CodeScribe - Pure Rust Build System -# Speech-to-text tray app for macOS +# Codescribe - Build System +# Speech-to-text for macOS: SwiftUI front-end (macos/) over a Rust engine +# (core/ + app/ lib) bridged with UniFFI (bridge/ = codescribe-ffi). +# The user-facing app is built by `make app` (xcodebuild); the Rust side no +# longer ships a standalone `codescribe` tray binary. -.PHONY: all build release release-codescribe release-qube install install-no-embed config bundle install-app \ +.PHONY: all build release release-codescribe release-codescribe-embedded release-qube app app-bindings install install-no-embed config install-app \ start stop restart status logs logs-follow \ bump bump-patch bump-minor bump-major version \ - lint format test test-quick test-e2e test-e2e-real test-sse test-formatting test-all \ + lint format test test-quick test-e2e test-e2e-real test-sse test-sse-release test-responses-live test-sse-heavy test-formatting test-all \ demo demo-raw demo-assistive check semgrep fix clean help \ dmg dmg-signed release-standard release-full release-dmgs notarize download-model download-e5 download-embedder ensure-models \ hooks @@ -22,9 +25,11 @@ CODESCRIBE_AUTO_CODESIGN_IDENTITY := $(if $(strip $(CODESCRIBE_APPLE_DEVELOPMENT # Example: # CODESCRIBE_CODESIGN_IDENTITY="Apple Development: Your Name (TEAMID)" make install-app CODESCRIBE_CODESIGN_IDENTITY ?= $(if $(CODESCRIBE_AUTO_CODESIGN_IDENTITY),$(CODESCRIBE_AUTO_CODESIGN_IDENTITY),-) -CODESCRIBE_APP_NAME ?= CodeScribe -CODESCRIBE_DISPLAY_NAME ?= CodeScribe -CODESCRIBE_BUNDLE_ID ?= com.codescribe.app +CODESCRIBE_APP_NAME ?= Codescribe +CODESCRIBE_DISPLAY_NAME ?= Codescribe +# SwiftUI app build profile for `make app` / `make app-bindings` (debug|release) +PROFILE ?= debug +CODESCRIBE_BUNDLE_ID ?= com.vetcoders.codescribe CODESCRIBE_MIN_MACOS ?= CODESCRIBE_LSUIELEMENT ?= true CODESCRIBE_ENTITLEMENTS ?= scripts/entitlements.plist @@ -60,13 +65,26 @@ build: @echo "Building (debug)..." @cargo build -release-codescribe: - @echo "Building codescribe (release, embedded support assets; Whisper from cache/download)..." - @cargo build --release --bin codescribe +release-codescribe: ensure-models + @echo "Building codescribe-ffi (release dylib, embedded models: Silero + MiniLM + Whisper)..." + @echo " The app front-end is no longer a Rust bin; this builds the UniFFI bridge dylib." + @echo " Produce the runnable SwiftUI app with: make app PROFILE=release" + @CODESCRIBE_EMBED_WHISPER=1 cargo build --release -p codescribe-ffi -release-codescribe-embedded: - @echo "Building codescribe (release, EMBEDDED Whisper — distribution)..." - @CODESCRIBE_EMBED_WHISPER=1 cargo build --release --bin codescribe +# Compatibility alias — embedding Whisper is now the default for release-codescribe. +# Kept so existing scripts / muscle memory keep working; NOT a separate public lane. +release-codescribe-embedded: release-codescribe + +# ── SwiftUI app (macos/) via the codescribe-ffi UniFFI bridge ──────────────── +# Full verified pipeline: cargo (ffi dylib) → uniffi-bindgen → xcodegen → xcodebuild. +# `app-bindings` stops after xcodegen (no Xcode needed) for fast Rust-side iteration. +app: + @echo "Building CodeScribe.app (SwiftUI, PROFILE=$(PROFILE))..." + @./scripts/build-app.sh $(PROFILE) + +app-bindings: + @echo "Regenerating UniFFI Swift bindings + Xcode project (PROFILE=$(PROFILE), no xcodebuild)..." + @SKIP_XCODEBUILD=1 ./scripts/build-app.sh $(PROFILE) release-qube: @echo "Building qube-* (release, runtime model resolve from HF cache)..." @@ -75,72 +93,44 @@ release-qube: release: release-codescribe release-qube install: - @echo "Installing CodeScribe (embedded support assets; Whisper from cache/download)..." + @echo "Installing qube tools (embedded models: Silero + MiniLM + Whisper)..." @./scripts/ensure-models.sh - @cargo install --path . --force + @CODESCRIBE_EMBED_WHISPER=1 cargo install --path . --force @mkdir -p ~/.codescribe @pwd > ~/.codescribe/repo_path @$(MAKE) hooks - @echo "Installed: codescribe $$(grep '^version' $(VERSION_FILE) | head -1 | sed 's/.*\"\(.*\)\"/v\1/')" + @echo "Installed: qube tools $$(grep '^version' $(VERSION_FILE) | head -1 | sed 's/.*\"\(.*\)\"/v\1/')" install-no-embed: - @echo "Installing CodeScribe (runtime Whisper fallback + no optional embedded support assets)..." + @echo "Installing qube tools (DEV/RECOVERY: runtime Whisper fallback + no optional embedded support assets)..." @./scripts/ensure-models.sh @CODESCRIBE_NO_EMBED=1 cargo install --path . --force @mkdir -p ~/.codescribe @pwd > ~/.codescribe/repo_path @$(MAKE) hooks - @echo "Installed: codescribe $$(grep '^version' $(VERSION_FILE) | head -1 | sed 's/.*\"\(.*\)\"/v\1/')" + @echo "Installed: qube tools $$(grep '^version' $(VERSION_FILE) | head -1 | sed 's/.*\"\(.*\)\"/v\1/')" @echo "Note: Set CODESCRIBE_MODEL_PATH at runtime" config: @mkdir -p ~/.codescribe @if [ ! -f ~/.codescribe/.env ]; then \ - cp .env.example ~/.codescribe/.env 2>/dev/null || echo "# CodeScribe Config" > ~/.codescribe/.env; \ + cp .env.example ~/.codescribe/.env 2>/dev/null || echo "# Codescribe Config" > ~/.codescribe/.env; \ echo "Created ~/.codescribe/.env"; \ fi @$(EDITOR) ~/.codescribe/.env -bundle: ensure-models release - @echo "Creating macOS app bundle..." - @rm -rf bundle/$(CODESCRIBE_APP_NAME).app - @mkdir -p bundle/$(CODESCRIBE_APP_NAME).app/Contents/{MacOS,Resources} - @cp target/release/codescribe bundle/$(CODESCRIBE_APP_NAME).app/Contents/MacOS/ - @cp target-noembed/release/qube-daemon bundle/$(CODESCRIBE_APP_NAME).app/Contents/MacOS/ 2>/dev/null || true - @cp target-noembed/release/qube-report bundle/$(CODESCRIBE_APP_NAME).app/Contents/MacOS/ 2>/dev/null || true - @cp assets/AppIcon.icns bundle/$(CODESCRIBE_APP_NAME).app/Contents/Resources/ 2>/dev/null || true - @VERSION=$$(grep '^version' $(VERSION_FILE) | head -1 | sed 's/.*"\(.*\)"/\1/'); \ - printf '%s\n' \ - '' \ - '' \ - '' \ - '' \ - " CFBundleName$(CODESCRIBE_APP_NAME)" \ - " CFBundleDisplayName$(CODESCRIBE_DISPLAY_NAME)" \ - " CFBundleIdentifier$(CODESCRIBE_BUNDLE_ID)" \ - " CFBundleVersion$$VERSION" \ - " CFBundleShortVersionString$$VERSION" \ - ' CFBundlePackageTypeAPPL' \ - ' CFBundleExecutablecodescribe' \ - ' CFBundleIconFileAppIcon' \ - " LSUIElement<$(CODESCRIBE_LSUIELEMENT)/>" \ - ' NSMicrophoneUsageDescriptionNeeded to transcribe speech.' \ - ' NSAccessibilityUsageDescriptionNeeded to monitor hotkeys and paste results.' \ - ' NSInputMonitoringUsageDescriptionNeeded to detect global hotkeys.' \ - ' NSScreenCaptureUsageDescriptionCapture screen context for AI-assisted features.' \ - ' NSAppleEventsUsageDescriptionNeeded to activate the previously focused app and restore selection context.' \ - '' \ - '' \ - > bundle/$(CODESCRIBE_APP_NAME).app/Contents/Info.plist; \ - if [ -n "$(CODESCRIBE_MIN_MACOS)" ]; then \ - /usr/libexec/PlistBuddy -c "Add :LSMinimumSystemVersion string $(CODESCRIBE_MIN_MACOS)" bundle/$(CODESCRIBE_APP_NAME).app/Contents/Info.plist >/dev/null 2>&1 || true; \ - fi - @echo "Bundle ready: bundle/$(CODESCRIBE_APP_NAME).app" -install-app: bundle - @echo "Installing to /Applications..." - @mkdir -p /Applications - @rsync -a --delete bundle/$(CODESCRIBE_APP_NAME).app/ /Applications/$(CODESCRIBE_APP_NAME).app/ +install-app: + @echo "Building $(CODESCRIBE_APP_NAME).app (SwiftUI, release) via scripts/build-app.sh ..." + @$(MAKE) --no-print-directory app PROFILE=release + @APP_SRC="macos/build/Build/Products/Release/Codescribe.app"; \ + if [ ! -d "$$APP_SRC" ]; then \ + echo "Build product missing: $$APP_SRC — 'make app PROFILE=release' did not produce the app."; \ + exit 1; \ + fi; \ + echo "Installing to /Applications ..."; \ + mkdir -p /Applications; \ + rsync -a --delete "$$APP_SRC/" "/Applications/$(CODESCRIBE_APP_NAME).app/" @if [ "$(CODESCRIBE_CODESIGN_IDENTITY)" = "-" ]; then \ echo "Codesigning ad-hoc (no stable signing identity found in keychain)."; \ echo "NOTE: macOS Accessibility/Input Monitoring may need re-grant after reinstall."; \ @@ -159,11 +149,13 @@ install-app: bundle # ============================================================================ start: - @nohup codescribe > /tmp/codescribe.log 2>&1 & disown - @echo "CodeScribe started (logs: /tmp/codescribe.log)" + @open -a "$(CODESCRIBE_APP_NAME)" 2>/dev/null \ + || open "/Applications/$(CODESCRIBE_APP_NAME).app" 2>/dev/null \ + || { echo "$(CODESCRIBE_APP_NAME).app not found — build it with 'make app' or install with 'make install-app'."; exit 1; } + @echo "$(CODESCRIBE_APP_NAME) launched" stop: - @pkill -f "^codescribe$$" 2>/dev/null || true + @pkill -x "$(CODESCRIBE_APP_NAME)" 2>/dev/null || true @rm -f ~/.codescribe/codescribe.pid 2>/dev/null || true @echo "Stopped" @@ -172,7 +164,7 @@ restart: stop @$(MAKE) start status: - @echo "=== CodeScribe Status ===" + @echo "=== Codescribe Status ===" @pgrep -fl codescribe 2>/dev/null || echo "Not running" logs: @@ -230,83 +222,121 @@ lint: @cargo clippy --workspace -- -D warnings TEST_LOG := /tmp/codescribe-tests.log +TEST_SSE_CARGO_JOBS ?= 2 +TEST_SSE_PROFILE ?= debug +TEST_SSE_PROFILE_ARGS := $(if $(filter release,$(TEST_SSE_PROFILE)),--release,) define TEST_SETUP LOG=$(TEST_LOG); \ export CODESCRIBE_DISABLE_KEYCHAIN=1; \ echo "" >> "$$LOG"; \ echo "╔══════════════════════════════════════════════════════════╗" | tee -a "$$LOG"; \ -echo "║ CodeScribe Test Suite — $$(date '+%Y-%m-%d %H:%M:%S') ║" | tee -a "$$LOG"; \ +echo "║ Codescribe Test Suite — $$(date '+%Y-%m-%d %H:%M:%S') ║" | tee -a "$$LOG"; \ echo "╚══════════════════════════════════════════════════════════╝" | tee -a "$$LOG"; \ open -a Console "$$LOG" endef test: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== Tests (workspace) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== Tests (ignored / real API) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test --workspace --all-targets -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --workspace --all-targets -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== Full Pipeline (STT) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); CODESCRIBE_E2E_STT=1 \ - cargo test --test e2e_full_pipeline -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --test e2e_full_pipeline -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-quick: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== Tests (quick, no real API) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-e2e: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== E2E Tests (mock) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test e2e --release -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test e2e --release -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-e2e-real: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== E2E Tests (real API) ===" | tee -a "$$LOG"; \ echo "Requires: LLM_API_KEY, LLM_ASSISTIVE_API_KEY" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test e2e --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test e2e --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-sse: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== SSE Streaming Tests ===" | tee -a "$$LOG"; \ + TEST_SSE_PROFILE="$(TEST_SSE_PROFILE)" CARGO_BUILD_JOBS="$(TEST_SSE_CARGO_JOBS)" ./scripts/test-sse-preflight.sh 2>&1 | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test e2e_sse --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ - echo "=== Responses Live Chain/Resume Tests ===" | tee -a "$$LOG"; \ - $(ENV_LOAD); CODESCRIBE_E2E_RESPONSES=1 \ - cargo test --test e2e_retry_responses -- --nocapture 2>&1 | tee -a "$$LOG"; \ + CARGO_BUILD_JOBS="$(TEST_SSE_CARGO_JOBS)" \ + cargo test --test e2e_sse_streaming $(TEST_SSE_PROFILE_ARGS) -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ + if [[ "$${CODESCRIBE_TEST_SSE_RESPONSES:-0}" == "1" ]]; then \ + echo "=== Responses Live Chain/Resume Tests ===" | tee -a "$$LOG"; \ + $(ENV_LOAD); CODESCRIBE_E2E_RESPONSES=1 CARGO_BUILD_JOBS="$(TEST_SSE_CARGO_JOBS)" \ + cargo test --test e2e_retry_responses -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ + else \ + echo "Skipping Responses Live Chain/Resume Tests (set CODESCRIBE_TEST_SSE_RESPONSES=1)." | tee -a "$$LOG"; \ + fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" +test-sse-release: + @CODESCRIBE_ALLOW_RELEASE_SSE=1 TEST_SSE_PROFILE=release $(MAKE) test-sse + +test-responses-live: + @CODESCRIBE_TEST_SSE_RESPONSES=1 $(MAKE) test-sse + +test-sse-heavy: + @CODESCRIBE_ALLOW_RELEASE_SSE=1 CODESCRIBE_TEST_SSE_RESPONSES=1 TEST_SSE_PROFILE=release $(MAKE) test-sse + test-formatting: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== AI Formatting Tests ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test formatting --release -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test formatting --release -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" test-all: @$(TEST_SETUP); \ + set -o pipefail; \ echo "=== Full Test Suite ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== Ignored / Real API ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test --workspace --all-targets -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --workspace --all-targets -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== Full Pipeline (STT) ===" | tee -a "$$LOG"; \ $(ENV_LOAD); CODESCRIBE_E2E_STT=1 \ - cargo test --test e2e_full_pipeline -- --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test --test e2e_full_pipeline -- --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "=== SSE Streaming ===" | tee -a "$$LOG"; \ $(ENV_LOAD); $(APPLY_TEST_LLM); \ - cargo test e2e_sse --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + cargo test e2e_sse --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; test_rc=$${PIPESTATUS[0]}; \ + if [[ $$test_rc -ne 0 ]]; then exit $$test_rc; fi; \ echo "Done. Log: $$LOG" | tee -a "$$LOG" demo: @@ -325,7 +355,7 @@ check: @echo "=== Format Check (Rust) ===" @cargo fmt --all -- --check @echo "=== Format Check (non-Rust) ===" - @npx --yes prettier@2.7.1 --check . --ignore-path .prettierignore --ignore-unknown || true + @npx --yes prettier@2.7.1 --check . --ignore-path .prettierignore --ignore-unknown @echo "=== Clippy (workspace, all targets) ===" @cargo clippy --workspace --all-targets -- -D warnings @echo "=== Semgrep ===" @@ -349,8 +379,8 @@ fix: hooks: @echo "Installing pre-commit hooks..." @command -v pre-commit >/dev/null 2>&1 || { echo "Install pre-commit: pipx install pre-commit"; exit 1; } - @pre-commit install --hook-type pre-commit --hook-type pre-push - @echo "Hooks installed: pre-commit (check+fmt) + pre-push (clippy+semgrep)" + @pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg + @echo "Hooks installed: pre-commit (check+fmt) + pre-push (clippy+semgrep) + commit-msg (provenance)" # ============================================================================ # Cleanup @@ -372,23 +402,22 @@ HELP_C_YELLOW := \033[33m HELP_C_RESET := \033[0m help: - @printf '\n$(HELP_C_CYAN)%s$(HELP_C_RESET)\n' 'CodeScribe - Speech-to-text (Pure Rust)' + @printf '\n$(HELP_C_CYAN)%s$(HELP_C_RESET)\n' 'Codescribe - Speech-to-text (Pure Rust)' @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'BUILD & INSTALL' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'build' 'Build debug binary' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release binary with embedded support assets; Whisper from cache/download' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install' 'Install CLI with embedded support assets' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release binary with embedded models (Silero + MiniLM + Whisper)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install' 'Install CLI with embedded models (Silero + MiniLM + Whisper)' @printf '%s\n' ' make install-no-embed Install without optional embedded assets (needs CODESCRIBE_MODEL_PATH)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'config' 'Edit ~/.codescribe/.env' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'bundle' 'Create CodeScribe.app bundle' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Install to /Applications' @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'RELEASE & DISTRIBUTION' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'dmg' 'Build DMG (ad-hoc signed)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'dmg-signed' 'Build DMG (Developer ID signed)' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-standard' 'Build + sign + notarize standard DMG' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-full' 'Build + sign + notarize full DMG with embedded Whisper' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-dmgs' 'Build both notarized release DMGs' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-standard' 'Build + sign + notarize release DMG (embedded models)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-full' 'Compatibility alias for release-standard (embedded by default)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-dmgs' 'Build the notarized release DMG' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'notarize' 'Notarize DMG with Apple' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'download-model' 'Download Whisper model from HF' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'download-e5' 'Download E5 embedder model from HF' @@ -396,8 +425,8 @@ help: @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'ensure-models' 'Download Whisper+MiniLM if missing from cache' @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'RUN' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'start' 'Start CodeScribe' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'stop' 'Stop CodeScribe' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'start' 'Start Codescribe' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'stop' 'Stop Codescribe' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'restart' 'Restart' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'status' 'Show status' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'logs' 'Show logs' @@ -422,7 +451,7 @@ help: @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-all' 'Run full test suite' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'check' 'Verify formatting + clippy + semgrep (CI-safe)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'semgrep' 'Run release security scan' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'hooks' 'Install pre-commit + pre-push hooks' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'hooks' 'Install pre-commit + pre-push + commit-msg hooks' # ============================================================================ # Release & Distribution @@ -434,17 +463,21 @@ dmg: dmg-signed: @./scripts/build-dmg.sh --sign -release-standard: +# ensure-models runs first so the embedded-Whisper release build finds the model +# in the HF cache. build.rs only warns on a missing model, so without this the +# signed/notarized DMG could ship without embedded Whisper. +release-standard: ensure-models @./scripts/build-dmg.sh --sign --notarize -release-full: - @./scripts/build-dmg.sh --sign --notarize --embed-whisper --dmg-suffix _full +# Compatibility alias — the standard DMG now embeds Whisper by default, so it IS +# the real user artifact. `_full` no longer denotes a separate "real" build. +release-full: release-standard -release-dmgs: release-standard release-full +release-dmgs: release-standard notarize: - @if ls CodeScribe_*.dmg 1> /dev/null 2>&1; then \ - DMG=$$(ls -t CodeScribe_*.dmg | head -1); \ + @if ls Codescribe_*.dmg 1> /dev/null 2>&1; then \ + DMG=$$(ls -t Codescribe_*.dmg | head -1); \ ./scripts/notarize.sh "$$DMG"; \ else \ echo "No DMG found. Run 'make dmg-signed' first."; \ diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..d577f9d4 --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +Codescribe Notices +================== + +Portions of the account authentication foundation are derived from +openai/codex (Apache-2.0), specifically the Codex CLI login PKCE, +device-code, and local callback server flow. + +Derived files: +- core/llm/account_auth/pkce.rs +- core/llm/account_auth/device_code.rs +- core/llm/account_auth/server.rs + +The derived portions retain Apache-2.0 attribution. Codescribe's repository +license metadata remains authoritative for this project. diff --git a/README.md b/README.md index 358b5dea..233c29ed 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,30 @@ -# ⌜ CodeScribe ⌟ +# ⌜ Codescribe ⌟ [![Version](https://img.shields.io/badge/version-0.12.2-6a9bcc)](Cargo.toml) [![License: FSL-1.1-ALv2](https://img.shields.io/badge/license-FSL--1.1--ALv2-d97757)](LICENSE) -[![CI](https://github.com/VetCoders/CodeScribe/actions/workflows/rust.yml/badge.svg)](https://github.com/VetCoders/CodeScribe/actions/workflows/rust.yml) -[![Landing](https://img.shields.io/badge/site-vetcoders.github.io%2FCodeScribe-788c5d)](https://vetcoders.github.io/CodeScribe/) +[![CI](https://github.com/vetcoders/codescribe/actions/workflows/rust.yml/badge.svg)](https://github.com/vetcoders/codescribe/actions/workflows/rust.yml) +[![Landing](https://img.shields.io/badge/site-vetcoders.github.io%2Fcodescribe-788c5d)](https://vetcoders.github.io/codescribe/) **Native macOS tray dictation and assistive voice overlay with local Whisper live preview, optional cloud final transcript paths, and quality tooling.** ## Overview -CodeScribe is a native macOS menu-bar application that captures audio through global hotkeys, shows live local +Codescribe is a native macOS menu-bar application that captures audio through global hotkeys, shows live local transcription while you speak, and pastes or routes the final result into the focused application. The shipped product -in this repo is a tray app with three explicit surfaces: onboarding, settings, and overlays. +in this repo is a tray app whose SwiftUI front-end has two explicit surfaces: settings and overlays. Local Whisper is the low-latency path. Cloud STT is optional and currently used as a post-capture transcript backend, not as live cloud preview. AI formatting and assistive mode use OpenAI Responses API (`/v1/responses`) by default, -configured in Onboarding, Settings, or `~/.codescribe/.env`. +configured in Settings or `~/.codescribe/.env`. ```mermaid flowchart TB classDef default fill:#fff,stroke:#333,stroke-width:1px; classDef box fill:#fafafa,stroke:#666,stroke-width:1px,stroke-dasharray: 0; - subgraph APP[CodeScribe Runtime] + subgraph APP[Codescribe Runtime] direction LR TRAY[Tray + Hotkeys] - ONB[Onboarding] SET[Settings Window] OVL[Dictation Overlay] CHAT[Assistive Voice Overlay] @@ -45,7 +44,6 @@ flowchart TB LCLI[qube-daemon] end - TRAY --> ONB TRAY --> SET TRAY --> OVL TRAY --> CHAT @@ -65,15 +63,15 @@ flowchart TB > **Status:** current source version is `0.12.2` (see `Cargo.toml`) and ships as a native macOS tray/settings/overlay app with local live preview, tiered settings (`settings.json` + Keychain + optional `.env`), and quality-loop tooling. -See: [`docs/WHISPER_LIVE.md`](docs/WHISPER_LIVE.md) | [`docs/BACKLOG.md`](docs/BACKLOG.md) | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) +See: [`docs/WHISPER_LIVE.md`](docs/WHISPER_LIVE.md) | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) ## OpenAI Provider -CodeScribe uses **OpenAI Responses API** (`/v1/responses`) by default for AI formatting and assistive mode. +Codescribe uses **OpenAI Responses API** (`/v1/responses`) by default for AI formatting and assistive mode. ### Default Setup -Put your OpenAI API key in Onboarding or Settings. CodeScribe stores it in macOS Keychain and applies it to both AI modes: +Put your OpenAI API key in Settings. Codescribe stores it in macOS Keychain and applies it to both AI modes: ```env # ~/.codescribe/.env @@ -98,18 +96,18 @@ LLM_ASSISTIVE_API_KEY=sk-proj-xxx ### MCP Extension Path -CodeScribe can load custom MCP servers from `~/.codescribe/mcp.json`. That keeps the free product useful with user-owned tools today, while leaving room for first-party Pro integrations such as AICX and Loctree later. +Codescribe can load custom MCP servers from `~/.codescribe/mcp.json`. That keeps the free product useful with user-owned tools today, while leaving room for first-party Pro integrations such as AICX and Loctree later. ## Features -- **Pure Rust Implementation** — Native macOS app built entirely in Rust with candle-core + Metal GPU +- **Rust core + SwiftUI app** — Native macOS SwiftUI shell over the Rust engine through UniFFI, with candle-core + Metal GPU - **Two DMG variants** — The standard DMG embeds Silero VAD + MiniLM support assets and resolves Whisper from cache/download. The `_full` DMG embeds Silero + MiniLM + Whisper for users who prefer one larger download. - **Whisper Live** — Streaming transcription happens _during recording_ (chunks + overlap), so `stop()` is near-instant - **Stream postprocess** — semantic gating + cleanup of live chunks before final output - **IPC Server** — Stable runtime interface for GUI/clients - **Quality Loop + Report** — Automated quality scoring and batch reports -- **CLI Suite** — `codescribe`, `qube-report`, `qube-daemon` (renamed from `codescribe-quality` / `codescribe-loop` in 0.8.1) +- **Qube CLI tools** — `qube-report` and `qube-daemon` from `bin/qube_report.rs` / `bin/qube_daemon.rs` - **Metal GPU Acceleration** — Hardware-accelerated inference on Apple Silicon - **System Tray App** — Minimal menu-bar presence with animated status glyphs - **Global Hotkeys** — Hold Fn (default) or double‑tap Option to record @@ -145,56 +143,57 @@ CodeScribe can load custom MCP servers from `~/.codescribe/mcp.json`. That keeps ```bash # Clone the repository -git clone https://github.com/VetCoders/CodeScribe.git -cd CodeScribe +git clone https://github.com/vetcoders/codescribe.git +cd codescribe # Install the hook runner once (required for local commit/push gates) pipx install pre-commit -# Install CLI + repo-local git hooks -make install +# Build the SwiftUI app +make app PROFILE=release -# Verify installation -codescribe --version +# Install the app bundle into /Applications +make install-app + +# Verify installation (prints the version) +make version ``` ### Install via Release DMG Tagged builds publish DMGs through GitHub Releases: -1. Open [Releases](https://github.com/VetCoders/CodeScribe/releases) -2. Download `CodeScribe_.dmg` for the standard build, or `CodeScribe__full.dmg` for the larger build with embedded Whisper. -3. Drag `CodeScribe.app` into `Applications` +1. Open [Releases](https://github.com/vetcoders/codescribe/releases) +2. Download `Codescribe_.dmg` for the standard build, or `Codescribe__full.dmg` for the larger build with embedded Whisper. +3. Drag `Codescribe.app` into `Applications` > **Current truth:** source install is the guaranteed path inside this repo until a current `v0.12.x` GitHub Release exists. Public release DMGs must be Developer ID signed and notarized; the release workflow is wired to fail if the required Apple signing/notary secrets are missing. ### Build Options ```bash -make build # Debug build -make release # Release build with embedded Silero + embedder; Whisper from cache/download -make install # Install CLI + repo-local git hooks -make install-app # Build + install macOS .app (auto-downloads models if missing) -make install-no-embed # Install without optional embedding + repo-local git hooks +make app # Debug SwiftUI app build +make app PROFILE=release # Release SwiftUI app build +make install-app # Build + install macOS .app into /Applications +make release-qube # Build qube CLI tools +make install # Install qube CLI tools + repo-local git hooks ``` ## Quick Start ```bash -# Start tray app -codescribe +# Build and install the app +make install-app + +# Launch installed app bundle +make start # Open/create config file make config -# or: codescribe --config - -# Verbose logging -codescribe -v +# or: edit ~/.codescribe/.env directly -# CLI transcription -codescribe transcribe audio.wav -codescribe transcribe -l pl audio.m4a -codescribe transcribe -f audio.mp3 # with AI formatting +# View app logs +make logs ``` ## Default Hotkeys (macOS) @@ -207,7 +206,7 @@ Hotkeys are configured in **Settings → Modes & Shortcuts**. Double‑tap modes ## Settings & Secrets -- GUI settings: `~/Library/Application Support/CodeScribe/settings.json` +- GUI settings: `~/Library/Application Support/Codescribe/settings.json` - API keys: macOS Keychain (`com.vetcoders.codescribe`) - Power‑user overrides: `~/.codescribe/.env` @@ -254,7 +253,7 @@ pipeline, while overlays/chat bubbles still receive only backspace-encoded | **Formatting** | Double‑tap `Left Option` | AI formatting pass, auto‑paste | | **Assistive (Agent)** | Double‑tap `Right Option` | Agent chat with optional selection context | -See [`docs/BACKLOG.md`](docs/BACKLOG.md) for detailed mode descriptions and future enhancements (VAD, Overlay). +See [`docs/guide/modes.md`](docs/guide/modes.md) for detailed mode descriptions. ## Configuration @@ -269,7 +268,7 @@ make config ```env # STT (Speech-to-Text) -WHISPER_LANGUAGE=pl # pl | en | de | fr (no auto!) +WHISPER_LANGUAGE=auto # auto | pl | en # CODESCRIBE_MODEL_PATH= # Override runtime Whisper model lookup # Hotkeys behavior @@ -306,43 +305,24 @@ LOG_LEVEL=INFO # TRACE | DEBUG | INFO | WARN | ERROR See `.env.example` for complete reference. -## CLI Reference +## Runtime and CLI Reference -### `codescribe` (Tray App) +### `Codescribe.app` -Main application — runs as menu bar app with global hotkeys. +The user-facing app is the native SwiftUI bundle built by `make app` and installed with `make install-app`. It runs as a menu-bar app with global hotkeys and talks to the Rust core through the UniFFI bridge. -```bash -codescribe [OPTIONS] +### Qube Tools -Options: - -v, --verbose Enable verbose logging - --config Create/edit config file - --version Show version - -h, --help Show help -``` - -### `codescribe transcribe` - -CLI transcription without tray app. +The repo still ships Rust CLI utilities for batch quality work: ```bash -codescribe transcribe FILE [OPTIONS] - -Arguments: - FILE Audio file (WAV, MP3, M4A) - -Options: - -l, --language Language hint (pl, en, de, fr) - -f, --format Apply AI formatting - -m, --model Model name (if using external) - --llm LLM model for formatting - -h, --help Show help +qube-report --help +qube-daemon --help ``` ## Model -CodeScribe uses **whisper-large-v3-turbo-mlx-q8**: +Codescribe uses **whisper-large-v3-turbo-mlx-q8**: - 4-layer turbo architecture (vs 32 layers in full model) - Q8 quantization (~894MB weights) @@ -351,7 +331,7 @@ CodeScribe uses **whisper-large-v3-turbo-mlx-q8**: ### Runtime Whisper (Current) -The standard build embeds Silero VAD and MiniLM semantic support assets, then resolves Whisper at runtime from the locations below. The full release DMG embeds the same support assets plus Whisper by building with `CODESCRIBE_EMBED_WHISPER=1`. +User-delivery app builds (`make app PROFILE=release`, `make install-app`, the release DMG) embed the Rust support assets through the SwiftUI bundle pipeline: Silero VAD, the MiniLM semantic embedder, and Whisper (`CODESCRIBE_EMBED_WHISPER=1`). Fast developer Rust builds stay lean and resolve Whisper at runtime from the locations below; that same resolution order is the fallback whenever a build is not embedded. 1. `CODESCRIBE_MODEL_PATH` environment variable 2. `~/.codescribe/models/whisper-large-v3-turbo-mlx-q8/` @@ -370,18 +350,19 @@ Model files required: ## Architecture ```text -CodeScribe/ +Codescribe/ ├── core/ # Portable pipeline, STT, config, quality -├── app/ # macOS app shell +├── app/ # Rust engine library (macOS) +│ ├── agent/ # Assistive agent + tools │ ├── controller/ # Recording/transcription orchestration -│ ├── os/ # Hotkeys, permissions, clipboard -│ └── ui/ -│ ├── settings/ # Persistent settings window -│ ├── onboarding/ # First-run flow -│ ├── overlay/ # Dictation overlay -│ ├── voice_chat/ # Assistive overlay -│ └── tray/ # Menu bar UI -├── bin/ # CLI entry points +│ ├── os/ # Hotkeys, permissions, clipboard, thermal +│ └── presentation/ # Overlay delta/typing emitter +├── bridge/ # UniFFI bridge (Rust <-> Swift) +├── macos/Codescribe/ # SwiftUI front-end +│ ├── Screens/ # Tray, Settings, Overlay, AgentChat +│ ├── DesignSystem/ # Tokens, typography, components +│ └── Bridge/ # Generated UniFFI Swift bindings +├── bin/ # CLI entry points (qube-report, qube-daemon) ├── tests/ # Integration + E2E tests └── docs/ # Product + technical docs ``` @@ -390,11 +371,12 @@ CodeScribe/ ```bash # Clone and setup -git clone https://github.com/VetCoders/CodeScribe.git -cd CodeScribe +git clone https://github.com/vetcoders/codescribe.git +cd codescribe -# Development build with explicit runtime Whisper fallback -CODESCRIBE_MODEL_PATH=./models/whisper-large-v3-turbo-mlx-q8 cargo run +# Development app build with explicit runtime Whisper fallback +CODESCRIBE_MODEL_PATH=./models/whisper-large-v3-turbo-mlx-q8 make app PROFILE=debug +open macos/build/Build/Products/Debug/Codescribe.app # Quality checks make lint # clippy + fmt check @@ -409,13 +391,14 @@ make format # cargo fmt ### Makefile Targets ``` -make build # Debug build -make release # Release build with embedded support assets; Whisper from cache/download -make install # Install CLI -make install-no-embed # Install without optional embedding +make app # Debug SwiftUI app build +make app PROFILE=release # Release SwiftUI app build +make install-app # Build + install /Applications/Codescribe.app +make release-qube # Build qube CLI tools +make install # Install qube CLI tools + repo-local hooks make release-dmgs # Build both signed + notarized release DMGs make config # Edit ~/.codescribe/.env -make start # Start as daemon +make start # Launch Codescribe.app make stop # Stop running instance make logs # View logs make lint # Clippy + format check @@ -434,7 +417,7 @@ make download-model # Download Whisper model ## Permissions -CodeScribe requires macOS permissions for: +Codescribe requires macOS permissions for: - **Microphone** — Audio recording - **Accessibility** — Global hotkey detection @@ -445,21 +428,21 @@ Grant permissions in System Settings > Privacy & Security when prompted. ## Current Focus - Keep the VAD auto-stop path honest and fully integrated before presenting it as the default hands-off mode. -- Preserve the explicit split between onboarding, settings, dictation overlay, and assistive overlay. +- Preserve the explicit split between settings, dictation overlay, and assistive overlay. - Ship the macOS distribution path cleanly: bundle, sign, and notarize the DMG story. -See [`docs/BACKLOG.md`](docs/BACKLOG.md) for the working backlog, [`docs/PUBLIC_RELEASE_CHECKLIST.md`](docs/PUBLIC_RELEASE_CHECKLIST.md) for the public launch gate, and [`docs/ARCHITECTURE_VISION.md`](docs/ARCHITECTURE_VISION.md) for longer-range ideas that are not part of the current shipped surface. +See [`docs/PUBLIC_RELEASE_CHECKLIST.md`](docs/PUBLIC_RELEASE_CHECKLIST.md) for the public launch gate. ## License -CodeScribe is licensed under the Functional Source License 1.1, ALv2 Future +Codescribe is licensed under the Functional Source License 1.1, ALv2 Future License (FSL-1.1-ALv2). This is a Fair Source / source-available license while the current FSL terms apply. You may read, fork, build, and modify the source for permitted purposes including personal use, education, research, and professional services. -Competing Use is not permitted: do not make CodeScribe available as a -commercial product or service that substitutes for CodeScribe. +Competing Use is not permitted: do not make codescribe available as a +commercial product or service that substitutes for codescribe. Each released version automatically converts to Apache-2.0 two years after the date we make that version available. See [`LICENSE`](LICENSE) and @@ -467,5 +450,4 @@ date we make that version available. See [`LICENSE`](LICENSE) and --- -**Made with (งಠ_ಠ)ง by the ⌜ VetCoders ⌟ 𝖙𝖊𝖆𝖒 (c) 2024-2026 -Maciej & Monika + Klaudiusz (AI) + Junie (AI)** +**Made with (งಠ_ಠ)ง by the ⌜ Vetcoders ⌟ 𝖙𝖊𝖆𝖒 (c) 2024-2026** diff --git a/SECURITY.md b/SECURITY.md index 7112833d..93f7a933 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -CodeScribe follows rolling support for the latest stable source and public release line. +Codescribe follows rolling support for the latest stable source and public release line. | Version | Supported | | ------- | --------- | diff --git a/app/agent/anthropic_provider.rs b/app/agent/anthropic_provider.rs new file mode 100644 index 00000000..7654cb1b --- /dev/null +++ b/app/agent/anthropic_provider.rs @@ -0,0 +1,1283 @@ +//! Anthropic Messages API agent provider. +//! +//! Second concrete [`AgentProvider`] alongside [`super::OpenAiProvider`]. It +//! targets the Anthropic Messages API (`POST /v1/messages`, streaming SSE) and +//! reuses the canonical provider-identity layer in +//! [`codescribe_core::llm::provider`] to gate request parameters per model — +//! `claude-opus-4-8` rejects sampling params with HTTP 400, `claude-sonnet-*` +//! tolerates `temperature` — so the request builder never sends a parameter the +//! target will reject. +//! +//! Wire spec (Anthropic Messages API, `anthropic-version: 2023-06-01`): +//! - auth via the `x-api-key` header (NOT a bearer token); +//! - `system` is a top-level field, not a message role; +//! - `tool_use` blocks live in assistant turns, `tool_result` blocks in user +//! turns — which is exactly how [`super::super`]'s `AgentSession` already +//! structures history, so the mapping is close to 1:1; +//! - images ride as `{"type":"image","source":{"type":"base64", ...}}`; +//! - streaming emits `message_start` / `content_block_start` / +//! `content_block_delta` / `content_block_stop` / `message_delta` / +//! `message_stop` SSE events (plus `ping` and `error`). +//! +//! Anthropic replays full history every turn (no `previous_response_id` +//! chaining), so this provider holds no per-turn chain state — the session +//! always sends the complete message list. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use futures_util::StreamExt; +use reqwest::Client; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +use codescribe_core::agent::{ + AgentEvent, AgentProvider, ContentBlock, ImageAsset, Message, Role, StreamOptions, + ToolDefinition, +}; +use codescribe_core::llm::provider::{ProviderKind, capability_policy}; + +const DEFAULT_ANTHROPIC_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; +/// Anthropic requires `max_tokens` on every request; used only when the caller +/// (assistive lane) leaves `options.max_tokens` unset. +const DEFAULT_MAX_TOKENS: u32 = 8192; +/// Safe Claude default so the agent path never falls into an empty model when +/// the assistive-lane model env is unset (Settings normally supplies one). +const DEFAULT_ANTHROPIC_MODEL: &str = "claude-opus-4-8"; + +const DEFAULT_INITIAL_RESPONSE_TIMEOUT_MS: u64 = 90_000; +const DEFAULT_INTER_CHUNK_TIMEOUT_MS: u64 = 90_000; +const STREAM_REQUEST_TIMEOUT: Duration = Duration::from_secs(3600); +const STREAM_DEADLINE: Duration = Duration::from_secs(10 * 60); + +#[derive(Clone)] +pub struct AnthropicProvider { + client: Client, + endpoint: String, + api_key: String, + anthropic_version: String, + default_model: String, + default_max_tokens: u32, + initial_response_timeout: Duration, + inter_chunk_timeout: Duration, +} + +impl AnthropicProvider { + pub fn from_env() -> Result { + let api_key = get_env_non_empty("LLM_ANTHROPIC_API_KEY", "Anthropic API key (assistive)")?; + let endpoint = std::env::var("LLM_ANTHROPIC_ENDPOINT") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_ANTHROPIC_ENDPOINT.to_string()); + // Model comes from the shared assistive-lane setting; Settings supplies a + // Claude model when the assistive provider is Anthropic. + let default_model = std::env::var("LLM_ASSISTIVE_MODEL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_ANTHROPIC_MODEL.to_string()); + + let initial_response_timeout = Duration::from_millis(parse_env_u64( + "CODESCRIBE_AI_ATTEMPT_TIMEOUT_MS", + DEFAULT_INITIAL_RESPONSE_TIMEOUT_MS, + )); + let inter_chunk_timeout = Duration::from_millis(parse_env_u64( + "CODESCRIBE_AI_INTER_CHUNK_TIMEOUT_MS", + DEFAULT_INTER_CHUNK_TIMEOUT_MS, + )); + + let client = Client::builder() + .timeout(STREAM_REQUEST_TIMEOUT) + .build() + .context("Failed to create Anthropic agent HTTP client")?; + + info!( + "Anthropic agent provider configured (model={}, initial_timeout={}s, inter_chunk_timeout={}s)", + default_model, + initial_response_timeout.as_secs(), + inter_chunk_timeout.as_secs() + ); + + Ok(Self { + client, + endpoint, + api_key, + anthropic_version: ANTHROPIC_VERSION.to_string(), + default_model, + default_max_tokens: DEFAULT_MAX_TOKENS, + initial_response_timeout, + inter_chunk_timeout, + }) + } +} + +#[async_trait] +impl AgentProvider for AnthropicProvider { + async fn stream( + &self, + messages: &[Message], + tools: &[ToolDefinition], + options: &StreamOptions, + ) -> Result> { + let model = if options.model.trim().is_empty() { + self.default_model.clone() + } else { + options.model.clone() + }; + + // Per-model capability gate (CORRECTION.md matrix): Opus-4.8 rejects + // sampling params with a 400, Sonnet tolerates temperature. `sanitize_ + // temperature` returns None when sampling is disallowed so the param is + // simply omitted (omitting can never 400; sending it can). + let policy = capability_policy(ProviderKind::AnthropicMessages, &model); + let temperature = policy.sanitize_temperature(options.temperature); + let max_tokens = options + .max_tokens + .filter(|tokens| *tokens > 0) + .unwrap_or(self.default_max_tokens); + + let system = build_system(options.system_prompt.as_deref(), messages); + let body = build_request_body(&model, system, messages, tools, max_tokens, temperature) + .context("Failed to build Anthropic Messages request")?; + + info!( + "Anthropic agent request (model={}, messages={}, tools={}, max_tokens={}, temperature={}, timeout={}s)", + model, + messages.len(), + tools.len(), + max_tokens, + temperature.map(|_| "present").unwrap_or("absent"), + self.initial_response_timeout.as_secs() + ); + + let (tx, rx) = mpsc::channel(256); + let client = self.client.clone(); + let endpoint = self.endpoint.clone(); + let api_key = self.api_key.clone(); + let anthropic_version = self.anthropic_version.clone(); + let initial_response_timeout = self.initial_response_timeout; + let inter_chunk_timeout = self.inter_chunk_timeout; + + tokio::spawn(async move { + if let Err(error) = run_anthropic_stream( + client, + endpoint, + api_key, + anthropic_version, + initial_response_timeout, + inter_chunk_timeout, + body, + tx.clone(), + ) + .await + { + let _ = tx.send(AgentEvent::Error(error.to_string())).await; + } + }); + + Ok(rx) + } + + fn build_tool_result( + &self, + call_id: &str, + content: Vec, + is_error: bool, + ) -> Message { + Message::new( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: call_id.to_string(), + content, + is_error, + }], + ) + } + + fn build_image_block(&self, data: &[u8], media_type: &str) -> ContentBlock { + ContentBlock::Image { + data: data.to_vec(), + media_type: media_type.to_string(), + } + } + + fn stream_timeouts(&self) -> Option<(Duration, Duration)> { + Some((self.initial_response_timeout, self.inter_chunk_timeout)) + } + + fn name(&self) -> &str { + "anthropic-messages" + } +} + +// ── Request building ───────────────────────────────────────────────────────── + +/// Compose the top-level `system` string from the caller's system prompt plus +/// any `System`-role history messages (folded in — Anthropic has no `system` +/// message role). `None` when there is nothing to say. +fn build_system(prompt: Option<&str>, messages: &[Message]) -> Option { + let mut parts = Vec::new(); + if let Some(prompt) = prompt + && !prompt.trim().is_empty() + { + parts.push(prompt.trim().to_string()); + } + for message in messages { + if message.role != Role::System { + continue; + } + for block in &message.content { + if let ContentBlock::Text(text) = block + && !text.trim().is_empty() + { + parts.push(text.trim().to_string()); + } + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } +} + +fn build_request_body( + model: &str, + system: Option, + messages: &[Message], + tools: &[ToolDefinition], + max_tokens: u32, + temperature: Option, +) -> Result { + let mut body = serde_json::Map::new(); + body.insert("model".to_string(), json!(model)); + body.insert("max_tokens".to_string(), json!(max_tokens)); + body.insert("stream".to_string(), json!(true)); + if let Some(system) = system.filter(|value| !value.trim().is_empty()) { + body.insert("system".to_string(), json!(system)); + } + body.insert( + "messages".to_string(), + json!(build_anthropic_messages(messages)?), + ); + let tool_payload = build_tool_payload(tools); + if !tool_payload.is_empty() { + body.insert("tools".to_string(), json!(tool_payload)); + } + if let Some(temperature) = temperature { + body.insert("temperature".to_string(), json!(temperature)); + } + Ok(Value::Object(body)) +} + +fn build_tool_payload(tools: &[ToolDefinition]) -> Vec { + tools + .iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema, + }) + }) + .collect() +} + +fn build_anthropic_messages(messages: &[Message]) -> Result> { + let mut out = Vec::new(); + for message in messages { + // System-role content is hoisted into the top-level `system` field. + if message.role == Role::System { + continue; + } + let content = message_content_blocks(message)?; + // Anthropic rejects a message with empty content — skip it entirely. + if content.is_empty() { + continue; + } + out.push(json!({ + "role": role_str(message.role), + "content": content, + })); + } + Ok(out) +} + +fn message_content_blocks(message: &Message) -> Result> { + let mut blocks = Vec::new(); + for block in &message.content { + match block { + ContentBlock::Text(text) => { + if !text.is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } + ContentBlock::Image { data, media_type } => { + // Images restored from the thread store carry no bytes + // (persisted with `data_omitted`). An empty base64 payload makes + // Anthropic reject the whole request, so skip empty images. + if data.is_empty() { + warn!( + "Skipping image content block with no bytes (likely restored from history)" + ); + continue; + } + blocks.push(image_block(data, media_type)); + } + ContentBlock::ImageAsset(asset) => { + blocks.push(image_asset_block(asset)?); + } + ContentBlock::ToolUse { id, name, input } => { + blocks.push(json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input, + })); + } + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } => { + blocks.push(tool_result_block(tool_use_id, content, *is_error)?); + } + } + } + Ok(blocks) +} + +fn tool_result_block(tool_use_id: &str, content: &[ContentBlock], is_error: bool) -> Result { + let mut inner = Vec::new(); + for block in content { + match block { + ContentBlock::Text(text) => { + if !text.trim().is_empty() { + inner.push(json!({ "type": "text", "text": text })); + } + } + ContentBlock::Image { data, media_type } => { + if !data.is_empty() { + inner.push(image_block(data, media_type)); + } + } + ContentBlock::ImageAsset(asset) => { + inner.push(image_asset_block(asset)?); + } + ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } => {} + } + } + if inner.is_empty() { + let fallback = if is_error { + "Tool execution failed" + } else { + "Tool executed successfully" + }; + inner.push(json!({ "type": "text", "text": fallback })); + } + let mut result = serde_json::Map::new(); + result.insert("type".to_string(), json!("tool_result")); + result.insert("tool_use_id".to_string(), json!(tool_use_id)); + result.insert("content".to_string(), json!(inner)); + if is_error { + result.insert("is_error".to_string(), json!(true)); + } + Ok(Value::Object(result)) +} + +fn image_asset_block(asset: &ImageAsset) -> Result { + // Tainted-path guard: asset paths ride through conversation state, so the + // read goes through the store, which honors only the file name re-rooted + // under the canonical assets dir. + let data = codescribe_core::agent::AgentAssetStore::read_image(&asset.path)?; + Ok(image_block(&data, &asset.media_type)) +} + +fn image_block(data: &[u8], media_type: &str) -> Value { + let media_type = { + let normalized = media_type.trim(); + if normalized.is_empty() { + "image/png" + } else { + normalized + } + }; + json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": BASE64.encode(data), + } + }) +} + +fn role_str(role: Role) -> &'static str { + match role { + Role::Assistant => "assistant", + // System is hoisted out earlier; map defensively to user. + Role::User | Role::System => "user", + } +} + +// ── SSE streaming ──────────────────────────────────────────────────────────── + +// allow(too_many_arguments): task entry point for one Anthropic SSE stream; all +// values are owned moves into the spawned task. +#[allow(clippy::too_many_arguments)] +async fn run_anthropic_stream( + client: Client, + endpoint: String, + api_key: String, + anthropic_version: String, + initial_response_timeout: Duration, + inter_chunk_timeout: Duration, + request_body: Value, + tx: mpsc::Sender, +) -> Result<()> { + let endpoint_url = + validate_anthropic_endpoint(&endpoint).context("Invalid Anthropic endpoint URL")?; + let request_builder = client + // nosemgrep: rust.actix.ssrf.reqwest-taint.reqwest-taint -- URL is validated by `validate_anthropic_endpoint`. + .post(endpoint_url) + .header("x-api-key", &api_key) + .header("anthropic-version", &anthropic_version) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .timeout(STREAM_REQUEST_TIMEOUT) + .json(&request_body); + + let response = + match tokio::time::timeout(initial_response_timeout, request_builder.send()).await { + Ok(Ok(resp)) => resp, + Ok(Err(error)) => return Err(error).context("Anthropic SSE request failed"), + Err(_) => anyhow::bail!( + "Anthropic SSE initial response timeout after {:?}", + initial_response_timeout + ), + }; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let detail = parse_http_error_body(&body).unwrap_or(body); + anyhow::bail!("Anthropic HTTP {} - {}", status, detail); + } + + let mut state = StreamState::default(); + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + let stream_deadline = tokio::time::Instant::now() + STREAM_DEADLINE; + + 'outer: loop { + if tokio::time::Instant::now() > stream_deadline { + anyhow::bail!( + "Anthropic SSE global safety timeout after {:?}", + STREAM_DEADLINE + ); + } + + let next_chunk = match tokio::time::timeout(inter_chunk_timeout, stream.next()).await { + Ok(chunk) => chunk, + Err(_) => { + anyhow::bail!( + "Anthropic SSE inter-chunk timeout after {:?}", + inter_chunk_timeout + ); + } + }; + + let Some(chunk_result) = next_chunk else { + break; + }; + let chunk = chunk_result.context("Anthropic SSE stream read error")?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim().to_string(); + buffer = buffer[newline_pos + 1..].to_string(); + + // Anthropic emits both `event:` and `data:` lines; every `data:` + // payload also carries its own `type`, so we key off the JSON and + // ignore the `event:` line (and SSE comments beginning with `:`). + if line.is_empty() || line.starts_with(':') || line.starts_with("event:") { + continue; + } + + let Some(data) = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:")) + else { + continue; + }; + let data = data.trim(); + if data.is_empty() { + continue; + } + + let event = match serde_json::from_str::(data) { + Ok(parsed) => parsed, + Err(error) => { + warn!("Skipping malformed Anthropic SSE chunk: {}", error); + continue; + } + }; + + if handle_chunk(&event, &mut state, &tx).await? { + break 'outer; + } + } + } + + // EOF (or `message_stop` already handled). If no terminal was emitted, the + // stream ended early — surface a dirty terminal so the session resets. + if !state.terminal_emitted + && tx + .send(AgentEvent::ResponseDone { + response_id: state.message_id.clone(), + clean: false, + }) + .await + .is_err() + { + return Ok(()); + } + + Ok(()) +} + +#[derive(Default)] +struct StreamState { + message_id: Option, + assistant_text: String, + stop_reason: Option, + /// index -> in-flight tool_use block (id, name, accumulated JSON). + tool_blocks: HashMap, + terminal_emitted: bool, +} + +struct ToolBlock { + id: String, + name: String, + json_buffer: String, +} + +/// Dispatch one parsed SSE chunk. Returns `Ok(true)` when a terminal was +/// emitted and the caller should stop reading; `Ok(false)` to keep going. An +/// `Err` propagates a fatal parse/consumer condition to the task wrapper. +async fn handle_chunk( + chunk: &AnthropicChunk, + state: &mut StreamState, + tx: &mpsc::Sender, +) -> Result { + match chunk.chunk_type.as_str() { + "message_start" => { + if let Some(message) = &chunk.message + && !message.id.is_empty() + { + state.message_id = Some(message.id.clone()); + } + Ok(false) + } + "content_block_start" => { + let (Some(index), Some(block)) = (chunk.index, chunk.content_block.as_ref()) else { + return Ok(false); + }; + if block.block_type == "tool_use" { + let id = block.id.clone().unwrap_or_default(); + let name = block + .name + .clone() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "unknown_tool".to_string()); + state.tool_blocks.insert( + index, + ToolBlock { + id: id.clone(), + name: name.clone(), + json_buffer: String::new(), + }, + ); + if send(tx, AgentEvent::ToolCallStart { id, name }) + .await + .is_err() + { + return Ok(true); + } + } + Ok(false) + } + "content_block_delta" => { + let Some(delta) = chunk.delta.as_ref() else { + return Ok(false); + }; + match delta.delta_type.as_deref() { + Some("text_delta") => { + if let Some(text) = &delta.text { + state.assistant_text.push_str(text); + if send(tx, AgentEvent::TextDelta(text.clone())).await.is_err() { + return Ok(true); + } + } + } + Some("input_json_delta") => { + if let (Some(index), Some(partial)) = (chunk.index, delta.partial_json.as_ref()) + && let Some(tool) = state.tool_blocks.get_mut(&index) + { + tool.json_buffer.push_str(partial); + if send( + tx, + AgentEvent::ToolCallArgsDelta { + id: tool.id.clone(), + delta: partial.clone(), + }, + ) + .await + .is_err() + { + return Ok(true); + } + } + } + Some("thinking_delta") => { + if let Some(thinking) = &delta.thinking + && send(tx, AgentEvent::ReasoningDelta(thinking.clone())) + .await + .is_err() + { + return Ok(true); + } + } + _ => {} + } + Ok(false) + } + "content_block_stop" => { + let Some(index) = chunk.index else { + return Ok(false); + }; + if let Some(tool) = state.tool_blocks.remove(&index) { + let raw = tool.json_buffer.trim(); + let raw = if raw.is_empty() { "{}" } else { raw }; + match serde_json::from_str::(raw) { + Ok(arguments) => { + if send( + tx, + AgentEvent::ToolCallReady { + id: tool.id, + name: tool.name, + arguments, + }, + ) + .await + .is_err() + { + return Ok(true); + } + } + Err(error) => { + if send( + tx, + AgentEvent::Error(format!( + "Failed to parse arguments for tool '{}': {}", + tool.name, error + )), + ) + .await + .is_err() + { + return Ok(true); + } + } + } + } + Ok(false) + } + "message_delta" => { + if let Some(delta) = chunk.delta.as_ref() + && let Some(stop_reason) = delta.stop_reason.as_ref() + { + state.stop_reason = Some(stop_reason.clone()); + } + Ok(false) + } + "message_stop" => { + state.terminal_emitted = true; + if state.stop_reason.as_deref() == Some("refusal") { + let _ = send( + tx, + AgentEvent::Error( + "Anthropic declined the request (stop_reason: refusal)".to_string(), + ), + ) + .await; + return Ok(true); + } + let text = state.assistant_text.trim(); + if !text.is_empty() + && send(tx, AgentEvent::TextDone(text.to_string())) + .await + .is_err() + { + return Ok(true); + } + let _ = send( + tx, + AgentEvent::ResponseDone { + response_id: state.message_id.clone(), + clean: true, + }, + ) + .await; + Ok(true) + } + "error" => { + state.terminal_emitted = true; + let detail = chunk + .error + .as_ref() + .map(format_stream_error) + .unwrap_or_else(|| "unknown error".to_string()); + let _ = send( + tx, + AgentEvent::Error(format!("Anthropic SSE error: {detail}")), + ) + .await; + Ok(true) + } + // `ping`, `message_delta` usage-only, and unknown lifecycle events. + other => { + debug!("Anthropic SSE ignoring event type={}", other); + Ok(false) + } + } +} + +async fn send(tx: &mpsc::Sender, event: AgentEvent) -> Result<(), ()> { + tx.send(event).await.map_err(|_| ()) +} + +fn format_stream_error(error: &AnthropicError) -> String { + match (error.error_type.as_deref(), error.message.as_deref()) { + (Some(kind), Some(message)) => format!("{kind}: {message}"), + (Some(kind), None) => kind.to_string(), + (None, Some(message)) => message.to_string(), + (None, None) => "unknown error".to_string(), + } +} + +/// Extract `error.message` (with optional `error.type`) from an HTTP error body, +/// falling back to `None` when the body is not the standard Anthropic error +/// envelope. +fn parse_http_error_body(body: &str) -> Option { + #[derive(Deserialize)] + struct Envelope { + error: Option, + } + let envelope = serde_json::from_str::(body).ok()?; + envelope.error.map(|error| format_stream_error(&error)) +} + +fn validate_anthropic_endpoint(endpoint: &str) -> Result { + let endpoint = endpoint.trim(); + if endpoint.is_empty() { + anyhow::bail!("Endpoint URL is empty"); + } + let url = reqwest::Url::parse(endpoint).context("Endpoint is not a valid URL")?; + let host = url.host_str().context("Endpoint URL is missing a host")?; + let is_loopback = matches!( + host.trim_matches(['[', ']']), + "localhost" | "127.0.0.1" | "::1" + ); + match url.scheme() { + "https" => {} + "http" if is_loopback => {} + "http" => anyhow::bail!("Plain HTTP is only allowed for localhost loopback endpoints"), + other => anyhow::bail!("Unsupported endpoint URL scheme: {}", other), + } + Ok(url) +} + +fn get_env_non_empty(key: &str, label: &str) -> Result { + let value = std::env::var(key).with_context(|| format!("{label} is required. Set {key}."))?; + let trimmed = value.trim(); + if trimmed.is_empty() { + anyhow::bail!("{label} is required. Set {key}."); + } + Ok(trimmed.to_string()) +} + +fn parse_env_u64(key: &str, default: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(default) +} + +// ── SSE wire types ─────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct AnthropicChunk { + #[serde(rename = "type")] + chunk_type: String, + #[serde(default)] + index: Option, + #[serde(default)] + message: Option, + #[serde(default)] + content_block: Option, + #[serde(default)] + delta: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Deserialize)] +struct AnthropicMessageMeta { + #[serde(default)] + id: String, +} + +#[derive(Debug, Deserialize)] +struct AnthropicContentBlockMeta { + #[serde(rename = "type")] + block_type: String, + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, +} + +#[derive(Debug, Deserialize)] +struct AnthropicDelta { + #[serde(rename = "type", default)] + delta_type: Option, + #[serde(default)] + text: Option, + #[serde(default)] + partial_json: Option, + #[serde(default)] + thinking: Option, + #[serde(default)] + stop_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct AnthropicError { + #[serde(rename = "type", default)] + error_type: Option, + #[serde(default)] + message: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use codescribe_core::agent::AgentAssetStore; + use serde_json::json; + use std::time::Duration; + + fn text_message(role: Role, text: &str) -> Message { + Message::new(role, vec![ContentBlock::Text(text.to_string())]) + } + + #[test] + fn build_system_folds_prompt_and_system_messages() { + let messages = vec![ + text_message(Role::System, "history system note"), + text_message(Role::User, "hi"), + ]; + let system = build_system(Some("primary prompt"), &messages) + .expect("system should combine prompt and system-role text"); + assert!(system.contains("primary prompt")); + assert!(system.contains("history system note")); + + assert_eq!(build_system(None, &[text_message(Role::User, "hi")]), None); + } + + #[test] + fn build_request_body_hoists_system_and_serializes_user_message() { + let messages = vec![text_message(Role::User, "hello world")]; + let body = build_request_body( + "claude-opus-4-8", + Some("be terse".to_string()), + &messages, + &[], + 4096, + None, + ) + .expect("request body should build"); + + assert_eq!(body["model"], "claude-opus-4-8"); + assert_eq!(body["max_tokens"], 4096); + assert_eq!(body["stream"], true); + assert_eq!(body["system"], "be terse"); + assert_eq!(body["messages"][0]["role"], "user"); + assert_eq!(body["messages"][0]["content"][0]["type"], "text"); + assert_eq!(body["messages"][0]["content"][0]["text"], "hello world"); + // No tools, no temperature when omitted. + assert!(body.get("tools").is_none()); + assert!(body.get("temperature").is_none()); + } + + #[test] + fn opus_strips_temperature_sonnet_keeps_it() { + let messages = vec![text_message(Role::User, "hi")]; + + let opus = capability_policy(ProviderKind::AnthropicMessages, "claude-opus-4-8"); + let opus_temp = opus.sanitize_temperature(Some(0.7)); + let opus_body = + build_request_body("claude-opus-4-8", None, &messages, &[], 1024, opus_temp).unwrap(); + assert!( + opus_body.get("temperature").is_none(), + "Opus-4.8 rejects sampling params; temperature must be omitted" + ); + + let sonnet = capability_policy(ProviderKind::AnthropicMessages, "claude-sonnet-4-6"); + let sonnet_temp = sonnet.sanitize_temperature(Some(0.3)); + let sonnet_body = + build_request_body("claude-sonnet-4-6", None, &messages, &[], 1024, sonnet_temp) + .unwrap(); + let sent = sonnet_body["temperature"] + .as_f64() + .expect("Sonnet keeps temperature as a number"); + assert!( + (sent - 0.3).abs() < 1e-6, + "Sonnet-4.6 tolerates temperature; expected ~0.3, got {sent}" + ); + } + + #[test] + fn tools_serialize_to_anthropic_input_schema() { + let tools = vec![ToolDefinition { + name: "read_file".to_string(), + description: "Read a file".to_string(), + input_schema: json!({"type": "object", "properties": {"path": {"type": "string"}}}), + }]; + let payload = build_tool_payload(&tools); + assert_eq!(payload[0]["name"], "read_file"); + assert_eq!(payload[0]["description"], "Read a file"); + assert_eq!(payload[0]["input_schema"]["type"], "object"); + } + + #[test] + fn tool_use_and_tool_result_map_to_anthropic_blocks() { + let assistant = Message::new( + Role::Assistant, + vec![ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({"path": "/tmp/x"}), + }], + ); + let blocks = message_content_blocks(&assistant).unwrap(); + assert_eq!(blocks[0]["type"], "tool_use"); + assert_eq!(blocks[0]["id"], "toolu_1"); + assert_eq!(blocks[0]["name"], "read_file"); + assert_eq!(blocks[0]["input"]["path"], "/tmp/x"); + + let user = Message::new( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: "toolu_1".to_string(), + content: vec![ContentBlock::Text("file body".to_string())], + is_error: false, + }], + ); + let result_blocks = message_content_blocks(&user).unwrap(); + assert_eq!(result_blocks[0]["type"], "tool_result"); + assert_eq!(result_blocks[0]["tool_use_id"], "toolu_1"); + assert_eq!(result_blocks[0]["content"][0]["text"], "file body"); + assert!(result_blocks[0].get("is_error").is_none()); + + let error_user = Message::new( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: "toolu_2".to_string(), + content: vec![], + is_error: true, + }], + ); + let error_blocks = message_content_blocks(&error_user).unwrap(); + assert_eq!(error_blocks[0]["is_error"], true); + assert_eq!( + error_blocks[0]["content"][0]["text"], + "Tool execution failed" + ); + } + + #[test] + fn image_block_uses_base64_source() { + let block = image_block(b"png bytes", "image/png"); + assert_eq!(block["type"], "image"); + assert_eq!(block["source"]["type"], "base64"); + assert_eq!(block["source"]["media_type"], "image/png"); + assert_eq!( + block["source"]["data"].as_str().unwrap(), + BASE64.encode(b"png bytes") + ); + // Empty media type normalizes to image/png. + assert_eq!(image_block(b"x", "")["source"]["media_type"], "image/png"); + } + + #[test] + fn empty_image_bytes_are_skipped() { + let message = Message::new( + Role::User, + vec![ + ContentBlock::Image { + data: vec![], + media_type: "image/png".to_string(), + }, + ContentBlock::Text("caption".to_string()), + ], + ); + let blocks = message_content_blocks(&message).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0]["type"], "text"); + } + + #[test] + fn user_message_inline_image_rides_into_request_body() { + // Composer 📎 path: `AgentSession::send` builds a [Text, Image{bytes}] + // user turn via `build_image_block`. A non-empty inline image must + // serialize into the request as a base64 image source alongside its + // caption — a regression here silently drops user attachments (the exact + // failure mode the composer vision path must never reintroduce). Parity + // with the OpenAI `input_image` guard. + let messages = vec![Message::new( + Role::User, + vec![ + ContentBlock::Text("what is in this image?".to_string()), + ContentBlock::Image { + data: b"png bytes".to_vec(), + media_type: "image/png".to_string(), + }, + ], + )]; + + let body = build_request_body("claude-opus-4-8", None, &messages, &[], 4096, None) + .expect("request body should build"); + + assert_eq!(body["messages"][0]["role"], "user"); + let content = body["messages"][0]["content"] + .as_array() + .expect("user content array"); + assert_eq!(content.len(), 2, "caption + image both survive"); + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["type"], "base64"); + assert_eq!(content[1]["source"]["media_type"], "image/png"); + assert_eq!( + content[1]["source"]["data"].as_str().unwrap(), + BASE64.encode(b"png bytes") + ); + } + + #[test] + fn tool_result_carries_image_asset_as_base64() { + let asset = AgentAssetStore::save_image(b"png bytes", "image/png") + .expect("image asset should save"); + let path = asset.path.clone(); + let message = Message::new( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: "toolu_shot".to_string(), + content: vec![ContentBlock::ImageAsset(asset)], + is_error: false, + }], + ); + let blocks = message_content_blocks(&message).unwrap(); + assert_eq!(blocks[0]["content"][0]["type"], "image"); + assert_eq!(blocks[0]["content"][0]["source"]["type"], "base64"); + std::fs::remove_file(path).ok(); + } + + #[test] + fn restored_thread_inline_image_reaches_prompt_on_next_turn() { + // Turn 2 on a restored thread: an inline composer image persisted via + // the thread store must come back as a disk-backed asset and still + // reach the request payload instead of being skipped as byteless. + let image_bytes = b"w5a-anthropic-turn2".to_vec(); + let original = Message::new( + Role::User, + vec![ContentBlock::Image { + data: image_bytes.clone(), + media_type: "image/png".to_string(), + }], + ); + let restored = codescribe_core::agent::ThreadMessage::from(&original).to_message(); + + let blocks = message_content_blocks(&restored).expect("restored image should serialize"); + assert_eq!(blocks.len(), 1, "restored image must not be skipped"); + assert_eq!(blocks[0]["type"], "image"); + assert_eq!( + blocks[0]["source"]["data"].as_str().unwrap(), + BASE64.encode(&image_bytes) + ); + + if let ContentBlock::ImageAsset(asset) = &restored.content[0] { + std::fs::remove_file(&asset.path).ok(); + } + } + + #[test] + fn validate_endpoint_rejects_non_loopback_http() { + assert!(validate_anthropic_endpoint("https://api.anthropic.com/v1/messages").is_ok()); + assert!(validate_anthropic_endpoint("http://127.0.0.1:8080/v1/messages").is_ok()); + assert!(validate_anthropic_endpoint("http://example.com/v1/messages").is_err()); + assert!(validate_anthropic_endpoint("ftp://example.com").is_err()); + assert!(validate_anthropic_endpoint(" ").is_err()); + } + + fn provider_for(endpoint: &str) -> AnthropicProvider { + AnthropicProvider { + client: Client::new(), + endpoint: endpoint.to_string(), + api_key: "test-key".to_string(), + anthropic_version: ANTHROPIC_VERSION.to_string(), + default_model: "claude-opus-4-8".to_string(), + default_max_tokens: 1024, + initial_response_timeout: Duration::from_secs(2), + inter_chunk_timeout: Duration::from_secs(2), + } + } + + #[tokio::test] + async fn stream_parses_text_tool_and_terminal_events() { + let mut server = mockito::Server::new_async().await; + let body = [ + r#"data: {"type":"message_start","message":{"id":"msg_1"}}"#, + "", + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + "", + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#, + "", + r#"data: {"type":"content_block_stop","index":0}"#, + "", + r#"data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_9","name":"read_file"}}"#, + "", + r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}"#, + "", + r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"/tmp/x\"}"}}"#, + "", + r#"data: {"type":"content_block_stop","index":1}"#, + "", + r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}"#, + "", + r#"data: {"type":"message_stop"}"#, + "", + ] + .join("\n"); + let mock = server + .mock("POST", "/v1/messages") + .match_header("x-api-key", "test-key") + .match_header("anthropic-version", ANTHROPIC_VERSION) + .with_status(200) + .with_header("content-type", "text/event-stream") + .with_body(body) + .create_async() + .await; + + let provider = provider_for(&format!("{}/v1/messages", server.url())); + let messages = vec![text_message(Role::User, "read it")]; + let mut rx = provider + .stream(&messages, &[], &StreamOptions::default()) + .await + .expect("stream should start"); + + let mut events = Vec::new(); + while let Ok(Some(event)) = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await { + events.push(event); + } + + assert!(events.contains(&AgentEvent::TextDelta("Hello".to_string()))); + assert!(events.contains(&AgentEvent::TextDone("Hello".to_string()))); + assert!(events.iter().any(|event| matches!( + event, + AgentEvent::ToolCallReady { id, name, arguments } + if id == "toolu_9" && name == "read_file" && arguments["path"] == "/tmp/x" + ))); + assert!(events.iter().any(|event| matches!( + event, + AgentEvent::ResponseDone { response_id: Some(id), clean: true } if id == "msg_1" + ))); + mock.assert_async().await; + } + + #[tokio::test] + async fn stream_surfaces_sse_error_event() { + let mut server = mockito::Server::new_async().await; + let body = [ + r#"data: {"type":"error","error":{"type":"overloaded_error","message":"try later"}}"#, + "", + ] + .join("\n"); + let mock = server + .mock("POST", "/v1/messages") + .with_status(200) + .with_header("content-type", "text/event-stream") + .with_body(body) + .create_async() + .await; + + let provider = provider_for(&format!("{}/v1/messages", server.url())); + let messages = vec![text_message(Role::User, "hi")]; + let mut rx = provider + .stream(&messages, &[], &StreamOptions::default()) + .await + .expect("stream should start"); + + let event = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("event before timeout") + .expect("one event"); + match event { + AgentEvent::Error(message) => { + assert!(message.contains("overloaded_error")); + assert!(message.contains("try later")); + } + other => panic!("expected Error, got {other:?}"), + } + mock.assert_async().await; + } + + #[tokio::test] + async fn stream_maps_http_error_to_error_event() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/v1/messages") + .with_status(400) + .with_header("content-type", "application/json") + .with_body(r#"{"type":"error","error":{"type":"invalid_request_error","message":"bad model"}}"#) + .create_async() + .await; + + let provider = provider_for(&format!("{}/v1/messages", server.url())); + let messages = vec![text_message(Role::User, "hi")]; + let mut rx = provider + .stream(&messages, &[], &StreamOptions::default()) + .await + .expect("stream should start"); + + let event = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("event before timeout") + .expect("one event"); + match event { + AgentEvent::Error(message) => { + assert!(message.contains("Anthropic HTTP 400")); + assert!(message.contains("bad model")); + } + other => panic!("expected Error, got {other:?}"), + } + mock.assert_async().await; + } +} diff --git a/app/agent/mod.rs b/app/agent/mod.rs index 7021c2e2..24c637a6 100644 --- a/app/agent/mod.rs +++ b/app/agent/mod.rs @@ -1,16 +1,27 @@ use anyhow::Result; use codescribe_core::agent::AgentProvider; +use codescribe_core::llm::provider::{LlmMode, ProviderKind, resolve_provider}; +pub mod anthropic_provider; pub mod openai_provider; #[cfg(target_os = "macos")] pub mod tools; +pub use anthropic_provider::AnthropicProvider; pub use openai_provider::OpenAiProvider; pub fn create_openai_provider_from_env() -> Result { OpenAiProvider::from_env() } +/// Build the agent (assistive-lane) provider from the configured provider +/// identity (`LLM_ASSISTIVE_PROVIDER`, resolved by +/// [`resolve_provider`]). A missing key for the SELECTED provider surfaces its +/// own clear error (`from_env`) rather than silently falling back to the other +/// provider — misconfiguration must never route to an unintended vendor. pub fn create_default_provider() -> Result> { - Ok(Box::new(OpenAiProvider::from_env()?)) + match resolve_provider(LlmMode::Assistive) { + ProviderKind::OpenAiResponses => Ok(Box::new(OpenAiProvider::from_env()?)), + ProviderKind::AnthropicMessages => Ok(Box::new(AnthropicProvider::from_env()?)), + } } diff --git a/app/agent/openai_provider.rs b/app/agent/openai_provider.rs index e67307f8..726ed27e 100644 --- a/app/agent/openai_provider.rs +++ b/app/agent/openai_provider.rs @@ -9,7 +9,7 @@ use reqwest::Client; use serde::Serialize; use serde_json::{Value, json}; use tokio::sync::{Mutex, mpsc}; -use tracing::info; +use tracing::{info, warn}; use codescribe_core::agent::{ AgentEvent, AgentProvider, ContentBlock, ImageAsset, Message, Role, StreamOptions, @@ -376,6 +376,16 @@ fn build_input_items(messages: &[Message]) -> Result> { } } ContentBlock::Image { data, media_type } => { + // Images restored from the thread store carry no bytes + // (persisted with `data_omitted`). Emitting an empty data URL + // makes the provider reject the whole request + // ("empty base64-encoded bytes"), so skip empty images. + if data.is_empty() { + warn!( + "Skipping image content block with no bytes (likely restored from history)" + ); + continue; + } content.push(json!({ "type": "input_image", "image_url": to_data_uri(data, media_type) @@ -578,7 +588,7 @@ fn parse_env_bool(key: &str, default: bool) -> bool { mod tests { use super::{ OpenAiProvider, build_request_input_items, format_tool_output, - forward_events_and_track_chain, request_messages, + forward_events_and_track_chain, request_messages, to_data_uri, }; use std::sync::Arc; use std::time::Duration; @@ -710,6 +720,36 @@ mod tests { assert!(!output.contains("bm90IHJlYWxseSBhIHBuZw")); } + #[test] + fn restored_thread_inline_image_reaches_prompt_on_next_turn() { + // Turn 2 on a restored thread: an inline composer image persisted via + // the thread store must come back as a disk-backed asset and still + // reach the request payload instead of being skipped as byteless. + let image_bytes = b"w5a-openai-turn2".to_vec(); + let original = Message::new( + Role::User, + vec![ContentBlock::Image { + data: image_bytes.clone(), + media_type: "image/png".to_string(), + }], + ); + let restored = codescribe_core::agent::ThreadMessage::from(&original).to_message(); + + let items = build_request_input_items(std::slice::from_ref(&restored), None) + .expect("restored image should serialize"); + assert_eq!(items.len(), 1, "restored image must not be skipped"); + assert_eq!(items[0]["type"], "message"); + assert_eq!(items[0]["content"][0]["type"], "input_image"); + let image_url = items[0]["content"][0]["image_url"] + .as_str() + .expect("image_url should be a string"); + assert_eq!(image_url, to_data_uri(&image_bytes, "image/png")); + + if let ContentBlock::ImageAsset(asset) = &restored.content[0] { + std::fs::remove_file(&asset.path).ok(); + } + } + #[test] fn tool_result_image_asset_adds_native_input_image_item() { let asset = AgentAssetStore::save_image(b"png bytes", "image/png") @@ -747,6 +787,40 @@ mod tests { std::fs::remove_file(asset_path).ok(); } + #[test] + fn user_message_inline_image_serializes_as_input_image() { + // Composer 📎 path parity with Anthropic: `AgentSession::send` builds a + // [Text, Image{bytes}] user turn via `build_image_block`. The request + // must carry the image as a native input_image data URI alongside the + // caption — a regression here silently drops user attachments. + let messages = vec![Message::new( + Role::User, + vec![ + ContentBlock::Text("what is in this image?".to_string()), + ContentBlock::Image { + data: b"png bytes".to_vec(), + media_type: "image/png".to_string(), + }, + ], + )]; + + let items = + build_request_input_items(&messages, None).expect("request input items should build"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["role"], "user"); + let content = items[0]["content"].as_array().expect("content array"); + assert_eq!(content.len(), 2, "caption + image both survive"); + assert_eq!(content[0]["type"], "input_text"); + assert_eq!(content[1]["type"], "input_image"); + assert!( + content[1]["image_url"] + .as_str() + .expect("image_url string") + .starts_with("data:image/png;base64,") + ); + } + #[tokio::test] async fn stream_surfaces_sse_error_event_as_specific_agent_error() { let mut server = mockito::Server::new_async().await; diff --git a/app/agent/tools/doctrine.rs b/app/agent/tools/doctrine.rs new file mode 100644 index 00000000..7ddfc53b --- /dev/null +++ b/app/agent/tools/doctrine.rs @@ -0,0 +1,58 @@ +//! Agent-facing operating doctrine appended to the system prompt: how to drive +//! long-running review-tool MCP servers (health/state/run, the +//! started->completed state machine, run_id/base_used reporting) and how to fall +//! back from the GitHub connector to the local checkout. Prompt-layer only — no +//! executor changes; this pins agent behaviour without touching tool internals. + +/// A concise review-tool + connector-fallback doctrine for the agent system +/// prompt. Kept tight on purpose: prompt space is a scarce resource, so this +/// section states the operating rules and nothing else. +pub fn review_doctrine_prompt_section() -> String { + "REVIEW TOOLS & CONNECTORS\n\ + Treat review-tool MCP servers (prview and any similar) as agent-facing \ + review systems, not CLI parsers. Sequence: check `health`, then inspect \ + `state`, and start a review only when no completed run exists for the \ + current HEAD. `running` is an intermediate state, never a verdict — do not \ + report it as a result. Always report run_id, base_used, and whether the \ + result is final. For multi-branch work, consider passing an explicit base \ + instead of relying on develop/main/master fallback.\n\ + For long-running calls, report a mini state machine — started / running / \ + stale / completed / failed / needs-rerun — and say \"review still running, \ + poll the verdict later\" rather than inventing an outcome. Poll with a \ + small retry cap and backoff; do not spam tool calls.\n\ + If a GitHub fetch (`fetch_github_file`) fails once or twice, stop retrying: \ + switch to the local checkout as the source of truth (`list_projects` + \ + git) and report the connector failure as a separate problem, not as \ + missing repository data." + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn doctrine_section_carries_the_load_bearing_anchors() { + let section = review_doctrine_prompt_section(); + // Header + the operator's follow-up-prompt invariants must survive edits. + assert!(section.starts_with("REVIEW TOOLS & CONNECTORS")); + for anchor in [ + "health", + "state", + "run_id", + "base_used", + "running", + "needs-rerun", + "poll the verdict later", + "fetch_github_file", + "local checkout", + "list_projects", + "separate problem", + ] { + assert!( + section.contains(anchor), + "doctrine section missing anchor: {anchor}" + ); + } + } +} diff --git a/app/agent/tools/github.rs b/app/agent/tools/github.rs index 89c3d7da..dd81cb3d 100644 --- a/app/agent/tools/github.rs +++ b/app/agent/tools/github.rs @@ -17,7 +17,7 @@ pub fn register(registry: &mut ToolRegistry) { fn fetch_github_file_definition() -> ToolDefinition { ToolDefinition { name: "fetch_github_file".to_string(), - description: "Fetch the raw content of a file from a GitHub repository. Supports public repos and private repos with a configured GITHUB_TOKEN. Input can be a full GitHub URL or owner/repo@ref:path format. Returns the file content as text plus metadata.".to_string(), + description: "Fetch the raw content of a file from a GitHub repository. Supports public repos and private repos with a configured GITHUB_TOKEN. Input can be a full GitHub URL or owner/repo@ref:path format. Returns the file content as text plus metadata. If a fetch fails once or twice (e.g. missing token or network), do not retry-loop: fall back to the local checkout via `list_projects` + git as the source of truth, and treat the connector failure as a separate problem from repository state.".to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/app/agent/tools/mcp.rs b/app/agent/tools/mcp.rs index fb6f3dde..f65bb7de 100644 --- a/app/agent/tools/mcp.rs +++ b/app/agent/tools/mcp.rs @@ -5,6 +5,7 @@ use std::thread; use anyhow::{Context, Result, bail}; use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; +use codescribe_core::llm::provider::{LlmMode, resolve_provider}; use codescribe_core::mcp::{McpClient, McpConfigFile, McpServerConfig, McpTool}; use tracing::{info, warn}; @@ -62,6 +63,13 @@ pub struct McpStatusRow { /// Honest snapshot of MCP config + runtime state for the Settings UI. pub struct McpStatusReport { pub config_path_display: String, + /// Whether the user has an `mcp.json` with at least one server defined. + /// `false` means "no config yet" (missing file OR present-but-empty) — the + /// onboarding readiness step surfaces the setup prompt instead of a status + /// card. A present config that fails to load still counts as `true`: the + /// file exists, the user configured something, so we show the concrete error + /// rather than pretend nothing is there. + configured: bool, rows: Vec, } @@ -70,9 +78,23 @@ impl McpStatusReport { &self.rows } - fn single(config_path_display: String, label: &str, value: String, tone: McpRowTone) -> Self { + /// `true` when an `mcp.json` exists with at least one server defined (or when + /// a present config failed to load — the file is still there). `false` only + /// when there is no config yet: missing file or present-but-no-servers. + pub fn configured(&self) -> bool { + self.configured + } + + fn single( + config_path_display: String, + configured: bool, + label: &str, + value: String, + tone: McpRowTone, + ) -> Self { Self { config_path_display, + configured, rows: vec![McpStatusRow { label: label.to_string(), value, @@ -94,6 +116,7 @@ pub fn probe_mcp_status() -> McpStatusReport { Err(error) => { return McpStatusReport::single( "unavailable".to_string(), + true, "Status:", format!("config path unavailable: {error}"), McpRowTone::Bad, @@ -109,6 +132,7 @@ fn probe_mcp_status_at(path: &Path) -> McpStatusReport { if !path.exists() { return McpStatusReport::single( config_path_display, + false, "Status:", "no mcp.json (optional — MCP off)".to_string(), McpRowTone::Neutral, @@ -120,6 +144,7 @@ fn probe_mcp_status_at(path: &Path) -> McpStatusReport { Err(error) => { return McpStatusReport::single( config_path_display, + true, "Config error:", anyhow_root_cause(&error), McpRowTone::Bad, @@ -130,6 +155,7 @@ fn probe_mcp_status_at(path: &Path) -> McpStatusReport { if config.servers.is_empty() { return McpStatusReport::single( config_path_display, + false, "Status:", "config present, no servers defined".to_string(), McpRowTone::Warn, @@ -169,6 +195,322 @@ fn probe_mcp_status_at(path: &Path) -> McpStatusReport { McpStatusReport { config_path_display, + configured: true, + rows, + } +} + +/// Operator-tooling MCP servers surfaced as INFORMATIONAL rows in the readiness +/// panel, each paired with the server name that satisfies it. +/// +/// These are Vetcoders operator surfaces (Vibecrafted / AICX / Loctree); an +/// end-user install will not have them. Per the C4 readiness-semantics decision +/// they are context only and NEVER gate `ready` — the core capability gate +/// (provider + key + native tools) is the sole arbiter of readiness. PRView is +/// handled separately by [`classify_prview`], also as optional context. +const AGENTIC_PREREQS: &[(&str, &str)] = &[ + ("Vibecrafted runtime:", "vibecrafted-mcp"), + ("AICX MCP:", "aicx-mcp"), + ("Loctree MCP:", "loctree-mcp"), +]; + +/// Core capability gate — the REAL ability of the agent to act. This is the only +/// input that decides `ready`: a configured assistive-lane provider whose API +/// key is present, plus the compiled-in native tool set. Operator tooling (MCP +/// servers) is informational and never enters this verdict. +#[derive(Debug, Clone)] +pub struct CoreReadiness { + /// Display name of the resolved assistive-lane provider. + pub provider_label: String, + /// Keychain/env account holding that provider's assistive key. + pub key_env_key: String, + /// Whether that key is present (non-empty) in the process env. + pub key_set: bool, + /// Number of native (compiled-in) tools available to the agent. + pub native_tool_count: usize, +} + +/// Probe the core capability gate from live process state: the configured +/// assistive provider ([`resolve_provider`]), whether its key is set, and the +/// count of native tools. Cheap — an env read plus building an in-memory registry +/// (no server spawning, no network). Callers that need Keychain-backed keys in +/// env must load `Config` first (the bridge does this before probing). +pub fn probe_core_readiness() -> CoreReadiness { + let provider = resolve_provider(LlmMode::Assistive); + let key_env_key = provider.api_key_env_key().to_string(); + let key_set = std::env::var(&key_env_key) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + + let mut registry = ToolRegistry::new(); + super::register_native_tools(&mut registry); + let native_tool_count = registry.definitions().len(); + + CoreReadiness { + provider_label: provider.display_name().to_string(), + key_env_key, + key_set, + native_tool_count, + } +} + +/// Readiness verdict for the Agentic operating lane. +/// +/// `ready` is decided SOLELY by the core capability gate ([`CoreReadiness`]: +/// assistive provider configured + its API key set + native tools compiled in). +/// The per-server MCP rows (Vibecrafted / AICX / Loctree / PRView) are +/// INFORMATIONAL context only — they can never flip `ready` — because they are +/// operator tooling an end-user install will not have. This is the C4 +/// readiness-semantics decision (redesign from the earlier "all four required" +/// gate, which left a working agent stuck at NOT READY). +pub struct AgenticReadinessReport { + pub config_path_display: String, + ready: bool, + rows: Vec, +} + +impl AgenticReadinessReport { + pub fn summary_rows(&self) -> &[McpStatusRow] { + &self.rows + } + + /// `true` only when the core capability gate passes: a configured assistive + /// provider with its API key set and at least one native tool available. + /// Operator-tooling MCP rows are informational and never affect this verdict. + pub fn is_ready(&self) -> bool { + self.ready + } +} + +/// Classify one operator-tooling MCP server as an INFORMATIONAL row. Never +/// blocking: a missing/failed/disabled operator server does not affect agent +/// readiness (the core gate owns that). Absent → neutral "not configured +/// (optional)"; live → good; configured-but-unstarted or failed → warn. +fn classify_operator_tool( + label: &str, + server_name: &str, + config: &McpConfigFile, + runtime: &BTreeMap, +) -> McpStatusRow { + let configured = config.servers.get(server_name); + let (value, tone) = match (configured, runtime.get(server_name)) { + // Not present in mcp.json — optional operator tooling is simply absent. + (None, _) => ("not configured (optional)".to_string(), McpRowTone::Neutral), + // Real discovery succeeded — tools are live. + (Some(_), Some(ServerRuntime::Tools(count))) => { + (format!("ready — {count} tool(s) live"), McpRowTone::Good) + } + // Configured but discovery failed: surface the concrete reason (warn, not + // blocking — the agent still works without this operator surface). + (Some(_), Some(ServerRuntime::Failed(reason))) => { + (format!("failed: {reason}"), McpRowTone::Warn) + } + (Some(cfg), runtime_state) => { + let enabled = cfg.enabled.unwrap_or(true); + if matches!(runtime_state, Some(ServerRuntime::Disabled)) || !enabled { + ("disabled".to_string(), McpRowTone::Neutral) + } else { + ( + "configured — agent not started yet".to_string(), + McpRowTone::Warn, + ) + } + } + }; + McpStatusRow { + label: label.to_string(), + value, + tone, + } +} + +/// Classify PRView as an INFORMATIONAL row. Per the C4 decision PRView is +/// OPTIONAL, not required substrate: its absence is a neutral "not configured +/// (optional)" and never blocks readiness. The canonical wiring is a `prview` +/// MCP server (`{"command":"prview","args":["mcp"]}`); we also honour any server +/// whose name contains "prview" so a manually wired entry is recognised. +fn classify_prview( + config: &McpConfigFile, + runtime: &BTreeMap, +) -> McpStatusRow { + let detected = config + .servers + .iter() + .find(|(name, _)| name.to_ascii_lowercase().contains("prview")); + let (value, tone) = match detected { + Some((name, cfg)) => match runtime.get(name) { + Some(ServerRuntime::Tools(count)) => ( + format!("ready — {count} tool(s) live (via \"{name}\")"), + McpRowTone::Good, + ), + Some(ServerRuntime::Failed(reason)) => (format!("failed: {reason}"), McpRowTone::Warn), + Some(ServerRuntime::Disabled) => ("disabled".to_string(), McpRowTone::Neutral), + None => { + if cfg.enabled.unwrap_or(true) { + ( + format!("configured — agent not started yet (via \"{name}\")"), + McpRowTone::Warn, + ) + } else { + ("disabled".to_string(), McpRowTone::Neutral) + } + } + }, + None => ("not configured (optional)".to_string(), McpRowTone::Neutral), + }; + McpStatusRow { + label: "PRView integration:".to_string(), + value, + tone, + } +} + +/// Agentic-lane readiness probe: `ready` is the core capability gate (provider + +/// key + native tools); the MCP rows are informational context. See +/// [`AgenticReadinessReport`] for the semantics decision. +pub fn probe_agentic_readiness() -> AgenticReadinessReport { + let core = probe_core_readiness(); + let path = match codescribe_core::mcp::default_mcp_config_path() { + Ok(path) => path, + Err(error) => { + // MCP is optional, so a missing config path is informational — the + // core gate still decides readiness. + return assemble_readiness( + "unavailable".to_string(), + core, + McpConfigFile { + servers: Default::default(), + }, + Some(format!("config path unavailable: {error}")), + ); + } + }; + probe_agentic_readiness_at(&path, core) +} + +fn probe_agentic_readiness_at(path: &Path, core: CoreReadiness) -> AgenticReadinessReport { + let config_path_display = path.display().to_string(); + + let (config, config_note) = if !path.exists() { + ( + McpConfigFile { + servers: Default::default(), + }, + None, + ) + } else { + match McpConfigFile::load(path) { + Ok(config) => (config, None), + // A broken mcp.json no longer blocks readiness (MCP is optional); it is + // surfaced as an informational warn row instead of a hard not-ready. + Err(error) => ( + McpConfigFile { + servers: Default::default(), + }, + Some(format!( + "mcp.json parse error (optional): {}", + anyhow_root_cause(&error) + )), + ), + } + }; + + assemble_readiness(config_path_display, core, config, config_note) +} + +/// Assemble the readiness report: the core-gate verdict + provider + native-tools +/// rows (which decide `ready`), followed by the informational operator-tooling +/// rows (which never do). +fn assemble_readiness( + config_path_display: String, + core: CoreReadiness, + config: McpConfigFile, + config_note: Option, +) -> AgenticReadinessReport { + let runtime = runtime_cache() + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone(); + + let tools_present = core.native_tool_count > 0; + let ready = core.key_set && tools_present; + + // ---- Core capability gate rows (these decide `ready`). ---- + let verdict = if ready { + McpStatusRow { + label: "Agentic readiness:".to_string(), + value: format!( + "ready — {} configured, key set, {} native tool(s)", + core.provider_label, core.native_tool_count + ), + tone: McpRowTone::Good, + } + } else { + let reason = if !core.key_set { + format!("assistive API key missing (set {})", core.key_env_key) + } else { + "no native tools available".to_string() + }; + McpStatusRow { + label: "Agentic readiness:".to_string(), + value: format!("not ready — {reason}"), + tone: McpRowTone::Bad, + } + }; + + let provider_row = McpStatusRow { + label: "Provider:".to_string(), + value: if core.key_set { + format!("{} — key set", core.provider_label) + } else { + format!( + "{} — key missing (set {})", + core.provider_label, core.key_env_key + ) + }, + tone: if core.key_set { + McpRowTone::Good + } else { + McpRowTone::Bad + }, + }; + + let tools_row = McpStatusRow { + label: "Native tools:".to_string(), + value: format!("{} tool(s) available", core.native_tool_count), + tone: if tools_present { + McpRowTone::Good + } else { + McpRowTone::Bad + }, + }; + + let mut rows = Vec::with_capacity(AGENTIC_PREREQS.len() + 5); + rows.push(verdict); + rows.push(provider_row); + rows.push(tools_row); + + // ---- Informational operator-tooling rows (never gate `ready`). ---- + if let Some(note) = config_note { + rows.push(McpStatusRow { + label: "MCP config:".to_string(), + value: note, + tone: McpRowTone::Warn, + }); + } + for (label, server_name) in AGENTIC_PREREQS { + rows.push(classify_operator_tool( + label, + server_name, + &config, + &runtime, + )); + } + rows.push(classify_prview(&config, &runtime)); + + AgenticReadinessReport { + config_path_display, + ready, rows, } } @@ -374,7 +716,8 @@ mod tests { use serde_json::json; use super::{ - McpRowTone, probe_mcp_status_at, public_tool_name, register_mcp_tools_from_config_path, + McpRowTone, probe_agentic_readiness_at, probe_mcp_status_at, public_tool_name, + register_mcp_tools_from_config_path, }; #[test] @@ -391,6 +734,8 @@ mod tests { "got: {}", rows[0].value ); + // No config yet → the onboarding step must offer the setup prompt. + assert!(!report.configured()); } #[test] @@ -405,6 +750,9 @@ mod tests { assert_eq!(rows[0].tone, McpRowTone::Bad); assert_eq!(rows[0].label, "Config error:"); assert!(!rows[0].value.is_empty()); + // File exists (user configured something) → show the error, not the + // onboarding prompt. + assert!(report.configured()); } #[test] @@ -416,6 +764,8 @@ mod tests { let rows = report.summary_rows(); assert_eq!(rows[0].tone, McpRowTone::Warn); assert!(rows[0].value.contains("no servers")); + // Present but empty config counts as "not configured yet" → prompt. + assert!(!report.configured()); } #[test] @@ -445,6 +795,8 @@ mod tests { "got: {}", row.value ); + // A config with at least one server → configured; no setup prompt. + assert!(report.configured()); } #[tokio::test] @@ -507,4 +859,214 @@ mod tests { fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } + + // --- Agentic readiness probe (C4 semantics) ------------------------------ + // + // `ready` is decided by the CORE capability gate (provider + key + native + // tools), supplied here as an explicit `CoreReadiness` so the tests stay + // deterministic and free of process-env / Keychain coupling. The MCP rows are + // informational and must never flip the verdict. The global runtime cache is + // never populated for these server names by any other test, so operator rows + // read as "configured, agent not started" or "not configured (optional)". + + use super::CoreReadiness; + + fn find_row<'a>( + report: &'a super::AgenticReadinessReport, + label: &str, + ) -> &'a super::McpStatusRow { + report + .summary_rows() + .iter() + .find(|row| row.label == label) + .unwrap_or_else(|| panic!("row '{label}' present")) + } + + /// Core gate that PASSES: provider configured, key set, native tools present. + fn core_ready() -> CoreReadiness { + CoreReadiness { + provider_label: "OpenAI (Responses)".to_string(), + key_env_key: "LLM_ASSISTIVE_API_KEY".to_string(), + key_set: true, + native_tool_count: 10, + } + } + + #[test] + fn probe_core_readiness_counts_native_tools() { + // The real native tool set is compiled in; the count must be non-zero so + // the core gate never fails purely on "no tools" in a healthy build. + let core = super::probe_core_readiness(); + assert!( + core.native_tool_count > 0, + "native tools should be registered" + ); + assert!(!core.provider_label.is_empty()); + assert!(!core.key_env_key.is_empty()); + } + + #[test] + fn readiness_is_driven_by_core_gate_not_operator_tooling() { + // Empty MCP config (no operator tooling at all) but a passing core gate: + // the agent is READY. Operator tooling is optional context. + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); // never created + + let report = probe_agentic_readiness_at(&path, core_ready()); + assert!( + report.is_ready(), + "core gate passing must be READY even with zero operator tooling: {:?}", + report + .summary_rows() + .iter() + .map(|r| format!("{}={}", r.label, r.value)) + .collect::>() + ); + let verdict = find_row(&report, "Agentic readiness:"); + assert_eq!(verdict.tone, McpRowTone::Good); + assert!(verdict.value.contains("ready"), "got: {}", verdict.value); + let provider = find_row(&report, "Provider:"); + assert_eq!(provider.tone, McpRowTone::Good); + assert!( + provider.value.contains("key set"), + "got: {}", + provider.value + ); + } + + #[test] + fn operator_tooling_absent_is_neutral_optional_and_non_blocking() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); // never created + + let report = probe_agentic_readiness_at(&path, core_ready()); + assert!(report.is_ready()); + for label in [ + "Vibecrafted runtime:", + "AICX MCP:", + "Loctree MCP:", + "PRView integration:", + ] { + let row = find_row(&report, label); + assert_eq!( + row.tone, + McpRowTone::Neutral, + "{label} must be neutral/optional" + ); + assert!(row.value.contains("optional"), "{label} got: {}", row.value); + } + } + + #[test] + fn missing_assistive_key_blocks_readiness_even_with_full_substrate() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + // Full operator substrate present — but the core gate has no key. + let config = json!({ + "mcpServers": { + "vibecrafted-mcp": { "command": "vibecrafted-mcp", "enabled": true }, + "aicx-mcp": { "command": "aicx-mcp", "enabled": true }, + "loctree-mcp": { "command": "loctree-mcp", "enabled": true }, + "prview": { "command": "prview", "args": ["mcp"], "enabled": true } + } + }); + fs::write(&path, config.to_string()).expect("write config"); + + let core = CoreReadiness { + key_set: false, + ..core_ready() + }; + let report = probe_agentic_readiness_at(&path, core); + assert!( + !report.is_ready(), + "no API key must block readiness regardless of MCP substrate" + ); + let verdict = find_row(&report, "Agentic readiness:"); + assert_eq!(verdict.tone, McpRowTone::Bad); + assert!( + verdict.value.contains("key missing") || verdict.value.contains("key"), + "got: {}", + verdict.value + ); + let provider = find_row(&report, "Provider:"); + assert_eq!(provider.tone, McpRowTone::Bad); + assert!( + provider.value.contains("LLM_ASSISTIVE_API_KEY"), + "provider row should name the missing key account, got: {}", + provider.value + ); + } + + #[test] + fn no_native_tools_blocks_readiness() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + let core = CoreReadiness { + native_tool_count: 0, + ..core_ready() + }; + let report = probe_agentic_readiness_at(&path, core); + assert!(!report.is_ready(), "zero native tools must block readiness"); + let tools = find_row(&report, "Native tools:"); + assert_eq!(tools.tone, McpRowTone::Bad); + let verdict = find_row(&report, "Agentic readiness:"); + assert!( + verdict.value.contains("no native tools"), + "got: {}", + verdict.value + ); + } + + #[test] + fn configured_prview_server_is_informational_good_or_warn() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + // Canonical PRView wiring: {"command":"prview","args":["mcp"]}. + let config = json!({ + "mcpServers": { + "prview": { "command": "prview", "args": ["mcp"], "enabled": true } + } + }); + fs::write(&path, config.to_string()).expect("write config"); + + let report = probe_agentic_readiness_at(&path, core_ready()); + assert!(report.is_ready(), "PRView presence never gates readiness"); + let prview = find_row(&report, "PRView integration:"); + // Configured but no runtime discovery yet → warn, and it names the server. + assert_eq!(prview.tone, McpRowTone::Warn); + assert!( + prview.value.contains("configured") && prview.value.contains("prview"), + "got: {}", + prview.value + ); + } + + #[test] + fn broken_mcp_json_is_informational_not_blocking() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + fs::write(&path, "{ not valid json").expect("write garbage"); + + // A broken config no longer blocks readiness — MCP is optional now. + let report = probe_agentic_readiness_at(&path, core_ready()); + assert!( + report.is_ready(), + "a broken mcp.json must not sink a working agent" + ); + let note = find_row(&report, "MCP config:"); + assert_eq!(note.tone, McpRowTone::Warn); + assert!(note.value.contains("parse error"), "got: {}", note.value); + } + + #[test] + fn basic_probe_stays_neutral_for_missing_config() { + // The Basic-lane probe is unchanged: a missing mcp.json is a single + // neutral/optional row, never an error. + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); // never created + + let basic = probe_mcp_status_at(&path); + assert_eq!(basic.summary_rows().len(), 1); + assert_eq!(basic.summary_rows()[0].tone, McpRowTone::Neutral); + } } diff --git a/app/agent/tools/mod.rs b/app/agent/tools/mod.rs index 4735384c..7635fbba 100644 --- a/app/agent/tools/mod.rs +++ b/app/agent/tools/mod.rs @@ -1,4 +1,5 @@ pub mod clipboard; +pub mod doctrine; pub mod filesystem; pub mod github; pub mod mcp; @@ -7,6 +8,7 @@ pub mod search_threads; pub mod selection; pub mod transcribe_audio; pub mod typing; +pub mod workspace; use codescribe_core::agent::ToolRegistry; @@ -24,6 +26,7 @@ fn register_native_tools(registry: &mut ToolRegistry) { github::register(registry); search_threads::register(registry); transcribe_audio::register(registry); + workspace::register(registry); } #[cfg(test)] @@ -48,6 +51,7 @@ mod tests { "fetch_github_file".to_string(), "get_frontmost_app".to_string(), "get_selected_text".to_string(), + "list_projects".to_string(), "read_clipboard".to_string(), "read_file".to_string(), "search_threads".to_string(), diff --git a/app/agent/tools/search_threads.rs b/app/agent/tools/search_threads.rs index b739c416..fbe5ff3c 100644 --- a/app/agent/tools/search_threads.rs +++ b/app/agent/tools/search_threads.rs @@ -21,7 +21,7 @@ pub fn register(registry: &mut ToolRegistry) { fn search_threads_definition() -> ToolDefinition { ToolDefinition { name: "search_threads".to_string(), - description: "Search the local CodeScribe thread index by title, tags, summary, notes, and message text. Read-only; returns top matching thread ids and snippets.".to_string(), + description: "Search the local Codescribe thread index by title, tags, summary, notes, and message text. Read-only; returns top matching thread ids and snippets.".to_string(), input_schema: json!({ "type": "object", "properties": { @@ -173,6 +173,7 @@ mod tests { created_at: updated_at - Duration::minutes(5), updated_at, title: title.to_string(), + title_is_custom: false, mode: "assistive".to_string(), tags: vec!["clinic".to_string()], notes: Vec::new(), diff --git a/app/agent/tools/transcribe_audio.rs b/app/agent/tools/transcribe_audio.rs index d62053ce..2269486e 100644 --- a/app/agent/tools/transcribe_audio.rs +++ b/app/agent/tools/transcribe_audio.rs @@ -6,7 +6,7 @@ use codescribe_core::{audio, stt::whisper, vad}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -/// Transcribe an existing local audio file through CodeScribe's shared Whisper +/// Transcribe an existing local audio file through Codescribe's shared Whisper /// singleton. Non-embedded builds need the normal runtime model fallback to be /// available, typically via `CODESCRIBE_MODEL_PATH`. pub fn register(registry: &mut ToolRegistry) { diff --git a/app/agent/tools/workspace.rs b/app/agent/tools/workspace.rs new file mode 100644 index 00000000..dc581f64 --- /dev/null +++ b/app/agent/tools/workspace.rs @@ -0,0 +1,251 @@ +//! `list_projects` native tool + workspace-root resolution shared with the agent +//! system prompt. +//! +//! The agent must resolve a project *name* ("vista") to an absolute path before +//! calling path-hungry tools (prview / loctree / vc_*). Without this it guesses +//! (`~/vista`, `~/dev/vista`, …) and misses the operator's convention that repos +//! live under `~/Git`. This tool enumerates the git checkouts under the +//! configured workspace roots so the model never has to guess. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; +use serde_json::{Value, json}; + +/// Built-in default workspace root when `AGENT_WORKSPACE_ROOTS` is unset. Mirrors +/// the bridge default (`bridge/src/config.rs`) and the operator convention that +/// repositories live under `~/Git`. +const DEFAULT_WORKSPACE_ROOT: &str = "~/Git"; + +/// Upper bound on returned repositories — keeps the tool result bounded even if a +/// root holds hundreds of checkouts. +const MAX_PROJECTS: usize = 100; + +pub fn register(registry: &mut ToolRegistry) { + registry + .register( + list_projects_definition(), + Box::new(|input| Box::pin(handle_list_projects(input))), + ) + .expect("register list_projects tool"); +} + +fn list_projects_definition() -> ToolDefinition { + ToolDefinition { + name: "list_projects".to_string(), + description: + "List the user's local project repositories (git checkouts) under the configured \ + workspace roots. Use this to resolve a project name to an absolute path before \ + calling tools that need a repo path (prview, loctree, vc_*)." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + } +} + +async fn handle_list_projects(_input: Value) -> Vec { + let roots = resolved_roots(); + let projects = scan_projects(&roots, MAX_PROJECTS); + let payload = json!({ + "roots": roots + .iter() + .map(|root| root.display().to_string()) + .collect::>(), + "count": projects.len(), + "projects": projects + .iter() + .map(|project| json!({ "name": project.name, "path": project.path })) + .collect::>(), + }); + match serde_json::to_string_pretty(&payload) { + Ok(text) => vec![ToolResultContent::Text(text)], + Err(error) => vec![ToolResultContent::Error(error.to_string())], + } +} + +/// A resolved local project repository. +struct Project { + name: String, + path: String, +} + +/// Configured workspace roots parsed from `AGENT_WORKSPACE_ROOTS` +/// (colon-separated, PATH-style), falling back to the built-in default. Returns +/// raw, unexpanded strings (tilde left intact) so they can be shown verbatim in +/// the system prompt. +pub(crate) fn configured_roots() -> Vec { + let roots: Vec = env::var("AGENT_WORKSPACE_ROOTS") + .ok() + .map(|value| { + value + .split(':') + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect() + }) + .unwrap_or_default(); + + if roots.is_empty() { + vec![DEFAULT_WORKSPACE_ROOT.to_string()] + } else { + roots + } +} + +/// Expanded, absolute workspace roots (tilde → `$HOME`). +fn resolved_roots() -> Vec { + configured_roots() + .iter() + .map(|root| expand_tilde(root)) + .collect() +} + +/// Expand a leading `~` / `~/` to `$HOME`. Non-tilde paths pass through unchanged. +fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } else if path == "~" + && let Ok(home) = env::var("HOME") + { + return PathBuf::from(home); + } + PathBuf::from(path) +} + +/// Scan each root one level deep and collect directories that are git checkouts +/// (contain a `.git` entry — dir OR file, to catch worktrees/submodules). No +/// recursion; bounded by `limit`; duplicate paths across roots are de-duped. +fn scan_projects(roots: &[PathBuf], limit: usize) -> Vec { + let mut projects = Vec::new(); + let mut seen = HashSet::new(); + + for root in roots { + let Ok(entries) = fs::read_dir(root) else { + // Missing / unreadable root is not an error — just contributes nothing. + continue; + }; + + let mut dirs: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + dirs.sort(); + + for dir in dirs { + if projects.len() >= limit { + return projects; + } + if !is_git_checkout(&dir) { + continue; + } + let path = dir.display().to_string(); + if !seen.insert(path.clone()) { + continue; + } + let name = dir + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.clone()); + projects.push(Project { name, path }); + } + } + + projects +} + +/// A directory is a git checkout when it holds a `.git` entry (a directory for a +/// normal clone, or a file for a worktree/submodule gitlink). +fn is_git_checkout(dir: &Path) -> bool { + dir.join(".git").exists() +} + +/// A concise workspace section for the agent system prompt: the configured roots +/// plus the instruction to resolve names via `list_projects` instead of guessing +/// filesystem paths. +pub fn workspace_prompt_section() -> String { + let roots = configured_roots().join(", "); + format!( + "WORKSPACE\n\ + The user's local project repositories live under: {roots}.\n\ + Before calling any tool that needs a repository path (prview, loctree, vc_*), \ + call `list_projects` to resolve a project name to its absolute path. \ + Do not guess or invent filesystem paths." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn scan_finds_only_git_checkouts_one_level_deep() { + let tmp = TempDir::new().expect("tempdir"); + let root = tmp.path(); + + // repo A: classic clone (.git directory) + fs::create_dir_all(root.join("alpha").join(".git")).unwrap(); + // repo B: worktree/submodule style (.git file) + fs::create_dir_all(root.join("beta")).unwrap(); + fs::write(root.join("beta").join(".git"), "gitdir: /elsewhere").unwrap(); + // plain dir without .git → skipped + fs::create_dir_all(root.join("plain")).unwrap(); + // repo nested TWO levels deep → must NOT be found (no recursion) + fs::create_dir_all(root.join("plain").join("nested").join(".git")).unwrap(); + + let projects = scan_projects(&[root.to_path_buf()], 100); + let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "beta"]); + assert!( + projects + .iter() + .all(|p| p.path.starts_with(root.to_str().unwrap())) + ); + } + + #[test] + fn scan_respects_limit() { + let tmp = TempDir::new().expect("tempdir"); + let root = tmp.path(); + for i in 0..5 { + fs::create_dir_all(root.join(format!("repo{i}")).join(".git")).unwrap(); + } + let projects = scan_projects(&[root.to_path_buf()], 3); + assert_eq!(projects.len(), 3); + } + + #[test] + fn scan_skips_missing_root_without_error() { + let projects = scan_projects(&[PathBuf::from("/nonexistent/xyzzy-workspace")], 100); + assert!(projects.is_empty()); + } + + #[test] + fn scan_dedupes_paths_across_overlapping_roots() { + let tmp = TempDir::new().expect("tempdir"); + let root = tmp.path(); + fs::create_dir_all(root.join("solo").join(".git")).unwrap(); + + // Same root listed twice: the checkout must appear once. + let projects = scan_projects(&[root.to_path_buf(), root.to_path_buf()], 100); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].name, "solo"); + } + + #[test] + fn expand_tilde_replaces_home_prefix() { + if let Ok(home) = env::var("HOME") { + assert_eq!(expand_tilde("~/Git"), PathBuf::from(&home).join("Git")); + assert_eq!(expand_tilde("~"), PathBuf::from(&home)); + } + // Non-tilde absolute path passes through untouched. + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); + } +} diff --git a/app/agent_delivery.rs b/app/agent_delivery.rs new file mode 100644 index 00000000..28fd41cc --- /dev/null +++ b/app/agent_delivery.rs @@ -0,0 +1,166 @@ +//! Voice-assistive agent delivery broadcast. +//! +//! The hotkey / voice-assistive send path streams the agent reply as +//! [`codescribe_core::agent::AgentUiEvent`]s inside +//! [`crate::controller::helpers`]'s `apply_agent_ui_event`. That drain runs in +//! the `codescribe` app crate, which sits *below* the `codescribe-ffi` bridge in +//! the dependency graph (`bridge -> codescribe -> codescribe-core`). It therefore +//! cannot call the Swift-facing UniFFI listener directly. +//! +//! This module owns a process-global `tokio::sync::broadcast` channel that the +//! app publishes turn / delta / tool / done events into; the bridge subscribes +//! and forwards each event onto a `CsAgentDeliveryListener`. It is the exact +//! mirror of the transcription forwarder (`bridge::hotkeys::spawn_event_forwarder` +//! over `RecordingController::subscribe_events`) — same broadcast + `Lagged`/ +//! `Closed` handling — but semantically dedicated to AgentChat so it never mixes +//! with the overlay/dictation `CsTranscriptionListener` stream. +//! +//! Legacy AppKit overlay delivery removed the sink that used to render these +//! events; this broadcast is its replacement. Persistence to disk still happens +//! in `apply`'s caller (`persist_runtime_thread`); this channel only carries the +//! *live* render. + +use std::sync::OnceLock; + +use tokio::sync::broadcast; + +/// Broadcast channel capacity. Sized like the transcription IPC channel (256): a +/// burst of token deltas during a fast reply must not lag a slow consumer into a +/// permanent tear-down. `Lagged` is handled gracefully on the bridge forwarder. +const AGENT_DELIVERY_CHANNEL_CAPACITY: usize = 256; + +/// One voice-assistive agent delivery event. Provider-agnostic; mirrors the +/// subset of `AgentUiEvent` the chat UI renders, plus a [`TurnStarted`] opener +/// that carries the correlation `thread_id` and the user's transcript so the UI +/// can open a fresh You-bubble + assistant placeholder before the reply streams. +/// +/// [`TurnStarted`]: AgentDeliveryEvent::TurnStarted +#[derive(Debug, Clone, PartialEq)] +pub enum AgentDeliveryEvent { + /// A new voice turn began. `thread_id` is the core runtime's + /// `thread_store_id` (the persistence id, disjoint from the SwiftUI store's + /// per-thread `UUID`); `user_text` is the finalized transcript sent to the + /// agent. + TurnStarted { + thread_id: String, + user_text: String, + }, + TextDelta(String), + TextDone(String), + ReasoningDelta(String), + ToolExecuting { + name: String, + id: String, + }, + ToolResult { + name: String, + id: String, + summary: String, + is_error: bool, + }, + Done, + Error(String), +} + +static AGENT_DELIVERY_TX: OnceLock> = OnceLock::new(); + +fn sender() -> &'static broadcast::Sender { + AGENT_DELIVERY_TX.get_or_init(|| broadcast::channel(AGENT_DELIVERY_CHANNEL_CAPACITY).0) +} + +/// Publish a delivery event to all subscribers. Lock-free and non-blocking. +/// +/// A send with no live subscribers returns `Err` (the event is dropped) — that +/// is expected and intentionally ignored: the reply is still persisted to disk +/// by the send path regardless of whether a UI is currently listening. +pub fn publish_agent_delivery_event(event: AgentDeliveryEvent) { + let _ = sender().send(event); +} + +/// Subscribe to the voice-assistive agent delivery stream. The bridge calls this +/// once at startup and spawns a forwarder task that translates each event onto +/// the registered `CsAgentDeliveryListener`. +pub fn subscribe_agent_delivery() -> broadcast::Receiver { + sender().subscribe() +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::broadcast::error::RecvError; + + /// Receive on `rx` until `matches(event)` holds, skipping any interleaved + /// events from other tests that share this process-global channel. Bounded so + /// a missing event fails the test instead of hanging. + async fn recv_until( + rx: &mut broadcast::Receiver, + mut matches: impl FnMut(&AgentDeliveryEvent) -> bool, + ) -> AgentDeliveryEvent { + for _ in 0..1024 { + match rx.recv().await { + Ok(event) if matches(&event) => return event, + Ok(_) => continue, + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => panic!("delivery channel closed unexpectedly"), + } + } + panic!("expected event never arrived on the delivery channel"); + } + + #[tokio::test] + async fn published_turn_started_reaches_a_subscriber() { + // Unique thread id so a concurrent test on the shared global channel can + // never satisfy this test's matcher. + let thread_id = "t_turn_started_reaches_subscriber".to_string(); + let mut rx = subscribe_agent_delivery(); + publish_agent_delivery_event(AgentDeliveryEvent::TurnStarted { + thread_id: thread_id.clone(), + user_text: "hello".to_string(), + }); + let received = recv_until(&mut rx, |event| { + matches!(event, AgentDeliveryEvent::TurnStarted { thread_id: t, .. } if *t == thread_id) + }) + .await; + assert_eq!( + received, + AgentDeliveryEvent::TurnStarted { + thread_id, + user_text: "hello".to_string(), + } + ); + } + + #[tokio::test] + async fn delta_then_done_preserve_sender_order() { + // A single sender guarantees per-channel FIFO for its own events even + // when other tests interleave; assert order across a uniquely-tagged + // pair by filtering to just this test's markers. + let tag = "delta_then_done_preserve_sender_order"; + let delta = format!("{tag}:delta"); + let mut rx = subscribe_agent_delivery(); + publish_agent_delivery_event(AgentDeliveryEvent::TextDelta(delta.clone())); + publish_agent_delivery_event(AgentDeliveryEvent::Error(tag.to_string())); + + let first = recv_until(&mut rx, |event| { + matches!(event, AgentDeliveryEvent::TextDelta(s) if *s == delta) + || matches!(event, AgentDeliveryEvent::Error(s) if s == tag) + }) + .await; + assert_eq!(first, AgentDeliveryEvent::TextDelta(delta)); + let second = recv_until( + &mut rx, + |event| matches!(event, AgentDeliveryEvent::Error(s) if s == tag), + ) + .await; + assert_eq!(second, AgentDeliveryEvent::Error(tag.to_string())); + } + + #[test] + fn publish_without_subscriber_is_silent() { + // No subscriber attached: the send returns Err internally but the public + // API must not panic or block (the disk persist path owns durability). + publish_agent_delivery_event(AgentDeliveryEvent::Error( + "publish_without_subscriber_is_silent".to_string(), + )); + } +} diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index c6248ff4..391b0a36 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -2,28 +2,29 @@ //! //! Session state management and utility functions. -use chrono::Utc; +use chrono::{DateTime, Utc}; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; -use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc}; -use tracing::{debug, warn}; +use tokio::sync::{Mutex as TokioMutex, mpsc}; +use tracing::{debug, info, warn}; -use crate::config::{Config, default_assistive_model}; +use crate::agent_delivery::AgentDeliveryEvent; +use crate::config::default_assistive_model; use anyhow::{Context, Result}; use codescribe_core::agent::{ - AgentSession, AgentUiEvent, ContentBlock, Message, Role, StreamOptions, Thread, ThreadMessage, - ThreadStore, ToolRegistry, + AgentSession, AgentUiEvent, ContentBlock, ImageAttachment, Message, Role, StreamOptions, + Thread, ThreadMessage, ThreadStore, ToolRegistry, }; -use serde_json::json; +use serde_json::{Value, json}; + +use crate::os::tray_status; /// Global flag for current session mode. /// true = assistive (chat UI), false = non-assistive (simple transcription overlay) /// This is set before recording starts and checked by the delta callback. static IS_ASSISTIVE_SESSION: AtomicBool = AtomicBool::new(false); -const CHAT_FINAL_FALLBACK_CHARS_PER_TICK: usize = 24; -const CHAT_FINAL_FALLBACK_TICK_MS: u64 = 18; /// Global flag for conversation mode (full-duplex Moshi). /// When true, audio is routed to ConversationEngine instead of Whisper. @@ -32,6 +33,7 @@ static IS_CONVERSATION_SESSION: AtomicBool = AtomicBool::new(false); /// Set the current session mode (called before recording starts) pub fn set_assistive_session(is_assistive: bool) { IS_ASSISTIVE_SESSION.store(is_assistive, Ordering::SeqCst); + tray_status::set_tray_assistive_session(is_assistive); } /// Check if current session is assistive mode @@ -49,27 +51,16 @@ pub fn is_conversation_session() -> bool { IS_CONVERSATION_SESSION.load(Ordering::SeqCst) } -fn transcription_overlay_enabled() -> bool { - std::env::var("TRANSCRIPTION_OVERLAY_ENABLED") - .ok() - .map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) - .unwrap_or(true) -} - /// Route transcription delta to the active overlay. /// /// Contract: /// - Assistive sessions stream into Agent overlay chat bubbles. -/// - Non-assistive sessions stream into Dictation/Transcription overlay preview. +/// - Non-assistive sessions publish engine events over IPC/FFI for the Swift overlay. /// - `delta` must already follow `TranscriptDelta` backspace semantics. /// This function must never receive full preview snapshots. -pub fn route_transcription_delta(delta: &str) { - if is_assistive_session() { - crate::ui::voice_chat::append_voice_chat_user_delta(delta); - } else if transcription_overlay_enabled() { - // Non-assistive: live dictation preview in ephemeral overlay - crate::ui::overlay::append_transcription_delta(delta); - } +pub fn route_transcription_delta(_delta: &str) { + // Legacy AppKit overlay delivery removed. Assistive deltas reach SwiftUI via + // the engine event broadcast (see IpcBroadcastSink / subscribe_events). } /// DeltaSink that routes deltas to the active UI overlay. @@ -89,9 +80,6 @@ static AGENT_THREAD_GENERATION: AtomicU64 = AtomicU64::new(1); static AGENT_SEND_IN_FLIGHT_COUNT: AtomicUsize = AtomicUsize::new(0); static SHARED_AGENT_RUNTIME_STATE: OnceLock>>>> = OnceLock::new(); -const RUNTIME_DEGRADED_REASON: &str = "Legacy formatter fallback is active."; -const RUNTIME_RECOVERED_MESSAGE: &str = - "Agent runtime recovered. Native agent pipeline is active again."; const CODESCRIBE_ASSISTIVE_LEGACY_BACKUP_ENV: &str = "CODESCRIBE_ASSISTIVE_LEGACY_TRANSCRIPT_BACKUP"; @@ -108,12 +96,6 @@ struct AgentRuntimeState { runtime_degraded: bool, } -#[derive(Default)] -struct AgentUiOverlayState { - streamed_any_delta: bool, - saw_reasoning_delta: bool, -} - struct AgentSendInFlightGuard; impl AgentSendInFlightGuard { @@ -251,13 +233,6 @@ fn shared_agent_runtime_state_slot() -> &'static StdMutex>) { - let mut guard = shared_agent_runtime_state_slot() - .lock() - .unwrap_or_else(|e| e.into_inner()); - *guard = Some(runtime_state); -} - fn shared_agent_runtime_state() -> Arc> { let mut guard = shared_agent_runtime_state_slot() .lock() @@ -285,26 +260,9 @@ pub(crate) async fn reset_agent_runtime_for_new_thread() -> Result { let runtime_state = shared_agent_runtime_state(); let mut guard = runtime_state.lock().await; - match guard.rotate_for_new_thread_with( - generation, - initialize_agent_runtime, - persist_runtime_thread, - ) { - Ok(persisted_previous) => { - crate::ui::voice_chat::set_voice_chat_runtime_degraded(false, None); - if persisted_previous { - crate::ui::voice_chat::refresh_drawer(); - } - Ok(generation) - } - Err(error) => { - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RUNTIME_DEGRADED_REASON), - ); - Err(error) - } - } + guard + .rotate_for_new_thread_with(generation, initialize_agent_runtime, persist_runtime_thread) + .map(|_| generation) } fn initialize_agent_runtime() -> Result { @@ -323,59 +281,6 @@ fn initialize_agent_runtime() -> Result { }) } -fn restore_thread_into_agent_runtime(mut runtime: AgentRuntime, thread: &Thread) -> AgentRuntime { - runtime.thread_store_id = thread.id.clone(); - runtime.session.restore_messages( - thread - .messages - .iter() - .map(ThreadMessage::to_message) - .collect(), - ); - runtime -} - -fn initialize_agent_runtime_from_thread(thread: &Thread) -> Result { - let runtime = initialize_agent_runtime()?; - let runtime = restore_thread_into_agent_runtime(runtime, thread); - Ok(runtime) -} - -pub(crate) async fn restore_agent_runtime_from_thread(thread: Thread) -> Result<()> { - let generation = request_new_agent_thread_boundary(); - let runtime_state = shared_agent_runtime_state(); - let mut guard = runtime_state.lock().await; - - if let Some(previous_runtime) = guard - .runtime - .as_ref() - .filter(|runtime| !runtime.session.messages().is_empty()) - && let Err(error) = persist_runtime_thread(previous_runtime) - { - warn!("Failed to persist previous Agent runtime before restore: {error}"); - } - - match initialize_agent_runtime_from_thread(&thread) { - Ok(runtime) => { - guard.runtime_generation = generation; - guard.runtime = Some(runtime); - guard.runtime_degraded = false; - crate::ui::voice_chat::set_voice_chat_runtime_degraded(false, None); - Ok(()) - } - Err(error) => { - guard.runtime_generation = generation; - guard.runtime = None; - guard.runtime_degraded = true; - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RUNTIME_DEGRADED_REASON), - ); - Err(error).context("Failed to initialize Agent runtime for restored thread") - } - } -} - fn build_agent_stream_options(ai_assistive_max_tokens: i32) -> StreamOptions { let max_tokens = u32::try_from(ai_assistive_max_tokens) .ok() @@ -391,7 +296,7 @@ fn build_agent_stream_options(ai_assistive_max_tokens: i32) -> StreamOptions { StreamOptions { model, - system_prompt: Some(crate::config::get_assistive_prompt()), + system_prompt: Some(compose_agent_system_prompt()), max_tokens, temperature: None, // First-attempt default: preserve conversational chain. Session retry @@ -400,82 +305,159 @@ fn build_agent_stream_options(ai_assistive_max_tokens: i32) -> StreamOptions { } } -fn chunk_text_for_local_chat_stream(text: &str, max_chars: usize) -> Vec { - let max_chars = max_chars.max(1); - let mut chunks = Vec::new(); - let mut current = String::new(); +/// Compose the agent system prompt: the base assistive prompt, the workspace +/// section that pins the configured project roots and tells the model to resolve +/// project names via `list_projects` instead of guessing filesystem paths, and +/// the review-tool + connector doctrine that governs long-running MCP review +/// calls and GitHub-connector fallback. +fn compose_agent_system_prompt() -> String { + let base = crate::config::get_assistive_prompt(); + let workspace = crate::agent::tools::workspace::workspace_prompt_section(); + let doctrine = crate::agent::tools::doctrine::review_doctrine_prompt_section(); + format!("{base}\n\n{workspace}\n\n{doctrine}") +} - for ch in text.chars() { - current.push(ch); - if current.chars().count() >= max_chars { - chunks.push(std::mem::take(&mut current)); +/// Title-case a `snake_case` / `kebab-case` identifier into readable words. +/// `brave_web_search` -> `Brave Web Search`. +fn prettify_identifier(s: &str) -> String { + let cleaned = s.replace(['_', '-'], " "); + let mut out = String::with_capacity(cleaned.len()); + for (i, word) in cleaned.split_whitespace().enumerate() { + if i > 0 { + out.push(' '); + } + let mut chars = word.chars(); + if let Some(first) = chars.next() { + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); } } + if out.is_empty() { s.to_string() } else { out } +} - if !current.is_empty() { - chunks.push(current); +/// Map a raw tool identifier (often `mcp____`) to a concise, +/// human-readable label for the conversation timeline. +/// +/// Collapsible Tool Evidence: raw MCP wire names like +/// `mcp__brave-search__brave_web_search` are transport noise in a conversation — +/// the user wants to read "Web search", not the addressing scheme. This is a pure +/// function so the mapping is unit-testable without a running UI. +pub(crate) fn friendly_tool_name(raw: &str) -> String { + match raw { + "mcp__brave-search__brave_web_search" | "brave_web_search" => return "Web search".into(), + "mcp__brave-search__brave_local_search" | "brave_local_search" => { + return "Local search".into(); + } + "mcp__brave-search__brave_news_search" | "brave_news_search" => { + return "News search".into(); + } + "mcp__brave-search__brave_image_search" | "brave_image_search" => { + return "Image search".into(); + } + "mcp__brave-search__brave_video_search" | "brave_video_search" => { + return "Video search".into(); + } + "mcp__brave-search__brave_summarizer" | "brave_summarizer" => return "Summarize".into(), + // Structural / intent / fleet MCP surfaces the operator named explicitly: + // the generic `mcp__` fallback would read "Context · Loctree mcp", which is + // both reversed and noisy. Pin the exact human labels here. + "mcp__loctree-mcp__context" => return "Loctree context".into(), + "mcp__loctree-mcp__find" => return "Loctree occurrences/find".into(), + "mcp__aicx-mcp__aicx_intents" => return "AICX intents".into(), + "mcp__vibecrafted-mcp__vc_run_observe" => return "Vibecrafted observe".into(), + // Native (non-mcp) tools: the bare snake_case prettifies to a reversed, + // verbose label ("Read Clipboard"); the operator wants noun-first copy. + "read_clipboard" => return "Clipboard read".into(), + "write_clipboard" => return "Clipboard write".into(), + "take_screenshot" => return "Screenshot".into(), + "transcribe_audio" => return "Audio transcription".into(), + _ => {} } - - chunks + if let Some(rest) = raw.strip_prefix("mcp__") { + let mut parts = rest.splitn(2, "__"); + let server = parts.next().unwrap_or(""); + let tool = parts.next().unwrap_or(server); + // Trailing `__` with no tool segment (e.g. `mcp__github__`) yields an + // empty `tool`. Without this guard the formatter below emits a dangling + // " · Github" — the separator with nothing in front of it. Fall back to + // the bare server label; if even the server is empty (`mcp__`), prettify + // the raw rather than returning an empty string. + if tool.is_empty() { + return if server.is_empty() { + prettify_identifier(raw) + } else { + prettify_identifier(server) + }; + } + let tool_pretty = prettify_identifier(tool); + if server.is_empty() || tool == server { + return tool_pretty; + } + return format!("{tool_pretty} · {}", prettify_identifier(server)); + } + prettify_identifier(raw) } -async fn stream_final_text_to_chat_locally(text: &str) { - for chunk in chunk_text_for_local_chat_stream(text, CHAT_FINAL_FALLBACK_CHARS_PER_TICK) { - crate::ui::voice_chat::append_voice_chat_assistant_delta(&chunk); - tokio::time::sleep(std::time::Duration::from_millis( - CHAT_FINAL_FALLBACK_TICK_MS, - )) - .await; +/// Translate a core `AgentUiEvent` into the voice-assistive delivery event the +/// bridge forwards to the SwiftUI AgentChat. 1:1 field mapping — the two enums +/// deliberately share the same shape so the Swift listener is symmetric to the +/// composer's `CsAgentListener`. +fn agent_ui_event_to_delivery(event: &AgentUiEvent) -> AgentDeliveryEvent { + match event { + AgentUiEvent::TextDelta(delta) => AgentDeliveryEvent::TextDelta(delta.clone()), + AgentUiEvent::TextDone(text) => AgentDeliveryEvent::TextDone(text.clone()), + AgentUiEvent::ReasoningDelta(delta) => AgentDeliveryEvent::ReasoningDelta(delta.clone()), + AgentUiEvent::ToolExecuting { name, id } => AgentDeliveryEvent::ToolExecuting { + name: name.clone(), + id: id.clone(), + }, + AgentUiEvent::ToolResult { + name, + id, + summary, + is_error, + } => AgentDeliveryEvent::ToolResult { + name: name.clone(), + id: id.clone(), + summary: summary.clone(), + is_error: *is_error, + }, + AgentUiEvent::Done => AgentDeliveryEvent::Done, + AgentUiEvent::Error(message) => AgentDeliveryEvent::Error(message.clone()), } } -async fn apply_agent_ui_event(event: AgentUiEvent, overlay_state: &mut AgentUiOverlayState) { +/// Drain a single agent UI event. +/// +/// Voice-assistive delivery: each event is published to the process-global +/// delivery broadcast (`crate::agent_delivery`) so the bridge can forward it onto +/// the SwiftUI AgentChat listener — this replaces the removed legacy AppKit +/// overlay sink. Consuming `ui_rx` here is also what advances `AgentSession::send` +/// to completion (the channel is bounded). Debug logging of tool activity stays; +/// disk persistence still happens in `run_agent_send_path` after the drain. +async fn apply_agent_ui_event(event: AgentUiEvent) { + crate::agent_delivery::publish_agent_delivery_event(agent_ui_event_to_delivery(&event)); match event { - AgentUiEvent::TextDelta(delta) => { - if delta.is_empty() { - return; - } - if !overlay_state.streamed_any_delta && !delta.trim().is_empty() { - crate::ui::voice_chat::update_voice_chat_status("Answering... (80%)"); - } - overlay_state.streamed_any_delta = true; - crate::ui::voice_chat::append_voice_chat_assistant_delta(&delta); - } - AgentUiEvent::TextDone(text) => { - if !overlay_state.streamed_any_delta && !text.trim().is_empty() { - overlay_state.streamed_any_delta = true; - crate::ui::voice_chat::update_voice_chat_status("Answering... (final stream)"); - stream_final_text_to_chat_locally(&text).await; - } - } - AgentUiEvent::ReasoningDelta(delta) => { - if delta.trim().is_empty() { - return; - } - if !overlay_state.saw_reasoning_delta { - crate::ui::voice_chat::update_voice_chat_status("Reasoning... (60%)"); - overlay_state.saw_reasoning_delta = true; - } - // Surface the model's live reasoning instead of dropping it on the floor - // (this is what left the overlay silent). Reuses the proven append path. - crate::ui::voice_chat::append_voice_chat_reasoning_delta(&delta); - } + AgentUiEvent::TextDelta(_) + | AgentUiEvent::TextDone(_) + | AgentUiEvent::ReasoningDelta(_) + | AgentUiEvent::Done => {} AgentUiEvent::ToolExecuting { name, .. } => { - crate::ui::voice_chat::update_voice_chat_status(&format!("Tool running: {name}")); - crate::ui::voice_chat::add_voice_chat_system_message(&format!( - "Tool call started: {name}" - )); + debug!("Tool executing: {name} -> {}", friendly_tool_name(&name)); } - AgentUiEvent::ToolResult { name, summary, .. } => { - crate::ui::voice_chat::update_voice_chat_status("Thinking... (70%)"); - crate::ui::voice_chat::add_voice_chat_system_message(&format!( - "Tool call finished: {name} ({summary})" - )); + AgentUiEvent::ToolResult { + name, + summary, + is_error, + .. + } => { + debug!( + "Tool result: {name} -> {} | is_error={is_error} | raw summary: {summary}", + friendly_tool_name(&name) + ); } - AgentUiEvent::Done => {} AgentUiEvent::Error(message) => { - crate::ui::voice_chat::update_voice_chat_status("Agent runtime failed"); - crate::ui::voice_chat::add_voice_chat_error_message(&message); + warn!("Agent runtime UI error event: {message}"); } } } @@ -513,11 +495,11 @@ fn derive_thread_title(messages: &[Message]) -> String { .iter() .find(|message| message.role == Role::User) .and_then(extract_text_from_message) - .unwrap_or_else(|| "CodeScribe Agent Chat".to_string()); + .unwrap_or_else(|| "Codescribe Agent Chat".to_string()); let mut title = candidate.chars().take(72).collect::(); if title.is_empty() { - title = "CodeScribe Agent Chat".to_string(); + title = "Codescribe Agent Chat".to_string(); } title } @@ -546,6 +528,28 @@ fn normalize_assistive_thread_text(text: &str) -> Option { } } +fn legacy_assistive_thread_messages( + user_text: String, + assistant_text: String, + now: DateTime, + metadata: Option, +) -> Vec { + vec![ + ThreadMessage { + role: "user".to_string(), + content: vec![json!({"type":"text","text":user_text})], + timestamp: now, + metadata: metadata.clone(), + }, + ThreadMessage { + role: "assistant".to_string(), + content: vec![json!({"type":"text","text":assistant_text})], + timestamp: now, + metadata, + }, + ] +} + fn persist_runtime_thread(runtime: &AgentRuntime) -> Result<()> { let store = ThreadStore::new().context("Failed to initialize ThreadStore")?; let now = Utc::now(); @@ -557,7 +561,8 @@ fn persist_runtime_thread(runtime: &AgentRuntime) -> Result<()> { id: runtime.thread_store_id.clone(), created_at: now, updated_at: now, - title: "CodeScribe Agent Chat".to_string(), + title: "Codescribe Agent Chat".to_string(), + title_is_custom: false, mode: "assistive".to_string(), tags: vec!["agent".to_string(), "overlay".to_string()], notes: Vec::new(), @@ -569,7 +574,9 @@ fn persist_runtime_thread(runtime: &AgentRuntime) -> Result<()> { }); thread.updated_at = now; - thread.title = derive_thread_title(runtime.session.messages()); + if !thread.title_is_custom { + thread.title = derive_thread_title(runtime.session.messages()); + } thread.summary = derive_thread_summary(runtime.session.messages()); thread.messages = runtime .session @@ -600,7 +607,7 @@ fn persist_legacy_assistive_thread(user_text: &str, assistant_text: &str) -> Res let mut title = user_text.chars().take(72).collect::(); if title.is_empty() { - title = "CodeScribe Agent Chat".to_string(); + title = "Codescribe Agent Chat".to_string(); } let mut summary = assistant_text.chars().take(240).collect::(); if summary.is_empty() { @@ -613,6 +620,7 @@ fn persist_legacy_assistive_thread(user_text: &str, assistant_text: &str) -> Res created_at: now, updated_at: now, title, + title_is_custom: false, mode: "assistive".to_string(), tags: vec![ "agent".to_string(), @@ -620,20 +628,7 @@ fn persist_legacy_assistive_thread(user_text: &str, assistant_text: &str) -> Res "fallback".to_string(), ], notes: Vec::new(), - messages: vec![ - ThreadMessage { - role: "user".to_string(), - content: vec![json!({"type":"input_text","text":user_text})], - timestamp: now, - metadata: metadata.clone(), - }, - ThreadMessage { - role: "assistant".to_string(), - content: vec![json!({"type":"output_text","text":assistant_text})], - timestamp: now, - metadata, - }, - ], + messages: legacy_assistive_thread_messages(user_text, assistant_text, now, metadata), summary: Some(summary), total_tokens: None, provider: "legacy-formatter".to_string(), @@ -680,6 +675,72 @@ fn agent_send_error_is_transient(error: &anyhow::Error) -> bool { .any(|pattern| message.contains(pattern)) } +/// Maximum number of image attachments forwarded to the model per message. +/// Kept in sync with the legacy (`ai_formatting`) cap so both send paths behave +/// alike. Sized for real multi-image use (e.g. comparing several wireframes); +/// vision-capable backends accept far more, images are size-capped individually. +const MAX_AGENT_VISION_IMAGES: usize = 16; + +/// Split an outgoing payload into its visible text and the loaded image +/// attachments referenced by the `ATTACHMENTS (image paths)` marker. +/// +/// This is the fix for the attachment pipeline: the voice-chat send path appends +/// image paths to the payload as *text* (`build_attachments_block`). Without this +/// step the agent path forwarded them as plain text and the model never received +/// real vision input. Here we strip the marker block from the text and load each +/// image as bytes so `AgentSession::send` can emit proper `input_image` blocks. +/// +/// Returns `(cleaned_text, loaded_images, dropped_names)`. `dropped_names` lists +/// images that could not be forwarded (missing/unreadable/too large) so the +/// caller can surface a visible attachment error instead of silently continuing. +fn build_image_attachments_from_text(text: &str) -> (String, Vec, Vec) { + let (cleaned, mut paths) = codescribe_core::attachment::parse_image_attachment_block(text); + + if paths.is_empty() { + return (cleaned, Vec::new(), Vec::new()); + } + + let mut dropped: Vec = Vec::new(); + + if paths.len() > MAX_AGENT_VISION_IMAGES { + for extra in &paths[MAX_AGENT_VISION_IMAGES..] { + dropped.push(file_label(extra)); + } + warn!( + "Too many image attachments ({}); forwarding first {} as vision input", + paths.len(), + MAX_AGENT_VISION_IMAGES + ); + paths.truncate(MAX_AGENT_VISION_IMAGES); + } + + let mut attachments = Vec::with_capacity(paths.len()); + for path in &paths { + match codescribe_core::attachment::load_image_for_vision( + path, + codescribe_core::attachment::MAX_VISION_IMAGE_BYTES, + ) { + Some((data, media_type)) => attachments.push(ImageAttachment { data, media_type }), + None => { + warn!( + "Dropping image attachment (unsupported, unreadable, or too large): {}", + path.display() + ); + dropped.push(file_label(path)); + } + } + } + + (cleaned, attachments, dropped) +} + +/// Short, user-facing label for an attachment path (file name, path fallback). +fn file_label(path: &std::path::Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()) +} + async fn run_agent_send_path( runtime_state: &mut AgentRuntimeState, runtime_generation: u64, @@ -691,22 +752,41 @@ async fn run_agent_send_path( Ok(state) => state, Err(error) => { runtime_state.mark_runtime_degraded(); - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RUNTIME_DEGRADED_REASON), - ); return Err(error).context("Agent runtime unavailable"); } }; - if recovered_from_degraded { - crate::ui::voice_chat::set_voice_chat_runtime_degraded(false, None); - crate::ui::voice_chat::add_voice_chat_system_message(RUNTIME_RECOVERED_MESSAGE); - } - let mut overlay_state = AgentUiOverlayState::default(); + let _ = recovered_from_degraded; let send_result = { + // Correlation id for the SwiftUI store (disjoint from its per-thread + // UUID). Captured before the mutable session/ui_rx split so the borrow of + // `runtime.thread_store_id` does not overlap the mutable field borrows. + let thread_store_id = runtime.thread_store_id.clone(); let (session, ui_rx) = (&mut runtime.session, &mut runtime.ui_rx); - let send_future = session.send(text, Vec::new(), &stream_options); + let (user_text, image_attachments, dropped_images) = + build_image_attachments_from_text(&text); + // Open the turn on the SwiftUI chat before streaming: the listener inserts + // a You-bubble (user_text) + assistant placeholder, then fills it from the + // deltas below. `user_text` is the attachment-marker-stripped transcript, + // so the bubble shows the spoken text, not the internal attachment block. + crate::agent_delivery::publish_agent_delivery_event(AgentDeliveryEvent::TurnStarted { + thread_id: thread_store_id, + user_text: user_text.clone(), + }); + if !image_attachments.is_empty() { + info!( + "Agent send: forwarding {} image(s) as vision input", + image_attachments.len() + ); + } + if !dropped_images.is_empty() { + warn!( + "Could not attach {} image(s) as vision input: {}", + dropped_images.len(), + dropped_images.join(", ") + ); + } + let send_future = session.send(user_text, image_attachments, &stream_options); tokio::pin!(send_future); let result = loop { @@ -714,7 +794,7 @@ async fn run_agent_send_path( result = &mut send_future => break result, maybe_event = ui_rx.recv() => { match maybe_event { - Some(event) => apply_agent_ui_event(event, &mut overlay_state).await, + Some(event) => apply_agent_ui_event(event).await, None => break Err(anyhow::anyhow!("Agent UI event channel closed")), } } @@ -722,7 +802,7 @@ async fn run_agent_send_path( }; while let Ok(event) = ui_rx.try_recv() { - apply_agent_ui_event(event, &mut overlay_state).await; + apply_agent_ui_event(event).await; } result @@ -730,25 +810,13 @@ async fn run_agent_send_path( match send_result { Ok(()) => { - crate::ui::voice_chat::update_voice_chat_status("AI Response:"); - if overlay_state.streamed_any_delta { - crate::ui::voice_chat::finalize_voice_chat_assistant_message(); - } if let Err(error) = persist_runtime_thread(runtime) { warn!("Failed to persist agent thread: {}", error); - } else { - crate::ui::voice_chat::refresh_drawer(); } - crate::ui::voice_chat::set_voice_chat_sending(false); Ok(()) } Err(error) => { - if overlay_state.streamed_any_delta { - crate::ui::voice_chat::finalize_voice_chat_assistant_message(); - } if !agent_send_error_allows_legacy_fallback(&error) { - crate::ui::voice_chat::set_voice_chat_sending(false); - crate::ui::voice_chat::update_voice_chat_status("Agent error"); return Ok(()); } // P1.7: distinguish a transient provider blip (conversation still @@ -759,68 +827,54 @@ async fn run_agent_send_path( } else { runtime_state.mark_runtime_degraded(); } - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RUNTIME_DEGRADED_REASON), - ); Err(error).context("AgentSession send failed") } } } +/// Map a legacy formatter result to the assistant text that should be +/// persisted, if any. +/// +/// A `Failed` status carries no real assistant content (previously it was +/// surfaced only as the "AI Failed" sentinel). A failed formatting attempt is +/// NOT a conversation and must not be persisted (operator decision +/// 2026-07-06): a dead API key would otherwise land a junk "AI Failed" thread +/// on disk for every retry, producing 3-4 duplicate garbage threads per +/// utterance. `Skipped` likewise has nothing to persist. Only genuine output +/// (`Applied` / `AiNoop`, i.e. partial or full success) is persisted, exactly +/// as before. +fn legacy_fallback_assistant_text( + status: crate::ai_formatting::AiFormatStatus, + text: String, +) -> Option { + use crate::ai_formatting::AiFormatStatus; + match status { + AiFormatStatus::Applied | AiFormatStatus::AiNoop => Some(text), + AiFormatStatus::Failed | AiFormatStatus::Skipped => None, + } +} + async fn run_legacy_send_path( text: &str, whisper_language: crate::config::Language, ) -> Option { - let use_streaming = true; - let streamed_any_delta = Arc::new(AtomicBool::new(false)); - - let delta_callback = if use_streaming { - let streamed_any_delta = Arc::clone(&streamed_any_delta); - Some(Arc::new(move |delta: &str| { - streamed_any_delta.store(true, Ordering::SeqCst); - crate::ui::voice_chat::append_voice_chat_assistant_delta(delta); - }) as Arc) - } else { - None - }; - let result = crate::ai_formatting::format_text_with_status_channels( text, - Some(whisper_language.as_str()), + whisper_language.whisper_hint(), true, - delta_callback, + None, None, ) .await; - match result.status { - crate::ai_formatting::AiFormatStatus::Applied - | crate::ai_formatting::AiFormatStatus::AiNoop => { - crate::ui::voice_chat::update_voice_chat_status("AI Response:"); - if use_streaming && streamed_any_delta.load(Ordering::SeqCst) { - crate::ui::voice_chat::finalize_voice_chat_assistant_message(); - } else { - crate::ui::voice_chat::set_voice_chat_text(&result.text); - } - if let Some(reasoning_text) = result.reasoning_text { - crate::ui::voice_chat::add_voice_chat_system_message(&format!( - "Reasoning summary:\n{}", - reasoning_text - )); - } - Some(result.text) - } - crate::ai_formatting::AiFormatStatus::Failed => { - crate::ui::voice_chat::update_voice_chat_status("AI Failed"); - crate::ui::voice_chat::add_voice_chat_error_message("AI Failed"); - Some("AI Failed".to_string()) - } - crate::ai_formatting::AiFormatStatus::Skipped => { - crate::ui::voice_chat::set_voice_chat_sending(false); - None - } + let status = result.status; + let assistant_text = legacy_fallback_assistant_text(status, result.text); + if assistant_text.is_none() && status == crate::ai_formatting::AiFormatStatus::Failed { + warn!( + "Legacy formatter failed; skipping thread persist for this attempt (a failed attempt is not a conversation)" + ); } + assistant_text } async fn run_agent_send_with_fallback( @@ -844,20 +898,11 @@ async fn run_agent_send_with_fallback( error ); debug!("Legacy fallback input length: {}", text.len()); - crate::ui::voice_chat::set_voice_chat_sending(true); - crate::ui::voice_chat::set_voice_chat_runtime_degraded(true, Some(RUNTIME_DEGRADED_REASON)); - crate::ui::voice_chat::update_voice_chat_status("Agent fallback active"); - crate::ui::voice_chat::add_voice_chat_system_message( - "Agent runtime unavailable. Using legacy formatter for this response.", - ); let fallback_assistant_text = run_legacy_send_path(&text, whisper_language).await; - crate::ui::voice_chat::set_voice_chat_sending(false); - if let Some(assistant_text) = fallback_assistant_text { - if let Err(error) = persist_legacy_assistive_thread(&text, &assistant_text) { - warn!("Failed to persist legacy assistive fallback thread: {error}"); - } else { - crate::ui::voice_chat::refresh_drawer(); - } + if let Some(assistant_text) = fallback_assistant_text + && let Err(error) = persist_legacy_assistive_thread(&text, &assistant_text) + { + warn!("Failed to persist legacy assistive fallback thread: {error}"); } } } @@ -877,63 +922,6 @@ pub(crate) async fn send_assistive_with_agent_runtime( .await; } -/// Setup the voice chat send callback with config -pub fn setup_voice_chat_send_callback(config: Arc>) { - let initial_generation = current_agent_thread_generation(); - let initial_runtime_state = match initialize_agent_runtime() { - Ok(runtime) => AgentRuntimeState { - runtime: Some(runtime), - runtime_generation: initial_generation, - runtime_degraded: false, - }, - Err(error) => { - warn!( - "Agent runtime init failed during callback setup; legacy fallback will be used until retry succeeds: {}", - error - ); - AgentRuntimeState { - runtime_generation: initial_generation, - runtime_degraded: true, - ..AgentRuntimeState::default() - } - } - }; - if initial_runtime_state.runtime_degraded { - crate::ui::voice_chat::set_voice_chat_runtime_degraded(true, Some(RUNTIME_DEGRADED_REASON)); - crate::ui::voice_chat::add_voice_chat_system_message( - "Agent runtime unavailable. Legacy formatter fallback is active until recovery.", - ); - } else { - crate::ui::voice_chat::set_voice_chat_runtime_degraded(false, None); - } - let runtime_state = Arc::new(TokioMutex::new(initial_runtime_state)); - set_shared_agent_runtime_state(Arc::clone(&runtime_state)); - - let callback_config = Arc::clone(&config); - let callback_runtime_state = Arc::clone(&runtime_state); - crate::ui::voice_chat::set_voice_chat_send_callback(Some(Arc::new(move |text: String| { - let config = Arc::clone(&callback_config); - let runtime_state = Arc::clone(&callback_runtime_state); - tokio::spawn(async move { - crate::ui::voice_chat::set_voice_chat_agent_thinking(true); - crate::ui::voice_chat::update_voice_chat_status("Thinking…"); - crate::ui::voice_chat::set_voice_chat_sending(true); - - let (whisper_language, ai_assistive_max_tokens) = { - let cfg = config.read().await; - (cfg.whisper_language, cfg.ai_assistive_max_tokens) - }; - run_agent_send_with_fallback( - &runtime_state, - text, - whisper_language, - ai_assistive_max_tokens, - ) - .await; - }); - }))); -} - /// Legacy transcript backup for assistive mode is opt-in. /// /// Non-assistive dictation keeps legacy transcript persistence unchanged. @@ -1086,12 +1074,145 @@ mod tests { use codescribe_core::agent::{AgentEvent, AgentProvider, ToolDefinition}; use std::sync::atomic::AtomicUsize; + // ── Collapsible Tool Evidence: friendly tool-name mapping ─────────────── + + #[test] + fn friendly_tool_name_maps_known_brave_tools() { + assert_eq!( + friendly_tool_name("mcp__brave-search__brave_web_search"), + "Web search" + ); + assert_eq!(friendly_tool_name("brave_web_search"), "Web search"); + assert_eq!( + friendly_tool_name("mcp__brave-search__brave_news_search"), + "News search" + ); + } + + #[test] + fn friendly_tool_name_prettifies_unknown_mcp_tools() { + // Unknown mcp__server__tool falls back to " · " — never the + // raw wire name in the conversation timeline. + assert_eq!( + friendly_tool_name("mcp__github__create_issue"), + "Create Issue · Github" + ); + // Bare snake_case identifier is title-cased. + assert_eq!(friendly_tool_name("read_file"), "Read File"); + // The raw mcp__ wire form must never survive verbatim. + assert!(!friendly_tool_name("mcp__github__create_issue").contains("mcp__")); + // Trailing `__` leaves an empty tool segment (`mcp__github__`). This must + // collapse to the bare server label — never a dangling " · Github" with + // the separator floating in front of nothing. + assert_eq!(friendly_tool_name("mcp__github__"), "Github"); + assert!(!friendly_tool_name("mcp__github__").contains('·')); + assert!(!friendly_tool_name("mcp__github__").starts_with(' ')); + // Fully degenerate `mcp__` (no server, no tool) must not yield an empty + // label either. + assert!(!friendly_tool_name("mcp__").is_empty()); + } + #[test] - fn local_chat_stream_chunks_preserve_unicode_and_order() { - let chunks = chunk_text_for_local_chat_stream("Zażółć gęślą jaźń", 4); + fn friendly_tool_name_honors_operator_label_table() { + // The operator's explicit raw→label table. Before this mapping these all + // fell into the generic `mcp__` / prettify fallback and read reversed or + // noisy (e.g. "Context · Loctree mcp", "Read Clipboard"). + assert_eq!( + friendly_tool_name("mcp__loctree-mcp__context"), + "Loctree context" + ); + assert_eq!( + friendly_tool_name("mcp__loctree-mcp__find"), + "Loctree occurrences/find" + ); + assert_eq!( + friendly_tool_name("mcp__aicx-mcp__aicx_intents"), + "AICX intents" + ); + assert_eq!( + friendly_tool_name("mcp__vibecrafted-mcp__vc_run_observe"), + "Vibecrafted observe" + ); + assert_eq!(friendly_tool_name("read_clipboard"), "Clipboard read"); + assert_eq!(friendly_tool_name("write_clipboard"), "Clipboard write"); + assert_eq!(friendly_tool_name("take_screenshot"), "Screenshot"); + assert_eq!( + friendly_tool_name("transcribe_audio"), + "Audio transcription" + ); + } + + #[test] + fn regression_sequence_raw_names_produce_expected_runtime_labels() { + // Operator regression scenario: the grouped block must show exactly these + // labels at runtime — not just in the pure-module test that hardcodes the + // display_name. This proves the controller maps the raw wire names the same + // way the timeline expects. + assert_eq!( + friendly_tool_name("mcp__brave-search__brave_web_search"), + "Web search" + ); + assert_eq!( + friendly_tool_name("mcp__loctree-mcp__context"), + "Loctree context" + ); + assert_eq!( + friendly_tool_name("mcp__aicx-mcp__aicx_intents"), + "AICX intents" + ); + } + + #[test] + fn legacy_assistive_thread_messages_use_canonical_text_blocks() { + let now = Utc::now(); + let metadata = Some(json!({"source":"legacy-fallback"})); + let messages = legacy_assistive_thread_messages( + "user prompt".to_string(), + "assistant reply".to_string(), + now, + metadata.clone(), + ); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, "user"); + assert_eq!(messages[0].content[0]["type"], "text"); + assert_eq!(messages[0].content[0]["text"], "user prompt"); + assert_eq!(messages[0].metadata, metadata); + assert_eq!(messages[1].role, "assistant"); + assert_eq!(messages[1].content[0]["type"], "text"); + assert_eq!(messages[1].content[0]["text"], "assistant reply"); + } + + #[test] + fn legacy_fallback_skips_persist_on_failed_status() { + use crate::ai_formatting::AiFormatStatus; + + // Failed: the formatter produced no real assistant content (dead API + // key -> "AI Failed"). Nothing to persist, so no thread is written and + // no messages are built. This is the regression guard for the ~12 junk + // "AI Failed" threads (incl. 3-4 duplicates per utterance) the operator + // saw after a dead key drove every retry through the legacy fallback. + assert_eq!( + legacy_fallback_assistant_text(AiFormatStatus::Failed, "AI Failed".to_string()), + None + ); + + // Skipped: also nothing to persist. + assert_eq!( + legacy_fallback_assistant_text(AiFormatStatus::Skipped, String::new()), + None + ); - assert!(chunks.iter().all(|chunk| chunk.chars().count() <= 4)); - assert_eq!(chunks.concat(), "Zażółć gęślą jaźń"); + // Applied / AiNoop: genuine output (partial or full success) is still + // persisted exactly as before. + assert_eq!( + legacy_fallback_assistant_text(AiFormatStatus::Applied, "formatted reply".to_string()), + Some("formatted reply".to_string()) + ); + assert_eq!( + legacy_fallback_assistant_text(AiFormatStatus::AiNoop, "verbatim reply".to_string()), + Some("verbatim reply".to_string()) + ); } struct NoopTestProvider; @@ -1578,54 +1699,6 @@ mod tests { assert_eq!(persist_calls.load(Ordering::SeqCst), 0); } - #[test] - fn test_restore_thread_into_runtime_keeps_thread_id_and_history() { - let now = Utc::now(); - let thread = Thread { - id: "thread_restored".to_string(), - created_at: now, - updated_at: now, - title: "Restored conversation".to_string(), - mode: "assistive".to_string(), - tags: Vec::new(), - notes: Vec::new(), - messages: vec![ - ThreadMessage { - role: "user".to_string(), - content: vec![serde_json::json!({"type":"text","text":"hello"})], - timestamp: now, - metadata: None, - }, - ThreadMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({"type":"text","text":"world"})], - timestamp: now, - metadata: None, - }, - ], - summary: None, - total_tokens: None, - provider: "test".to_string(), - model: "test-model".to_string(), - }; - - let runtime = - restore_thread_into_agent_runtime(runtime_with_thread_id("thread_old"), &thread); - - assert_eq!(runtime.thread_store_id, "thread_restored"); - assert_eq!(runtime.session.messages().len(), 2); - assert_eq!(runtime.session.messages()[0].role, Role::User); - assert_eq!(runtime.session.messages()[1].role, Role::Assistant); - assert!(matches!( - &runtime.session.messages()[0].content[0], - ContentBlock::Text(text) if text == "hello" - )); - assert!(matches!( - &runtime.session.messages()[1].content[0], - ContentBlock::Text(text) if text == "world" - )); - } - #[test] fn test_rotate_for_new_thread_marks_degraded_when_reinit_fails() { let mut runtime_state = AgentRuntimeState { @@ -1644,4 +1717,137 @@ mod tests { assert!(runtime_state.runtime_degraded); assert!(runtime_state.runtime.is_none()); } + + #[test] + fn test_build_image_attachments_passthrough_without_marker() { + let text = "plain message, no attachments"; + let (cleaned, images, dropped) = build_image_attachments_from_text(text); + assert_eq!(cleaned, text); + assert!(images.is_empty()); + assert!(dropped.is_empty()); + } + + #[test] + fn test_build_image_attachments_loads_real_image_and_reports_dropped() { + let dir = std::env::temp_dir().join(format!("cs_helpers_vision_{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let img = dir.join("shot.png"); + std::fs::write(&img, b"\x89PNG\r\n\x1a\nfake").unwrap(); + let missing = dir.join("gone.png"); + + let text = format!( + "describe these\n\n---\nATTACHMENTS (image paths)\n- {}\n- {}\n", + img.display(), + missing.display() + ); + let (cleaned, images, dropped) = build_image_attachments_from_text(&text); + + // Marker block and raw paths are gone from the model-visible text. + assert!(!cleaned.contains("ATTACHMENTS (image paths)")); + assert!(!cleaned.contains(&img.display().to_string())); + assert!(cleaned.contains("describe these")); + + // Only the readable image becomes a real vision attachment; the missing + // one is reported as dropped (visible error), never forwarded as text. + assert_eq!(images.len(), 1); + assert_eq!(images[0].media_type, "image/png"); + assert!(!images[0].data.is_empty()); + assert_eq!(dropped, vec!["gone.png".to_string()]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_build_image_attachments_caps_and_reports_overflow() { + let dir = std::env::temp_dir().join(format!("cs_helpers_cap_{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let mut lines = String::from("multi\n\nATTACHMENTS (image paths)\n"); + for i in 0..(MAX_AGENT_VISION_IMAGES + 2) { + let p = dir.join(format!("img{i}.png")); + std::fs::write(&p, b"\x89PNG\r\n\x1a\nfake").unwrap(); + lines.push_str(&format!("- {}\n", p.display())); + } + let (_cleaned, images, dropped) = build_image_attachments_from_text(&lines); + + // Cap honored, overflow surfaced (not silently dropped). + assert_eq!(images.len(), MAX_AGENT_VISION_IMAGES); + assert_eq!(dropped.len(), 2); + + let _ = std::fs::remove_dir_all(&dir); + } + + // ── Voice-assistive delivery: UI-event → delivery-event mapping + publish ── + + #[test] + fn agent_ui_event_maps_to_delivery_event_one_to_one() { + assert_eq!( + agent_ui_event_to_delivery(&AgentUiEvent::TextDelta("hi".into())), + AgentDeliveryEvent::TextDelta("hi".into()) + ); + assert_eq!( + agent_ui_event_to_delivery(&AgentUiEvent::TextDone("done".into())), + AgentDeliveryEvent::TextDone("done".into()) + ); + assert_eq!( + agent_ui_event_to_delivery(&AgentUiEvent::ReasoningDelta("r".into())), + AgentDeliveryEvent::ReasoningDelta("r".into()) + ); + assert_eq!( + agent_ui_event_to_delivery(&AgentUiEvent::ToolExecuting { + name: "grep".into(), + id: "1".into(), + }), + AgentDeliveryEvent::ToolExecuting { + name: "grep".into(), + id: "1".into(), + } + ); + assert_eq!( + agent_ui_event_to_delivery(&AgentUiEvent::ToolResult { + name: "grep".into(), + id: "1".into(), + summary: "2 hits".into(), + is_error: false, + }), + AgentDeliveryEvent::ToolResult { + name: "grep".into(), + id: "1".into(), + summary: "2 hits".into(), + is_error: false, + } + ); + assert_eq!( + agent_ui_event_to_delivery(&AgentUiEvent::Done), + AgentDeliveryEvent::Done + ); + assert_eq!( + agent_ui_event_to_delivery(&AgentUiEvent::Error("boom".into())), + AgentDeliveryEvent::Error("boom".into()) + ); + } + + #[tokio::test] + async fn apply_agent_ui_event_publishes_to_delivery_broadcast() { + use crate::agent_delivery::{AgentDeliveryEvent, subscribe_agent_delivery}; + use tokio::sync::broadcast::error::RecvError; + + // Unique payload so a concurrent test on the shared global broadcast can + // never satisfy this matcher. + let marker = "apply_agent_ui_event_publishes_to_delivery_broadcast"; + let mut rx = subscribe_agent_delivery(); + apply_agent_ui_event(AgentUiEvent::TextDone(marker.into())).await; + + let mut found = None; + for _ in 0..1024 { + match rx.recv().await { + Ok(AgentDeliveryEvent::TextDone(text)) if text == marker => { + found = Some(text); + break; + } + Ok(_) | Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => panic!("delivery channel closed unexpectedly"), + } + } + assert_eq!(found.as_deref(), Some(marker)); + } } diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 3d9d5f96..aa0b1f4f 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -1,6 +1,6 @@ //! Recording pipeline state machine controller //! -//! This module implements the core hotkey-driven state machine for CodeScribe. +//! This module implements the core hotkey-driven state machine for Codescribe. //! It manages recording lifecycle, state transitions, and interaction with the //! transcription backend. //! @@ -25,24 +25,18 @@ mod helpers; mod types; -pub(crate) use helpers::restore_agent_runtime_from_thread; pub use helpers::{ is_assistive_session, is_conversation_session, set_assistive_session, set_conversation_session, }; -pub use types::{HotkeyAction, HotkeyInput, HotkeyType, State}; +pub use types::{HotkeyAction, HotkeyInput, HotkeyType, State, TranscriptionActionContractMode}; use crate::presentation::emitter::PresentationEmitter; use crate::stream_postprocess::StreamPostProcessor; use anyhow::{Context, Result}; -use dispatch::Queue; -#[cfg(target_os = "macos")] -use objc::runtime::Class; -#[cfg(target_os = "macos")] -use objc::{msg_send, sel, sel_impl}; use std::path::PathBuf; +use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use tokio::sync::{Mutex, RwLock, broadcast}; use tokio::task::JoinHandle; use tracing::{debug, error, info, warn}; @@ -53,24 +47,18 @@ use crate::config::models::ModelManager; use crate::config::{Config, UserSettings}; use crate::os::clipboard; use crate::os::hotkeys::HoldMode; -use crate::os::permissions::{ - PermissionStatus, check_accessibility, check_input_monitoring, check_microphone, -}; use crate::os::selection::{ AssistiveContext, build_assistive_input, capture_assistive_context, capture_assistive_context_with_prior_frontmost, capture_frontmost_app_only, capture_frontmost_app_only_with_prior_frontmost, get_recent_assistive_context, store_recent_assistive_context, }; -use crate::{BadgeMode, hide_hold_badge, show_badge_for_mode}; // Moshi conversation engine and audio output use codescribe_core::conversation::{ConversationEngine, MoshiConfig}; -use codescribe_core::ipc::{IpcEvent, IpcEventPayload}; +use codescribe_core::ipc::{EngineEventWire, IpcEvent, IpcEventPayload}; use codescribe_core::tts::AudioPlayer; -// UI state for conversation mode -use crate::ui::voice_chat::ConversationModeState; use codescribe_core::pipeline::contracts::{ FileTranscriptionOptions, FinalPassDisposition, TranscriptionConfidenceFlag, TranscriptionVerdict, @@ -79,15 +67,12 @@ use codescribe_core::pipeline::contracts::{ use helpers::{ SessionTelemetrySnapshot, SharedSessionTelemetry, new_session_telemetry, raw_save_enabled, reset_agent_runtime_for_new_thread as reset_agent_runtime_for_new_thread_impl, - reset_session_telemetry, send_assistive_with_agent_runtime, setup_voice_chat_send_callback, - snapshot_session_telemetry, + reset_session_telemetry, send_assistive_with_agent_runtime, snapshot_session_telemetry, }; use types::{ RecordingFallbackClass, RecordingTranscriptSource, RecordingTruthMetadata, ValidatedAudioPath, }; -static OVERLAY_CONTROLLER: OnceLock> = OnceLock::new(); - const LIVE_PROFILE_BUFFER_DELAY_MS: u64 = 280; const LIVE_PROFILE_TYPING_CPS: f32 = 90.0; const LIVE_PROFILE_EMIT_WORDS_MAX: u64 = 2; @@ -104,10 +89,30 @@ fn effective_hold_start_delay_ms(configured_ms: u64, assistive: bool) -> u64 { } const TOGGLE_STOP_ADJUDICATE_TIMEOUT: Duration = Duration::from_secs(120); +const STOP_TIMEOUT: Duration = TOGGLE_STOP_ADJUDICATE_TIMEOUT; #[cfg(test)] fn toggle_stop_adjudicate_timeout() -> Duration { - TOGGLE_STOP_ADJUDICATE_TIMEOUT + STOP_TIMEOUT +} + +#[cfg(test)] +static PROCESS_RECORDING_TEST_HANG: AtomicBool = AtomicBool::new(false); + +#[cfg(test)] +struct ProcessRecordingHangGuard; + +#[cfg(test)] +fn hang_process_recording_for_test() -> ProcessRecordingHangGuard { + PROCESS_RECORDING_TEST_HANG.store(true, Ordering::SeqCst); + ProcessRecordingHangGuard +} + +#[cfg(test)] +impl Drop for ProcessRecordingHangGuard { + fn drop(&mut self) { + PROCESS_RECORDING_TEST_HANG.store(false, Ordering::SeqCst); + } } #[derive(Debug, Clone, Copy)] @@ -129,26 +134,6 @@ fn normalize_for_diff(s: &str) -> String { } } -const OVERLAY_FORMAT_FAILED_MARKER: &str = "(raw — formatting failed)"; - -fn overlay_format_result_text( - raw_text: &str, - result: crate::ai_formatting::AiFormatResult, -) -> String { - if result.text.trim().is_empty() - || result.status == crate::ai_formatting::AiFormatStatus::Failed - { - let raw = raw_text.trim_end(); - if raw.is_empty() { - OVERLAY_FORMAT_FAILED_MARKER.to_string() - } else { - format!("{raw}\n\n{OVERLAY_FORMAT_FAILED_MARKER}") - } - } else { - result.text - } -} - impl ActionQualityProbe { fn from_transcripts( raw_text: &str, @@ -230,13 +215,7 @@ fn apply_runtime_transcription_profile(config: &Config, assistive: bool) -> bool } fn non_empty_transcript(text: Option) -> Option { - text.and_then(|text| { - if text.trim().is_empty() { - None - } else { - Some(text) - } - }) + text.filter(|text| !text.trim().is_empty()) } #[derive(Debug, Clone, Default)] @@ -650,19 +629,39 @@ fn is_hotkey_start_event(event: &HotkeyInput) -> bool { ) } -fn should_block_hotkey_during_agent_send(current_state: State, event: &HotkeyInput) -> bool { - current_state == State::Idle - && is_hotkey_start_event(event) - && helpers::is_agent_send_in_flight() +/// An assistive *start* hotkey — FN+Shift hold-down, an assistive toggle press, +/// or any start event flagged `assistive` (Chat / Selection / assistive toggle). +/// These are the "Talk Anytime" inputs the user fires to add a new voice intent +/// while Emil/the agent is still answering. +fn is_assistive_start_event(event: &HotkeyInput) -> bool { + is_hotkey_start_event(event) && event.assistive } -fn present_agent_send_hotkey_block() { - info!("Agent response is still streaming; ignoring hotkey start"); - if !cfg!(test) { - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::update_voice_chat_status("Agent is answering..."); - } +/// Block a *new* hotkey start while a previously-dispatched agent turn is still +/// streaming. This fires only at `State::Idle` — the controller has already +/// returned the mic/transcription pipeline; the agent is answering in the +/// background (a detached `tokio::spawn`, see `send_assistive_with_agent_runtime`). +/// +/// Exception — **Assistive Talk Anytime**: assistive start events are allowed +/// through so the user can record a *new* voice intent while the agent answers. +/// The resulting utterance is captured into the existing pending-follow-up +/// buffer (`should_capture_pending_followup` → `get_or_create_pending_followup_index`), +/// not dropped — the living intent grows instead of being ignored. Non-assistive +/// (raw) dictation starts stay blocked: barging a raw transcript into a live +/// agent turn is never wanted, and blocking preserves the single-pipeline +/// guarantee for the dictation path. +/// +/// `agent_send_in_flight` is passed in (rather than read from the global) so the +/// decision is a pure function and unit-testable without touching shared state. +fn should_block_hotkey_during_agent_send( + current_state: State, + event: &HotkeyInput, + agent_send_in_flight: bool, +) -> bool { + current_state == State::Idle + && agent_send_in_flight + && is_hotkey_start_event(event) + && !is_assistive_start_event(event) } fn transcript_output_category(output_kind: crate::state::history::TranscriptKind) -> &'static str { @@ -725,17 +724,6 @@ const SHORT_AI_QUALITY_GATE_MIN_CHARS: usize = 10; const QUALITY_GATE_DROP_RATIO: f32 = 0.35; const QUALITY_GATE_DIFF_RATIO: f32 = 0.62; const QUALITY_GATE_CORRECTION_RATIO: f32 = 0.40; -const RECORDER_RUNTIME_DEGRADED_REASON: &str = - "Microphone recorder unavailable. Voice capture is disabled."; -const RECOVERY_UI_COOLDOWN_MS: u64 = 3_000; -static RUNTIME_RECOVERY_LAST_SHOWN_MS: AtomicU64 = AtomicU64::new(0); - -fn should_attempt_recorder_runtime_recovery( - microphone_status: PermissionStatus, - recorder_missing: bool, -) -> bool { - microphone_status == PermissionStatus::Granted && recorder_missing -} struct AtomicFlagGuard { flag: Arc, @@ -759,16 +747,14 @@ struct ProcessRecordingOutcome { no_speech_reason: Option, commit_trigger: Option, transcript_present: bool, - final_status: String, } impl ProcessRecordingOutcome { - fn no_speech(reason: impl Into, final_status: impl Into) -> Self { + fn no_speech(reason: impl Into) -> Self { Self { no_speech_reason: Some(reason.into()), commit_trigger: None, transcript_present: false, - final_status: final_status.into(), } } } @@ -818,281 +804,6 @@ fn evaluate_quality_commit_trigger( None } -fn resolve_transcription_action_contract_mode( - force_raw: bool, - force_ai: bool, - ai_formatting_enabled: bool, - ai_key_available: bool, -) -> crate::ui::overlay::TranscriptionActionContractMode { - if force_raw { - crate::ui::overlay::TranscriptionActionContractMode::Raw - } else if force_ai || (ai_formatting_enabled && ai_key_available) { - crate::ui::overlay::TranscriptionActionContractMode::AiFormat - } else { - crate::ui::overlay::TranscriptionActionContractMode::Raw - } -} - -/// Register the controller for overlay actions (commit/close fragment). -pub fn register_overlay_controller(controller: Arc) { - if OVERLAY_CONTROLLER.set(controller).is_err() { - warn!("Overlay controller already registered"); - } -} - -pub fn request_permission_runtime_reconcile() { - let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { - debug!("Overlay controller not registered; skipping permission runtime reconcile"); - return; - }; - - // P2.4/P3.4 DEFERRED (cross-cut, owned by the runtime/bin group): - // This builds a fresh current_thread runtime per call, which bypasses the - // intentional 4-worker cap of the main multi-threaded runtime - // (bin/codescribe.rs). The clean fix is to reuse a cached - // `tokio::runtime::Handle` from the main runtime. We CANNOT use - // `Handle::current()` here: the sole caller - // (ui/onboarding/permission_flow.rs::reconcile_permission_runtime_after_grant) - // is a synchronous fn driven from the AppKit/objc permission-grant flow on - // the main thread, which is NOT a tokio worker, so `Handle::current()` would - // panic with "there is no reactor running". A proper fix requires a - // startup-side `OnceLock` populated in bin/codescribe.rs (the same - // cached-Handle pattern noted in ui/voice_chat/handlers/connectors.rs) — - // that lives outside this file's single-ownership domain. Until that cache - // exists, the per-call runtime is kept deliberately to avoid a main-thread - // panic regression. - std::thread::spawn(move || { - match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime.block_on(async move { - controller.reconcile_runtime_after_permission_grant().await; - }), - Err(error) => warn!("Failed to build runtime for permission reconcile: {error}"), - } - }); -} - -/// Stop the current recording and force the finish pipeline without waiting for VAD. -/// -/// Note: this is the "external stop trigger" — distinct from `request_segment_commit` -/// which saves a segment without stopping. The overlay's Commit button used to call -/// this (legacy behavior = stop on Commit); now it routes through `request_segment_commit` -/// for incremental clipping during a continuing hands-off session. -pub fn request_recording_stop() { - let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { - warn!("Overlay controller not registered; cannot stop recording"); - return; - }; - - tokio::spawn(async move { - let result = controller.stop_recording_from_external_surface().await; - if let Err(e) = result { - error!("Overlay stop failed: {}", e); - } - }); -} - -/// Save the current recording segment (audio + transcript + Quick Notes) WITHOUT -/// stopping the recorder. Buffer offset advances so the next segment starts from -/// here. Recording stream continues; VAD worker keeps running. -/// -/// Fire-and-forget — spawns async task to perform save off the calling thread. -/// Errors are logged but not propagated to the caller (Overlay button handler). -pub fn request_segment_commit() { - let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { - warn!("Overlay controller not registered; cannot commit segment"); - return; - }; - - tokio::spawn(async move { - if let Err(e) = controller.commit_segment(false).await { - error!("Segment commit failed: {}", e); - } - }); -} - -/// Save the current segment (like `request_segment_commit`) AND trigger LLM -/// augmentation via the voice chat overlay handoff. Recording continues. -/// -/// LLM handoff runs off-main-thread to avoid AppKit deadlock — the controller's -/// `commit_segment(with_augment=true)` spawns the voice-chat invocation as a -/// follow-up task after the synchronous save completes. -pub fn request_segment_commit_and_augment() { - let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { - warn!("Overlay controller not registered; cannot commit segment + augment"); - return; - }; - - tokio::spawn(async move { - if let Err(e) = controller.commit_segment(true).await { - error!("Segment commit + augment failed: {}", e); - } - }); -} - -/// Format the decision-mode transcript with AI and return the result to the overlay. -/// -/// Revision 2026-06-11: `[Format]` stays in the overlay and becomes editable -/// after the async formatting pass. The callback is executed on the main queue. -pub fn request_format_for_overlay(text: String, on_done: F) -where - F: FnOnce(String) + Send + 'static, -{ - if text.trim().is_empty() { - return; - } - let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { - warn!("Overlay controller not registered; cannot format overlay transcript"); - return; - }; - - tokio::spawn(async move { - let formatted = controller.format_decision_text(text).await; - Queue::main().exec_async(move || on_done(formatted)); - }); -} - -/// Paste the current editable overlay text into the app captured before the overlay. -pub fn request_overlay_paste(text: String) { - if text.trim().is_empty() { - return; - } - let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { - warn!("Overlay controller not registered; pasting without target reactivation"); - paste_overlay_text_with_target(text, None); - return; - }; - - tokio::spawn(async move { - let context_target = { - controller - .assistive_context - .read() - .await - .clone() - .and_then(|ctx| ctx.frontmost_app) - }; - let prior_target = { controller.pre_overlay_frontmost_app.read().await.clone() }; - let target_app = context_target.or(prior_target); - let config = { controller.config.read().await.clone() }; - let paste_text = maybe_wrap_transcript_for_delivery(&text, &config, "dictation"); - paste_overlay_text_with_target(paste_text, target_app); - }); -} - -#[cfg(target_os = "macos")] -fn activate_target_app(app_name: &str) { - unsafe { - let Some(ns_workspace) = Class::get("NSWorkspace") else { - return; - }; - let workspace: crate::ui_helpers::Id = msg_send![ns_workspace, sharedWorkspace]; - let running: crate::ui_helpers::Id = msg_send![workspace, runningApplications]; - let count: usize = msg_send![running, count]; - for i in 0..count { - let app: crate::ui_helpers::Id = msg_send![running, objectAtIndex: i]; - let name: crate::ui_helpers::Id = msg_send![app, localizedName]; - if !name.is_null() { - let name_cstr: *const std::ffi::c_char = msg_send![name, UTF8String]; - if !name_cstr.is_null() { - let name_str = std::ffi::CStr::from_ptr(name_cstr).to_string_lossy(); - if name_str == app_name { - let _: bool = msg_send![app, activateWithOptions: 1u64]; - break; - } - } - } - } - } -} - -fn paste_overlay_text_with_target(text: String, target_app: Option) { - #[cfg(target_os = "macos")] - { - if let Some(app_name) = target_app { - Queue::main().exec_async({ - let app_name = app_name.clone(); - move || activate_target_app(&app_name) - }); - std::thread::spawn(move || { - // Confirm focus actually landed on the target before pasting, - // instead of a fixed sleep that is too short under a CPU spike and - // lets the synthetic Cmd+V hit the wrong window. `activate_target_app` - // uses `activateWithOptions`, and `wait_for_frontmost_app` reads - // `NSWorkspace.frontmostApplication`, so the confirm works regardless - // of the activation mechanism. Budget is bounded so a stuck - // activation cannot wedge the paste path; on miss we proceed - // best-effort, matching the selection-capture path. - let budget = Duration::from_millis(200); - if !crate::os::selection::wait_for_frontmost_app(&app_name, budget) { - debug!( - "Overlay paste: focus did not confirm on '{}' within {:?}; pasting best-effort", - app_name, budget - ); - } - Queue::main().exec_async(move || paste_overlay_text_now(&text)); - }); - } else { - Queue::main().exec_async(move || paste_overlay_text_now(&text)); - } - } - - #[cfg(not(target_os = "macos"))] - { - paste_overlay_text_now(&text); - } -} - -fn paste_overlay_text_now(text: &str) { - if cfg!(test) { - info!("Skipping overlay paste in tests ({} chars)", text.len()); - return; - } - if let Err(e) = clipboard::paste_text(text) { - warn!("Overlay paste failed: {e}"); - } else { - info!("Overlay transcript pasted ({} chars)", text.len()); - } -} - -/// Start a toggle recording session from the UI (CTA). -pub fn request_toggle_recording_start(assistive: bool) { - let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { - warn!("Overlay controller not registered; cannot start recording"); - return; - }; - - tokio::spawn(async move { - let event = HotkeyInput { - key_type: HotkeyType::Toggle, - action: HotkeyAction::Press, - assistive, - hold_mode: HoldMode::Raw, - force_raw: !assistive, - force_ai: assistive, - }; - if let Err(e) = controller.handle_hotkey_event(event).await { - error!("CTA start recording failed: {}", e); - } - }); -} - -/// Rotate the backend agent thread/runtime boundary for a fresh chat thread. -pub fn request_new_agent_thread() { - tokio::spawn(async { - match reset_agent_runtime_for_new_thread_impl().await { - Ok(generation) => { - debug!("UI requested new agent thread boundary (generation={generation})"); - } - Err(error) => { - warn!("Failed to rotate agent thread boundary: {error}"); - } - } - }); -} - /// Rotate runtime + thread identity and return generation once backend reset completes. pub async fn reset_agent_runtime_for_new_thread() -> Result { reset_agent_runtime_for_new_thread_impl().await @@ -1156,14 +867,8 @@ pub struct RecordingController { /// may steal focus and destroy the user's selection context. assistive_context: Arc>>, /// App that was frontmost when the user initiated a hold session, before - /// CodeScribe badge/overlay UI can become frontmost. + /// Codescribe badge/overlay UI can become frontmost. pre_overlay_frontmost_app: Arc>>, - /// True when we opened the unified overlay solely to show a raw transcription preview. - /// - /// This lets us preserve the old behavior: - /// - If the user had the overlay already open (Drawer/Agent), don't close it after dictation. - /// - If we popped it open just for raw dictation, auto-hide it after processing. - opened_voice_chat_overlay_for_transcription: Arc, /// Sample offset (in the recorder buffer) marking the start of the next /// incremental segment. Advances on each `commit_segment` call so segment @@ -1202,10 +907,6 @@ pub struct RecordingController { impl RecordingController { fn recorder_unavailable_error(context: &str) -> anyhow::Error { warn!("{context}: streaming recorder unavailable; voice capture is disabled"); - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RECORDER_RUNTIME_DEGRADED_REASON), - ); anyhow::anyhow!("{context}: streaming recorder unavailable") } @@ -1214,10 +915,6 @@ impl RecordingController { Ok(recorder) => Some(recorder), Err(error) => { warn!("{context}: failed to initialize streaming recorder: {error}"); - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RECORDER_RUNTIME_DEGRADED_REASON), - ); None } } @@ -1241,130 +938,29 @@ impl RecordingController { .ok_or_else(|| Self::recorder_unavailable_error(context)) } - fn should_emit_runtime_recovery_message() -> bool { - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(); - loop { - let last_ms = RUNTIME_RECOVERY_LAST_SHOWN_MS.load(Ordering::SeqCst); - if now_ms.saturating_sub(last_ms) < RECOVERY_UI_COOLDOWN_MS { - return false; - } - if RUNTIME_RECOVERY_LAST_SHOWN_MS - .compare_exchange(last_ms, now_ms, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - return true; - } - } - } - - fn format_recorder_recovery_message( - missing_permissions: &[&str], - dictation_binding: &str, - formatting_binding: &str, - assistive_binding: &str, - ) -> String { - if missing_permissions.is_empty() { - format!( - "Mic unavailable: recorder failed to initialize. Open Settings to review hotkeys, input device, and runtime services, then retry. Configured shortcuts: Dictation={} • Formatting={} • Assistive={}.", - dictation_binding, formatting_binding, assistive_binding - ) - } else { - format!( - "Mic unavailable: recorder failed to initialize. Missing permissions: {}. Open Settings to grant access, then retry your hotkey. Configured shortcuts: Dictation={} • Formatting={} • Assistive={}.", - missing_permissions.join(", "), - dictation_binding, - formatting_binding, - assistive_binding - ) - } - } - - fn format_backend_recovery_message(detail: Option<&str>) -> String { - let mut message = - "Speech backend unavailable. Open Settings to verify the transcription provider, endpoint, and runtime service, then retry." - .to_string(); - if let Some(detail) = detail.map(str::trim).filter(|text| !text.is_empty()) { - message.push_str(" Details: "); - message.push_str(detail); - } - message + /// Create a new recording controller with configuration loaded from disk + pub fn new() -> Self { + Self::with_config(Config::load(), "RecordingController::new") } - fn recording_recovery_guidance() -> String { - let settings = crate::config::UserSettings::load(); - let dictation_binding = settings - .mode_binding_for(crate::config::WorkMode::Dictation) - .label(); - let formatting_binding = settings - .mode_binding_for(crate::config::WorkMode::Formatting) - .label(); - let assistive_binding = settings - .mode_binding_for(crate::config::WorkMode::Assistive) - .label(); - let mut missing_permissions = Vec::new(); - if check_accessibility() != PermissionStatus::Granted { - missing_permissions.push("Accessibility"); - } - if check_input_monitoring() != PermissionStatus::Granted { - missing_permissions.push("Input Monitoring"); - } - if check_microphone() != PermissionStatus::Granted { - missing_permissions.push("Microphone"); - } - - Self::format_recorder_recovery_message( - &missing_permissions, - dictation_binding, - formatting_binding, - assistive_binding, + /// Create a new recording controller without populating secrets from Keychain. + /// + /// Used by the SwiftUI redesign dictation bridge: starting local recording must + /// not ask for API-key access as an incidental side effect. + pub fn new_without_keychain() -> Self { + Self::with_config( + Config::load_without_keychain(), + "RecordingController::new_without_keychain", ) } - fn present_runtime_recovery_ui(status: &str, message: &str) { - let emit_recovery_message = Self::should_emit_runtime_recovery_message(); - let overlay_visible = crate::ui::voice_chat::is_voice_chat_overlay_visible(); - if emit_recovery_message || !overlay_visible { - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - } - crate::ui::voice_chat::update_voice_chat_status(status); - if emit_recovery_message { - crate::ui::voice_chat::add_voice_chat_error_message(message); - crate::ui::settings::show_settings_window(); - } else { - debug!("Runtime recovery UI throttled (cooldown active)"); - } - } - - fn present_recorder_unavailable(context: &str) { - warn!("{context}: recorder unavailable; routing to settings recovery"); - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RECORDER_RUNTIME_DEGRADED_REASON), - ); - let message = Self::recording_recovery_guidance(); - Self::present_runtime_recovery_ui("Recorder unavailable", &message); - } - - fn present_backend_unavailable(context: &str, detail: Option<&str>) { - warn!("{context}: backend unavailable; routing to settings recovery"); - let message = Self::format_backend_recovery_message(detail); - Self::present_runtime_recovery_ui("Backend unavailable", &message); - } - - /// Create a new recording controller with configuration loaded from disk - pub fn new() -> Self { - let config = Config::load(); - + fn with_config(config: Config, recorder_context: &str) -> Self { info!( "Initializing RecordingController (hold_delay={}ms, beep={}, language={:?})", config.hold_start_delay_ms, config.beep_on_start, config.whisper_language ); - let recorder = Self::init_streaming_recorder("RecordingController::new"); + let recorder = Self::init_streaming_recorder(recorder_context); if !cfg!(test) { match ModelManager::new() { @@ -1378,21 +974,39 @@ impl RecordingController { Err(error) => warn!("Model manager unavailable during startup: {error}"), } - // Initialize Whisper engine if not already done (daemon pre-inits) - if !crate::whisper::is_initialized() - && let Err(e) = crate::whisper::init() - { - warn!("Failed to initialize Whisper engine: {}", e); + if !crate::whisper::is_initialized() { + // Best-effort BACKGROUND prewarm — never block recording readiness. + // + // Product invariant: recording readiness is NOT engine readiness. + // Audio capture must start the moment the user presses record; the + // live pipeline and the final pass lazy-load the engine on first use. + // A failed prewarm is a warning, not an app or recording failure. + // The idle-unload reaper (commit 2b8bb1f) may legitimately drop the + // engine later and the next call reloads it — pinning it here would + // undo that GPU/host-memory reclaim. + // + // Warm the ACTIVE router engine (Apple SpeechAnalyzer on macOS 26+, + // Candle on fallback/older macOS) AND run a synthetic warmup + // inference, so the first dictation pays neither model-load nor + // Metal kernel-compilation latency — matching the old always-instant + // behaviour where the long-lived daemon was warm before first use. + std::thread::Builder::new() + .name("stt-prewarm".into()) + .spawn(|| { + if let Err(e) = crate::stt::prewarm_active_engine() { + warn!( + "STT background prewarm failed (will lazy-load on first use): {}", + e + ); + } + }) + .ok(); } } let config = Arc::new(RwLock::new(config)); - setup_voice_chat_send_callback(Arc::clone(&config)); if recorder.is_none() { - crate::ui::voice_chat::set_voice_chat_runtime_degraded( - true, - Some(RECORDER_RUNTIME_DEGRADED_REASON), - ); + warn!("Recorder unavailable at controller init; voice capture is disabled"); } let (event_broadcast, _) = broadcast::channel::(256); let session_telemetry = new_session_telemetry(); @@ -1416,7 +1030,6 @@ impl RecordingController { toggle_assistant_has_text: Arc::new(AtomicBool::new(false)), assistive_context: Arc::new(RwLock::new(None)), pre_overlay_frontmost_app: Arc::new(RwLock::new(None)), - opened_voice_chat_overlay_for_transcription: Arc::new(AtomicBool::new(false)), last_segment_audio_offset: Arc::new(AtomicUsize::new(0)), // Conversation mode (lazy init) conversation_engine: Arc::new(Mutex::new(None)), @@ -1438,18 +1051,19 @@ impl RecordingController { self.event_broadcast.subscribe() } - #[cfg(test)] - pub(crate) fn publish_ipc_event_for_test(&self, payload: IpcEventPayload) { - let _ = self.event_broadcast.send(IpcEvent { - timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), - payload, - }); - } - async fn set_state(&self, new_state: State) { Self::set_state_with_broadcast(&self.state, &self.event_broadcast, new_state).await; } + async fn show_processing_badge_if_enabled(&self) { + let hold_indicator = self.config.read().await.hold_indicator; + if hold_indicator { + crate::os::hold_badge::show_badge_for_mode( + crate::os::hold_badge::BadgeMode::Processing, + ); + } + } + async fn set_state_with_broadcast( state: &Arc>, event_broadcast: &broadcast::Sender, @@ -1463,6 +1077,11 @@ impl RecordingController { }; if old_state != new_state { + // Recording ended → always tear down the cursor badge (covers finalize, + // cancel, error, no-speech — any path back to Idle). + if new_state == State::Idle { + crate::os::hold_badge::hide_hold_badge(); + } let _ = event_broadcast.send(IpcEvent { timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), payload: IpcEventPayload::StateChange { @@ -1483,29 +1102,6 @@ impl RecordingController { self.config.read().await.clone() } - async fn reconcile_runtime_after_permission_grant(&self) { - let mut recorder_guard = self.recorder.lock().await; - if !should_attempt_recorder_runtime_recovery(check_microphone(), recorder_guard.is_none()) { - return; - } - - *recorder_guard = Self::init_streaming_recorder("Permission runtime reconcile"); - if recorder_guard.is_some() { - crate::ui::voice_chat::set_voice_chat_runtime_degraded(false, None); - info!("Permission runtime reconcile: recorder runtime recovered after grant"); - } - } - - /// Check if VAD (silence detection) has triggered auto-stop - pub fn is_vad_triggered(&self) -> bool { - self.vad_triggered.load(Ordering::SeqCst) - } - - /// Clear the VAD triggered flag - pub fn clear_vad_triggered(&self) { - self.vad_triggered.store(false, Ordering::SeqCst); - } - /// Cancel any pending delayed hold-start task async fn cancel_pending_hold_start(&self) { let generation = self.hold_start_generation.fetch_add(1, Ordering::SeqCst) + 1; @@ -1578,14 +1174,11 @@ impl RecordingController { self.reset_session_fields().await; set_assistive_session(false); reset_session_telemetry(&self.session_telemetry); - hide_hold_badge(); - crate::ui::voice_chat::update_voice_chat_status("Ready"); } async fn reset_finished_recording_state(&self) { self.reset_session_fields().await; set_assistive_session(false); - hide_hold_badge(); } async fn handle_processed_recording_result( @@ -1595,68 +1188,50 @@ impl RecordingController { ) { match result { Ok(outcome) => { - let final_status = if outcome.final_status.trim().is_empty() { - "Ready" - } else { - outcome.final_status.as_str() - }; - crate::ui::voice_chat::update_voice_chat_status(final_status); info!("Processing finished successfully. State reset to IDLE."); + // The transcription just freed large transient buffers (audio, + // mel, model scratch). Hand those freed-but-retained pages back + // to the OS now, while idle, instead of letting phys_footprint + // creep up across a long session. + codescribe_core::memory::release_freed_heap(); + if let Some(reason) = outcome.no_speech_reason.as_deref() { info!("NoSpeech outcome in finish_recording: reason={reason}"); - if !assistive { - let opened = self - .opened_voice_chat_overlay_for_transcription - .swap(false, Ordering::SeqCst); - if opened { - crate::ui::voice_chat::hide_voice_chat_overlay(); - } - crate::ui::overlay::update_transcription_status(final_status); - crate::ui::overlay::schedule_auto_hide(); - } } else if !assistive { let cfg = self.config.read().await.clone(); - let show_decision_overlay = outcome.transcript_present - && cfg.transcription_overlay_enabled - && !(cfg.quick_notes_enabled && cfg.quick_notes_save_only); - - let opened = self - .opened_voice_chat_overlay_for_transcription - .swap(false, Ordering::SeqCst); - if opened { - crate::ui::voice_chat::hide_voice_chat_overlay(); - } - if show_decision_overlay { - crate::ui::overlay::update_transcription_status(final_status); + if outcome.transcript_present + && cfg.transcription_overlay_enabled + && !(cfg.quick_notes_enabled && cfg.quick_notes_save_only) + { let reason = outcome .commit_trigger .as_deref() .unwrap_or("quality_gate_clean"); info!("COMMIT decision: trigger={reason}"); - crate::ui::overlay::enter_decision_mode(); - crate::ui::overlay::schedule_auto_hide(); } else if cfg.quick_notes_enabled && cfg.quick_notes_save_only { info!("COMMIT decision: skipped (quick_notes_save_only)"); - crate::ui::overlay::hide_transcription_overlay(); } else { info!("COMMIT decision: skipped (quality gate clean)"); - crate::ui::overlay::hide_transcription_overlay(); } } } Err(e) => { error!("Processing failed: {}", e); - crate::ui::voice_chat::update_voice_chat_status("Processing failed"); - - let opened = self - .opened_voice_chat_overlay_for_transcription - .swap(false, Ordering::SeqCst); - if opened { - crate::ui::voice_chat::hide_voice_chat_overlay(); - } - crate::ui::overlay::hide_transcription_overlay(); + // Surface the failure to the user instead of leaving it as a + // log-only event. Reuse the existing engine `Warning` channel: + // the bridge forwarder (forward_event_to_listener) turns it into + // `listener.on_error(...)` + a tray Error state, so the SwiftUI + // surface reflects the failed transcription. + let _ = self.event_broadcast.send(IpcEvent { + timestamp: chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + payload: IpcEventPayload::Engine(EngineEventWire::Warning { + code: "transcription_failed".to_string(), + message: format!("Transcription failed: {e}"), + }), + }); } } } @@ -1713,8 +1288,6 @@ impl RecordingController { .store(false, Ordering::SeqCst); set_assistive_session(false); reset_session_telemetry(&self.session_telemetry); - hide_hold_badge(); - crate::ui::voice_chat::update_voice_chat_status("Ready"); info!("RECOVERY decision: stale active stream cleared, controller remains IDLE"); } @@ -1747,7 +1320,7 @@ impl RecordingController { fn configure_toggle_event_sink( recorder: &mut StreamingRecorder, preview_deltas_enabled: bool, - flush_voice_chat_on_vad_end: bool, + _flush_voice_chat_on_vad_end: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, ) { @@ -1763,20 +1336,7 @@ impl RecordingController { Arc::new(helpers::RoutingDeltaSink) as Arc }); - let mut pe = PresentationEmitter::new(tb, delta_sink, None); - if flush_voice_chat_on_vad_end { - // Assistive (variant A): VAD-end only refreshes the live UI bubble; it must - // NOT dispatch to the agent. The sole agent sender is the toggle-stop path - // (handle stop -> build_assistive_input -> send_assistive_with_agent_runtime), - // which sends ONE wrapped message carrying full context (selection + frontmost - // app). Committing on VAD-end as well made the same utterance reach the agent - // twice — plain via VAD-end and wrapped via toggle-stop — producing a double - // request and a double answer. Restores the "one utterance = one agent message" - // invariant (regression introduced by 57b1bcc; original invariant from c3ce222). - pe.set_utterance_callback(Some(Arc::new(|text: String| { - crate::ui::voice_chat::append_voice_chat_user_utterance(&text); - }))); - } + let pe = PresentationEmitter::new(tb, delta_sink, None); let pe: Arc = Arc::new(pe); let ipc_sink: Arc = @@ -1824,8 +1384,12 @@ impl RecordingController { current_state ); - if should_block_hotkey_during_agent_send(current_state, &event) { - present_agent_send_hotkey_block(); + if should_block_hotkey_during_agent_send( + current_state, + &event, + helpers::is_agent_send_in_flight(), + ) { + info!("Agent response is still streaming; ignoring hotkey start"); return Ok(()); } @@ -1858,18 +1422,7 @@ impl RecordingController { *self.force_ai_mode.write().await = event.force_ai; if matches!(current_state, State::RecHold | State::RecToggle) { - let overlay_enabled = - self.config.read().await.transcription_overlay_enabled; set_assistive_session(false); - self.opened_voice_chat_overlay_for_transcription - .store(false, Ordering::SeqCst); - crate::ui::overlay::clear_transcription_text(); - if overlay_enabled { - crate::ui::overlay::show_transcription_overlay(); - crate::ui::overlay::enter_recording_mode(); - } else { - crate::ui::overlay::hide_transcription_overlay(); - } } } HoldMode::Chat => { @@ -1890,19 +1443,7 @@ impl RecordingController { .await .unwrap_or_default(); *self.assistive_context.write().await = Some(ctx); - crate::ui::voice_chat::set_voice_chat_target_app( - self.assistive_context - .read() - .await - .clone() - .unwrap_or_default() - .frontmost_app, - ); set_assistive_session(true); - crate::ui::overlay::hide_transcription_overlay(); - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::update_voice_chat_status("Listening..."); } } HoldMode::Selection => { @@ -1923,19 +1464,7 @@ impl RecordingController { .await .unwrap_or_default(); *self.assistive_context.write().await = Some(ctx); - crate::ui::voice_chat::set_voice_chat_target_app( - self.assistive_context - .read() - .await - .clone() - .unwrap_or_default() - .frontmost_app, - ); set_assistive_session(true); - crate::ui::overlay::hide_transcription_overlay(); - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::update_voice_chat_status("Listening..."); } } } @@ -1967,7 +1496,19 @@ impl RecordingController { ); } - // Ignore all hotkeys when busy + // Ignore all hotkeys when busy. `State::Busy` covers the active audio + // pipeline: recorder drain → transcription → (for the hold/toggle + // dictation path) the final assistive agent turn, which is awaited while + // `serial_lock` is held. Letting a second start through here would race a + // live audio/transcription pipeline, so it stays blocked unconditionally + // (acceptance: "non-assistive busy/audio/transcription paths remain + // protected; do not run two audio pipelines concurrently"). + // + // Assistive "Talk Anytime" is handled one gate up, at the `Idle` agent- + // send gate (`should_block_hotkey_during_agent_send`): once a turn is + // dispatched in the background the controller returns to `Idle` and the + // mic is free, which is the only state where overlapping a new recording + // is safe. if current_state == State::Busy { info!("App busy; ignoring hotkey event"); return Ok(()); @@ -2048,7 +1589,8 @@ impl RecordingController { State::Busy => { warn!( "Toggle pressed while previous stop is still processing (state=BUSY). \ - If recording badge persists, stop watchdog will force recovery within 45s." + If recording badge persists, stop watchdog will force recovery within {}s.", + STOP_TIMEOUT.as_secs() ); } _ => { @@ -2095,7 +1637,6 @@ impl RecordingController { let recorder_guard = self.recorder.lock().await; if recorder_guard.is_none() { let error = Self::recorder_unavailable_error("Conversation-start"); - Self::present_recorder_unavailable("Conversation-start"); return Err(error); } } @@ -2111,10 +1652,6 @@ impl RecordingController { // Pre-initialize to load models now (rather than on first audio) if let Err(e) = engine.init() { error!("ConversationEngine init failed: {}", e); - crate::ui::voice_chat::add_voice_chat_error_message(&format!( - "Moshi init failed: {}", - e - )); return Err(e); } *engine_guard = Some(engine); @@ -2122,10 +1659,6 @@ impl RecordingController { } Err(e) => { error!("Failed to create ConversationEngine: {}", e); - crate::ui::voice_chat::add_voice_chat_error_message(&format!( - "Moshi unavailable: {}", - e - )); return Err(e); } } @@ -2162,13 +1695,6 @@ impl RecordingController { self.set_state(State::Conversation).await; info!("STATE TRANSITION: IDLE → CONVERSATION"); - // 6. Update UI - show_badge_for_mode(BadgeMode::Assistive); - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::update_voice_chat_status("Listening..."); - crate::ui::voice_chat::update_conversation_state(ConversationModeState::Listening); - // 7. Start the conversation audio processing task let engine = Arc::clone(&self.conversation_engine); let player = Arc::clone(&self.audio_player); @@ -2235,14 +1761,10 @@ impl RecordingController { Err(error) => { error!("Conversation mode unavailable: {error}"); drop(rec_guard); - // Full cleanup on failure: state, session flag, badge, UI + // Full cleanup on failure: state, session flag, badge Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; helpers::set_conversation_session(false); - hide_hold_badge(); - crate::ui::voice_chat::update_conversation_state( - ConversationModeState::Inactive, - ); - Self::present_recorder_unavailable("Conversation-loop start"); + codescribe_core::memory::release_freed_heap(); return; } }; @@ -2252,13 +1774,10 @@ impl RecordingController { if let Err(e) = rec.recorder.start().await { error!("Failed to start recorder for conversation: {}", e); - // Full cleanup on failure: state, session flag, badge, UI + // Full cleanup on failure: state, session flag, badge Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; helpers::set_conversation_session(false); - hide_hold_badge(); - crate::ui::voice_chat::update_voice_chat_status("Recorder error"); - crate::ui::voice_chat::update_conversation_state(ConversationModeState::Inactive); - crate::ui::voice_chat::add_voice_chat_error_message(&format!("Mic error: {}", e)); + codescribe_core::memory::release_freed_heap(); return; } } @@ -2273,11 +1792,7 @@ impl RecordingController { drop(rec_guard); Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; helpers::set_conversation_session(false); - hide_hold_badge(); - crate::ui::voice_chat::update_conversation_state( - ConversationModeState::Inactive, - ); - Self::present_recorder_unavailable("Conversation-loop sample rate"); + codescribe_core::memory::release_freed_heap(); return; } }; @@ -2288,12 +1803,6 @@ impl RecordingController { // Processing loop let mut last_response_check = std::time::Instant::now(); let response_check_interval = Duration::from_millis(100); - // Track the last conversation state pushed to the UI so the per-chunk - // loop (~20Hz) only dispatches on the main thread when the state - // actually changes, instead of every audio chunk (P2.5). The status - // string is derived 1:1 from `ui_state`, so tracking the state covers - // both dispatches. - let mut last_emitted_conv_state: Option = None; while !stop_flag.load(Ordering::SeqCst) { // Process incoming audio chunks @@ -2301,34 +1810,10 @@ impl RecordingController { Ok(Some(samples)) => { // Feed audio to ConversationEngine let mut engine_guard = engine.lock().await; - if let Some(ref mut eng) = *engine_guard { - if let Err(e) = eng.process_audio_any_rate(&samples, sample_rate) { - warn!("ConversationEngine.process_audio error: {}", e); - } - - // Update UI based on conversation state (only if still current session) - let current_gen = generation_counter.load(Ordering::SeqCst); - if current_gen == my_generation { - let conv_state = eng.state(); - let (status, ui_state) = match conv_state { - codescribe_core::conversation::context::ConversationState::UserSpeaking => { - ("You're speaking...", ConversationModeState::UserSpeaking) - } - codescribe_core::conversation::context::ConversationState::AssistantSpeaking => { - ("Moshi responding...", ConversationModeState::AssistantSpeaking) - } - codescribe_core::conversation::context::ConversationState::Processing => { - ("Processing...", ConversationModeState::Processing) - } - _ => ("Listening...", ConversationModeState::Listening), - }; - // Only dispatch to the main thread when the state changed. - if last_emitted_conv_state != Some(ui_state) { - crate::ui::voice_chat::update_voice_chat_status(status); - crate::ui::voice_chat::update_conversation_state(ui_state); - last_emitted_conv_state = Some(ui_state); - } - } + if let Some(ref mut eng) = *engine_guard + && let Err(e) = eng.process_audio_any_rate(&samples, sample_rate) + { + warn!("ConversationEngine.process_audio error: {}", e); } } Ok(None) => { @@ -2365,19 +1850,9 @@ impl RecordingController { continue; } - crate::ui::voice_chat::update_voice_chat_status("Moshi speaking..."); - crate::ui::voice_chat::update_conversation_state( - ConversationModeState::AssistantSpeaking, - ); - // Keep the per-chunk dedup tracker in sync with the playback - // dispatch so the next state change is still emitted (P2.5). - last_emitted_conv_state = Some(ConversationModeState::AssistantSpeaking); - // Play response audio in separate blocking task (non-blocking for loop) // This preserves full-duplex: we can still process mic while playing let player_clone = Arc::clone(&player); - let stop_flag_clone = Arc::clone(&stop_flag); - let generation_clone = Arc::clone(&generation_counter); let playback_active_clone = Arc::clone(&playback_active); let handle = tokio::runtime::Handle::current(); @@ -2414,19 +1889,6 @@ impl RecordingController { { warn!("AudioPlayer.play error: {}", e); } - // Only update UI if: - // 1. Conversation wasn't stopped (stop_flag) - // 2. This is still the current session (generation matches) - // This prevents cross-session UI races - let current_gen = generation_clone.load(Ordering::SeqCst); - if !stop_flag_clone.load(Ordering::SeqCst) - && current_gen == my_generation - { - crate::ui::voice_chat::update_voice_chat_status("Listening..."); - crate::ui::voice_chat::update_conversation_state( - ConversationModeState::Listening, - ); - } })); if let Err(panic_payload) = body { @@ -2467,9 +1929,10 @@ impl RecordingController { Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; helpers::set_conversation_session(false); - hide_hold_badge(); - crate::ui::voice_chat::update_voice_chat_status("Conversation ended"); - crate::ui::voice_chat::update_conversation_state(ConversationModeState::Inactive); + // Return freed host memory to the OS after a conversation session + // (the dictation stop path already does this; conversation exits did + // not, leaving malloc retention). Memory-lifecycle only. + codescribe_core::memory::release_freed_heap(); info!( "Loop cleanup: conversation ended unexpectedly (gen {})", my_generation @@ -2531,13 +1994,10 @@ impl RecordingController { // 7. Transition back to IDLE self.set_state(State::Idle).await; + // Return freed host memory after a conversation session (see note above). + codescribe_core::memory::release_freed_heap(); info!("STATE TRANSITION: CONVERSATION → IDLE"); - // 8. Update UI - hide_hold_badge(); - crate::ui::voice_chat::update_voice_chat_status("Conversation ended"); - crate::ui::voice_chat::update_conversation_state(ConversationModeState::Inactive); - Ok(()) } @@ -2587,8 +2047,6 @@ impl RecordingController { let hold_start_generation = Arc::clone(&self.hold_start_generation); let start_transition_in_flight = Arc::clone(&self.start_transition_in_flight); let session_telemetry = Arc::clone(&self.session_telemetry); - let opened_overlay_for_transcription = - Arc::clone(&self.opened_voice_chat_overlay_for_transcription); let task = tokio::spawn(async move { // Wait for the configured delay @@ -2614,38 +2072,15 @@ impl RecordingController { return; } - // Check backend health only after the dwell time has actually elapsed. - // A tap or transient Ctrl bump should be a full no-op, including no - // backend-unavailable UI. - if !cfg!(test) { - match crate::client::check_health().await { - Ok(true) => {} - Ok(false) => { - warn!("Whisper engine not ready"); - Self::present_backend_unavailable("Hold-start health check", None); - return; - } - Err(e) => { - error!("Whisper engine unavailable: {}", e); - let detail = e.to_string(); - Self::present_backend_unavailable( - "Hold-start health check", - Some(detail.as_str()), - ); - return; - } - } - } - if hold_start_generation.load(Ordering::SeqCst) != task_generation { - debug!("Hold-start cancelled: superseded generation after health check"); + debug!("Hold-start cancelled: superseded generation before recorder start"); return; } let current_state = *state.read().await; if current_state != State::Idle { debug!( - "Hold-start cancelled after health check: state changed to {}", + "Hold-start cancelled before recorder start: state changed to {}", current_state ); return; @@ -2661,6 +2096,15 @@ impl RecordingController { let hold_mode = *hold_mode.read().await; let is_assistive = matches!(hold_mode, HoldMode::Chat | HoldMode::Selection); + // Cursor-following recording badge (config-gated): red for hold dictation, + // purple for assistive/agent. Works headless — no overlay needed. + if config.hold_indicator { + crate::os::hold_badge::show_badge_for_mode(if is_assistive { + crate::os::hold_badge::BadgeMode::Assistive + } else { + crate::os::hold_badge::BadgeMode::Hold + }); + } let overlay_enabled = apply_runtime_transcription_profile(&config, is_assistive); // Start the recorder (skip in tests: no CoreAudio device needed) @@ -2673,7 +2117,6 @@ impl RecordingController { drop(rec_guard); *session_id.write().await = None; set_assistive_session(false); - Self::present_recorder_unavailable("Hold-start"); return; } }; @@ -2706,9 +2149,11 @@ impl RecordingController { Arc::clone(&session_telemetry), ); if !cfg!(test) { - let start_result = rec - .start_event_session(Some(language.as_str().to_string())) - .await; + let language_hint = language.whisper_hint().map(str::to_string); + // Audio-first cold start: do not preflight Whisper here. The + // recorder starts feedback now while STT lazy-loads behind the + // StreamingRecorder backlog. + let start_result = rec.start_event_session(language_hint.clone()).await; if let Err(e) = start_result { if Self::is_already_in_progress_error(&e) { warn!("Hold-start hit stale recorder lock; forcing stop and retrying once"); @@ -2722,9 +2167,7 @@ impl RecordingController { event_broadcast.clone(), Arc::clone(&session_telemetry), ); - let retry_result = rec - .start_event_session(Some(language.as_str().to_string())) - .await; + let retry_result = rec.start_event_session(language_hint).await; if let Err(retry_err) = retry_result { error!("Failed to start recorder after recovery: {retry_err}"); Self::clear_recorder_callbacks(rec); @@ -2768,17 +2211,8 @@ impl RecordingController { crate::audio::play_sound_with_volume("Tink", sound_volume); } - // Show badge with appropriate mode (Hold=red solid, Assistive=purple) - let badge_mode = if is_assistive { - BadgeMode::Assistive - } else { - BadgeMode::Hold - }; - show_badge_for_mode(badge_mode); - if is_assistive { - opened_overlay_for_transcription.store(false, Ordering::SeqCst); - // Capture context BEFORE showing any overlay (overlays can steal focus). + // Capture context BEFORE starting (paste-back / frontmost tracking). let prior_frontmost_app = pre_overlay_frontmost_app.read().await.clone(); let ctx = match hold_mode { HoldMode::Selection => tokio::task::spawn_blocking(move || { @@ -2793,42 +2227,12 @@ impl RecordingController { .unwrap_or_default(), }; *assistive_context.write().await = Some(ctx); - crate::ui::voice_chat::set_voice_chat_target_app( - assistive_context - .read() - .await - .clone() - .unwrap_or_default() - .frontmost_app, - ); - - crate::ui::overlay::hide_transcription_overlay(); - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::update_voice_chat_status("Listening..."); } else { // Capture frontmost app for paste actions (no selection/clipboard). let ctx = tokio::task::spawn_blocking(capture_frontmost_app_only) .await .unwrap_or_default(); *assistive_context.write().await = Some(ctx); - crate::ui::voice_chat::set_voice_chat_target_app( - assistive_context - .read() - .await - .clone() - .unwrap_or_default() - .frontmost_app, - ); - opened_overlay_for_transcription.store(false, Ordering::SeqCst); - crate::ui::overlay::clear_transcription_text(); - if overlay_enabled { - crate::ui::overlay::show_transcription_overlay(); - crate::ui::overlay::enter_recording_mode(); - crate::ui::overlay::update_transcription_status("Recording • Live preview"); - } else { - crate::ui::overlay::hide_transcription_overlay(); - } } }); @@ -2838,27 +2242,6 @@ impl RecordingController { /// Start recording in toggle mode (immediate, no delay) async fn start_toggle_recording(&self, is_assistive: bool) -> Result<()> { - // Check backend health before starting (skip in tests: no backend available) - if !cfg!(test) { - match crate::client::check_health().await { - Ok(true) => {} - Ok(false) => { - warn!("Whisper engine not ready"); - Self::present_backend_unavailable("Toggle-start health check", None); - return Ok(()); - } - Err(e) => { - error!("Whisper engine unavailable: {}", e); - let detail = e.to_string(); - Self::present_backend_unavailable( - "Toggle-start health check", - Some(detail.as_str()), - ); - return Ok(()); - } - } - } - // Acquire serial lock to prevent race conditions let _guard = self.serial_lock.lock().await; @@ -2891,6 +2274,15 @@ impl RecordingController { info!("Starting toggle recording (session={})", new_session_id); let config = self.config.read().await.clone(); + // Cursor-following recording badge (config-gated): pulsing red for toggle / + // hands-off, purple for assistive/agent. + if config.hold_indicator { + crate::os::hold_badge::show_badge_for_mode(if is_assistive { + crate::os::hold_badge::BadgeMode::Assistive + } else { + crate::os::hold_badge::BadgeMode::Toggle + }); + } let language = config.whisper_language; let toggle_silence_sec = config.toggle_silence_sec; let beep_enabled = config.beep_on_start; @@ -2904,7 +2296,6 @@ impl RecordingController { Err(error) => { drop(recorder_guard); self.reset_session_after_start_failure("Toggle-start").await; - Self::present_recorder_unavailable("Toggle-start"); return Err(error); } }; @@ -2937,10 +2328,11 @@ impl RecordingController { ); // Skip actual audio stream in tests (no CoreAudio device needed) + let language_hint = language.whisper_hint().map(str::to_string); + // Audio-first cold start: do not preflight Whisper here. The recorder + // starts feedback now while STT lazy-loads behind the StreamingRecorder backlog. if !cfg!(test) - && let Err(e) = recorder - .start_event_session(Some(language.as_str().to_string())) - .await + && let Err(e) = recorder.start_event_session(language_hint.clone()).await { if Self::is_already_in_progress_error(&e) { warn!("Toggle start hit stale recorder lock; forcing stop and retrying once"); @@ -2955,10 +2347,7 @@ impl RecordingController { self.event_broadcast.clone(), Arc::clone(&self.session_telemetry), ); - if let Err(retry_err) = recorder - .start_event_session(Some(language.as_str().to_string())) - .await - { + if let Err(retry_err) = recorder.start_event_session(language_hint).await { drop(recorder_guard); self.reset_session_after_start_failure("Toggle-start retry") .await; @@ -2988,17 +2377,7 @@ impl RecordingController { crate::audio::play_sound_with_volume("Tink", sound_volume); } - // Show badge with appropriate mode - let badge_mode = if is_assistive { - BadgeMode::Assistive - } else { - BadgeMode::Toggle - }; - show_badge_for_mode(badge_mode); - if is_assistive { - self.opened_voice_chat_overlay_for_transcription - .store(false, Ordering::SeqCst); // Toggle-assistive is a hands-off chat loop with optional selection context. // Capture selection when available (best-effort), otherwise just app name. let ctx = tokio::task::spawn_blocking(capture_assistive_context) @@ -3011,43 +2390,12 @@ impl RecordingController { .await .ok(); *self.assistive_context.write().await = Some(ctx); - crate::ui::voice_chat::set_voice_chat_target_app( - self.assistive_context - .read() - .await - .clone() - .unwrap_or_default() - .frontmost_app, - ); - - crate::ui::overlay::hide_transcription_overlay(); - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::update_voice_chat_status("Listening..."); } else { // Capture frontmost app for paste actions (no selection/clipboard). let ctx = tokio::task::spawn_blocking(capture_frontmost_app_only) .await .unwrap_or_default(); *self.assistive_context.write().await = Some(ctx); - crate::ui::voice_chat::set_voice_chat_target_app( - self.assistive_context - .read() - .await - .clone() - .unwrap_or_default() - .frontmost_app, - ); - self.opened_voice_chat_overlay_for_transcription - .store(false, Ordering::SeqCst); - crate::ui::overlay::clear_transcription_text(); - if overlay_enabled { - crate::ui::overlay::show_transcription_overlay(); - crate::ui::overlay::enter_recording_mode(); - crate::ui::overlay::update_transcription_status("Recording • Live preview"); - } else { - crate::ui::overlay::hide_transcription_overlay(); - } } Ok(()) @@ -3141,38 +2489,17 @@ impl RecordingController { // path), so clearing it here via the shared helper is safe. self.reset_session_fields().await; - // Reset UI indicators - hide_hold_badge(); - - // Hide transcription overlay explicitly on toggle stop - crate::ui::overlay::hide_transcription_overlay(); - // Assistive: finalize the cumulative bubble and invoke the agent ONCE with the // complete session message. if assistive && !session_transcript.trim().is_empty() { let ctx = assistive_ctx.unwrap_or_default(); let assistive_input = build_assistive_input(&session_transcript, &ctx); - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - // Single source of truth: set the user bubble to EXACTLY the - // session_transcript that is sent to the agent. The streaming lane - // (`append_voice_chat_user_utterance`) skips empty utterances, so a - // state-only finalize could leave the bubble empty/absent while the - // agent still received the final-pass transcript. `finalize_user_message_impl` - // (via `set_voice_chat_user_text`) reuses the active streaming index if - // present, so this REPLACES the existing bubble rather than creating a - // second one — exactly one user bubble whose text == agent input. - crate::ui::voice_chat::set_voice_chat_user_text(&session_transcript); - crate::ui::voice_chat::set_voice_chat_sending(true); - crate::ui::voice_chat::update_voice_chat_status("Thinking…"); helpers::send_assistive_with_agent_runtime( assistive_input, config.whisper_language, config.ai_assistive_max_tokens, ) .await; - } else { - crate::ui::voice_chat::update_voice_chat_status("Ready"); } if let Some(e) = stop_error { @@ -3182,23 +2509,6 @@ impl RecordingController { Ok(()) } - /// Format the given transcript with AI for the overlay `[Format]` action. - /// - /// On-demand formatting for the decision-mode transcript. Falls back to the raw - /// text with a visible marker if AI formatting is unavailable or returns empty. - async fn format_decision_text(&self, text: String) -> String { - let language = self.config.read().await.whisper_language; - let lang_str = language.as_str().to_string(); - let result = crate::ai_formatting::format_text_with_status( - &text, - Some(lang_str.as_str()), - false, - None, - ) - .await; - overlay_format_result_text(&text, result) - } - async fn stop_toggle_and_adjudicate(&self) -> Result<()> { if cfg!(test) { return self.stop_toggle_recording().await; @@ -3213,7 +2523,6 @@ impl RecordingController { // on Metal device, RwLock contention, recorder.stop blocked on cpal callback — // force recovery to Idle so subsequent toggle presses register, badge clears, // and tray reflects truth instead of showing Idle while recording is hung. - const STOP_TIMEOUT: Duration = TOGGLE_STOP_ADJUDICATE_TIMEOUT; match tokio::time::timeout(STOP_TIMEOUT, self.stop_toggle_and_adjudicate_inner()).await { Ok(result) => result, Err(_) => { @@ -3233,10 +2542,10 @@ impl RecordingController { async fn stop_toggle_and_adjudicate_inner(&self) -> Result<()> { // Phase-timed instrumentation: the watchdog above wraps this entire fn - // in a 45s timeout, but until now we couldn't tell WHICH await hung. + // in STOP_TIMEOUT, but until now we couldn't tell WHICH await hung. // Operator reported "hands-off, double option, który potrafi wywołać // nagrywanie, ale nie potrafi zakończyć nagrywania" — confirmed in - // /Users/maciejgad/.codescribe/logs/codescribe.log @ 2026-05-13 23:03:22 PDT + // ~/.codescribe/logs/codescribe.log @ 2026-05-13 23:03:22 PDT // where "Stopping toggle recording with final-pass adjudication" was // followed by 41s of silence before watchdog forced recovery. // These per-phase elapsed logs will identify the exact hang point next @@ -3264,7 +2573,7 @@ impl RecordingController { // Self-deadlock guard (Rust 2024): the read guard temporary from an // if-let chain scrutinee outlives the chain body. Inlining the read // would keep the guard alive across `.write().await`, blocking the - // write on this same task's read guard → 45s hang reproduced in + // write on this same task's read guard → STOP_TIMEOUT hang reproduced in // ~/.codescribe/logs/codescribe.log 2026-05-14T00:16:23 (PHASE 1 // never reached; watchdog forced recovery). Materialize the snapshot // first so the read guard drops at the semicolon. @@ -3274,8 +2583,7 @@ impl RecordingController { } self.set_state(State::Busy).await; - show_badge_for_mode(BadgeMode::Processing); - crate::ui::overlay::enter_processing_mode(); + self.show_processing_badge_if_enabled().await; let result = { let phase1 = std::time::Instant::now(); @@ -3351,129 +2659,7 @@ impl RecordingController { /// is no longer alive. async fn recover_from_stuck_stop(&self) { warn!("Recovery: forcing controller to Idle after stuck stop"); - self.set_state(State::Idle).await; - *self.assistive_mode.write().await = false; - *self.hold_mode.write().await = HoldMode::Raw; - *self.force_raw_mode.write().await = false; - *self.force_ai_mode.write().await = false; - *self.session_id.write().await = None; - self.assistive_loop_active.store(false, Ordering::SeqCst); - self.toggle_user_has_text.store(false, Ordering::SeqCst); - self.toggle_assistant_has_text - .store(false, Ordering::SeqCst); - self.start_transition_in_flight - .store(false, Ordering::SeqCst); - hide_hold_badge(); - crate::ui::voice_chat::update_voice_chat_status("Ready"); - crate::ui::overlay::hide_transcription_overlay(); - } - - /// Save the current recording segment to disk WITHOUT stopping the recorder. - /// - /// Reads buffer slice [`last_segment_audio_offset`..now] from the underlying - /// recorder, writes WAV via `core::state::history::save_audio` + transcript - /// text via `save_entry_with_timestamp`, appends a Quick Notes line, then - /// advances `last_segment_audio_offset` for the next segment. - /// - /// `with_augment=true` adds an off-main-thread voice-chat handoff after - /// the synchronous save completes. - /// - /// Graceful no-op returns `Ok(())` if state is not `RecToggle` or recorder - /// is unavailable (protects against overlay button race). Errors only on - /// actual save failures (e.g. recorder buffer lock poisoned during - /// snapshot_wav). - async fn commit_segment(&self, with_augment: bool) -> Result<()> { - let current_state = self.current_state().await; - if current_state != State::RecToggle { - warn!( - "commit_segment called while state={}; ignoring (overlay button race?)", - current_state - ); - return Ok(()); - } - - // Read overlay text NOW — before any async work — so we capture what was - // visible at click time, not at save-completion time. - let segment_text = crate::ui::overlay::current_segment_text(); - if segment_text.trim().is_empty() { - info!("commit_segment: empty segment text; skipping save"); - return Ok(()); - } - - // Snapshot recorder buffer slice [last_segment_audio_offset..now]. - let from_offset = self.last_segment_audio_offset.load(Ordering::SeqCst); - let snapshot = { - let recorder_guard = self.recorder.lock().await; - let Some(streaming) = recorder_guard.as_ref() else { - warn!("commit_segment: no recorder available; skipping save"); - return Ok(()); - }; - streaming - .recorder - .snapshot_wav(from_offset) - .await - .context("snapshot_wav failed")? - }; - - let Some(snapshot) = snapshot else { - info!("commit_segment: no new audio since last segment; skipping save"); - return Ok(()); - }; - - info!( - "Segment commit: {} samples ({:.2}s) from buffer offset {} to {}, with_augment={}", - snapshot.sample_count, - snapshot.duration_sec, - from_offset, - snapshot.end_offset, - with_augment - ); - - // Save transcript + audio + Quick Notes through existing core helpers. - let now = chrono::Local::now(); - let entry = codescribe_core::state::history::save_entry_with_timestamp( - segment_text.as_str(), - Some(now), - codescribe_core::state::history::TranscriptKind::Raw, - ); - - if codescribe_core::state::history::save_audio( - &snapshot.wav_path, - now, - Some(segment_text.as_str()), - codescribe_core::state::history::TranscriptKind::Raw, - ) - .is_none() - { - warn!( - "Segment audio save returned None; transcript .txt saved at {}", - entry.path.display() - ); - } - - if let Err(e) = - codescribe_core::state::notes::append_quick_note(segment_text.as_str(), now, None) - { - warn!("Segment Quick Notes append failed: {}", e); - } - - // Advance the segment marker so the next commit clips from here. - self.last_segment_audio_offset - .store(snapshot.end_offset, Ordering::SeqCst); - - info!("Segment transcript saved: {}", entry.path.display()); - - // Augment: spawn LLM handoff off-thread (no main thread block). - if with_augment { - let text_for_chat = segment_text.clone(); - tokio::spawn(async move { - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::handoff_transcript_to_chat(&text_for_chat); - }); - } - - Ok(()) + self.reset_finished_recording_state().await; } pub async fn stop_recording_from_external_surface(&self) -> Result<()> { @@ -3522,6 +2708,7 @@ impl RecordingController { // Transition to BUSY debug!("STATE TRANSITION: {} → BUSY", current_state); self.set_state(State::Busy).await; + self.show_processing_badge_if_enabled().await; // Get session ID and mode flags before we reset them let session_id = self.session_id.read().await.clone(); @@ -3530,12 +2717,26 @@ impl RecordingController { let force_raw = *self.force_raw_mode.read().await; let force_ai = *self.force_ai_mode.read().await; - // Switch badge to processing mode (orange, pulsing) - show_badge_for_mode(BadgeMode::Processing); - - let result = self - .process_recording(session_id, assistive, hold_mode, force_raw, force_ai) - .await; + let result = match tokio::time::timeout( + STOP_TIMEOUT, + self.process_recording(session_id, assistive, hold_mode, force_raw, force_ai), + ) + .await + { + Ok(result) => result, + Err(_) => { + error!( + "Hold stop processing stalled >{}s — forcing recovery to Idle. \ + Recording session abandoned; future hotkeys will start fresh.", + STOP_TIMEOUT.as_secs() + ); + self.recover_from_stuck_stop().await; + return Err(anyhow::anyhow!( + "Hold stop timeout after {}s; state forced to Idle", + STOP_TIMEOUT.as_secs() + )); + } + }; self.reset_finished_recording_state().await; self.handle_processed_recording_result(assistive, &result) @@ -3574,7 +2775,7 @@ impl RecordingController { let config = self.config.read().await.clone(); let language = config.whisper_language; - let language_opt = Some(language.as_str()); + let language_opt = language.whisper_hint(); let use_local_stt = config.use_local_stt; let raw_save_enabled = raw_save_enabled(assistive); @@ -3591,7 +2792,6 @@ impl RecordingController { } }; - let chat_active = assistive; let assistive_loop = assistive && self.assistive_loop_active.load(Ordering::SeqCst); let mut local_final_pass_verdict = None; @@ -3631,10 +2831,6 @@ impl RecordingController { let wav_path = path.as_path().to_path_buf(); let lang = language_opt.map(str::to_string); - if chat_active { - crate::ui::voice_chat::update_voice_chat_status("Finalizing… (20%)"); - } - info!( "Running final-pass local STT adjudicator: {}", wav_path.display() @@ -3749,10 +2945,6 @@ impl RecordingController { info!("NoSpeech outcome: reason={} stats=unavailable", reason); } if assistive_loop { - if chat_active { - crate::ui::voice_chat::set_voice_chat_sending(false); - crate::ui::voice_chat::update_voice_chat_status("Listening..."); - } warn!("NoSpeech in assistive loop; continuing hands-off listening"); } @@ -3803,18 +2995,14 @@ impl RecordingController { write_truth_sidecar_logged(&audio_saved_path, &truth_metadata); } - if !assistive { - crate::ui::overlay::update_transcription_status(&final_status); - } - - return Ok(ProcessRecordingOutcome::no_speech(reason, final_status)); + return Ok(ProcessRecordingOutcome::no_speech(reason)); } }; info!("Raw transcript captured ({} chars)", raw_text.len()); let transcript_present = !raw_text.trim().is_empty(); - let language_opt = Some(language.as_str().to_string()); + let language_opt = language.whisper_hint().map(str::to_string); let pipeline_outcome = self .process_transcript_text_pipeline(types::TranscriptPipelineParams { raw_text, @@ -3851,7 +3039,6 @@ impl RecordingController { no_speech_reason: None, commit_trigger: pipeline_outcome.commit_trigger, transcript_present, - final_status: pipeline_outcome.final_status, }) } @@ -3870,6 +3057,12 @@ impl RecordingController { force_raw: bool, force_ai: bool, ) -> Result { + #[cfg(test)] + if PROCESS_RECORDING_TEST_HANG.load(Ordering::SeqCst) { + info!("process_recording: hanging in test until stuck-stop watchdog cancels it"); + std::future::pending::<()>().await; + } + if cfg!(test) { info!( "process_recording: skipped in tests (assistive={}, hold_mode={:?}, force_raw={}, force_ai={})", @@ -3927,7 +3120,7 @@ impl RecordingController { truth_display_status, append_mode, live_stream_session, - user_needs_separator, + user_needs_separator: _user_needs_separator, assistant_needs_separator: _assistant_needs_separator, skip_user_bubble, } = p; @@ -3977,7 +3170,6 @@ impl RecordingController { Some(&raw_text), ); info!("Raw transcript saved: {}", raw_entry.path.display()); - crate::ui::voice_chat::update_drawer_after_save(raw_entry.path.as_path()); Some(raw_entry.path) } else { None @@ -4019,31 +3211,19 @@ impl RecordingController { effective_hold_mode ); - if chat_active { - crate::ui::voice_chat::show_voice_chat_overlay(); - if skip_user_bubble { - // Event pipeline: Preview already streamed text into the bubble. - // Just finalize the user message (stop streaming indicator) - // without re-writing the text. - crate::ui::voice_chat::finalize_voice_chat_user_message(); - self.toggle_user_has_text.store(true, Ordering::SeqCst); - } else if !should_allow_full_user_bubble_rewrite( - skip_user_bubble, - append_mode, - live_stream_session, - ) { - // Delta-first path: avoid full rewrites while stream is active. - if user_needs_separator { - crate::ui::voice_chat::append_voice_chat_user_delta("\n\n"); - } - crate::ui::voice_chat::append_voice_chat_user_delta(&clean_text); - self.toggle_user_has_text.store(true, Ordering::SeqCst); - } else { - crate::ui::voice_chat::set_voice_chat_user_text(&clean_text); - } - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::set_voice_chat_sending(true); - crate::ui::voice_chat::update_voice_chat_status("Thinking… (35%)"); + if chat_active + && (skip_user_bubble + || !should_allow_full_user_bubble_rewrite( + skip_user_bubble, + append_mode, + live_stream_session, + )) + { + // Preserve controller state: the user-bubble was either finalized + // (event pipeline) or built from deltas (delta-first path); both mark + // that the toggle session carries user text. The full-rewrite branch + // does not set this flag. + self.toggle_user_has_text.store(true, Ordering::SeqCst); } let mut ctx = self @@ -4061,20 +3241,6 @@ impl RecordingController { .and_then(|c| c.frontmost_app); } - { - let app = ctx - .frontmost_app - .as_deref() - .unwrap_or("?") - .trim() - .to_string(); - let sel_len = ctx.selected_text.as_deref().unwrap_or("").len(); - crate::ui::voice_chat::update_voice_chat_context_summary(&format!( - "ctx: {} | sel: {}", - app, sel_len - )); - } - let missing_selection = matches!(effective_hold_mode, HoldMode::Selection) && ctx.selected_text.as_deref().unwrap_or("").trim().is_empty(); if missing_selection { @@ -4082,14 +3248,6 @@ impl RecordingController { "Selection mode requested, but no selected text captured; falling back to Chat mode" ); effective_hold_mode = HoldMode::Chat; - if chat_active { - crate::ui::voice_chat::update_voice_chat_status( - "Selection unavailable - chat fallback", - ); - crate::ui::voice_chat::add_voice_chat_system_message( - "Selection was not detected. Continuing without selected-text context.", - ); - } } // Split behavior: @@ -4097,12 +3255,6 @@ impl RecordingController { // - Selection: if no selection was captured, we already downgraded to Chat mode. let assistive_input = build_assistive_input(&clean_text, &ctx); if chat_active { - // Single source of truth: render the user bubble with exactly what - // the agent receives (clean_text), reusing the streaming bubble — - // same fix as the stop_toggle_recording path. Prevents the desync - // where the agent answers but no user bubble shows. - crate::ui::voice_chat::set_voice_chat_user_text(&clean_text); - crate::ui::voice_chat::set_voice_chat_sending(true); send_assistive_with_agent_runtime( assistive_input, config.whisper_language, @@ -4300,6 +3452,22 @@ impl RecordingController { let final_formatted_text = formatted_text.clone(); + // Surface the authoritative final transcript to external dictation surfaces + // (the SwiftUI overlay). This is the same `final_formatted_text` that is + // pasted (auto-delivery) and written to history (tray "Copy"), so the overlay + // FINAL can replace its raw per-utterance streaming assembly with the clean + // LocalFinalPass text. Emitted here — inside the awaited stop pipeline, before + // the Idle StateChange — so it reaches the listener ahead of the stop/finalise + // events that drive the overlay's finalize. + if !assistive && !final_formatted_text.trim().is_empty() { + let _ = self.event_broadcast.send(IpcEvent { + timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + payload: IpcEventPayload::FinalTranscript { + text: final_formatted_text.clone(), + }, + }); + } + let final_status = compose_final_status(&truth_display_status, output_kind); let truth_metadata = RecordingTruthMetadata { source: transcript_source, @@ -4325,28 +3493,12 @@ impl RecordingController { write_truth_sidecar_logged(path, &truth_metadata); } - if !assistive { - crate::ui::overlay::update_transcription_status(&final_status); - } - - if should_apply_transcription_action_contract(assistive, live_stream_session) - && config.transcription_overlay_enabled - { - let action_contract_mode = resolve_transcription_action_contract_mode( - force_raw, - force_ai, - config.ai_formatting_enabled, - ai_key_available, - ); - // Keep the ephemeral transcription overlay in sync with what we will paste/save. - // This makes it easier to understand differences between streaming preview and final-pass output. - crate::ui::overlay::set_transcription_action_contract( - &raw_text, - &final_formatted_text, - action_contract_mode, - truth_display_status.clone(), - ); - } else if !assistive { + // The action-contract rewrite was retired with the AppKit delivery path; + // keep only the live-stream skip breadcrumb (formatting is bypassed there). + let action_contract_applies = + should_apply_transcription_action_contract(assistive, live_stream_session) + && config.transcription_overlay_enabled; + if !(assistive || action_contract_applies) { debug!( "Skipping transcription action contract rewrite during live stream (mode={mode_label})" ); @@ -4354,21 +3506,12 @@ impl RecordingController { // Quick Notes: optionally save to daily note file (dictation-only). if !assistive && config.quick_notes_enabled { - let frontmost_app = tokio::task::spawn_blocking(capture_frontmost_app_only) - .await - .ok() - .and_then(|ctx| ctx.frontmost_app); - - match crate::state::notes::append_quick_note( - &formatted_text, - recording_timestamp, - frontmost_app.as_deref(), - ) { + match crate::state::notes::append_quick_note(&formatted_text, recording_timestamp) { Ok(path) => { info!("Quick note saved: {}", path.display()); #[cfg(target_os = "macos")] crate::os::notifications::notify( - "CodeScribe", + "Codescribe", &format!( "Saved note: {}", path.file_name().and_then(|s| s.to_str()).unwrap_or("note") @@ -4399,19 +3542,30 @@ impl RecordingController { write_truth_sidecar_logged(&audio_saved_path, &truth_metadata); } - if cfg!(test) { - info!("Skipping paste in tests (mode={})", mode_label); - } else if should_auto_paste { - let paste_text = - maybe_wrap_transcript_for_delivery(&final_formatted_text, &config, &mode_label); - // Paste the text into the active application - clipboard::paste_text(&paste_text).context("Failed to paste text")?; - info!("Text pasted successfully"); - } else { - info!("Auto-paste skipped (mode={})", mode_label); + // Overlay disabled = no decision surface. The action-driven gates above + // (commit_trigger / toggle-adjudicated / live-stream) hand the transcript + // to the overlay; with no overlay it would just vanish. Deliver headless by + // pasting directly at the cursor — unless there is nothing to paste (no + // speech) or Notes Mode chose save-only. + let overlay_disabled = !config.transcription_overlay_enabled; + let has_final_text = !final_formatted_text.trim().is_empty(); + let notes_save_only = config.quick_notes_enabled && config.quick_notes_save_only; + if overlay_disabled + && !assistive + && truth_no_speech_reason.is_none() + && has_final_text + && !notes_save_only + { + should_auto_paste = true; } - // Save final transcript (skip duplicate when RAW already stored and unchanged) + // Save final transcript (skip duplicate when RAW already stored and + // unchanged) BEFORE the paste attempt. Paste can fail transiently (e.g. + // no focused app / Accessibility hiccup) and its `?` early-returns from + // this function; persisting first guarantees the AI-formatted layer is + // recoverable from history even when delivery fails. History write is a + // disk-only side effect (not user-visible), so ordering it ahead of the + // paste leaves the user-visible side-effect order unchanged. let needs_final_save = !assistive && !live_stream_session && (!raw_save_enabled @@ -4426,7 +3580,6 @@ impl RecordingController { ); info!("Transcript saved: {}", entry.path.display()); write_truth_sidecar_logged(&entry.path, &truth_metadata); - crate::ui::voice_chat::refresh_drawer(); } else if assistive { info!( "Assistive flow: skipping legacy final transcript save (ThreadStore is source of truth)" @@ -4435,6 +3588,18 @@ impl RecordingController { info!("Final transcript matches RAW; skipping duplicate save"); } + if cfg!(test) { + info!("Skipping paste in tests (mode={})", mode_label); + } else if should_auto_paste { + let paste_text = + maybe_wrap_transcript_for_delivery(&final_formatted_text, &config, &mode_label); + // Paste the text into the active application + clipboard::paste_text(&paste_text).context("Failed to paste text")?; + info!("Text pasted successfully"); + } else { + info!("Auto-paste skipped (mode={})", mode_label); + } + if let Some(cloud_verdict) = cloud_verdict_opt { let entry = crate::state::history::save_entry_with_timestamp_and_slug( &cloud_verdict.text, @@ -4506,10 +3671,7 @@ impl RecordingController { }); } - Ok(types::TranscriptProcessOutcome { - commit_trigger, - final_status, - }) + Ok(types::TranscriptProcessOutcome { commit_trigger }) } /// Force reset to IDLE state without stopping recorder. @@ -4525,12 +3687,6 @@ impl RecordingController { async fn reset_state(&self) { self.reset_session_fields().await; - // Hide UI indicators - hide_hold_badge(); - - // Update shared UI status - crate::ui::voice_chat::update_voice_chat_status("Idle"); - info!("State reset to IDLE complete"); } diff --git a/app/controller/tests.rs b/app/controller/tests.rs index 44c85a30..86fe2304 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -52,18 +52,6 @@ async fn test_last_segment_audio_offset_atomic_advance_and_reset() { ); } -#[test] -fn test_renamed_request_recording_stop_is_callable() { - // Compile-time guard that the rename `request_recording_commit` → - // `request_recording_stop` shipped intact. If anyone re-renames or - // removes the function this test stops compiling. Body doesn't have to - // execute (OVERLAY_CONTROLLER won't be registered in tests, so the call - // gates out early with a warn!) — we just need the symbol to resolve. - let _: fn() = request_recording_stop; - let _: fn() = request_segment_commit; - let _: fn() = request_segment_commit_and_augment; -} - #[test] fn test_assistive_hold_delay_floor_preserves_higher_configured_delay() { assert_eq!(effective_hold_start_delay_ms(200, false), 200); @@ -79,48 +67,6 @@ fn test_toggle_stop_watchdog_allows_default_ai_attempt_budget() { ); } -#[test] -fn test_overlay_format_result_marks_failed_formatting_raw() { - let out = overlay_format_result_text( - "raw transcript", - crate::ai_formatting::AiFormatResult { - text: "raw transcript".to_string(), - reasoning_text: None, - status: crate::ai_formatting::AiFormatStatus::Failed, - }, - ); - - assert_eq!(out, "raw transcript\n\n(raw — formatting failed)"); -} - -#[test] -fn test_overlay_format_result_marks_empty_formatting_output() { - let out = overlay_format_result_text( - "raw transcript", - crate::ai_formatting::AiFormatResult { - text: " ".to_string(), - reasoning_text: None, - status: crate::ai_formatting::AiFormatStatus::Applied, - }, - ); - - assert_eq!(out, "raw transcript\n\n(raw — formatting failed)"); -} - -#[test] -fn test_overlay_format_result_keeps_applied_formatting() { - let out = overlay_format_result_text( - "raw transcript", - crate::ai_formatting::AiFormatResult { - text: "Formatted transcript.".to_string(), - reasoning_text: None, - status: crate::ai_formatting::AiFormatStatus::Applied, - }, - ); - - assert_eq!(out, "Formatted transcript."); -} - #[tokio::test] #[serial] async fn test_hold_down_schedules_delayed_start() { @@ -367,42 +313,6 @@ async fn test_hold_down_sets_force_raw_mode() { ); } -#[test] -fn test_action_contract_mode_prefers_raw_when_forced() { - let mode = resolve_transcription_action_contract_mode(true, false, true, true); - assert_eq!( - mode, - crate::ui::overlay::TranscriptionActionContractMode::Raw - ); -} - -#[test] -fn test_action_contract_mode_uses_ai_format_when_force_ai_enabled() { - let mode = resolve_transcription_action_contract_mode(false, true, false, false); - assert_eq!( - mode, - crate::ui::overlay::TranscriptionActionContractMode::AiFormat - ); -} - -#[test] -fn test_action_contract_mode_uses_ai_format_for_toggle_ai_path() { - let mode = resolve_transcription_action_contract_mode(false, false, true, true); - assert_eq!( - mode, - crate::ui::overlay::TranscriptionActionContractMode::AiFormat - ); -} - -#[test] -fn test_action_contract_mode_uses_raw_for_toggle_without_ai() { - let mode = resolve_transcription_action_contract_mode(false, false, true, false); - assert_eq!( - mode, - crate::ui::overlay::TranscriptionActionContractMode::Raw - ); -} - #[test] fn test_truth_engine_label_maps_toggle_session_adjudicated_to_local_whisper() { assert_eq!( @@ -497,7 +407,7 @@ fn test_transcript_delivery_wrap_uses_config_when_enabled() { assert_eq!( maybe_wrap_transcript_for_delivery("literal transcript", &config, "dictation"), - "\nliteral transcript\n" + "\nliteral transcript\n" ); } @@ -523,21 +433,26 @@ fn test_toggle_stop_event_preserves_active_session_identity() { #[tokio::test] #[serial] -async fn test_agent_send_in_flight_blocks_new_hotkey_starts() { +async fn test_agent_send_in_flight_blocks_nonassistive_hotkey_starts() { + // Contract (preserved): a *raw* dictation start fired while a background + // agent turn is still streaming stays blocked — barging a raw transcript + // into a live agent turn is never wanted, and it preserves the single audio + // pipeline. The block lands before the mode-flag section, so the controller + // stays Idle with default flags. let controller = RecordingController::new(); helpers::set_agent_send_in_flight_for_test(true); - let selection_hold = HotkeyInput { + let raw_hold = HotkeyInput { key_type: HotkeyType::Hold, action: HotkeyAction::Down, - assistive: true, - hold_mode: HoldMode::Selection, - force_raw: false, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; controller - .handle_hotkey_event(selection_hold) + .handle_hotkey_event(raw_hold) .await .expect("agent-busy hotkey block should be non-fatal"); @@ -547,6 +462,97 @@ async fn test_agent_send_in_flight_blocks_new_hotkey_starts() { helpers::set_agent_send_in_flight_for_test(false); } +/// Assistive Talk Anytime — the agent-send gate decision is a pure function of +/// (state, event, in-flight flag). These assertions pin the new contract +/// without spawning the heavy async recording machinery. +#[test] +fn test_assistive_talk_anytime_gate_predicate() { + let assistive_chat_hold = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Down, + assistive: true, + hold_mode: HoldMode::Chat, + force_raw: false, + force_ai: false, + }; + let raw_hold = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Down, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + let assistive_toggle = HotkeyInput { + key_type: HotkeyType::Toggle, + action: HotkeyAction::Press, + assistive: true, + hold_mode: HoldMode::Raw, + force_raw: false, + force_ai: false, + }; + let release = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Up, + assistive: true, + hold_mode: HoldMode::Chat, + force_raw: false, + force_ai: false, + }; + + // Classifier: only *start* events flagged assistive are Talk-Anytime starts. + assert!(is_assistive_start_event(&assistive_chat_hold)); + assert!(is_assistive_start_event(&assistive_toggle)); + assert!(!is_assistive_start_event(&raw_hold)); // raw start is not assistive + assert!(!is_assistive_start_event(&release)); // a release is not a start + + // Talk Anytime: an assistive start is ALLOWED through while the agent + // answers in the background (Idle + in-flight) — it must reach the recording + // path so its utterance flows into the pending-follow-up buffer. + assert!( + !should_block_hotkey_during_agent_send(State::Idle, &assistive_chat_hold, true), + "FN+Shift Talk Anytime must not be ignored while an agent turn streams" + ); + assert!( + !should_block_hotkey_during_agent_send(State::Idle, &assistive_toggle, true), + "assistive toggle Talk Anytime must not be ignored while an agent turn streams" + ); + + // Protected: a raw dictation start stays blocked during a streaming turn. + assert!( + should_block_hotkey_during_agent_send(State::Idle, &raw_hold, true), + "raw dictation must not barge a live agent turn" + ); + + // No agent in flight → nothing is gated (normal idle dictation works). + assert!(!should_block_hotkey_during_agent_send( + State::Idle, + &raw_hold, + false + )); + assert!(!should_block_hotkey_during_agent_send( + State::Idle, + &assistive_chat_hold, + false + )); + + // Non-start events (key release) are never blocked by this gate, so a hold + // release can always cancel/finish even mid-turn. + assert!(!should_block_hotkey_during_agent_send( + State::Idle, + &release, + true + )); + + // The gate only acts at Idle: while audio/transcription holds State::Busy the + // separate Busy guard owns the decision (this gate stays out of its way). + assert!(!should_block_hotkey_during_agent_send( + State::Busy, + &raw_hold, + true + )); +} + fn make_final_pass_verdict( text: &str, speech_pct: f32, @@ -730,6 +736,78 @@ fn test_adjudicate_recording_truth_marks_raw_streaming_preview_as_degraded_fallb assert_eq!(verdict.display_status, "Streaming fallback"); } +#[test] +fn test_adjudicate_recording_truth_cold_whisper_empty_live_recovers_via_final_pass() { + // P0 decoupling contract: recording readiness != Whisper readiness. + // + // Cold-start scenario — the user pressed record and spoke while Whisper was + // still loading (or had been idle-unloaded). The live worker therefore + // produced NOTHING (its chunks dropped under backpressure during the cold + // load), so `streaming_text` is empty. But audio capture never blocked: the + // full WAV was saved, and `finish_recording` ran the final pass on it, + // lazy-loading the engine. The beginning the user spoke during warm-up must + // be recovered, not lost — the saved audio path is the authoritative truth. + let verdict = adjudicate_recording_truth( + true, + true, + Some(make_final_pass_verdict( + "poczatek wypowiedzi z zimnego startu", + 79.0, + Some(-0.28), + false, + )), + // Empty live preview: cold Whisper meant no live transcript at all. + String::new(), + None, + &SessionTelemetrySnapshot::default(), + ); + + assert_eq!( + verdict.raw_text.as_deref(), + Some("poczatek wypowiedzi z zimnego startu"), + "final pass on the saved WAV must recover speech spoken during Whisper warm-up" + ); + assert_eq!( + verdict.transcript_source, + Some(RecordingTranscriptSource::LocalFinalPass), + "cold-start recovery is authoritative LocalFinalPass, not a degraded fallback" + ); + assert_eq!(verdict.fallback_class, None); + assert!( + verdict.confidence_flags.is_empty(), + "a clean final-pass recovery carries no degraded/unverified flags" + ); + assert_eq!(verdict.commit_trigger, None); + assert_eq!(verdict.display_status, "Final-pass local"); +} + +#[test] +fn test_recording_start_paths_do_not_gate_on_whisper_health() { + let source = include_str!("mod.rs"); + let hold_start = source + .split("let task = tokio::spawn(async move {") + .nth(1) + .and_then(|tail| tail.split("Self::configure_hold_event_sink(").next()) + .expect("hold start pre-recorder block should be present"); + let toggle_start = source + .split("async fn start_toggle_recording") + .nth(1) + .and_then(|tail| { + tail.split("let _guard = self.serial_lock.lock().await;") + .next() + }) + .expect("toggle start pre-lock block should be present"); + + assert!( + !hold_start.contains("check_health"), + "hold recording start must not wait for Whisper health; saved audio/final pass recovers cold STT" + ); + assert!( + !toggle_start.contains("check_health"), + "toggle recording start must not wait for Whisper health; saved audio/final pass recovers cold STT" + ); +} + #[test] fn test_adjudicate_recording_truth_uses_typed_cloud_primary_verdict() { let verdict = adjudicate_recording_truth( @@ -783,46 +861,6 @@ fn test_adjudicate_recording_truth_marks_low_logprob_as_unsafe() { assert_eq!(verdict.display_status, "Possible hallucination"); } -#[test] -fn test_recorder_runtime_recovery_requires_granted_microphone_and_missing_recorder() { - assert!(should_attempt_recorder_runtime_recovery( - PermissionStatus::Granted, - true - )); - assert!(!should_attempt_recorder_runtime_recovery( - PermissionStatus::Denied, - true - )); - assert!(!should_attempt_recorder_runtime_recovery( - PermissionStatus::Granted, - false - )); -} - -#[test] -fn test_recorder_recovery_message_uses_settings_language() { - let message = RecordingController::format_recorder_recovery_message( - &["Accessibility", "Microphone"], - "DictationHotkey", - "FormattingHotkey", - "AssistiveHotkey", - ); - - assert!(message.contains("Open Settings")); - assert!(!message.contains("Setup")); - assert!(message.contains("Accessibility, Microphone")); -} - -#[test] -fn test_backend_recovery_message_uses_settings_language() { - let message = - RecordingController::format_backend_recovery_message(Some("Cloud endpoint timed out")); - - assert!(message.contains("Open Settings")); - assert!(!message.contains("Setup")); - assert!(message.contains("Cloud endpoint timed out")); -} - // ── Pure-function unit tests for truth helpers (push_typed_flag, // truth_review_trigger, truth_display_status). These guard the // truth-surface adjudicator primitives so regressions in precedence or @@ -1404,7 +1442,7 @@ async fn test_finish_recording_resets_unconditionally_assistive() { async fn test_no_decision_mode_state_exists() { // Compile-time + runtime proof: State enum has exactly these variants. // There is NO "DecisionMode" variant — the paste regression was caused - // by `enter_decision_mode()` which has been replaced by `schedule_auto_hide()`. + // by the old legacy transcription overlay decision mode, which has been removed. let states = [State::Idle, State::RecHold, State::RecToggle]; for state in &states { let controller = RecordingController::new(); @@ -1557,14 +1595,12 @@ fn test_delta_first_guards_allow_full_rewrite_offline() { #[test] fn test_process_recording_outcome_no_speech_is_soft() { - let outcome = - ProcessRecordingOutcome::no_speech("vad_no_speech_detected", "No reliable speech detected"); + let outcome = ProcessRecordingOutcome::no_speech("vad_no_speech_detected"); assert_eq!( outcome.no_speech_reason.as_deref(), Some("vad_no_speech_detected") ); assert!(outcome.commit_trigger.is_none()); - assert_eq!(outcome.final_status, "No reliable speech detected"); } #[tokio::test] @@ -1714,6 +1750,73 @@ async fn test_reset_session_after_start_failure_clears_transient_state() { assert!(!is_assistive_session()); } +#[tokio::test(start_paused = true)] +#[serial] +async fn test_hold_stuck_stop_watchdog_recovers_idle_and_clears_session_fields() { + let _hang = hang_process_recording_for_test(); + let controller = RecordingController::new(); + *controller.state.write().await = State::RecHold; + *controller.assistive_mode.write().await = true; + *controller.hold_mode.write().await = HoldMode::Chat; + *controller.force_raw_mode.write().await = true; + *controller.force_ai_mode.write().await = true; + *controller.session_id.write().await = Some("stuck-hold-session".to_string()); + *controller.assistive_context.write().await = Some(AssistiveContext::default()); + *controller.pre_overlay_frontmost_app.write().await = Some("com.example.Editor".to_string()); + controller + .start_transition_in_flight + .store(true, std::sync::atomic::Ordering::SeqCst); + controller + .assistive_loop_active + .store(true, std::sync::atomic::Ordering::SeqCst); + controller + .toggle_user_has_text + .store(true, std::sync::atomic::Ordering::SeqCst); + controller + .toggle_assistant_has_text + .store(true, std::sync::atomic::Ordering::SeqCst); + set_assistive_session(true); + + let err = controller + .finish_recording() + .await + .expect_err("pending process_recording must trip the hold stuck-stop watchdog"); + + assert!( + err.to_string().contains("Hold stop timeout"), + "unexpected timeout error: {err}" + ); + assert_eq!(controller.current_state().await, State::Idle); + assert!(!*controller.assistive_mode.read().await); + assert_eq!(*controller.hold_mode.read().await, HoldMode::Raw); + assert!(!*controller.force_raw_mode.read().await); + assert!(!*controller.force_ai_mode.read().await); + assert!(controller.session_id.read().await.is_none()); + assert!(controller.assistive_context.read().await.is_none()); + assert!(controller.pre_overlay_frontmost_app.read().await.is_none()); + assert!( + !controller + .start_transition_in_flight + .load(std::sync::atomic::Ordering::SeqCst) + ); + assert!( + !controller + .assistive_loop_active + .load(std::sync::atomic::Ordering::SeqCst) + ); + assert!( + !controller + .toggle_user_has_text + .load(std::sync::atomic::Ordering::SeqCst) + ); + assert!( + !controller + .toggle_assistant_has_text + .load(std::sync::atomic::Ordering::SeqCst) + ); + assert!(!is_assistive_session()); +} + /// Regression guard for the toggle-stop self-deadlock (root cause behind /// commit 91b2346's watchdog). /// @@ -1756,3 +1859,56 @@ async fn rwlock_session_id_read_then_write_does_not_self_deadlock() { "expected the write to land after the read guard dropped" ); } + +/// A failed transcription must reach the user, not just the log. The Err branch +/// of `handle_processed_recording_result` broadcasts an engine `Warning` over the +/// existing IPC channel; the bridge forwarder turns that into `listener.on_error` +/// + a tray Error state on the SwiftUI surface. Without this the failure was a +/// silent `error!()` and the user saw nothing. +#[tokio::test] +async fn test_processing_failure_emits_user_visible_warning() { + let controller = RecordingController::new(); + let mut events = controller.subscribe_events(); + + controller + .handle_processed_recording_result( + false, + &Err(anyhow::anyhow!("simulated transcription failure")), + ) + .await; + + let event = events + .try_recv() + .expect("a processing failure must broadcast a user-visible warning event"); + match event.payload { + IpcEventPayload::Engine(EngineEventWire::Warning { code, message }) => { + assert_eq!(code, "transcription_failed"); + assert!( + message.contains("simulated transcription failure"), + "warning message must carry the underlying failure: {message}" + ); + } + other => panic!("expected an engine Warning payload, got {other:?}"), + } +} + +/// The formatted transcript must be persisted BEFORE the paste attempt so a paste +/// error (which `?`-early-returns from `process_stopped_recording`) cannot drop the +/// AI-formatted layer from history. Paste is skipped under `cfg!(test)`, so the +/// ordering invariant is asserted structurally against the source: if anyone moves +/// the `needs_final_save` block back below the paste call, this fails. +#[test] +fn test_formatted_transcript_persists_before_paste() { + let source = include_str!("mod.rs"); + let save_idx = source + .find("let needs_final_save") + .expect("final-transcript save block must be present"); + let paste_idx = source + .find("clipboard::paste_text(") + .expect("auto-paste call must be present"); + assert!( + save_idx < paste_idx, + "formatted transcript save must precede the paste attempt so a failed paste \ + cannot drop the AI-formatted layer from history" + ); +} diff --git a/app/controller/types.rs b/app/controller/types.rs index 4849fb38..011f52b2 100644 --- a/app/controller/types.rs +++ b/app/controller/types.rs @@ -147,6 +147,12 @@ pub struct HotkeyInput { pub force_ai: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranscriptionActionContractMode { + Raw, + AiFormat, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RecordingTranscriptSource { @@ -329,7 +335,6 @@ pub struct TranscriptPipelineParams { pub struct TranscriptProcessOutcome { /// Why manual commit/decision mode should be shown (if required). pub commit_trigger: Option, - pub final_status: String, } #[cfg(test)] diff --git a/app/dev/lab_server.rs b/app/dev/lab_server.rs deleted file mode 100644 index eaebded7..00000000 --- a/app/dev/lab_server.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! Minimal HTTP server for Lab UI -//! -//! Serves static files from assets/lab/ directory. -//! No external dependencies - just tokio TCP. -//! -//! NOTE: Currently unused - will be activated when Tauri frontend is integrated. - -use std::path::PathBuf; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tracing::{debug, error, info}; - -const LAB_PORT: u16 = 8237; - -/// Get the lab assets directory -fn lab_assets_dir() -> PathBuf { - // Try relative to executable first - if let Ok(exe) = std::env::current_exe() - && let Some(parent) = exe.parent() - { - let assets = parent.join("assets").join("lab"); - if assets.exists() { - return assets; - } - // Try one level up (for dev builds) - let assets = parent.parent().map(|p| p.join("assets").join("lab")); - if let Some(ref a) = assets - && a.exists() - { - return a.clone(); - } - } - - // Try repo_path from ~/.codescribe/repo_path (set by build.rs during cargo install) - if let Ok(home) = std::env::var("HOME") { - let repo_path_file = PathBuf::from(&home).join(".codescribe").join("repo_path"); - if let Ok(repo_path) = std::fs::read_to_string(&repo_path_file) { - let assets = PathBuf::from(repo_path.trim()).join("assets").join("lab"); - if assets.exists() { - return assets; - } - } - } - - // Fallback to cwd - PathBuf::from("assets/lab") -} - -/// MIME type for file extension -fn mime_type(path: &str) -> &'static str { - match path.rsplit('.').next() { - Some("html") => "text/html; charset=utf-8", - Some("css") => "text/css; charset=utf-8", - Some("js") => "application/javascript; charset=utf-8", - Some("json") => "application/json; charset=utf-8", - Some("png") => "image/png", - Some("svg") => "image/svg+xml", - Some("ico") => "image/x-icon", - _ => "application/octet-stream", - } -} - -/// Start the lab server (non-blocking, spawns tokio task) -pub fn start_lab_server() { - tokio::spawn(async { - if let Err(e) = run_server().await { - error!("Lab server error: {}", e); - } - }); -} - -async fn run_server() -> anyhow::Result<()> { - let addr = format!("127.0.0.1:{}", LAB_PORT); - let listener = TcpListener::bind(&addr).await?; - let assets_dir = lab_assets_dir(); - - info!("Lab server started at http://{}", addr); - info!("Serving files from: {:?}", assets_dir); - - loop { - let (socket, peer) = listener.accept().await?; - let assets = assets_dir.clone(); - - tokio::spawn(async move { - if let Err(e) = handle_connection(socket, assets).await { - debug!("Connection error from {}: {}", peer, e); - } - }); - } -} - -async fn handle_connection( - mut socket: tokio::net::TcpStream, - assets_dir: PathBuf, -) -> anyhow::Result<()> { - let (reader, mut writer) = socket.split(); - let mut buf_reader = BufReader::new(reader); - let mut request_line = String::new(); - buf_reader.read_line(&mut request_line).await?; - - // Parse: GET /path HTTP/1.1 - let parts: Vec<&str> = request_line.split_whitespace().collect(); - if parts.len() < 2 { - return Ok(()); - } - - let method = parts[0]; - let mut path = parts[1]; - - // Only handle GET - if method != "GET" { - let response = "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n"; - writer.write_all(response.as_bytes()).await?; - return Ok(()); - } - - // Drain headers (we don't need them for static serving) - loop { - let mut line = String::new(); - buf_reader.read_line(&mut line).await?; - if line == "\r\n" || line.is_empty() { - break; - } - } - - // Normalize path - if path == "/" { - path = "/index.html"; - } - - // Security: prevent directory traversal - let clean_path = path.trim_start_matches('/').replace("..", ""); - let file_path = assets_dir.join(&clean_path); - - // Check file exists and is within assets dir - let canonical = match file_path.canonicalize() { - Ok(p) => p, - Err(_) => { - send_404(&mut writer).await?; - return Ok(()); - } - }; - - let assets_canonical = assets_dir.canonicalize().unwrap_or(assets_dir); - if !canonical.starts_with(&assets_canonical) { - send_404(&mut writer).await?; - return Ok(()); - } - - // Read and send file - match tokio::fs::read(&canonical).await { - Ok(contents) => { - let mime = mime_type(&clean_path); - let response = format!( - "HTTP/1.1 200 OK\r\n\ - Content-Type: {}\r\n\ - Content-Length: {}\r\n\ - Access-Control-Allow-Origin: *\r\n\ - Cache-Control: no-cache\r\n\ - \r\n", - mime, - contents.len() - ); - writer.write_all(response.as_bytes()).await?; - writer.write_all(&contents).await?; - debug!("Served: {} ({} bytes)", clean_path, contents.len()); - } - Err(_) => { - send_404(&mut writer).await?; - } - } - - Ok(()) -} - -async fn send_404(writer: &mut tokio::net::tcp::WriteHalf<'_>) -> anyhow::Result<()> { - let body = "404 Not Found"; - let response = format!( - "HTTP/1.1 404 Not Found\r\n\ - Content-Type: text/plain\r\n\ - Content-Length: {}\r\n\ - \r\n\ - {}", - body.len(), - body - ); - writer.write_all(response.as_bytes()).await?; - Ok(()) -} diff --git a/app/dev/mod.rs b/app/dev/mod.rs deleted file mode 100644 index b265c6cd..00000000 --- a/app/dev/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(target_os = "macos")] -pub mod lab_server; diff --git a/app/ipc/mod.rs b/app/ipc/mod.rs deleted file mode 100644 index bd79f79a..00000000 --- a/app/ipc/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Unix Socket IPC for CLI ↔ GUI communication -//! -//! Socket path: /ipc/codescribe.sock (user-only) - -mod server; - -pub use server::run_server; -// Re-export types and the socket path from core: the server must bind exactly -// the path core's IpcClient connects to, so the computation lives once, in core. -pub use codescribe_core::ipc::{AppStatus, IpcCommand, IpcResponse, socket_path}; diff --git a/app/ipc/server.rs b/app/ipc/server.rs deleted file mode 100644 index 6f1d05bc..00000000 --- a/app/ipc/server.rs +++ /dev/null @@ -1,939 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::io::ErrorKind; -use std::os::unix::fs::PermissionsExt; -use std::os::unix::io::AsRawFd; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use anyhow::{Context, Result, bail}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::Semaphore; -use tracing::{info, warn}; - -use super::{AppStatus, IpcCommand, IpcResponse}; -use crate::audio::load_audio_file; -use crate::config::prompts::{ - DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, get_assistive_prompt, - get_assistive_prompt_path, get_formatting_prompt, get_formatting_prompt_path, -}; -use crate::config::{Config, UserSettings, keychain, settings::is_promoted_key}; -use crate::controller::{HotkeyAction, HotkeyInput, HotkeyType, RecordingController, State}; -use crate::stream_postprocess::StreamPostProcessor; -use crate::whisper; -use crate::{ai_formatting, os::hotkeys}; - -const REDACTED_VALUE: &str = ""; - -/// Maximum number of concurrent IPC client connections. -/// Prevents resource exhaustion from malicious/buggy local clients. -const MAX_CONCURRENT_CLIENTS: usize = 32; - -pub async fn run_server(controller: Arc) -> Result<()> { - let socket_path = super::socket_path(); - ensure_socket_dir(&socket_path)?; - - match fs::remove_file(&socket_path) { - Ok(()) => {} - Err(e) if e.kind() == ErrorKind::NotFound => {} - Err(e) => { - warn!( - "Failed to remove existing IPC socket {}: {}", - socket_path.display(), - e - ); - } - } - - let listener = UnixListener::bind(&socket_path)?; - set_socket_permissions(&socket_path); - info!("IPC server listening on {}", socket_path.display()); - - // Semaphore to limit concurrent connections (DoS protection) - let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_CLIENTS)); - - loop { - let (stream, _) = listener.accept().await?; - let controller = Arc::clone(&controller); - - // Acquire permit before spawning (blocks if at limit) - let permit = Arc::clone(&semaphore).acquire_owned().await; - if permit.is_err() { - warn!("IPC semaphore closed unexpectedly"); - continue; - } - let permit = permit.unwrap(); - - tokio::spawn(async move { - // Permit is held for the duration of the connection - let _permit = permit; - - if let Err(e) = verify_peer(&stream) { - warn!("IPC client rejected: {}", e); - return; - } - if let Err(e) = handle_client(stream, controller).await { - warn!("IPC client error: {}", e); - } - }); - } -} - -async fn handle_client(stream: UnixStream, controller: Arc) -> Result<()> { - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader); - let mut line = String::new(); - let mut event_rx: Option> = - None; - - loop { - tokio::select! { - read_result = reader.read_line(&mut line) => { - let n = read_result?; - if n == 0 { - break; - } - - let cmd = match serde_json::from_str::(&line) { - Ok(cmd) => cmd, - Err(e) => { - let response = IpcResponse::Error(format!("Invalid JSON: {}", e)); - write_response(&mut writer, &response).await?; - line.clear(); - continue; - } - }; - - match cmd { - IpcCommand::Subscribe => { - event_rx = Some(controller.subscribe_events()); - write_response(&mut writer, &IpcResponse::Ok).await?; - } - IpcCommand::Unsubscribe => { - event_rx = None; - write_response(&mut writer, &IpcResponse::Ok).await?; - } - _ => { - let response = handle_command(cmd, &controller).await; - write_response(&mut writer, &response).await?; - } - } - - line.clear(); - } - event = async { - event_rx.as_mut().expect("event_rx checked by guard").recv().await - }, if event_rx.is_some() => { - match event { - Ok(ev) => { - write_response(&mut writer, &IpcResponse::Event(ev)).await?; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!("IPC subscriber lagged by {} event(s)", n); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - event_rx = None; - } - } - } - } - } - - Ok(()) -} - -async fn write_response( - writer: &mut tokio::net::unix::OwnedWriteHalf, - response: &IpcResponse, -) -> Result<()> { - let json = serde_json::to_string(response)?; - writer.write_all(json.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - Ok(()) -} - -async fn handle_command(cmd: IpcCommand, controller: &RecordingController) -> IpcResponse { - match cmd { - IpcCommand::GetConfig => { - let config = redact_config_for_ipc(Config::load()); - IpcResponse::Config(Box::new(config)) - } - IpcCommand::SaveConfig { config } => { - let config = merge_sensitive_fields(*config); - if let Err(e) = persist_config(&config) { - return IpcResponse::Error(format!("Failed to save config: {}", e)); - } - - hotkeys::apply_hotkey_config(&config); - - controller.set_config(config).await; - IpcResponse::Ok - } - IpcCommand::ReloadRuntimeConfig => { - // UI handlers already set hotkey atomics synchronously before - // sending this IPC command — only controller config reload needed. - let config = Config::load(); - controller.set_config(config).await; - IpcResponse::Ok - } - IpcCommand::GetPrompt { prompt_type } => match prompt_type.as_str() { - "formatting" => IpcResponse::Prompt(get_formatting_prompt()), - "assistive" => IpcResponse::Prompt(get_assistive_prompt()), - _ => IpcResponse::Error(format!("Unknown prompt type: {}", prompt_type)), - }, - IpcCommand::SavePrompt { - prompt_type, - content, - } => match prompt_spec(&prompt_type) { - Some((path, _default)) => match save_prompt(&path, &content) { - Ok(()) => IpcResponse::Ok, - Err(e) => IpcResponse::Error(format!("Failed to save prompt: {}", e)), - }, - None => IpcResponse::Error(format!("Unknown prompt type: {}", prompt_type)), - }, - IpcCommand::ResetPrompt { prompt_type } => match prompt_spec(&prompt_type) { - Some((path, default)) => match save_prompt(&path, default) { - Ok(()) => IpcResponse::Prompt(default.to_string()), - Err(e) => IpcResponse::Error(format!("Failed to reset prompt: {}", e)), - }, - None => IpcResponse::Error(format!("Unknown prompt type: {}", prompt_type)), - }, - IpcCommand::SendMessage { message } => { - if message.trim().is_empty() { - return IpcResponse::Error("Empty message".to_string()); - } - - let language = Config::load().whisper_language; - let response = ai_formatting::format_text_with_status( - &message, - Some(language.as_str()), - true, - None, - ) - .await; - IpcResponse::Message(response.text) - } - IpcCommand::ResetContext => { - ai_formatting::reset_ollama_memory(); - crate::state::conversation::reset_conversation(); - IpcResponse::Ok - } - IpcCommand::FormatTranscript { - text, - language, - assistive, - } => { - if text.trim().is_empty() { - return IpcResponse::Error("Empty text cannot be formatted".to_string()); - } - - let lang = language.as_deref(); - let formatted = - ai_formatting::format_text_with_status(&text, lang, assistive, None).await; - - if formatted.text.trim().is_empty() { - IpcResponse::Error("Formatting returned empty result".to_string()) - } else { - IpcResponse::Message(formatted.text) - } - } - IpcCommand::TranscribeFile { path } => { - let audio_path = PathBuf::from(&path); - if !audio_path.exists() { - return IpcResponse::Error(format!( - "Audio file not found: {}", - audio_path.display() - )); - } - - let (samples, sample_rate) = match load_audio_file(&audio_path) { - Ok(data) => data, - Err(e) => { - return IpcResponse::Error(format!("Failed to load audio: {}", e)); - } - }; - - let language = Config::load().whisper_language; - // Single-pass: engine handles 25s/5s chunking internally - match whisper::transcribe(&samples, sample_rate, Some(language.as_str())) { - Ok(raw_text) => { - // Apply lexicon/cleanup postprocessing - let mut postprocessor = StreamPostProcessor::new(); - let text = postprocessor.process(&raw_text).unwrap_or(raw_text); - if text.trim().is_empty() { - IpcResponse::Error("Transcription returned empty result".to_string()) - } else { - IpcResponse::Message(text) - } - } - Err(e) => IpcResponse::Error(format!("Transcription failed: {}", e)), - } - } - IpcCommand::GetStatus => { - let state = controller.current_state().await; - let status = AppStatus { - state: state_to_string(state), - ai_formatting: Config::load().ai_formatting_enabled, - }; - IpcResponse::Status(status) - } - IpcCommand::StartRecording { assistive } => { - if controller.is_recording().await || controller.is_busy().await { - return IpcResponse::Error("Recording already in progress".to_string()); - } - - let event = HotkeyInput { - key_type: HotkeyType::Toggle, - action: HotkeyAction::Press, - assistive, - hold_mode: crate::os::hotkeys::HoldMode::Raw, - force_raw: !assistive, - force_ai: false, - }; - - match controller.handle_hotkey_event(event).await { - Ok(()) => IpcResponse::Ok, - Err(e) => IpcResponse::Error(format!("Failed to start recording: {}", e)), - } - } - IpcCommand::StopRecording => { - if !controller.is_recording().await { - return IpcResponse::Error("No recording in progress".to_string()); - } - - match controller.stop_recording_from_external_surface().await { - Ok(()) => IpcResponse::Ok, - Err(e) => IpcResponse::Error(format!("Failed to stop recording: {}", e)), - } - } - IpcCommand::Subscribe | IpcCommand::Unsubscribe => { - IpcResponse::Error("Subscribe/Unsubscribe are handled at connection level".to_string()) - } - } -} - -fn state_to_string(state: State) -> String { - match state { - State::Idle => "idle".to_string(), - State::RecHold | State::RecToggle => "recording".to_string(), - State::Busy => "busy".to_string(), - State::Conversation => "conversation".to_string(), - } -} - -fn prompt_spec(prompt_type: &str) -> Option<(PathBuf, &'static str)> { - match prompt_type { - "formatting" => Some((get_formatting_prompt_path(), DEFAULT_FORMATTING_PROMPT)), - "assistive" => Some((get_assistive_prompt_path(), DEFAULT_ASSISTIVE_PROMPT)), - _ => None, - } -} - -fn save_prompt(path: &PathBuf, content: &str) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, content)?; - Ok(()) -} - -fn persist_config(config: &Config) -> Result<()> { - enum EnvUpdate { - Set(String, String), - Remove(String), - } - - let env_path = Config::env_path(); - if let Some(parent) = env_path.parent() { - std::fs::create_dir_all(parent)?; - } - let mut env_vars = if env_path.exists() { - Config::parse_env_file(&env_path)? - } else { - HashMap::new() - }; - - let mut updated: Vec = Vec::new(); - let mut settings: Option = None; - let mut promoted_keys: Vec = Vec::new(); - - let mut put = |key: &str, value: String, env_vars: &mut HashMap| { - if is_promoted_key(key) { - let settings = settings.get_or_insert_with(UserSettings::load); - persist_promoted_setting(settings, key, &value); - promoted_keys.push(key.to_string()); - // Keep promoted settings out of legacy .env to avoid stale overrides. - env_vars.remove(key); - } else { - env_vars.insert(key.to_string(), value.clone()); - } - updated.push(EnvUpdate::Set(key.to_string(), value)); - }; - - put( - "HOLD_EXCLUSIVE", - bool_to_env(config.hold_exclusive), - &mut env_vars, - ); - put( - "HOLD_START_DELAY_MS", - config.hold_start_delay_ms.to_string(), - &mut env_vars, - ); - put( - "DOUBLE_TAP_INTERVAL_MS", - config.double_tap_interval_ms.to_string(), - &mut env_vars, - ); - put( - "TOGGLE_SILENCE_SEC", - config.toggle_silence_sec.to_string(), - &mut env_vars, - ); - - put( - "WHISPER_LANGUAGE", - config.whisper_language.as_str().to_string(), - &mut env_vars, - ); - - put( - "AI_FORMATTING_ENABLED", - bool_to_env(config.ai_formatting_enabled), - &mut env_vars, - ); - put( - "TRANSCRIPT_TAGGING_ENABLED", - bool_to_env(config.transcript_tagging_enabled), - &mut env_vars, - ); - put( - "TRANSCRIPT_TAG_TEMPLATE", - config.transcript_tag_template.clone(), - &mut env_vars, - ); - put( - "AI_MAX_TOKENS", - config.ai_max_tokens.to_string(), - &mut env_vars, - ); - put( - "AI_ASSISTIVE_MAX_TOKENS", - config.ai_assistive_max_tokens.to_string(), - &mut env_vars, - ); - - put( - "SHOW_TRAY_GLYPH", - bool_to_env(config.show_tray_glyph), - &mut env_vars, - ); - put( - "SHOW_DOCK_ICON", - bool_to_env(config.show_dock_icon), - &mut env_vars, - ); - put( - "TRANSCRIPTION_OVERLAY_ENABLED", - bool_to_env(config.transcription_overlay_enabled), - &mut env_vars, - ); - put( - "HOLD_INDICATOR", - bool_to_env(config.hold_indicator), - &mut env_vars, - ); - put( - "HOLD_BADGE_SIZE", - config.hold_badge_size.to_string(), - &mut env_vars, - ); - put( - "HOLD_BADGE_OFFSET_X", - config.hold_badge_offset_x.to_string(), - &mut env_vars, - ); - put( - "HOLD_BADGE_OFFSET_Y", - config.hold_badge_offset_y.to_string(), - &mut env_vars, - ); - - put( - "BEEP_ON_START", - bool_to_env(config.beep_on_start), - &mut env_vars, - ); - put("SOUND_NAME", config.sound_name.clone(), &mut env_vars); - put( - "SOUND_VOLUME", - config.sound_volume.to_string(), - &mut env_vars, - ); - - put( - "AUDIO_INPUT_DEVICE", - config.audio_input_device.clone().unwrap_or_default(), - &mut env_vars, - ); - - put( - "HISTORY_ENABLED", - bool_to_env(config.history_enabled), - &mut env_vars, - ); - - put( - "USE_LOCAL_STT", - bool_to_env(config.use_local_stt), - &mut env_vars, - ); - put("LOCAL_MODEL", config.local_model.clone(), &mut env_vars); - put( - "STT_ENDPOINT", - config.stt_endpoint.clone().unwrap_or_default(), - &mut env_vars, - ); - put( - "LLM_ENDPOINT", - config.llm_endpoint.clone().unwrap_or_default(), - &mut env_vars, - ); - - put( - "RESTORE_CLIPBOARD", - bool_to_env(config.restore_clipboard), - &mut env_vars, - ); - put( - "RESTORE_CLIPBOARD_DELAY_MS", - config.restore_clipboard_delay_ms.to_string(), - &mut env_vars, - ); - - put( - "START_AT_LOGIN", - bool_to_env(config.start_at_login), - &mut env_vars, - ); - put( - "DUMP_AUDIO_LOGS", - bool_to_env(config.dump_audio_logs), - &mut env_vars, - ); - - match persist_secret_setting( - "LLM_API_KEY", - config.llm_api_key.clone().unwrap_or_default().as_str(), - &mut env_vars, - )? { - Some(secret) => updated.push(EnvUpdate::Set("LLM_API_KEY".to_string(), secret)), - None => updated.push(EnvUpdate::Remove("LLM_API_KEY".to_string())), - } - match persist_secret_setting( - "STT_API_KEY", - config.stt_api_key.clone().unwrap_or_default().as_str(), - &mut env_vars, - )? { - Some(secret) => updated.push(EnvUpdate::Set("STT_API_KEY".to_string(), secret)), - None => updated.push(EnvUpdate::Remove("STT_API_KEY".to_string())), - } - - Config::write_env_file(&env_path, &env_vars)?; - - if let Some(settings) = settings - && let Err(e) = settings.save() - { - let settings_path = UserSettings::settings_path(); - warn!( - "IPC SaveConfig failed to persist promoted settings to {} (keys: {}). \ - Values are applied for this process only and may be lost on restart: {}", - settings_path.display(), - promoted_keys.join(", "), - e - ); - } - - for update in updated { - match update { - EnvUpdate::Set(key, value) => { - // SAFETY: This mirrors Config::save_to_env to keep runtime env in sync. - unsafe { std::env::set_var(&key, &value) }; - } - EnvUpdate::Remove(key) => { - // SAFETY: This mirrors Config::save_to_env semantics for clearing keys. - unsafe { std::env::remove_var(&key) }; - } - } - } - - Ok(()) -} - -fn persist_secret_setting( - key: &str, - raw_value: &str, - env_vars: &mut HashMap, -) -> Result> { - // Never store secrets in plaintext .env. - env_vars.remove(key); - - let value = raw_value.trim(); - if value.is_empty() { - keychain::delete_key(key) - .with_context(|| format!("Failed to delete {key} from Keychain"))?; - return Ok(None); - } - - keychain::save_key(key, value).with_context(|| format!("Failed to save {key} to Keychain"))?; - Ok(Some(value.to_string())) -} - -fn bool_to_env(value: bool) -> String { - if value { - "1".to_string() - } else { - "0".to_string() - } -} - -fn persist_promoted_setting(settings: &mut UserSettings, key: &str, value: &str) { - // String fields - match key { - "WHISPER_LANGUAGE" => settings.whisper_language = Some(value.to_string()), - "LOCAL_MODEL" => settings.local_model = Some(value.to_string()), - "STT_ENDPOINT" => settings.stt_endpoint = Some(value.to_string()), - "AUDIO_INPUT_DEVICE" => settings.audio_input_device = Some(value.to_string()), - "SOUND_NAME" => settings.sound_name = Some(value.to_string()), - "LLM_ENDPOINT" => settings.llm_endpoint = Some(value.to_string()), - "LLM_MODEL" => settings.llm_model = Some(value.to_string()), - "LLM_ASSISTIVE_ENDPOINT" => settings.llm_assistive_endpoint = Some(value.to_string()), - "LLM_ASSISTIVE_MODEL" => settings.llm_assistive_model = Some(value.to_string()), - "FORMATTING_LEVEL" => settings.formatting_level = Some(value.to_string()), - "LLM_FORMATTING_ENDPOINT" => settings.llm_formatting_endpoint = Some(value.to_string()), - "LLM_FORMATTING_MODEL" => settings.llm_formatting_model = Some(value.to_string()), - "TRANSCRIPT_SEND_MODE" => settings.transcript_send_mode = Some(value.to_string()), - "TRANSCRIPT_TAG_TEMPLATE" => settings.transcript_tag_template = Some(value.to_string()), - "WHISPER_MODEL" => settings.whisper_model = Some(value.to_string()), - // u64 fields - "HOLD_START_DELAY_MS" => { - if let Ok(v) = value.parse::() { - settings.hold_start_delay_ms = Some(v); - } - } - "DOUBLE_TAP_INTERVAL_MS" => { - if let Ok(v) = value.parse::() { - settings.double_tap_interval_ms = Some(v); - } - } - "CODESCRIBE_BUFFER_DELAY_MS" => { - if let Ok(v) = value.parse::() { - settings.buffer_delay_ms = Some(v); - } - } - "CODESCRIBE_EMIT_WORDS_MAX" => { - if let Ok(v) = value.parse::() { - settings.emit_words_max = Some(v); - } - } - "BACKEND_MAX_UPLOAD_MB" => { - if let Ok(v) = value.parse::() { - settings.backend_max_upload_mb = Some(v); - } - } - // f32 fields - "TOGGLE_SILENCE_SEC" => { - if let Ok(v) = value.parse::() { - settings.toggle_silence_sec = Some(v); - } - } - "SOUND_VOLUME" => { - if let Ok(v) = value.parse::() { - settings.sound_volume = Some(v); - } - } - "CODESCRIBE_TYPING_CPS" => { - if let Ok(v) = value.parse::() { - settings.typing_cps = Some(v); - } - } - "CODESCRIBE_BUFFERED_INTERIM_SEC" => { - if let Ok(v) = value.parse::() { - settings.buffered_interim_sec = Some(v); - } - } - // bool fields - "HOLD_EXCLUSIVE" - | "AI_FORMATTING_ENABLED" - | "TRANSCRIPT_TAGGING_ENABLED" - | "BEEP_ON_START" - | "USE_LOCAL_STT" - | "HISTORY_ENABLED" - | "START_AT_LOGIN" - | "QUICK_NOTES_ENABLED" - | "QUICK_NOTES_SAVE_ONLY" - | "AGENT_ENTER_SENDS" - | "SHOW_DOCK_ICON" - | "TRANSCRIPTION_OVERLAY_ENABLED" => { - let bool_val = matches!(value, "1" | "true" | "yes" | "on"); - match key { - "HOLD_EXCLUSIVE" => settings.hold_exclusive = Some(bool_val), - "AI_FORMATTING_ENABLED" => settings.ai_formatting_enabled = Some(bool_val), - "TRANSCRIPT_TAGGING_ENABLED" => { - settings.transcript_tagging_enabled = Some(bool_val) - } - "BEEP_ON_START" => settings.beep_on_start = Some(bool_val), - "USE_LOCAL_STT" => settings.use_local_stt = Some(bool_val), - "HISTORY_ENABLED" => settings.history_enabled = Some(bool_val), - "START_AT_LOGIN" => settings.start_at_login = Some(bool_val), - "QUICK_NOTES_ENABLED" => settings.quick_notes_enabled = Some(bool_val), - "QUICK_NOTES_SAVE_ONLY" => settings.quick_notes_save_only = Some(bool_val), - "AGENT_ENTER_SENDS" => settings.agent_enter_sends = Some(bool_val), - "SHOW_DOCK_ICON" => settings.show_dock_icon = Some(bool_val), - "TRANSCRIPTION_OVERLAY_ENABLED" => { - settings.transcription_overlay_enabled = Some(bool_val) - } - // The outer and inner key lists are maintained by hand; a key - // added to the outer arm but not here must not abort the - // settings-write path. Log and leave settings untouched - // (symmetric to the outer fallback below). - _ => { - warn!("IPC bool setting key has no inner mapping to UserSettings: {key}"); - } - } - } - _ => { - warn!("IPC promoted setting key is not mapped to UserSettings: {key}"); - } - } -} - -fn redact_config_for_ipc(mut config: Config) -> Config { - if config.llm_api_key.is_some() { - config.llm_api_key = Some(REDACTED_VALUE.to_string()); - } - if config.stt_api_key.is_some() { - config.stt_api_key = Some(REDACTED_VALUE.to_string()); - } - config -} - -fn merge_sensitive_fields(mut config: Config) -> Config { - let existing = Config::load(); - if config.llm_api_key.as_deref() == Some(REDACTED_VALUE) || config.llm_api_key.is_none() { - config.llm_api_key = existing.llm_api_key; - } - if config.stt_api_key.as_deref() == Some(REDACTED_VALUE) || config.stt_api_key.is_none() { - config.stt_api_key = existing.stt_api_key; - } - config -} - -fn ensure_socket_dir(socket_path: &Path) -> Result<()> { - let dir = socket_path - .parent() - .ok_or_else(|| anyhow::anyhow!("IPC socket path missing parent"))?; - fs::create_dir_all(dir)?; - let permissions = fs::Permissions::from_mode(0o700); - if let Err(e) = fs::set_permissions(dir, permissions) { - warn!( - "Failed to set IPC socket directory permissions for {}: {}", - dir.display(), - e - ); - } - Ok(()) -} - -fn set_socket_permissions(socket_path: &Path) { - let permissions = fs::Permissions::from_mode(0o600); - if let Err(e) = fs::set_permissions(socket_path, permissions) { - warn!( - "Failed to set IPC socket permissions for {}: {}", - socket_path.display(), - e - ); - } -} - -fn verify_peer(stream: &UnixStream) -> Result<()> { - let current_uid = unsafe { libc::geteuid() }; - let Some(peer_uid) = peer_uid(stream) else { - bail!("Unable to determine peer uid"); - }; - if peer_uid != current_uid { - bail!( - "Peer uid {} does not match current uid {}", - peer_uid, - current_uid - ); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn peer_uid(stream: &UnixStream) -> Option { - let fd = stream.as_raw_fd(); - let mut ucred: libc::ucred = unsafe { std::mem::zeroed() }; - let mut len = std::mem::size_of::() as libc::socklen_t; - let rc = unsafe { - libc::getsockopt( - fd, - libc::SOL_SOCKET, - libc::SO_PEERCRED, - &mut ucred as *mut _ as *mut _, - &mut len, - ) - }; - (rc == 0).then_some(ucred.uid) -} - -#[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "dragonfly" -))] -fn peer_uid(stream: &UnixStream) -> Option { - let fd = stream.as_raw_fd(); - let mut uid: libc::uid_t = 0; - let mut gid: libc::gid_t = 0; - let rc = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) }; - (rc == 0).then_some(uid) -} - -#[cfg(not(any( - target_os = "linux", - target_os = "macos", - target_os = "ios", - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "dragonfly" -)))] -fn peer_uid(_stream: &UnixStream) -> Option { - None -} - -#[cfg(test)] -mod tests { - use super::*; - use codescribe_core::ipc::{EngineEventWire, IpcEventPayload}; - use tokio::time::{Duration, timeout}; - - async fn write_command( - writer: &mut tokio::net::unix::OwnedWriteHalf, - cmd: &IpcCommand, - ) -> Result<()> { - let json = serde_json::to_string(cmd)?; - writer.write_all(json.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - Ok(()) - } - - async fn read_response( - reader: &mut BufReader, - ) -> Result { - let mut line = String::new(); - let bytes = timeout(Duration::from_secs(1), reader.read_line(&mut line)) - .await - .context("timeout waiting for IPC response")??; - anyhow::ensure!(bytes > 0, "IPC connection closed unexpectedly"); - Ok(serde_json::from_str::(&line)?) - } - - #[tokio::test] - async fn subscribe_streams_state_and_engine_events() { - let controller = Arc::new(RecordingController::new()); - let (client, server) = UnixStream::pair().expect("unix pair"); - - let server_controller = Arc::clone(&controller); - let server_task = - tokio::spawn(async move { handle_client(server, server_controller).await }); - - let (reader_half, mut writer_half) = client.into_split(); - let mut reader = BufReader::new(reader_half); - - write_command(&mut writer_half, &IpcCommand::Subscribe) - .await - .expect("subscribe command"); - match read_response(&mut reader) - .await - .expect("subscribe response") - { - IpcResponse::Ok => {} - other => panic!("expected Ok after Subscribe, got {:?}", other), - } - - controller.publish_ipc_event_for_test(IpcEventPayload::StateChange { - from: "idle".to_string(), - to: "recording".to_string(), - }); - match read_response(&mut reader) - .await - .expect("state change event response") - { - IpcResponse::Event(event) => match event.payload { - IpcEventPayload::StateChange { from, to } => { - assert_eq!(from, "idle"); - assert_eq!(to, "recording"); - } - payload => panic!("expected state_change payload, got {:?}", payload), - }, - other => panic!("expected Event response, got {:?}", other), - } - - controller.publish_ipc_event_for_test(IpcEventPayload::Engine(EngineEventWire::Preview { - rev: 7, - text: "hello".to_string(), - })); - match read_response(&mut reader) - .await - .expect("engine event response") - { - IpcResponse::Event(event) => match event.payload { - IpcEventPayload::Engine(EngineEventWire::Preview { rev, text }) => { - assert_eq!(rev, 7); - assert_eq!(text, "hello"); - } - payload => panic!("expected preview engine payload, got {:?}", payload), - }, - other => panic!("expected Event response, got {:?}", other), - } - - write_command(&mut writer_half, &IpcCommand::Unsubscribe) - .await - .expect("unsubscribe command"); - match read_response(&mut reader) - .await - .expect("unsubscribe response") - { - IpcResponse::Ok => {} - other => panic!("expected Ok after Unsubscribe, got {:?}", other), - } - - controller.publish_ipc_event_for_test(IpcEventPayload::StateChange { - from: "recording".to_string(), - to: "idle".to_string(), - }); - - let mut line = String::new(); - let next = timeout(Duration::from_millis(150), reader.read_line(&mut line)).await; - assert!( - next.is_err(), - "unsubscribed client unexpectedly received an event line: {line:?}" - ); - - drop(writer_half); - let join = timeout(Duration::from_secs(1), server_task) - .await - .expect("server task timeout") - .expect("server task panicked"); - assert!(join.is_ok(), "server task failed: {join:?}"); - } -} diff --git a/app/lib.rs b/app/lib.rs index 02eaeef6..ecee890b 100644 --- a/app/lib.rs +++ b/app/lib.rs @@ -1,4 +1,4 @@ -//! CodeScribe - native macOS tray dictation app with runtime Whisper lookup. +//! Codescribe - native macOS tray dictation app with runtime Whisper lookup. //! //! This crate re-exports the core functionality from `codescribe_core` //! and provides the macOS-specific UI, tray, and hotkey layers. @@ -21,8 +21,9 @@ pub use codescribe_core::{ // ═══════════════════════════════════════════════════════════ pub mod agent; +pub mod agent_delivery; +pub mod logging; pub mod os; -pub mod qube_lifecycle; #[cfg(target_os = "macos")] pub mod controller; @@ -31,30 +32,11 @@ pub mod controller; pub mod presentation; #[cfg(target_os = "macos")] -pub mod ipc; - -#[cfg(target_os = "macos")] -pub mod ui; - -#[cfg(target_os = "macos")] -pub mod ui_helpers; - -#[cfg(target_os = "macos")] -pub mod dev; - -#[cfg(target_os = "macos")] -pub use ui::{ - BadgeMode, HoldBadgeConfig, apply_dock_icon_visibility, focused_element_accepts_text, - get_caret_position, get_cursor_position, hide_hold_badge, install_basic_edit_menu, - set_dock_icon, show_badge_for_mode, show_hold_badge, show_hold_badge_with_config, +pub use os::onboarding::{ + load_onboarding_progress, mark_onboarding_done, save_onboarding_progress, + should_show_onboarding, }; -#[cfg(target_os = "macos")] -pub use ui::onboarding::{should_show_onboarding, show_onboarding_wizard}; - -#[cfg(target_os = "macos")] -pub use ui::tray; - #[cfg(target_os = "macos")] pub use os::clipboard; #[cfg(target_os = "macos")] diff --git a/app/logging.rs b/app/logging.rs new file mode 100644 index 00000000..018e7994 --- /dev/null +++ b/app/logging.rs @@ -0,0 +1,122 @@ +//! Process-wide tracing/log initialization. +//! +//! Historically this lived in the CLI entrypoint `bin/codescribe.rs`, which was +//! deleted together with the legacy AppKit UI (commit 37efe51). The SwiftUI app +//! enters exclusively through the UniFFI bridge and never had a `main()` that +//! called it — so from that excision onward the app installed **no** tracing +//! subscriber and stopped writing `~/.codescribe/logs/codescribe.log`. +//! +//! [`init_logging`] restores that behaviour. It is safe to call from every FFI +//! entry point: a [`Once`] guard makes it idempotent, so whichever bridge object +//! Swift constructs first wins and the rest are no-ops. + +use std::env; +use std::path::PathBuf; +use std::sync::Once; + +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`). +pub fn init_logging() { + INIT.call_once(|| { + init_tracing(); + install_panic_hook(); + }); +} + +fn init_tracing() { + use tracing_subscriber::prelude::*; + use tracing_subscriber::{EnvFilter, fmt}; + + // Prefer `RUST_LOG`, fall back to legacy `LOG_LEVEL`. + let filter = match env::var("RUST_LOG") { + Ok(v) => v, + Err(_) => match env::var("LOG_LEVEL") { + Ok(v) => v.to_lowercase(), + Err(_) => "info".to_string(), + }, + }; + + 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) + .with_thread_ids(true) + .with_thread_names(true); + + 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); + + if let Ok(file) = file { + let file = std::sync::Arc::new(file); + let file_layer = fmt::layer() + .with_ansi(false) + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .with_writer(move || (*file).try_clone().expect("Failed to clone log file")); + + let _ = tracing_subscriber::registry() + .with(filter_layer) + .with(stderr_layer) + .with(file_layer) + .try_init(); + } else { + let _ = tracing_subscriber::registry() + .with(filter_layer) + .with(stderr_layer) + .try_init(); + } +} + +/// Install a global panic hook that logs every panic through `tracing` before +/// the process unwinds or aborts. +/// +/// This is the only diagnostic that survives `panic="abort"` in the release +/// profile: `std::panic::set_hook` runs the hook BEFORE the abort, so even a +/// panic crossing an `extern "C"` boundary — where `catch_unwind` is useless — +/// leaves a symbolizable trace (payload + location + thread name + backtrace) +/// in `~/.codescribe/logs/codescribe.log`. +/// +/// MUST be installed AFTER `init_tracing()` (so a subscriber exists) and BEFORE +/// the first task/thread is spawned, otherwise early panics would be silent. +fn install_panic_hook() { + std::panic::set_hook(Box::new(|info| { + // Extract a human-readable payload (panic message). + let payload = info.payload(); + let message = payload + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_string()); + + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "".to_string()); + + let thread = std::thread::current(); + let thread_name = thread.name().unwrap_or("").to_string(); + + let backtrace = std::backtrace::Backtrace::force_capture(); + + tracing::error!( + target: "panic", + thread = %thread_name, + location = %location, + "PANIC: {message}\nbacktrace:\n{backtrace}" + ); + })); +} diff --git a/app/os/clipboard.rs b/app/os/clipboard.rs index ab76f358..ed64dbb9 100644 --- a/app/os/clipboard.rs +++ b/app/os/clipboard.rs @@ -142,13 +142,6 @@ impl ClipboardSnapshot { } } -/// Takes a snapshot of the current clipboard -/// -/// Convenience function for ClipboardSnapshot::capture() -pub fn snapshot_clipboard() -> Result { - ClipboardSnapshot::capture() -} - /// Sets the clipboard content without simulating paste /// /// # Arguments @@ -185,17 +178,6 @@ pub fn get_clipboard() -> Result { Ok(text) } -/// Alias for set_clipboard - copies text to clipboard without pasting -/// -/// # Arguments -/// * `text` - The text to copy to clipboard -/// -/// # Errors -/// Returns error if clipboard operation fails -pub fn copy(text: &str) -> Result<()> { - set_clipboard(text) -} - /// Simulates a key press using CGEvent (thread-safe, no TSM issues) /// /// # Arguments @@ -296,44 +278,6 @@ fn simulate_right_arrow() -> Result<()> { Ok(()) } -/// Simple paste function - just sets clipboard and simulates Cmd+V -/// -/// Does NOT restore the previous clipboard content. Use paste_and_restore() -/// for smart clipboard management. -/// -/// # Arguments -/// * `text` - The text to paste -/// -/// # Errors -/// Returns error if clipboard or keyboard simulation fails -/// -/// # Focus / scope note (E: focus-confirm) -/// This module only sets the clipboard and posts the synthetic Cmd+V; it does -/// NOT activate or focus-confirm a target window. The "Cmd+C robustness" work -/// (cut E: `changeCount` + `wait_for_frontmost_app` focus-confirm backoff, -/// commit 65e713a) hardened the *selection capture* path (Cmd+C) in -/// `crate::os::selection`. The *paste* path (Cmd+V) that activates a target app -/// before pasting lives in `app/controller/mod.rs` -/// (`paste_overlay_text_with_target`) and still uses a fixed `sleep(80ms)` -/// after `activate_target_app` — it has the same activation→focus race but is -/// owned by a different module, so E does not rewire it here. The reusable -/// `crate::os::selection::wait_for_frontmost_app` (now `pub(crate)`) is the -/// intended drop-in replacement for that fixed sleep in a controller-owned -/// follow-up. -pub fn paste(text: &str) -> Result<()> { - if text.is_empty() { - warn!("Paste called with empty text"); - return Ok(()); - } - - set_clipboard(text).context("Failed to set clipboard for paste")?; - - // Simulate Cmd+V using CGEvent (thread-safe) - simulate_cmd_v().context("Failed to simulate Cmd+V")?; - - Ok(()) -} - /// Smart paste with configurable clipboard restoration /// /// This is a more flexible version of paste_text that allows you to control @@ -470,9 +414,15 @@ mod tests { fn test_set_and_get_clipboard() { let _guard = ClipboardTestGuard::capture(); let test_text = "Test clipboard content"; - set_clipboard(test_text).expect("Failed to set clipboard"); - - let retrieved = get_clipboard().expect("Failed to get clipboard"); + let Some(()) = skip_if_clipboard_unavailable(set_clipboard(test_text), "set clipboard") + else { + return; + }; + + let Some(retrieved) = skip_if_clipboard_unavailable(get_clipboard(), "get clipboard") + else { + return; + }; assert_eq!(retrieved, test_text); } @@ -490,10 +440,19 @@ mod tests { fn test_clipboard_snapshot_capture() { let _guard = ClipboardTestGuard::capture(); // Set some text - set_clipboard("Test snapshot content").expect("Failed to set clipboard"); + let Some(()) = skip_if_clipboard_unavailable( + set_clipboard("Test snapshot content"), + "set snapshot clipboard", + ) else { + return; + }; // Capture snapshot - let snapshot = ClipboardSnapshot::capture().expect("Failed to capture snapshot"); + let Some(snapshot) = + skip_if_clipboard_unavailable(ClipboardSnapshot::capture(), "capture snapshot") + else { + return; + }; // Should have text assert!(snapshot.text.is_some()); @@ -507,31 +466,51 @@ mod tests { let _guard = ClipboardTestGuard::capture(); // Set original content let original = "Original clipboard text"; - set_clipboard(original).expect("Failed to set clipboard"); + let Some(()) = + skip_if_clipboard_unavailable(set_clipboard(original), "set original clipboard") + else { + return; + }; // Capture snapshot - let snapshot = ClipboardSnapshot::capture().expect("Failed to capture snapshot"); + let Some(snapshot) = + skip_if_clipboard_unavailable(ClipboardSnapshot::capture(), "capture snapshot") + else { + return; + }; // Change clipboard - set_clipboard("Different text").expect("Failed to change clipboard"); + let Some(()) = + skip_if_clipboard_unavailable(set_clipboard("Different text"), "change clipboard") + else { + return; + }; // Restore snapshot - snapshot.restore().expect("Failed to restore snapshot"); + let Some(()) = skip_if_clipboard_unavailable(snapshot.restore(), "restore snapshot") else { + return; + }; // Should match original - let restored = get_clipboard().expect("Failed to get clipboard"); + let Some(restored) = skip_if_clipboard_unavailable(get_clipboard(), "get clipboard") else { + return; + }; assert_eq!(restored, original); } - #[test] - #[serial] - fn test_copy_alias() { - let _guard = ClipboardTestGuard::capture(); - let test_text = "Copy alias test"; - copy(test_text).expect("Failed to copy"); + fn skip_if_clipboard_unavailable(result: Result, action: &str) -> Option { + match result { + Ok(value) => Some(value), + Err(error) if is_clipboard_unavailable(&error) => { + eprintln!("skipping clipboard integration test: {action}: {error:#}"); + None + } + Err(error) => panic!("{action}: {error:#}"), + } + } - let retrieved = get_clipboard().expect("Failed to get clipboard"); - assert_eq!(retrieved, test_text); + fn is_clipboard_unavailable(error: &anyhow::Error) -> bool { + format!("{error:#}").contains("not supported with the current system configuration") } struct ClipboardTestGuard(Option); diff --git a/app/os/hold_badge.rs b/app/os/hold_badge.rs new file mode 100644 index 00000000..1973d738 --- /dev/null +++ b/app/os/hold_badge.rs @@ -0,0 +1,854 @@ +//! Cursor-following "hold badge" recording indicator. +//! +//! A small colored dot that floats near the text caret (via the Accessibility +//! API) and falls back to the mouse cursor when no caret is available. Used to +//! signal recording / processing / assistive states during dictation. +//! +//! Resurrected as a self-contained `app/os` module (previously lived in the now +//! excised `app/ui` AppKit layer). All AppKit/objc code is macOS-only; a no-op +//! stub surface keeps non-macOS builds compiling. +//! +//! Vibecrafted with AI Agents by Vetcoders (c)2026 Vetcoders + +// ───────────────────────────────────────────────────────────── +// Platform-agnostic surface (pure data, no AppKit) +// ───────────────────────────────────────────────────────────── + +/// Badge display mode for different recording/processing states +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BadgeMode { + /// Hold mode (Ctrl): Red, solid - "trzymaj palec!" + Hold, + /// Toggle mode (⌥⌥): Red, pulsing - "nagrywam hands-off" + Toggle, + /// Processing: Orange - "transkrybuję/formatuję" + Processing, + /// AI mode (Chat/Selection): Purple with glow - "AI słucha" + Assistive, +} + +impl BadgeMode { + /// Get the base color for this mode (RGBA) + pub fn color(&self) -> (f64, f64, f64, f64) { + match self { + BadgeMode::Hold => (1.0, 0.0, 0.0, 0.8), // Red + BadgeMode::Toggle => (1.0, 0.0, 0.0, 0.8), // Red (will pulse) + BadgeMode::Processing => (1.0, 0.5, 0.0, 0.85), // Orange + BadgeMode::Assistive => (0.6, 0.2, 0.9, 0.85), // Purple + } + } + + /// Whether this mode should pulse (opacity animation) + pub fn should_pulse(&self) -> bool { + matches!(self, BadgeMode::Toggle | BadgeMode::Processing) + } + + /// Whether this mode has glow effect + pub fn has_glow(&self) -> bool { + matches!(self, BadgeMode::Assistive) + } + + /// Get diameter multiplier for this mode + pub fn diameter_multiplier(&self) -> f64 { + match self { + BadgeMode::Assistive => 1.2, // Slightly larger for AI mode + _ => 1.0, + } + } +} + +/// Configuration for the hold badge +#[derive(Debug, Clone)] +pub struct HoldBadgeConfig { + /// Diameter of the badge circle in pixels + pub diameter: f64, + /// Offset from caret/cursor position (x, y) + pub offset: (f64, f64), + /// Update interval in milliseconds + pub update_interval_ms: u64, + /// Badge color (R, G, B, A) + pub color: (f64, f64, f64, f64), + /// Badge mode for animations + pub mode: BadgeMode, +} + +impl Default for HoldBadgeConfig { + fn default() -> Self { + Self { + diameter: 12.0, + offset: (10.0, -10.0), + update_interval_ms: 150, + color: (1.0, 0.0, 0.0, 0.8), // Red with 80% opacity + mode: BadgeMode::Hold, + } + } +} + +impl HoldBadgeConfig { + /// Create config from badge mode with appropriate colors + pub fn from_mode(mode: BadgeMode) -> Self { + let base = Self::default(); + Self { + diameter: base.diameter * mode.diameter_multiplier(), + color: mode.color(), + mode, + ..base + } + } +} + +#[cfg(test)] +mod tests { + use super::{BadgeMode, HoldBadgeConfig}; + + #[test] + fn hold_badge_modes_encode_processing_and_assistive_affordances() { + assert_eq!(BadgeMode::Processing.color(), (1.0, 0.5, 0.0, 0.85)); + assert!(BadgeMode::Processing.should_pulse()); + assert!(!BadgeMode::Processing.has_glow()); + assert_eq!(BadgeMode::Processing.diameter_multiplier(), 1.0); + + assert_eq!(BadgeMode::Assistive.color(), (0.6, 0.2, 0.9, 0.85)); + assert!(!BadgeMode::Assistive.should_pulse()); + assert!(BadgeMode::Assistive.has_glow()); + assert_eq!(BadgeMode::Assistive.diameter_multiplier(), 1.2); + } + + #[test] + fn hold_badge_config_from_mode_preserves_layout_and_applies_mode_visuals() { + let base = HoldBadgeConfig::default(); + let processing = HoldBadgeConfig::from_mode(BadgeMode::Processing); + assert_eq!(processing.mode, BadgeMode::Processing); + assert_eq!(processing.color, BadgeMode::Processing.color()); + assert_eq!(processing.diameter, base.diameter); + assert_eq!(processing.offset, base.offset); + assert_eq!(processing.update_interval_ms, base.update_interval_ms); + + let assistive = HoldBadgeConfig::from_mode(BadgeMode::Assistive); + assert_eq!(assistive.mode, BadgeMode::Assistive); + assert_eq!(assistive.color, BadgeMode::Assistive.color()); + assert_eq!( + assistive.diameter, + base.diameter * BadgeMode::Assistive.diameter_multiplier() + ); + assert_eq!(assistive.offset, base.offset); + assert_eq!(assistive.update_interval_ms, base.update_interval_ms); + } +} + +// ───────────────────────────────────────────────────────────── +// macOS implementation +// ───────────────────────────────────────────────────────────── + +#[cfg(target_os = "macos")] +mod imp { + use super::{BadgeMode, HoldBadgeConfig}; + + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + use dispatch::Queue; + use objc::runtime::Class; + use objc::{msg_send, sel, sel_impl}; + use objc2_app_kit::{ + NSBackingStoreType, NSColor, NSEvent, NSWindowCollectionBehavior, NSWindowStyleMask, + }; + use std::ptr; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::Duration; + use tracing::{debug, warn}; + + use crate::os::Id; + + // Accessibility API bindings (use raw pointers compatible with C FFI) + type AXId = *mut std::ffi::c_void; + + #[link(name = "ApplicationServices", kind = "framework")] + unsafe extern "C" { + fn AXUIElementCopyAttributeValue(element: AXId, attribute: AXId, value: *mut AXId) -> i32; + fn AXUIElementCreateSystemWide() -> AXId; + fn AXValueGetValue(value: AXId, type_: i32, value_ptr: *mut std::ffi::c_void) -> bool; + fn CFRelease(cf: *const std::ffi::c_void); + } + + // CGColor functions + #[link(name = "CoreGraphics", kind = "framework")] + unsafe extern "C" { + fn CGColorCreate( + space: *const std::ffi::c_void, + components: *const f64, + ) -> *const std::ffi::c_void; + fn CGColorSpaceCreateDeviceRGB() -> *const std::ffi::c_void; + fn CGColorSpaceRelease(space: *const std::ffi::c_void); + fn CGColorRelease(color: *const std::ffi::c_void); + } + + // AX constants + const AX_ERROR_SUCCESS: i32 = 0; + const AX_FOCUSED_UIELEMENT_ATTRIBUTE: &str = "AXFocusedUIElement"; + const AX_ROLE_ATTRIBUTE: &str = "AXRole"; + const AX_SELECTED_TEXT_RANGE_ATTRIBUTE: &str = "AXSelectedTextRange"; + const AX_POSITION_ATTRIBUTE: &str = "AXPosition"; + const AX_SIZE_ATTRIBUTE: &str = "AXSize"; + + // AXValue types + const AX_VALUE_CGPOINT_TYPE: i32 = 1; + const AX_VALUE_CGSIZE_TYPE: i32 = 2; + const AX_VALUE_CFRANGE_TYPE: i32 = 3; + + // Window level constants + const NS_STATUS_WINDOW_LEVEL: i64 = 25; + + // ── inlined AppKit helpers (were `ui::shared::helpers::shell`) ── + + /// Add subview to a view. + /// # Safety + /// `parent` and `child` must be valid Objective-C views. + unsafe fn add_subview(parent: Id, child: Id) { + unsafe { + let _: () = msg_send![parent, addSubview: child]; + } + } + + /// Show panel (order front, even when app is inactive). + /// # Safety + /// `panel` must be a valid `NSPanel` / `NSWindow` instance. + unsafe fn panel_show(panel: Id) { + unsafe { + let _: () = msg_send![panel, orderFrontRegardless]; + } + } + + /// Close panel. + /// # Safety + /// `panel` must be a valid `NSPanel` / `NSWindow` instance. + unsafe fn panel_close(panel: Id) { + unsafe { + let _: () = msg_send![panel, close]; + } + } + + /// Hold badge state + struct HoldBadgeState { + window: Option, // Stores the NSPanel pointer as usize to make it Send. + timer_running: bool, + config: HoldBadgeConfig, + last_position: (f64, f64), + } + + lazy_static::lazy_static! { + static ref BADGE_STATE: Arc> = Arc::new(Mutex::new(HoldBadgeState { + window: None, + timer_running: false, + config: HoldBadgeConfig::default(), + last_position: (f64::NAN, f64::NAN), + })); + } + + /// Monotonic show generation. A show captures it at REQUEST time; `hide_hold_badge` + /// bumps it. When a queued (`exec_async`) show finally runs on the main thread it + /// aborts if the generation moved — otherwise a hide that raced ahead of the + /// enqueued show would be undone, leaving a badge panel + updater thread stuck + /// with nothing to tear them down. + static BADGE_GENERATION: AtomicU64 = AtomicU64::new(0); + + /// Check if the currently focused element accepts text input + pub fn focused_element_accepts_text() -> bool { + unsafe { + let system_wide = AXUIElementCreateSystemWide(); + if system_wide.is_null() { + return false; + } + + let mut focused_element: AXId = ptr::null_mut(); + let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); + let result = AXUIElementCopyAttributeValue( + system_wide, + attr_name.as_concrete_TypeRef() as AXId, + &mut focused_element, + ); + + CFRelease(system_wide); + + if result != AX_ERROR_SUCCESS || focused_element.is_null() { + return false; + } + + // Get role attribute + let mut role_value: AXId = ptr::null_mut(); + let role_attr = CFString::new(AX_ROLE_ATTRIBUTE); + let role_result = AXUIElementCopyAttributeValue( + focused_element, + role_attr.as_concrete_TypeRef() as AXId, + &mut role_value, + ); + + CFRelease(focused_element); + + if role_result != AX_ERROR_SUCCESS || role_value.is_null() { + return false; + } + + // Convert role to string + let role_str = CFString::wrap_under_get_rule(role_value as *const _); + let role = role_str.to_string(); + CFRelease(role_value); + + // Check if role indicates text input + matches!( + role.as_str(), + "AXTextArea" | "AXTextField" | "AXComboBox" | "AXTextView" | "AXWebArea" + ) + } + } + + /// Get the current text caret position in screen coordinates + pub fn get_caret_position() -> Option<(f64, f64)> { + unsafe { + let system_wide = AXUIElementCreateSystemWide(); + if system_wide.is_null() { + return None; + } + + let mut focused_element: AXId = ptr::null_mut(); + let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); + let result = AXUIElementCopyAttributeValue( + system_wide, + attr_name.as_concrete_TypeRef() as AXId, + &mut focused_element, + ); + + CFRelease(system_wide); + + if result != AX_ERROR_SUCCESS || focused_element.is_null() { + return None; + } + + // Get selected text range + let mut range_value: AXId = ptr::null_mut(); + let range_attr = CFString::new(AX_SELECTED_TEXT_RANGE_ATTRIBUTE); + let range_result = AXUIElementCopyAttributeValue( + focused_element, + range_attr.as_concrete_TypeRef() as AXId, + &mut range_value, + ); + + if range_result != AX_ERROR_SUCCESS || range_value.is_null() { + CFRelease(focused_element); + return None; + } + + // Extract range. Populated by `AXValueGetValue` through an out-pointer; + // fields are written by the framework, so `dead_code` on them is spurious. + #[repr(C)] + #[allow(dead_code)] + struct CFRange { + location: i64, + length: i64, + } + + let mut cf_range = CFRange { + location: 0, + length: 0, + }; + + let range_ok = AXValueGetValue( + range_value, + AX_VALUE_CFRANGE_TYPE, + &mut cf_range as *mut _ as *mut std::ffi::c_void, + ); + + CFRelease(range_value); + + if !range_ok { + CFRelease(focused_element); + return None; + } + + // Try to get position and size of the focused element + let mut position_value: AXId = ptr::null_mut(); + let position_attr = CFString::new(AX_POSITION_ATTRIBUTE); + let position_result = AXUIElementCopyAttributeValue( + focused_element, + position_attr.as_concrete_TypeRef() as AXId, + &mut position_value, + ); + + let mut size_value: AXId = ptr::null_mut(); + let size_attr = CFString::new(AX_SIZE_ATTRIBUTE); + let size_result = AXUIElementCopyAttributeValue( + focused_element, + size_attr.as_concrete_TypeRef() as AXId, + &mut size_value, + ); + + CFRelease(focused_element); + + if position_result != AX_ERROR_SUCCESS + || position_value.is_null() + || size_result != AX_ERROR_SUCCESS + || size_value.is_null() + { + if !position_value.is_null() { + CFRelease(position_value); + } + if !size_value.is_null() { + CFRelease(size_value); + } + return None; + } + + // Extract position + let mut position = CGPoint { x: 0.0, y: 0.0 }; + let position_ok = AXValueGetValue( + position_value, + AX_VALUE_CGPOINT_TYPE, + &mut position as *mut _ as *mut std::ffi::c_void, + ); + + CFRelease(position_value); + + // Extract size + let mut size = CGSize { + width: 0.0, + height: 0.0, + }; + let size_ok = AXValueGetValue( + size_value, + AX_VALUE_CGSIZE_TYPE, + &mut size as *mut _ as *mut std::ffi::c_void, + ); + + CFRelease(size_value); + + if !position_ok || !size_ok { + return None; + } + + // Estimate caret position (top-left of element + small offset) + // For better accuracy, we'd need to parse the text layout, but this is a reasonable approximation + Some((position.x, position.y + size.height / 2.0)) + } + } + + /// Get the current mouse cursor position in screen coordinates + pub fn get_cursor_position() -> (f64, f64) { + let mouse_location = NSEvent::mouseLocation(); + (mouse_location.x, mouse_location.y) + } + + /// Get the best available position for the badge (caret or cursor) + fn get_badge_position() -> (f64, f64) { + get_caret_position().unwrap_or_else(get_cursor_position) + } + + /// Create a CGColor from RGBA components + /// # Safety + /// Returns a `+1` retained `CGColorRef`; caller must `CGColorRelease` it. + unsafe fn create_cg_color(r: f64, g: f64, b: f64, a: f64) -> *const std::ffi::c_void { + unsafe { + let color_space = CGColorSpaceCreateDeviceRGB(); + let components: [f64; 4] = [r, g, b, a]; + let color = CGColorCreate(color_space, components.as_ptr()); + CGColorSpaceRelease(color_space); + color + } + } + + /// Create the circular badge view using CALayer for reliable rendering + /// # Safety + /// Must run on the main thread; returns a `+1` retained `NSView`. + unsafe fn create_badge_view(config: &HoldBadgeConfig) -> Id { + unsafe { + // Use a plain NSView with a CALayer for drawing + let ns_view = Class::get("NSView").unwrap(); + let view: Id = msg_send![ns_view, alloc]; + let frame = CGRect { + origin: CGPoint { x: 0.0, y: 0.0 }, + size: CGSize { + width: config.diameter, + height: config.diameter, + }, + }; + let view: Id = msg_send![view, initWithFrame: frame]; + + // Enable layer-backing + let _: () = msg_send![view, setWantsLayer: true]; + + // Get the layer + let layer: Id = msg_send![view, layer]; + if layer.is_null() { + warn!("Badge layer is null - badge will not be visible"); + return view; + } + + // Configure the layer to draw a circle. + // Set background color from config (default: red with 80% opacity). + let cg_color = create_cg_color( + config.color.0, + config.color.1, + config.color.2, + config.color.3, + ); + let _: () = msg_send![layer, setBackgroundColor: cg_color]; + CGColorRelease(cg_color); + + // Make it circular by setting corner radius to half the diameter + let corner_radius = config.diameter / 2.0; + let _: () = msg_send![layer, setCornerRadius: corner_radius]; + + // Ensure the layer clips to bounds (for the circle shape) + let _: () = msg_send![layer, setMasksToBounds: true]; + + view + } + } + + /// Create the hold badge panel + /// # Safety + /// Must run on the main thread; returns a `+1` retained non-activating `NSPanel`. + unsafe fn create_badge_panel(config: &HoldBadgeConfig) -> Id { + unsafe { + let ns_panel = Class::get("NSPanel").unwrap(); + + // Get initial position + let (x, y) = get_badge_position(); + let adjusted_x = x + config.offset.0; + let adjusted_y = y + config.offset.1; + debug!( + "Badge position: raw=({:.1}, {:.1}), adjusted=({:.1}, {:.1}), diameter={}", + x, y, adjusted_x, adjusted_y, config.diameter + ); + + // Create panel frame using CGRect (screen coordinates) + let panel_frame = CGRect { + origin: CGPoint { + x: adjusted_x, + y: adjusted_y, + }, + size: CGSize { + width: config.diameter, + height: config.diameter, + }, + }; + + // Create a non-activating panel so the badge never steals app/key focus. + let panel: Id = msg_send![ns_panel, alloc]; + let style_mask = NSWindowStyleMask::Borderless | NSWindowStyleMask::NonactivatingPanel; + let backing = NSBackingStoreType::Buffered; + let panel: Id = msg_send![ + panel, + initWithContentRect: panel_frame + styleMask: style_mask + backing: backing + defer: false + ]; + + // Configure panel for floating transparent overlay. + let clear_color = NSColor::clearColor(); + let clear_color_ptr = &*clear_color as *const _ as Id; + let _: () = msg_send![panel, setOpaque: false]; + let _: () = msg_send![panel, setBackgroundColor: clear_color_ptr]; + let _: () = msg_send![panel, setIgnoresMouseEvents: true]; + let _: () = msg_send![panel, setHidesOnDeactivate: false]; + let _: () = msg_send![panel, setBecomesKeyOnlyIfNeeded: true]; + let _: () = msg_send![panel, setFloatingPanel: true]; + // Status-window level (25) is above floating level (3), preserving the + // previous always-on-top behavior while keeping this surface a panel. + let _: () = msg_send![panel, setLevel: NS_STATUS_WINDOW_LEVEL]; + // Ensure the helper panel shows over fullscreen Spaces and stays out of cycling. + let collection_behavior = NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::FullScreenAuxiliary + | NSWindowCollectionBehavior::IgnoresCycle; + let _: () = msg_send![panel, setCollectionBehavior: collection_behavior]; + + // Enable layer-backed views for better transparency/compositing + let content_view: Id = msg_send![panel, contentView]; + let _: () = msg_send![content_view, setWantsLayer: true]; + + // Create badge view (circular colored indicator) + let badge_view = create_badge_view(config); + add_subview(content_view, badge_view); + + // Force the view to display + let _: () = msg_send![badge_view, setNeedsDisplay: true]; + + panel + } + } + + /// Show the hold badge and start position tracking (default: Hold mode) + pub fn show_hold_badge() { + show_hold_badge_with_config(HoldBadgeConfig::default()); + } + + /// Show badge for specific mode with appropriate color/animation + pub fn show_badge_for_mode(mode: BadgeMode) { + show_hold_badge_with_config(HoldBadgeConfig::from_mode(mode)); + } + + /// Internal implementation that must run on the main thread. + /// + /// `generation` is the show generation captured when this show was requested. + /// If a `hide_hold_badge` bumped it in the meantime, the show is stale and is + /// aborted so it cannot resurrect a badge the user already dismissed. + fn show_hold_badge_impl(config: HoldBadgeConfig, generation: u64) { + // A hide issued after this show was enqueued already tore the badge down; + // honor it and do not create a new panel/updater. + if generation != BADGE_GENERATION.load(Ordering::SeqCst) { + debug!("Skipping stale hold-badge show (superseded by hide)"); + return; + } + debug!("Showing hold badge (diameter={})", config.diameter); + unsafe { + // IMPORTANT: do not hold BADGE_STATE while calling `panel_close`. + // Closing a panel can trigger AppKit callbacks/notifications which may + // re-enter our code and attempt to lock BADGE_STATE again → deadlock. + let old_panel_ptr = { + let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.window.take() + }; + if let Some(panel_ptr) = old_panel_ptr { + panel_close(panel_ptr as Id); + } + + // Create new badge panel (MUST be on main thread) + let panel = create_badge_panel(&config); + + // Make panel visible without activating the app. + panel_show(panel); + + // Force content view to redraw + let content_view: Id = msg_send![panel, contentView]; + let _: () = msg_send![content_view, setNeedsDisplay: true]; + + // Update shared state and determine whether we need to start the updater thread. + let update_interval = config.update_interval_ms; + let start_updater; + { + let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); + // Re-check under the lock: a hide that landed between the top-of-fn + // check and here bumped the generation. Honor it — drop the lock, + // close the panel we just created (never hold BADGE_STATE across + // panel_close, see above), and leave the state torn down. + if generation != BADGE_GENERATION.load(Ordering::SeqCst) { + drop(state); + panel_close(panel); + debug!("Aborting hold-badge show; hide raced in during panel creation"); + return; + } + let was_running = state.timer_running; + state.window = Some(panel as usize); + state.config = config.clone(); + state.timer_running = true; + start_updater = !was_running; + } + + // Start a SINGLE position updater thread. Subsequent calls to `show_*` just update state. + if start_updater { + thread::spawn(move || { + let mut pulse_phase: f64 = 0.0; + let pulse_speed = 0.15; // Radians per update cycle + + loop { + thread::sleep(Duration::from_millis(update_interval)); + + // Snapshot state without blocking the main thread. Never hold the lock + // during AX queries (get_badge_position), which can be slow or stall. + let (window_ptr, config, should_pulse, last_position) = { + use std::sync::TryLockError; + let state = match BADGE_STATE.try_lock() { + Ok(state) => state, + Err(TryLockError::Poisoned(err)) => err.into_inner(), + Err(TryLockError::WouldBlock) => { + // Skip this tick if the state is busy; avoid blocking UI. + continue; + } + }; + if !state.timer_running { + break; + } + ( + state.window, + state.config.clone(), + state.config.mode.should_pulse(), + state.last_position, + ) + }; + + let Some(window_ptr) = window_ptr else { + continue; + }; + + // Calculate pulse opacity (sine wave from 0.4 to 1.0) + let pulse_opacity = if should_pulse { + pulse_phase += pulse_speed; + 0.7 + 0.3 * pulse_phase.sin() // Range: 0.4 to 1.0 + } else { + 1.0 + }; + + // Check if cursor actually moved (hysteresis: skip if < 2px) + let (new_x, new_y) = get_badge_position(); + let (lx, ly) = last_position; + let first_sample = lx.is_nan(); + let moved_x = (new_x - lx).abs() > 2.0; + let moved_y = (new_y - ly).abs() > 2.0; + let position_changed = first_sample || moved_x || moved_y; + + // Skip main-thread dispatch entirely when nothing visual changed + if !position_changed && !should_pulse { + continue; + } + + let adjusted_x = new_x + config.offset.0; + let adjusted_y = new_y + config.offset.1; + + // Position and opacity updates need main thread + Queue::main().exec_async(move || { + // Ensure state is still valid; do not block UI if busy. + { + use std::sync::TryLockError; + let mut state = match BADGE_STATE.try_lock() { + Ok(state) => state, + Err(TryLockError::Poisoned(err)) => err.into_inner(), + Err(TryLockError::WouldBlock) => return, + }; + if !state.timer_running || state.window != Some(window_ptr) { + return; + } + state.last_position = (new_x, new_y); + } + + let window = window_ptr as Id; + if position_changed { + let new_origin = CGPoint { + x: adjusted_x, + y: adjusted_y, + }; + let _: () = msg_send![window, setFrameOrigin: new_origin]; + } + + if should_pulse { + let content_view: Id = msg_send![window, contentView]; + if !content_view.is_null() { + let subviews: Id = msg_send![content_view, subviews]; + let count: usize = msg_send![subviews, count]; + if count > 0 { + let badge_view: Id = + msg_send![subviews, objectAtIndex: 0usize]; + let layer: Id = msg_send![badge_view, layer]; + if !layer.is_null() { + let _: () = + msg_send![layer, setOpacity: pulse_opacity as f32]; + } + } + } + } + }); + } + }); + } + } + } + + /// Show the hold badge with custom configuration. + /// This dispatches to the main thread for thread safety with AppKit. + pub fn show_hold_badge_with_config(config: HoldBadgeConfig) { + // Capture the show generation at REQUEST time. A hide that lands before the + // (possibly queued) impl runs bumps the generation, so the impl recognises + // itself as stale and does not resurrect a dismissed badge. + let generation = BADGE_GENERATION.load(Ordering::SeqCst); + + // Check if we're already on the main thread by checking thread name. + // Note: exec_sync on main queue from main thread causes deadlock. + let is_main_thread = std::thread::current().name() == Some("main"); + + if is_main_thread { + show_hold_badge_impl(config, generation); + } else { + // Dispatch to main thread - AppKit panel creation MUST be on main thread. + // Using exec_async to avoid deadlock when called from tokio runtime. + Queue::main().exec_async(move || { + show_hold_badge_impl(config, generation); + }); + } + } + + /// Hide the hold badge and stop position tracking. + /// This dispatches to the main thread for thread safety with AppKit. + pub fn hide_hold_badge() { + debug!("Hiding hold badge"); + + // Bump the show generation so any show enqueued before this hide (or racing + // its panel creation) is recognised as stale and does not resurrect the + // badge after we tear it down. + BADGE_GENERATION.fetch_add(1, Ordering::SeqCst); + + // Stop the timer first (can be done on any thread) + let panel_ptr = { + let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.timer_running = false; + state.window.take() + }; + + // Dispatch panel close to main thread. Do NOT hold BADGE_STATE while closing. + Queue::main().exec_async(move || { + if let Some(panel_ptr) = panel_ptr { + unsafe { + panel_close(panel_ptr as Id); + } + } + }); + } +} + +#[cfg(target_os = "macos")] +pub use imp::{ + focused_element_accepts_text, get_caret_position, get_cursor_position, hide_hold_badge, + show_badge_for_mode, show_hold_badge, show_hold_badge_with_config, +}; + +// ───────────────────────────────────────────────────────────── +// Non-macOS no-op stubs (keep the public surface compiling) +// ───────────────────────────────────────────────────────────── + +#[cfg(not(target_os = "macos"))] +mod stubs { + use super::{BadgeMode, HoldBadgeConfig}; + + /// No-op: focused-element text detection is macOS-only. + pub fn focused_element_accepts_text() -> bool { + false + } + + /// No-op: caret tracking is macOS-only. + pub fn get_caret_position() -> Option<(f64, f64)> { + None + } + + /// No-op: cursor tracking is macOS-only. + pub fn get_cursor_position() -> (f64, f64) { + (0.0, 0.0) + } + + /// No-op on non-macOS platforms. + pub fn show_hold_badge() {} + + /// No-op on non-macOS platforms. + pub fn show_badge_for_mode(_mode: BadgeMode) {} + + /// No-op on non-macOS platforms. + pub fn show_hold_badge_with_config(_config: HoldBadgeConfig) {} + + /// No-op on non-macOS platforms. + pub fn hide_hold_badge() {} +} + +#[cfg(not(target_os = "macos"))] +pub use stubs::{ + focused_element_accepts_text, get_caret_position, get_cursor_position, hide_hold_badge, + show_badge_for_mode, show_hold_badge, show_hold_badge_with_config, +}; diff --git a/app/os/hotkeys/detector.rs b/app/os/hotkeys/detector.rs index a0e06a8e..e8268fad 100644 --- a/app/os/hotkeys/detector.rs +++ b/app/os/hotkeys/detector.rs @@ -78,7 +78,7 @@ pub enum DoubleTapBlockReason { impl DoubleTapBlockReason { pub fn message(self) -> &'static str { match self { - Self::BindingDisabled => "that gesture is not assigned to a CodeScribe mode", + Self::BindingDisabled => "that gesture is not assigned to a Codescribe mode", Self::ModifierComboActive => "another modifier or hold gesture is active", } } diff --git a/app/os/hotkeys/manager.rs b/app/os/hotkeys/manager.rs index 9ad269c5..d2e834cb 100644 --- a/app/os/hotkeys/manager.rs +++ b/app/os/hotkeys/manager.rs @@ -61,7 +61,14 @@ pub fn install_global_hotkey_manager(tx: Sender) -> Result<(), Stri replace_global_hotkey_manager(&mut guard) } -/// Recreate the process-global hotkey runtime after permissions or settings change. +/// Recreate the process-global hotkey runtime after a permission or settings +/// change, reusing the sender retained by `install_global_hotkey_manager`. +/// +/// This is the runtime re-arm path for the "TCC fresh-grant" case: the +/// CGEventTap reads Accessibility / Input Monitoring only at creation, so a +/// first-run grant leaves hotkeys dead until the tap is rebuilt (or the app +/// restarts). Returns `Err` when no sender is installed yet (i.e. `start()` was +/// never called) or when the tap still cannot be created (permission absent). pub fn refresh_global_hotkey_manager() -> Result<(), String> { let mut guard = global_hotkey_service() .lock() @@ -138,7 +145,7 @@ mod tests { // value depends on whether prior tests have spun up the global hotkey // service (process-global Mutex), so we just assert the call returns // a bool without crashing. This guards the dedup path in - // `app/ui/onboarding/permission_flow.rs::reconcile_permission_runtime_after_grant` + // `bridge/src/hotkeys.rs::CodescribeHotkeys::rearm_after_permission_grant` // which calls this helper before deciding to refresh the manager. let active: bool = is_global_hotkey_manager_active(); let _ = active; diff --git a/app/os/mod.rs b/app/os/mod.rs index 27d0ffdc..32aeac00 100644 --- a/app/os/mod.rs +++ b/app/os/mod.rs @@ -1,10 +1,14 @@ #[cfg(target_os = "macos")] pub mod clipboard; +// Cross-platform: macOS AppKit impl + non-macOS no-op stubs live in the module. +pub mod hold_badge; #[cfg(target_os = "macos")] pub mod hotkeys; #[cfg(target_os = "macos")] pub mod notifications; #[cfg(target_os = "macos")] +pub mod onboarding; +#[cfg(target_os = "macos")] pub mod permissions; #[cfg(target_os = "macos")] pub mod selection; @@ -12,3 +16,10 @@ pub mod selection; pub mod shortcut_registry; #[cfg(target_os = "macos")] pub mod thermal; +#[cfg(target_os = "macos")] +pub mod tray_status; + +/// Objective-C object pointer alias (compatible with the `objc` crate's `msg_send!`). +/// Migrated out of `app/ui/shared/helpers` so OS-level code no longer depends on `ui`. +#[cfg(target_os = "macos")] +pub type Id = *mut objc::runtime::Object; diff --git a/app/os/notifications.rs b/app/os/notifications.rs index 312e88a1..303fded3 100644 --- a/app/os/notifications.rs +++ b/app/os/notifications.rs @@ -4,7 +4,7 @@ //! Requires a proper app bundle — bare binaries (e.g. `~/.cargo/bin/`) get a //! graceful no-op with a tracing::warn instead of an ObjC exception crash. //! -//! Vibecrafted with AI Agents by VetCoders (c)2026 VetCoders +//! Vibecrafted with AI Agents by Vetcoders (c)2026 Vetcoders use objc::runtime::Class; use objc::{msg_send, sel, sel_impl}; @@ -16,7 +16,7 @@ use tracing::warn; #[link(name = "UserNotifications", kind = "framework")] unsafe extern "C" {} -use crate::ui_helpers::Id; +use crate::os::Id; static AUTH_ONCE: Once = Once::new(); @@ -136,54 +136,3 @@ pub fn notify(title: &str, body: &str) { msg_send![center, addNotificationRequest:request withCompletionHandler:nil_block]; } } - -/// Show notification with title, subtitle, and body. -pub fn notify_with_subtitle(title: &str, subtitle: &str, body: &str) { - ensure_authorized(); - - unsafe { - let center = notification_center(); - if center.is_null() { - warn!("Notification skipped (no app bundle): {title} — {subtitle} — {body}"); - return; - } - - let content_cls = match Class::get("UNMutableNotificationContent") { - Some(c) => c, - None => return, - }; - let content: Id = msg_send![content_cls, new]; - if content.is_null() { - return; - } - - let _: () = msg_send![content, setTitle: ns_string(title)]; - let _: () = msg_send![content, setSubtitle: ns_string(subtitle)]; - let _: () = msg_send![content, setBody: ns_string(body)]; - - let sound_cls = match Class::get("UNNotificationSound") { - Some(c) => c, - None => return, - }; - let sound: Id = msg_send![sound_cls, defaultSound]; - let _: () = msg_send![content, setSound: sound]; - - let request_cls = match Class::get("UNNotificationRequest") { - Some(c) => c, - None => return, - }; - let identifier = ns_string(&format!( - "codescribe-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - )); - let trigger: Id = std::ptr::null_mut(); - let request: Id = msg_send![request_cls, requestWithIdentifier:identifier content:content trigger:trigger]; - - let nil_block: Id = std::ptr::null_mut(); - let _: () = - msg_send![center, addNotificationRequest:request withCompletionHandler:nil_block]; - } -} diff --git a/app/os/onboarding.rs b/app/os/onboarding.rs new file mode 100644 index 00000000..56cfdd44 --- /dev/null +++ b/app/os/onboarding.rs @@ -0,0 +1,220 @@ +//! Onboarding setup-sentinel checks that survive the legacy AppKit UI excision. +//! +//! This is the non-UI half of the old `ui/onboarding/session` module: the +//! filesystem/permission sentinel (`should_show_onboarding`) plus the marker +//! migration and permission-invalidation helpers it transitively needs. The +//! AppKit wizard window (`show_onboarding_wizard`) was removed with the rest of +//! the legacy UI; this logic lives here in `os` because its real dependency is +//! the permission probe surface (`crate::os::permissions`). + +use std::fs; +use std::path::PathBuf; + +use tracing::warn; + +use crate::config::Config; +use crate::os::permissions::{PermissionKind, PermissionStatus, permission_status}; + +fn setup_done_path() -> PathBuf { + Config::config_dir().join("setup_done") +} + +fn onboarding_done_path() -> PathBuf { + Config::config_dir().join("onboarding_done") +} + +fn legacy_bootstrap_done_path() -> PathBuf { + Config::config_dir().join("bootstrap_done") +} + +fn onboarding_progress_path() -> PathBuf { + Config::config_dir().join("onboarding_progress") +} + +/// Canonical first-run wizard step count: `Welcome`, `Mode`, 5× `Permission`, +/// `Language`, `ApiKey`, `HotkeyMode`, `AgenticReadiness`, `Done`. Mirrors the +/// SwiftUI `OnboardingStep` flow; kept here so the persisted resume index can be +/// clamped without depending on the (excised) AppKit step table. +const TOTAL_ONBOARDING_STEPS: usize = 12; + +/// Persist the wizard's current step so a relaunch resumes where the user left +/// off. Writer half of the `onboarding_progress` marker; the SwiftUI wizard is +/// now the reader via [`load_onboarding_progress`]. +pub fn save_onboarding_progress(step_index: usize) { + let path = onboarding_progress_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(path, step_index.to_string()); +} + +/// Resume step persisted by [`save_onboarding_progress`], clamped to the last +/// valid step. Returns `0` (Welcome) when no marker exists or it is unparsable. +pub fn load_onboarding_progress() -> usize { + let raw = fs::read_to_string(onboarding_progress_path()).ok(); + let step = raw + .as_deref() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + step.min(TOTAL_ONBOARDING_STEPS.saturating_sub(1)) +} + +/// Mark first-run onboarding complete: clear the resume marker and write the +/// canonical `setup_done` sentinel so [`should_show_onboarding`] returns `false` +/// on the next launch. +pub fn mark_onboarding_done() { + let _ = fs::remove_file(onboarding_progress_path()); + let setup_done = setup_done_path(); + if let Some(parent) = setup_done.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(setup_done, "done"); +} + +const REQUIRED_SETUP_PERMISSIONS: [PermissionKind; 4] = [ + PermissionKind::Microphone, + PermissionKind::Accessibility, + PermissionKind::InputMonitoring, + PermissionKind::ScreenRecording, +]; + +/// Leading non-permission wizard steps (`Welcome`, `Mode`) that precede the +/// permission block. This offset defines the resume-step layout persisted to +/// the `onboarding_progress` marker; it must match whatever onboarding surface +/// consumes that marker (none does today — the legacy wizard was excised). +const WIZARD_STEPS_BEFORE_PERMISSIONS: usize = 2; + +/// Permission steps in resume-flow order, immediately following the leading +/// steps. +const PERMISSION_STEP_ORDER: [PermissionKind; 5] = [ + PermissionKind::Microphone, + PermissionKind::Accessibility, + PermissionKind::InputMonitoring, + PermissionKind::ScreenRecording, + PermissionKind::FullDiskAccess, +]; + +/// Resolve a permission's index within the resume flow (leading steps + +/// permission offset). Self-contained so this module does not depend on the +/// removed `app/ui` wizard. +fn permission_step_index(kind: PermissionKind) -> Option { + PERMISSION_STEP_ORDER + .iter() + .position(|candidate| *candidate == kind) + .map(|offset| WIZARD_STEPS_BEFORE_PERMISSIONS + offset) +} + +fn current_runtime_is_app_bundle() -> bool { + std::env::current_exe() + .map(|path| executable_is_app_bundle(&path)) + .unwrap_or(false) +} + +fn executable_is_app_bundle(path: &std::path::Path) -> bool { + path.to_string_lossy().contains(".app/Contents/MacOS/") +} + +fn permission_status_from_snapshot( + kind: PermissionKind, + microphone: PermissionStatus, + accessibility: PermissionStatus, + input_monitoring: PermissionStatus, + screen_recording: PermissionStatus, +) -> PermissionStatus { + match kind { + PermissionKind::Microphone => microphone, + PermissionKind::Accessibility => accessibility, + PermissionKind::InputMonitoring => input_monitoring, + PermissionKind::ScreenRecording => screen_recording, + PermissionKind::FullDiskAccess => PermissionStatus::Granted, + } +} + +fn setup_done_refresh_target( + setup_done_exists: bool, + app_bundle_runtime: bool, + microphone: PermissionStatus, + accessibility: PermissionStatus, + input_monitoring: PermissionStatus, + screen_recording: PermissionStatus, +) -> Option { + if !setup_done_exists || !app_bundle_runtime { + return None; + } + + REQUIRED_SETUP_PERMISSIONS + .into_iter() + .find(|kind| { + permission_status_from_snapshot( + *kind, + microphone, + accessibility, + input_monitoring, + screen_recording, + ) != PermissionStatus::Granted + }) + .and_then(permission_step_index) +} + +fn invalidate_setup_done_if_permissions_missing() { + let setup_done = setup_done_path(); + if !setup_done.exists() { + return; + } + + // Outside an app bundle the permission model does not apply, so setup_done is + // never invalidated here. Return before the four system-wide permission probes + // below (evaluated as call arguments) so dev/CLI runs pay nothing. + if !current_runtime_is_app_bundle() { + return; + } + + let Some(resume_step) = setup_done_refresh_target( + true, + current_runtime_is_app_bundle(), + permission_status(PermissionKind::Microphone), + permission_status(PermissionKind::Accessibility), + permission_status(PermissionKind::InputMonitoring), + permission_status(PermissionKind::ScreenRecording), + ) else { + return; + }; + + match fs::remove_file(&setup_done) { + Ok(()) => { + save_onboarding_progress(resume_step); + warn!( + "Onboarding: removed stale setup_done because required permissions are missing; resuming at step {resume_step}" + ); + } + Err(error) => warn!( + "Onboarding: failed to remove stale setup_done despite missing required permissions: {error}" + ), + } +} + +fn migrate_legacy_setup_done_marker() { + let setup_done = setup_done_path(); + if setup_done.exists() { + return; + } + + // Older builds tracked onboarding and settings completion separately. + // The current runtime only needs one canonical setup marker. + if onboarding_done_path().exists() && legacy_bootstrap_done_path().exists() { + if let Some(parent) = setup_done.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(setup_done, "done"); + } +} + +/// Returns `true` iff first-run onboarding should be shown: migrates any legacy +/// completion markers, invalidates a stale `setup_done` when required +/// permissions are missing, then reports whether the canonical `setup_done` +/// marker is absent. +pub fn should_show_onboarding() -> bool { + migrate_legacy_setup_done_marker(); + invalidate_setup_done_if_permissions_missing(); + !setup_done_path().exists() +} diff --git a/app/os/permissions.rs b/app/os/permissions.rs index ec82629d..b800a5ee 100644 --- a/app/os/permissions.rs +++ b/app/os/permissions.rs @@ -9,6 +9,8 @@ // This module provides functions to check permission status and prompt the user // to grant permissions in System Settings if not already granted. +#[cfg(target_os = "macos")] +use block2::RcBlock; #[cfg(target_os = "macos")] use core_foundation::base::TCFType; #[cfg(target_os = "macos")] @@ -18,6 +20,8 @@ use dispatch::Queue; #[cfg(target_os = "macos")] use objc::{msg_send, runtime::Class, sel, sel_impl}; #[cfg(target_os = "macos")] +use objc2::runtime::Bool; +#[cfg(target_os = "macos")] use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; #[cfg(target_os = "macos")] use std::time::{Duration, Instant}; @@ -33,6 +37,31 @@ pub enum PermissionStatus { NotDetermined, } +/// The macOS permission classes Codescribe probes at startup and during +/// onboarding. Relocated here (out of `app/ui/onboarding/steps`) so the +/// non-UI permission model lives next to the `check_*` probes it drives and +/// survives the legacy AppKit UI excision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionKind { + Microphone, + Accessibility, + InputMonitoring, + ScreenRecording, + FullDiskAccess, +} + +/// Probe the live status of a single permission class. Dispatches to the +/// per-permission `check_*` probes in this module. +pub fn permission_status(kind: PermissionKind) -> PermissionStatus { + match kind { + PermissionKind::Microphone => check_microphone(), + PermissionKind::Accessibility => check_accessibility(), + PermissionKind::InputMonitoring => check_input_monitoring(), + PermissionKind::ScreenRecording => check_screen_recording(), + PermissionKind::FullDiskAccess => check_full_disk_access(), + } +} + /// Check if Accessibility permission is granted /// /// Accessibility permission is required for global hotkeys to work. @@ -82,51 +111,6 @@ pub fn check_input_monitoring() -> PermissionStatus { PermissionStatus::Granted } -/// Prompt user to grant Accessibility permission -/// -/// Opens System Settings > Privacy & Security > Accessibility -/// Returns true if the prompt was shown successfully -#[cfg(target_os = "macos")] -pub fn request_accessibility() -> bool { - // Use AXIsProcessTrustedWithOptions() to show the system prompt - unsafe extern "C" { - fn AXIsProcessTrustedWithOptions(options: *const std::ffi::c_void) -> bool; - } - - use core_foundation::boolean::CFBoolean; - use core_foundation::dictionary::CFDictionary; - - // Create options dictionary with kAXTrustedCheckOptionPrompt = true - let key = CFString::new("AXTrustedCheckOptionPrompt"); - let value = CFBoolean::true_value(); - - let options = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); - - unsafe { AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef() as *const _) } -} - -#[cfg(not(target_os = "macos"))] -pub fn request_accessibility() -> bool { - true // Not needed on other platforms -} - -/// Request Input Monitoring permission (macOS) -/// -/// Shows system prompt asking to allow key event listening. -#[cfg(target_os = "macos")] -pub fn request_input_monitoring() -> bool { - unsafe extern "C" { - fn CGRequestListenEventAccess() -> bool; - } - - unsafe { CGRequestListenEventAccess() } -} - -#[cfg(not(target_os = "macos"))] -pub fn request_input_monitoring() -> bool { - true -} - /// Check if Microphone permission is granted /// /// Microphone permission is required for audio recording. @@ -184,10 +168,9 @@ fn start_microphone_request(callback_tx: Sender) -> bool { let media_type = CFString::new("soun"); unsafe { - let request_block = block::ConcreteBlock::new(move |granted: bool| { - let _ = callback_tx.send(granted); - }) - .copy(); + let request_block: RcBlock = RcBlock::new(move |granted: Bool| { + let _ = callback_tx.send(granted.as_bool()); + }); let _: () = msg_send![ av_class, @@ -257,7 +240,7 @@ fn wait_for_microphone_resolution(callback_rx: Receiver) -> bool { } warn!( - "Microphone permission denied. Enable CodeScribe in System Settings > Privacy & Security > Microphone." + "Microphone permission denied. Enable Codescribe in System Settings > Privacy & Security > Microphone." ); return false; } @@ -268,7 +251,7 @@ fn wait_for_microphone_resolution(callback_rx: Receiver) -> bool { } PermissionStatus::Denied => { warn!( - "Microphone permission is denied/restricted. Enable CodeScribe in System Settings > Privacy & Security > Microphone." + "Microphone permission is denied/restricted. Enable Codescribe in System Settings > Privacy & Security > Microphone." ); return false; } @@ -380,26 +363,6 @@ pub fn check_full_disk_access() -> PermissionStatus { PermissionStatus::Granted } -pub fn hotkey_permissions_granted() -> bool { - check_accessibility() == PermissionStatus::Granted - && check_input_monitoring() == PermissionStatus::Granted -} - -/// Request Full Disk Access by opening the relevant System Settings pane. -#[cfg(target_os = "macos")] -pub fn request_full_disk_access() -> bool { - if check_full_disk_access() == PermissionStatus::Granted { - return true; - } - open_privacy_settings("Privacy_AllFiles"); - false -} - -#[cfg(not(target_os = "macos"))] -pub fn request_full_disk_access() -> bool { - true -} - #[cfg(target_os = "macos")] fn full_disk_access_status() -> PermissionStatus { use std::path::Path; @@ -434,260 +397,6 @@ fn full_disk_access_status() -> PermissionStatus { } } -#[cfg(target_os = "macos")] -pub fn open_privacy_settings(deeplink: &str) { - let url = format!( - "x-apple.systempreferences:com.apple.preference.security?{}", - deeplink - ); - let _ = std::process::Command::new("open").arg(url).spawn(); -} - -/// Check all required permissions and log status -pub fn check_all_permissions() { - use tracing::{info, warn}; - - // Check Accessibility - match check_accessibility() { - PermissionStatus::Granted => { - info!("Accessibility permission: Granted"); - } - PermissionStatus::Denied => { - warn!("Accessibility permission: DENIED - Mode bindings may not trigger."); - warn!("Grant access in: System Settings > Privacy & Security > Accessibility"); - } - _ => { - warn!("Accessibility permission: Unknown status"); - } - } - - // Check Input Monitoring - match check_input_monitoring() { - PermissionStatus::Granted => { - info!("Input Monitoring permission: Granted"); - } - PermissionStatus::Denied => { - warn!("Input Monitoring permission: DENIED - Mode bindings may not trigger."); - warn!("Grant access in: System Settings > Privacy & Security > Input Monitoring"); - } - _ => { - warn!("Input Monitoring permission: Unknown status"); - } - } - - // Check Microphone - match check_microphone() { - PermissionStatus::Granted => { - info!("Microphone permission: Granted"); - } - PermissionStatus::NotDetermined => { - info!( - "Microphone permission: Not determined (macOS prompt may appear on first recording attempt)." - ); - info!( - "If recording does not start, open System Settings > Privacy & Security > Microphone and enable CodeScribe." - ); - } - PermissionStatus::Denied => { - warn!("Microphone permission: DENIED - Recording will not work!"); - warn!("Grant access in: System Settings > Privacy & Security > Microphone"); - warn!( - "After enabling access, retry recording or reopen Setup so CodeScribe can recheck live." - ); - } - } -} - -/// Request all required permissions (with user prompts) -pub fn request_all_permissions() { - use tracing::info; - - info!("Checking and requesting required permissions..."); - - // Request Accessibility (shows system prompt if not granted) - if check_accessibility() != PermissionStatus::Granted { - info!("Requesting Accessibility permission..."); - request_accessibility(); - } - - // Request Input Monitoring (shows system prompt if not granted) - if check_input_monitoring() != PermissionStatus::Granted { - info!("Requesting Input Monitoring permission..."); - request_input_monitoring(); - } - - if check_microphone() != PermissionStatus::Granted { - info!( - "Microphone permission not granted yet; CodeScribe will request it when recording starts. If no prompt appears, open System Settings > Privacy & Security > Microphone." - ); - } -} - -pub fn diagnostics_report() -> String { - use std::fmt::Write; - - let mut out = String::new(); - let _ = writeln!(&mut out, "CodeScribe diagnostics"); - let _ = writeln!(&mut out, "pid: {}", std::process::id()); - let exe = std::env::current_exe() - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| "".to_string()); - let _ = writeln!(&mut out, "exe: {}", exe); - - if let Some(bundle_id) = current_bundle_identifier() { - let _ = writeln!(&mut out, "bundle_id: {}", bundle_id); - } - - let app_bundle = exe.contains(".app/Contents/MacOS/"); - let _ = writeln!( - &mut out, - "app_bundle: {}", - if app_bundle { "yes" } else { "no" } - ); - - let _ = writeln!(&mut out, "accessibility: {:?}", check_accessibility()); - let _ = writeln!(&mut out, "input_monitoring: {:?}", check_input_monitoring()); - - let settings = crate::config::UserSettings::load(); - let _ = writeln!( - &mut out, - "mode_binding.dictation: {}", - settings - .mode_binding_for(crate::config::WorkMode::Dictation) - .as_str() - ); - let _ = writeln!( - &mut out, - "mode_binding.formatting: {}", - settings - .mode_binding_for(crate::config::WorkMode::Formatting) - .as_str() - ); - let _ = writeln!( - &mut out, - "mode_binding.assistive: {}", - settings - .mode_binding_for(crate::config::WorkMode::Assistive) - .as_str() - ); - - let conflicts = crate::os::shortcut_registry::detect_hotkey_conflicts(&settings); - let _ = writeln!( - &mut out, - "mode_binding.conflicts.count: {}", - conflicts.len() - ); - if conflicts.is_empty() { - let _ = writeln!(&mut out, "mode_binding.conflicts.status: clear"); - } else { - let _ = writeln!(&mut out, "mode_binding.conflicts.status: detected"); - for (index, conflict) in conflicts.iter().take(5).enumerate() { - let _ = writeln!( - &mut out, - "mode_binding.conflict.{}: {} -> {}", - index + 1, - conflict.gesture.label(), - conflict.message - ); - } - } - if let Some(note) = crate::os::shortcut_registry::fn_tap_intercept_note(&settings) { - let _ = writeln!(&mut out, "mode_binding.note: {note}"); - } - - // Small, safe config hints (do not print secrets). - let config = crate::config::Config::load(); - let _ = writeln!( - &mut out, - "WHISPER_LANGUAGE: {}", - config.whisper_language.as_str() - ); - let _ = writeln!( - &mut out, - "mode_binding.hold_start_delay_ms: {}", - config.hold_start_delay_ms - ); - let _ = writeln!( - &mut out, - "mode_binding.double_tap_interval_ms: {}", - config.double_tap_interval_ms - ); - let _ = writeln!( - &mut out, - "mode_binding.toggle_silence_sec: {}", - config.toggle_silence_sec - ); - if let Ok(val) = std::env::var("CODESCRIBE_STREAM_CHUNK_SEC") { - let _ = writeln!(&mut out, "CODESCRIBE_STREAM_CHUNK_SEC: {val}"); - } - let _ = writeln!( - &mut out, - "thermal.level: {:?}", - crate::os::thermal::current_thermal_level() - ); - - // Best-effort codesign info (helps debug TCC resets). - #[cfg(target_os = "macos")] - { - let _ = writeln!(&mut out); - let _ = writeln!(&mut out, "codesign:"); - if let Ok(output) = std::process::Command::new("codesign") - .args(["-dv", "--verbose=2", &exe]) - .output() - { - let stderr = String::from_utf8_lossy(&output.stderr); - for line in stderr.lines().take(40) { - let _ = writeln!(&mut out, " {}", line); - } - } else { - let _ = writeln!(&mut out, " "); - } - } - - // Best-effort process list (helps spot stray CLI/daemon processes). - #[cfg(target_os = "macos")] - { - let _ = writeln!(&mut out); - let _ = writeln!(&mut out, "processes:"); - if let Ok(output) = std::process::Command::new("ps") - .args(["-ax", "-o", "pid=,comm=,args="]) - .output() - { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout - .lines() - .filter(|l| l.to_lowercase().contains("codescribe")) - .take(30) - { - let _ = writeln!(&mut out, " {}", line.trim()); - } - } else { - let _ = writeln!(&mut out, " "); - } - } - - out -} - -fn current_bundle_identifier() -> Option { - let exe = std::env::current_exe().ok()?; - // If running from an .app bundle, Info.plist is usually at ../Info.plist. - // Example: .../CodeScribe.app/Contents/MacOS/codescribe - let info_plist = exe - .parent() - .and_then(|p| p.parent()) - .map(|p| p.join("Info.plist"))?; - let content = std::fs::read_to_string(info_plist).ok()?; - - // Extremely small parser: find the first string after CFBundleIdentifier key. - let key_idx = content.find("CFBundleIdentifier")?; - let after_key = &content[key_idx..]; - let string_open = after_key.find("")?; - let after_open = &after_key[string_open + "".len()..]; - let string_close = after_open.find("")?; - Some(after_open[..string_close].trim().to_string()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/app/os/selection.rs b/app/os/selection.rs index 1e037cbc..33a49f2a 100644 --- a/app/os/selection.rs +++ b/app/os/selection.rs @@ -12,6 +12,15 @@ use tracing::{debug, warn}; use crate::os::clipboard::{self, ClipboardSnapshot}; +// Accessibility (AX) FFI surface for reading the focused element's selection. +// Migrated out of `app/ui/mod.rs` so selection capture no longer depends on `ui`. +#[cfg(target_os = "macos")] +use core_foundation::base::TCFType; +#[cfg(target_os = "macos")] +use core_foundation::string::CFString; +#[cfg(target_os = "macos")] +use std::ptr; + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AssistiveContext { pub frontmost_app: Option, @@ -101,7 +110,7 @@ pub fn capture_assistive_context() -> AssistiveContext { } /// Capture assistive context while preferring the app that was frontmost before -/// CodeScribe UI could activate. +/// Codescribe UI could activate. pub fn capture_assistive_context_with_prior_frontmost( prior_frontmost_app: Option, ) -> AssistiveContext { @@ -145,7 +154,7 @@ pub fn capture_frontmost_app_only() -> AssistiveContext { capture_frontmost_app_only_with_prior_frontmost(None) } -/// Capture only the frontmost app name, using a pre-overlay app if CodeScribe is +/// Capture only the frontmost app name, using a pre-overlay app if Codescribe is /// currently frontmost. pub fn capture_frontmost_app_only_with_prior_frontmost( prior_frontmost_app: Option, @@ -175,7 +184,7 @@ pub fn capture_frontmost_app_only_with_prior_frontmost( /// Best-effort app activation by localized app name. /// -/// Used to recover focus before synthetic paste when frontmost temporarily flips to CodeScribe. +/// Used to recover focus before synthetic paste when frontmost temporarily flips to Codescribe. #[cfg(target_os = "macos")] pub fn activate_app_by_name(app_name: &str) -> bool { use std::process::Command; @@ -274,15 +283,10 @@ fn nsworkspace_frontmost_app_name() -> Option { /// confirm and fall through after the budget so the caller may still attempt the /// copy (best-effort, never break recording). /// -/// This is the shared focus-confirm primitive. It already backs the Cmd+C +/// This is the shared focus-confirm primitive backing the Cmd+C /// selection-capture path (`capture_selected_text_with_effective_frontmost`). -/// The Cmd+V *paste* path has the same activation→focus race but lives in -/// `app/controller/mod.rs` (`paste_overlay_text_with_target`) and still uses a -/// fixed `sleep(80ms)` after `activate_target_app`. It is exposed `pub(crate)` -/// so that paste path can reuse this confirm instead of the fixed sleep; that -/// rewire is deferred to a controller-owned follow-up (single-file ownership: -/// the sleep is not in this module). See the scope note on -/// `crate::os::clipboard::paste` / `paste_text_smart`. +/// Exposed `pub(crate)` so any other activation→focus path can reuse this +/// confirm instead of a fixed sleep. #[cfg(target_os = "macos")] pub(crate) fn wait_for_frontmost_app(expected_app: &str, budget: Duration) -> bool { let expected = expected_app.trim(); @@ -359,9 +363,9 @@ fn capture_assistive_context_from_parts( let (frontmost_app, should_restore_prior_app) = resolve_effective_frontmost_app(current_frontmost_app, prior_frontmost_app); - // Avoid capturing from ourselves (frontmost can temporarily become CodeScribe) + // Avoid capturing from ourselves (frontmost can temporarily become Codescribe) if frontmost_app.as_deref().is_some_and(is_codescribe_app) { - debug!("Assistive context: frontmost is CodeScribe, skipping selection capture"); + debug!("Assistive context: frontmost is Codescribe, skipping selection capture"); return AssistiveContext { frontmost_app, selected_text: None, @@ -503,8 +507,7 @@ fn selected_text_from_frontmost( // // Some apps report `AXSelectedTextRange.length == 0` even when `AXSelectedText` is non-empty, // so we do *not* early-return on length==0 before checking `AXSelectedText`. - let sel_len = crate::ui::get_selected_text_length(); - if let Some(selected) = crate::ui::get_selected_text(max_chars) { + if let Some(selected) = crate::os::selection::get_selected_text(max_chars) { return Some(selected); } @@ -521,6 +524,10 @@ fn selected_text_from_frontmost( ); return None; } + // Only now (fallback path) pay for the system-wide AX length probe — it feeds + // this diagnostic only, and the AX-selection early-return above skips it + // entirely on the common success path. + let sel_len = crate::os::selection::get_selected_text_length(); debug!( "Assistive context: AX gave no selection for {:?} (range len={:?}); trying Cmd+C fallback", frontmost_app, sel_len @@ -598,6 +605,170 @@ fn selected_text_from_frontmost( Some(copied) } +// --------------------------------------------------------------------------- +// Accessibility (AX) selection queries +// +// Migrated verbatim from `app/ui/mod.rs` (the originals remain there until the +// later `app/ui` delete step). These are pure Accessibility AX queries with no +// UI windows. Duplicate `extern "C"` declarations and constants against a system +// framework are legal, so this compiles alongside the originals. +// --------------------------------------------------------------------------- + +// Accessibility API bindings (use raw pointers compatible with C FFI) +#[cfg(target_os = "macos")] +type AXId = *mut std::ffi::c_void; + +#[cfg(target_os = "macos")] +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + fn AXUIElementCopyAttributeValue(element: AXId, attribute: AXId, value: *mut AXId) -> i32; + fn AXUIElementCreateSystemWide() -> AXId; + fn AXValueGetValue(value: AXId, type_: i32, value_ptr: *mut std::ffi::c_void) -> bool; + fn CFRelease(cf: *const std::ffi::c_void); +} + +// AX constants +#[cfg(target_os = "macos")] +const AX_ERROR_SUCCESS: i32 = 0; +#[cfg(target_os = "macos")] +const AX_FOCUSED_UIELEMENT_ATTRIBUTE: &str = "AXFocusedUIElement"; +#[cfg(target_os = "macos")] +const AX_SELECTED_TEXT_ATTRIBUTE: &str = "AXSelectedText"; +#[cfg(target_os = "macos")] +const AX_SELECTED_TEXT_RANGE_ATTRIBUTE: &str = "AXSelectedTextRange"; + +// AXValue types +#[cfg(target_os = "macos")] +const AX_VALUE_CFRANGE_TYPE: i32 = 3; + +/// Get the currently selected text from the focused UI element via Accessibility. +/// +/// Notes: +/// - Requires Accessibility permission. +/// - Many apps expose selected text via `AXSelectedText`, but not all. +/// - Returns `None` if there's no selection or the attribute isn't supported. +#[cfg(target_os = "macos")] +pub fn get_selected_text(max_chars: usize) -> Option { + unsafe { + let system_wide = AXUIElementCreateSystemWide(); + if system_wide.is_null() { + return None; + } + + let mut focused_element: AXId = ptr::null_mut(); + let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); + let result = AXUIElementCopyAttributeValue( + system_wide, + attr_name.as_concrete_TypeRef() as AXId, + &mut focused_element, + ); + + CFRelease(system_wide); + + if result != AX_ERROR_SUCCESS || focused_element.is_null() { + return None; + } + + let mut selected_value: AXId = ptr::null_mut(); + let selected_attr = CFString::new(AX_SELECTED_TEXT_ATTRIBUTE); + let selected_result = AXUIElementCopyAttributeValue( + focused_element, + selected_attr.as_concrete_TypeRef() as AXId, + &mut selected_value, + ); + + CFRelease(focused_element); + + if selected_result != AX_ERROR_SUCCESS || selected_value.is_null() { + return None; + } + + let selected_str = CFString::wrap_under_get_rule(selected_value as *const _).to_string(); + CFRelease(selected_value); + + let mut s = selected_str.trim().to_string(); + if s.is_empty() { + return None; + } + + let char_count = s.chars().count(); + if max_chars > 0 && char_count > max_chars { + s = s.chars().take(max_chars).collect(); + s.push('…'); + } + + Some(s) + } +} + +/// Get the current selected text length (range length) from the focused UI element. +/// +/// Returns: +/// - `Some(0)` if the element supports `AXSelectedTextRange` but there's no selection +/// - `Some(n>0)` if there's a selection +/// - `None` if the attribute isn't available or any step fails +#[cfg(target_os = "macos")] +pub fn get_selected_text_length() -> Option { + unsafe { + let system_wide = AXUIElementCreateSystemWide(); + if system_wide.is_null() { + return None; + } + + let mut focused_element: AXId = ptr::null_mut(); + let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); + let result = AXUIElementCopyAttributeValue( + system_wide, + attr_name.as_concrete_TypeRef() as AXId, + &mut focused_element, + ); + + CFRelease(system_wide); + + if result != AX_ERROR_SUCCESS || focused_element.is_null() { + return None; + } + + let mut range_value: AXId = ptr::null_mut(); + let range_attr = CFString::new(AX_SELECTED_TEXT_RANGE_ATTRIBUTE); + let range_result = AXUIElementCopyAttributeValue( + focused_element, + range_attr.as_concrete_TypeRef() as AXId, + &mut range_value, + ); + + CFRelease(focused_element); + + if range_result != AX_ERROR_SUCCESS || range_value.is_null() { + return None; + } + + #[repr(C)] + struct CFRange { + location: i64, + length: i64, + } + + let mut cf_range = CFRange { + location: 0, + length: 0, + }; + + let ok = AXValueGetValue( + range_value, + AX_VALUE_CFRANGE_TYPE, + &mut cf_range as *mut _ as *mut std::ffi::c_void, + ); + CFRelease(range_value); + + if !ok { + return None; + } + + Some(cf_range.length.max(0) as usize) + } +} + #[cfg(not(target_os = "macos"))] fn selected_text_from_frontmost( _max_chars: usize, @@ -615,7 +786,7 @@ mod tests { #[test] fn effective_frontmost_prefers_prior_when_codescribe_is_current() { let (app, should_restore) = resolve_effective_frontmost_app( - Some("CodeScribe".to_string()), + Some("Codescribe".to_string()), Some("Terminal".to_string()), ); @@ -626,7 +797,7 @@ mod tests { #[test] fn assistive_capture_uses_prior_frontmost_after_overlay_activation() { let ctx = capture_assistive_context_from_parts( - Some("CodeScribe".to_string()), + Some("Codescribe".to_string()), Some("Terminal".to_string()), |frontmost_app, should_restore_prior_app| { assert_eq!(frontmost_app, Some("Terminal")); @@ -682,9 +853,9 @@ mod tests { #[test] fn effective_frontmost_preserves_codescribe_guard_without_prior_app() { let (app, should_restore) = - resolve_effective_frontmost_app(Some("CodeScribe".to_string()), None); + resolve_effective_frontmost_app(Some("Codescribe".to_string()), None); - assert_eq!(app.as_deref(), Some("CodeScribe")); + assert_eq!(app.as_deref(), Some("Codescribe")); assert!(!should_restore); } @@ -711,7 +882,7 @@ mod tests { clear_recent_assistive_context_for_tests(); let ctx = AssistiveContext { - frontmost_app: Some("CodeScribe".to_string()), + frontmost_app: Some("Codescribe".to_string()), selected_text: Some("old".to_string()), }; store_recent_assistive_context(&ctx); diff --git a/app/os/shortcut_registry.rs b/app/os/shortcut_registry.rs index bd7252a1..fe054d98 100644 --- a/app/os/shortcut_registry.rs +++ b/app/os/shortcut_registry.rs @@ -1,4 +1,4 @@ -//! macOS keyboard shortcut registry conflict checks for CodeScribe hotkeys. +//! macOS keyboard shortcut registry conflict checks for Codescribe hotkeys. //! //! Reads the system SymbolicHotkeys registry and reports potential collisions //! with our modifier-only gestures (Fn/Ctrl/Option). @@ -55,7 +55,7 @@ pub fn fn_tap_intercept_note(settings: &UserSettings) -> Option<&'static str> { } fn_tap_symbols_enabled().then_some( - "Fn/Globe tap is configured by macOS. CodeScribe Hold Fn may intercept that tap while dictation is active; this is informational, not a shortcut conflict.", + "Fn/Globe tap is configured by macOS. Codescribe Hold Fn may intercept that tap while dictation is active; this is informational, not a shortcut conflict.", ) } @@ -226,7 +226,7 @@ fn gesture_conflicts_with_symbolic(gesture: HotkeyGesture, signature: SymbolicSi match gesture { HotkeyGesture::HoldFn => { // macOS symbolic IDs 160 and 164 are tap/double-tap Globe/Fn actions - // (Emoji & Symbols / Dictation). CodeScribe's HoldFn binding listens + // (Emoji & Symbols / Dictation). Codescribe's HoldFn binding listens // for a held modifier gesture, so reporting those tap-only shortcuts as // "Hold Fn" conflicts is noisy and misleading. signature.id != 160 diff --git a/app/os/thermal.rs b/app/os/thermal.rs index 7ea14c2e..62f94544 100644 --- a/app/os/thermal.rs +++ b/app/os/thermal.rs @@ -76,6 +76,7 @@ unsafe fn apply_current_state(source: &str) { match level { ThermalLevel::Nominal | ThermalLevel::Fair => { tracing::info!(?level, source, "macOS thermal pressure changed"); + clear_thermal_tray_status_if_current(); } ThermalLevel::Serious => { tracing::warn!( @@ -83,6 +84,7 @@ unsafe fn apply_current_state(source: &str) { source, "macOS thermal pressure serious; STT refine lane paused" ); + clear_thermal_tray_status_if_current(); } ThermalLevel::Critical => { tracing::error!( @@ -90,18 +92,29 @@ unsafe fn apply_current_state(source: &str) { source, "macOS thermal pressure critical; STT commit/refine lanes paused" ); - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Thermal); + crate::os::tray_status::update_tray_status( + crate::os::tray_status::TrayStatus::Thermal, + ); } } } } +#[cfg(any(target_os = "macos", test))] +fn clear_thermal_tray_status_if_current() { + use crate::os::tray_status::{TrayStatus, current_tray_status, update_tray_status}; + + if current_tray_status() == TrayStatus::Thermal { + update_tray_status(TrayStatus::Idle); + } +} + #[cfg(target_os = "macos")] fn thermal_observer_class() -> *const Class { static CLASS: OnceLock = OnceLock::new(); *CLASS.get_or_init(|| { let superclass = Class::get("NSObject").expect("NSObject class missing"); - let mut decl = ClassDecl::new("CodeScribeThermalObserver", superclass).expect("class decl"); + let mut decl = ClassDecl::new("CodescribeThermalObserver", superclass).expect("class decl"); unsafe { decl.add_method( sel!(thermalStateDidChange:), @@ -118,3 +131,41 @@ unsafe fn ns_string(value: &str) -> *mut Object { let cls = Class::get("NSString").expect("NSString class missing"); msg_send![cls, stringWithUTF8String: c_str.as_ptr()] } + +#[cfg(test)] +mod tests { + use super::*; + use crate::os::tray_status::{TrayStatus, current_tray_status, update_tray_status}; + + #[test] + fn install_thermal_probe_publishes_a_level_and_is_idempotent() { + // On macOS this genuinely registers the NSProcessInfo thermal observer + // and reads the live thermal state into the scheduler-visible atomic + // (core/stt/scheduler.rs consumes current_process_thermal_level). A + // second call must be a guarded no-op and must not panic. This proves + // the probe installs cleanly now that CodescribeHotkeys::start wires it + // at runtime bootstrap (previously the fn had zero callers). + install_thermal_probe(); + let first = current_thermal_level(); + install_thermal_probe(); + let second = current_thermal_level(); + assert_eq!( + first, second, + "thermal level must be stable across idempotent probe installs" + ); + } + + #[test] + #[serial_test::serial] + fn thermal_recovery_clears_only_thermal_tray_status() { + update_tray_status(TrayStatus::Thermal); + clear_thermal_tray_status_if_current(); + assert_eq!(current_tray_status(), TrayStatus::Idle); + + update_tray_status(TrayStatus::Listening); + clear_thermal_tray_status_if_current(); + assert_eq!(current_tray_status(), TrayStatus::Listening); + + update_tray_status(TrayStatus::Idle); + } +} diff --git a/app/os/tray_status.rs b/app/os/tray_status.rs new file mode 100644 index 00000000..d4f79bc6 --- /dev/null +++ b/app/os/tray_status.rs @@ -0,0 +1,232 @@ +//! Surviving home for `TrayStatus` and `update_tray_status`. +//! +//! Relocated out of the legacy AppKit `app/ui/tray` module. The new SwiftUI +//! app owns the menu bar, but the core still owns the status truth. Producers +//! call `update_tray_status`, and the bridge registers a process-local sink that +//! forwards each status to Swift. +//! +//! Only the pure methods of `TrayStatus` are copied here. The icon-rendering +//! methods (`to_icon` / `to_icon_with_glyph`) stay behind in `app/ui/tray` +//! because they depend on `tray_icon::Icon` and `crate::tray::icons::*`, which +//! die with the legacy AppKit tray. + +use std::sync::{Arc, OnceLock, RwLock}; + +use tracing::trace; + +type TrayStatusSink = Arc; + +/// Status of the Codescribe system, formerly reflected in the AppKit tray icon. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayStatus { + /// App process is visible, but runtime initialization is still in progress. + Starting, + /// Idle, waiting for activation + Idle, + /// Actively listening/recording + Listening, + /// Processing/transcribing + Thinking, + /// Successfully completed + Success, + /// Error state - backend not available + Error, + /// System thermal pressure is high enough to throttle STT. + Thermal, + /// A hotkey gesture was detected but blocked before dispatch. + HotkeyConflict, +} + +impl TrayStatus { + /// Get the human-readable tooltip for this status + pub fn tooltip(&self) -> String { + match self { + TrayStatus::Starting => "Codescribe - Starting...".to_string(), + TrayStatus::Idle => "Codescribe - Ready".to_string(), + TrayStatus::Listening => "Codescribe - Recording...".to_string(), + TrayStatus::Thinking => "Codescribe - Processing...".to_string(), + TrayStatus::Success => "Codescribe - Done!".to_string(), + TrayStatus::Error => "Codescribe - Backend unavailable!".to_string(), + TrayStatus::Thermal => "Codescribe - Thermal throttling".to_string(), + TrayStatus::HotkeyConflict => "Codescribe - Hotkey conflict".to_string(), + } + } + + /// Get the status line text for the menu + pub fn menu_label(&self) -> &'static str { + match self { + TrayStatus::Starting => "Status: Starting...", + TrayStatus::Idle => "Status: Idle", + TrayStatus::Listening => "Status: Recording...", + TrayStatus::Thinking => "Status: Processing...", + TrayStatus::Success => "Status: Done!", + TrayStatus::Error => "Status: Error", + TrayStatus::Thermal => "Status: Thermal throttling", + TrayStatus::HotkeyConflict => "Status: Hotkey conflict", + } + } +} + +/// Tray status plus session lane. `assistive` is retained even while the visible +/// status is idle/starting so the next Listening/Thinking beat can tint correctly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrayStatusSnapshot { + pub status: TrayStatus, + pub assistive: bool, +} + +impl TrayStatusSnapshot { + pub fn new(status: TrayStatus, assistive: bool) -> Self { + Self { status, assistive } + } + + pub fn is_assistive_visible(&self) -> bool { + self.assistive + && matches!( + self.status, + TrayStatus::Starting | TrayStatus::Listening | TrayStatus::Thinking + ) + } + + pub fn tooltip(&self) -> String { + if self.is_assistive_visible() { + match self.status { + TrayStatus::Listening => "Codescribe - Agent listening...".to_string(), + TrayStatus::Thinking => "Codescribe - Agent processing...".to_string(), + _ => self.status.tooltip(), + } + } else { + self.status.tooltip() + } + } + + pub fn menu_label(&self) -> &'static str { + if self.is_assistive_visible() { + match self.status { + TrayStatus::Listening => "Status: Agent listening...", + TrayStatus::Thinking => "Status: Agent processing...", + _ => self.status.menu_label(), + } + } else { + self.status.menu_label() + } + } +} + +fn current_status_store() -> &'static RwLock { + static CURRENT_STATUS: OnceLock> = OnceLock::new(); + CURRENT_STATUS.get_or_init(|| RwLock::new(TrayStatusSnapshot::new(TrayStatus::Idle, false))) +} + +fn tray_status_sink_store() -> &'static RwLock> { + static TRAY_STATUS_SINK: OnceLock>> = OnceLock::new(); + TRAY_STATUS_SINK.get_or_init(|| RwLock::new(None)) +} + +/// Register the process-local bridge sink that mirrors core status to Swift. +/// +/// The app crate cannot depend on the UniFFI bridge (the bridge wraps this +/// crate), so the bridge injects a plain callback here instead. +pub fn set_tray_status_sink(sink: Option) { + let mut guard = tray_status_sink_store() + .write() + .unwrap_or_else(|error| error.into_inner()); + *guard = sink; +} + +/// Latest core-side tray status, used to seed new Swift listeners. +pub fn current_tray_status() -> TrayStatus { + current_status_store() + .read() + .unwrap_or_else(|error| error.into_inner()) + .status +} + +/// Latest core-side tray status snapshot, including the active assistive lane. +pub fn current_tray_status_snapshot() -> TrayStatusSnapshot { + *current_status_store() + .read() + .unwrap_or_else(|error| error.into_inner()) +} + +/// Update only the active assistive lane and notify Swift if the payload changed. +pub fn set_tray_assistive_session(assistive: bool) { + let snapshot = { + let mut current = current_status_store() + .write() + .unwrap_or_else(|error| error.into_inner()); + let next = TrayStatusSnapshot::new(current.status, assistive); + if *current == next { + return; + } + *current = next; + next + }; + + notify_tray_status(snapshot); +} + +/// Update the menu-bar status truth and notify the Swift bridge when registered. +/// +/// Kept callable with the same signature surviving producers expect, but no +/// longer a stub: hotkeys, thermal throttling, and recording lifecycle changes +/// flow through the registered bridge sink. +pub fn update_tray_status(status: TrayStatus) { + let snapshot = { + let mut current = current_status_store() + .write() + .unwrap_or_else(|error| error.into_inner()); + let next = TrayStatusSnapshot::new(status, current.assistive); + *current = next; + next + }; + + notify_tray_status(snapshot); +} + +fn notify_tray_status(snapshot: TrayStatusSnapshot) { + let sink = tray_status_sink_store() + .read() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .map(Arc::clone); + + if let Some(sink) = sink { + sink(snapshot); + } else { + trace!( + status = ?snapshot.status, + assistive = snapshot.assistive, + "tray status updated before Swift bridge listener registration" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assistive_visible_covers_starting_through_thinking() { + // The assistive lane is set before the pipeline emits `Listening`, so the + // long "Starting..." warm-up must already read as assistive - otherwise the + // menu bar flashes dictation styling until the first `Listening` beat. + for status in [ + TrayStatus::Starting, + TrayStatus::Listening, + TrayStatus::Thinking, + ] { + assert!( + TrayStatusSnapshot::new(status, true).is_assistive_visible(), + "{status:?} with an active assistive lane should read as assistive" + ); + } + } + + #[test] + fn assistive_visible_ignores_terminal_and_non_assistive_states() { + assert!(!TrayStatusSnapshot::new(TrayStatus::Idle, true).is_assistive_visible()); + assert!(!TrayStatusSnapshot::new(TrayStatus::Success, true).is_assistive_visible()); + assert!(!TrayStatusSnapshot::new(TrayStatus::Starting, false).is_assistive_visible()); + } +} diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs index 923aeae6..6235528b 100644 --- a/app/presentation/emitter.rs +++ b/app/presentation/emitter.rs @@ -133,6 +133,39 @@ impl SessionTranscriptState { rendered } + /// Apply an ADR bounded patch (`ReplaceRange` / `InsertAnnotation`) to the + /// committed utterance it targets, so the authoritative transcript + /// (`transcript_buffer` → paste/history) reflects the same correction the + /// overlay receives. Offsets are char offsets within `utterance_id` (see + /// `EngineEvent::apply_to_committed_text`). Returns whether the buffer + /// changed — `false` when the utterance is not (yet) committed or the offsets + /// fall outside it (the patch is dropped rather than corrupting the buffer). + fn apply_layered_patch(&mut self, event: &EngineEvent) -> bool { + let utterance_id = match event { + EngineEvent::ReplaceRange { utterance_id, .. } + | EngineEvent::InsertAnnotation { utterance_id, .. } => *utterance_id, + _ => return false, + }; + let Some(record) = self + .committed + .iter_mut() + .find(|record| record.utterance_id == utterance_id) + else { + return false; + }; + match event.apply_to_committed_text(&mut record.text) { + Ok(applied) => applied, + Err(error) => { + tracing::warn!( + ?error, + utterance_id, + "layered transcript patch offsets out of range; dropped" + ); + false + } + } + } + #[cfg(test)] fn committed(&self) -> &[TranscriptUtteranceRecord] { &self.committed @@ -466,6 +499,27 @@ impl EventSink for PresentationEmitter { EngineEvent::Warning { code, message } => { tracing::warn!("Engine warning [{}]: {}", code, message); } + EngineEvent::ReplaceRange { .. } | EngineEvent::InsertAnnotation { .. } => { + // Apply the same bounded correction to the authoritative buffer + // (transcript_buffer → paste/history) that the overlay already + // received, so phase-1 layered patches don't diverge between the + // two sinks. Only re-render when the buffer actually changed. + let rendered = { + let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); + if state.apply_layered_patch(event) { + Some(match self.delta_render_mode { + DeltaRenderMode::SessionRendered => state.rendered_text(), + DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), + }) + } else { + None + } + }; + if let Some(rendered) = rendered { + self.send_cmd(EmitterCmd::SetTargetText(rendered)); + } + } + EngineEvent::SessionFinalised { .. } => {} } } } @@ -473,7 +527,9 @@ impl EventSink for PresentationEmitter { #[cfg(test)] mod tests { use super::{DeltaRenderMode, PresentationEmitter, SessionTranscriptState}; - use codescribe_core::pipeline::contracts::{EngineEvent, EventSink, TranscriptSegment}; + use codescribe_core::pipeline::contracts::{ + AnnotationKind, EngineEvent, EventSink, LayerSource, TranscriptSegment, + }; use std::sync::{Arc, Mutex as StdMutex}; use tokio::sync::Mutex; @@ -495,6 +551,54 @@ mod tests { assert_eq!(state.rendered_text(), "Pierwszy fragment drugi partial"); } + #[test] + fn replace_range_patches_committed_utterance_in_authoritative_buffer() { + // A phase-1 ReplaceRange fixing "wrold"→"world" must land in the + // committed (paste/history) buffer, not just the overlay. + let mut state = SessionTranscriptState::default(); + state.finalize(1, "hello wrold", "hello wrold", 0.0, 1.0, Vec::new()); + let event = EngineEvent::ReplaceRange { + utterance_id: 1, + start: 6, + end: 11, + text: "world".to_string(), + source: LayerSource::TailPatch, + }; + assert!(state.apply_layered_patch(&event)); + assert_eq!(state.rendered_text(), "hello world"); + } + + #[test] + fn insert_annotation_lands_in_committed_utterance() { + let mut state = SessionTranscriptState::default(); + state.finalize(2, "yes", "yes", 0.0, 1.0, Vec::new()); + let event = EngineEvent::InsertAnnotation { + utterance_id: 2, + position: 3, + text: " [pauza]".to_string(), + kind: AnnotationKind::HesitationPause, + }; + assert!(state.apply_layered_patch(&event)); + assert_eq!(state.rendered_text(), "yes [pauza]"); + } + + #[test] + fn patch_for_uncommitted_utterance_is_ignored() { + // Offsets reference an utterance the authoritative buffer has not + // committed yet — drop the patch instead of corrupting another one. + let mut state = SessionTranscriptState::default(); + state.finalize(1, "hello", "hello", 0.0, 1.0, Vec::new()); + let event = EngineEvent::ReplaceRange { + utterance_id: 99, + start: 0, + end: 1, + text: "X".to_string(), + source: LayerSource::Lexicon, + }; + assert!(!state.apply_layered_patch(&event)); + assert_eq!(state.rendered_text(), "hello"); + } + #[test] fn session_state_correction_stays_local_to_active_tail() { let mut state = SessionTranscriptState::default(); diff --git a/app/qube_lifecycle.rs b/app/qube_lifecycle.rs deleted file mode 100644 index 7c0ddda2..00000000 --- a/app/qube_lifecycle.rs +++ /dev/null @@ -1,459 +0,0 @@ -use crate::config::UserSettings; -use crate::qube_daemon::QubeDaemonState; -use chrono::{DateTime, Duration, Utc}; -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, ExitStatus, Stdio}; -use std::sync::{Mutex, OnceLock}; -use tracing::{info, warn}; - -const DAEMON_STATE_FRESHNESS_SECS: i64 = 300; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum QubeLifecycleState { - Disabled, - MissingBinary { attempted: PathBuf }, - Running { pid: Option, owned: bool }, - Stopped, - StartFailed { message: String }, -} - -impl QubeLifecycleState { - pub fn is_running(&self) -> bool { - matches!(self, Self::Running { .. }) - } -} - -#[derive(Debug, Clone)] -pub struct QubeDashboardSnapshot { - pub daemon_state: QubeDaemonState, - pub lifecycle: QubeLifecycleState, - pub available: bool, - pub last_check_fresh: bool, -} - -impl QubeDashboardSnapshot { - pub fn availability_label(&self) -> &'static str { - if self.available { - "Available" - } else { - match self.lifecycle { - QubeLifecycleState::Disabled => "Disabled", - QubeLifecycleState::MissingBinary { .. } => "Binary missing", - QubeLifecycleState::Running { .. } => "Stale", - QubeLifecycleState::Stopped => "Not running", - QubeLifecycleState::StartFailed { .. } => "Start failed", - } - } - } -} - -#[derive(Debug, Default)] -struct QubeLifecycleRuntime { - child: Option, - last_state: Option, -} - -static QUBE_LIFECYCLE: OnceLock> = OnceLock::new(); - -fn runtime() -> &'static Mutex { - QUBE_LIFECYCLE.get_or_init(|| Mutex::new(QubeLifecycleRuntime::default())) -} - -fn autostart_enabled() -> bool { - UserSettings::load() - .qube_daemon_autostart - .unwrap_or_else(|| { - std::env::var("QUBE_DAEMON_AUTOSTART") - .map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) - }) -} - -pub fn qube_daemon_candidate_for_exe(current_exe: &Path) -> PathBuf { - current_exe.with_file_name("qube-daemon") -} - -pub fn resolve_qube_daemon_executable_from( - current_exe: &Path, - path_env: Option<&OsStr>, -) -> Option { - let sibling = qube_daemon_candidate_for_exe(current_exe); - if sibling.exists() { - return Some(sibling); - } - - let path_env = path_env?; - for dir in std::env::split_paths(path_env) { - let candidate = dir.join("qube-daemon"); - if candidate.exists() { - return Some(candidate); - } - } - - None -} - -pub fn resolve_qube_daemon_executable() -> Option { - let current_exe = std::env::current_exe().ok()?; - resolve_qube_daemon_executable_from(¤t_exe, std::env::var_os("PATH").as_deref()) -} - -fn process_list_contains_qube_daemon(ps_output: &str, executable: &Path) -> bool { - let executable_name = executable - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("qube-daemon"); - let executable_path = executable.to_string_lossy(); - - ps_output.lines().any(|line| { - let trimmed = line.trim(); - if trimmed.is_empty() || !trimmed.contains("--daemon") || !trimmed.contains(executable_name) - { - return false; - } - - trimmed.contains(executable_path.as_ref()) || trimmed.contains(executable_name) - }) -} - -fn is_qube_daemon_running(executable: &Path) -> bool { - let output = Command::new("ps") - .args(["-ax", "-o", "comm=,args="]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let stdout = String::from_utf8_lossy(&output.stdout); - process_list_contains_qube_daemon(&stdout, executable) - } - Ok(output) => { - warn!( - "Qube lifecycle: failed to inspect process list (ps exit={})", - output.status - ); - false - } - Err(err) => { - warn!("Qube lifecycle: failed to run ps: {err}"); - false - } - } -} - -fn reap_child_state(child: &mut Child) -> Option { - match child.try_wait() { - Ok(Some(status)) => Some(status), - Ok(None) => None, - Err(err) => { - warn!("Qube lifecycle: failed to query child status: {err}"); - None - } - } -} - -fn sync_runtime_state(runtime: &mut QubeLifecycleRuntime) { - let Some(child) = runtime.child.as_mut() else { - return; - }; - - if let Some(status) = reap_child_state(child) { - runtime.child = None; - runtime.last_state = Some(QubeLifecycleState::StartFailed { - message: format!("qube-daemon exited with status {status}"), - }); - } -} - -pub fn start_if_enabled() -> QubeLifecycleState { - if autostart_enabled() { - start_managed() - } else { - current_state() - } -} - -pub fn start_managed() -> QubeLifecycleState { - let mut runtime = runtime().lock().unwrap_or_else(|e| e.into_inner()); - sync_runtime_state(&mut runtime); - - if let Some(child) = runtime.child.as_ref() { - let state = QubeLifecycleState::Running { - pid: Some(child.id()), - owned: true, - }; - runtime.last_state = Some(state.clone()); - return state; - } - - let current_exe = match std::env::current_exe() { - Ok(path) => path, - Err(err) => { - let state = QubeLifecycleState::StartFailed { - message: format!("failed to resolve current executable: {err}"), - }; - runtime.last_state = Some(state.clone()); - return state; - } - }; - let attempted = qube_daemon_candidate_for_exe(¤t_exe); - let Some(executable) = - resolve_qube_daemon_executable_from(¤t_exe, std::env::var_os("PATH").as_deref()) - else { - let state = QubeLifecycleState::MissingBinary { attempted }; - runtime.last_state = Some(state.clone()); - warn!("Qube lifecycle: autostart enabled, but qube-daemon binary was not found"); - return state; - }; - - if is_qube_daemon_running(&executable) { - let state = QubeLifecycleState::Running { - pid: None, - owned: false, - }; - runtime.last_state = Some(state.clone()); - return state; - } - - match Command::new(&executable) - .arg("--daemon") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - { - Ok(child) => { - let pid = child.id(); - runtime.child = Some(child); - let state = QubeLifecycleState::Running { - pid: Some(pid), - owned: true, - }; - runtime.last_state = Some(state.clone()); - info!( - "Started qube-daemon via lifecycle manager (pid={}, path={})", - pid, - executable.display() - ); - state - } - Err(err) => { - let state = QubeLifecycleState::StartFailed { - message: format!("failed to spawn {}: {err}", executable.display()), - }; - runtime.last_state = Some(state.clone()); - if let QubeLifecycleState::StartFailed { message } = &state { - warn!("Qube lifecycle: {message}"); - } - state - } - } -} - -pub fn stop_managed() -> QubeLifecycleState { - let mut runtime = runtime().lock().unwrap_or_else(|e| e.into_inner()); - sync_runtime_state(&mut runtime); - - if let Some(mut child) = runtime.child.take() { - let pid = child.id(); - if let Err(err) = child.kill() { - let state = QubeLifecycleState::StartFailed { - message: format!("failed to stop managed qube-daemon (pid={pid}): {err}"), - }; - runtime.last_state = Some(state.clone()); - return state; - } - let _ = child.wait(); - let state = QubeLifecycleState::Stopped; - runtime.last_state = Some(state.clone()); - info!("Stopped managed qube-daemon (pid={pid})"); - return state; - } - - let state = if autostart_enabled() { - QubeLifecycleState::Stopped - } else { - QubeLifecycleState::Disabled - }; - runtime.last_state = Some(state.clone()); - state -} - -pub fn current_state() -> QubeLifecycleState { - let mut runtime = runtime().lock().unwrap_or_else(|e| e.into_inner()); - sync_runtime_state(&mut runtime); - - if let Some(child) = runtime.child.as_ref() { - let state = QubeLifecycleState::Running { - pid: Some(child.id()), - owned: true, - }; - runtime.last_state = Some(state.clone()); - return state; - } - - if let Some(executable) = resolve_qube_daemon_executable() { - if is_qube_daemon_running(&executable) { - let state = QubeLifecycleState::Running { - pid: None, - owned: false, - }; - runtime.last_state = Some(state.clone()); - return state; - } - } else if autostart_enabled() { - let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("codescribe")); - let state = QubeLifecycleState::MissingBinary { - attempted: qube_daemon_candidate_for_exe(¤t_exe), - }; - runtime.last_state = Some(state.clone()); - return state; - } - - let state = if autostart_enabled() { - runtime - .last_state - .clone() - .unwrap_or(QubeLifecycleState::Stopped) - } else { - QubeLifecycleState::Disabled - }; - runtime.last_state = Some(state.clone()); - state -} - -fn is_last_check_fresh(last_check: &str, now: DateTime) -> bool { - let trimmed = last_check.trim(); - if trimmed.is_empty() { - return false; - } - - DateTime::parse_from_rfc3339(trimmed) - .map(|parsed| { - parsed.with_timezone(&Utc) >= now - Duration::seconds(DAEMON_STATE_FRESHNESS_SECS) - }) - .unwrap_or(false) -} - -pub fn dashboard_snapshot() -> QubeDashboardSnapshot { - let daemon_state = crate::qube_daemon::read_daemon_state(); - let lifecycle = current_state(); - let last_check_fresh = is_last_check_fresh(&daemon_state.last_check, Utc::now()); - let available = daemon_state.available && lifecycle.is_running() && last_check_fresh; - - QubeDashboardSnapshot { - daemon_state, - lifecycle, - available, - last_check_fresh, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn qube_daemon_candidate_uses_sibling_binary() { - let current_exe = PathBuf::from("/Applications/CodeScribe.app/Contents/MacOS/codescribe"); - assert_eq!( - qube_daemon_candidate_for_exe(¤t_exe), - PathBuf::from("/Applications/CodeScribe.app/Contents/MacOS/qube-daemon") - ); - } - - #[test] - fn resolve_qube_daemon_executable_prefers_sibling_binary() { - let tmp = tempfile::TempDir::new().expect("tempdir"); - let macos_dir = tmp.path().join("CodeScribe.app/Contents/MacOS"); - std::fs::create_dir_all(&macos_dir).expect("create bundle dir"); - let current_exe = macos_dir.join("codescribe"); - let sibling = macos_dir.join("qube-daemon"); - std::fs::write(¤t_exe, "").expect("write fake current exe"); - std::fs::write(&sibling, "").expect("write fake qube daemon"); - - let resolved = resolve_qube_daemon_executable_from(¤t_exe, None); - assert_eq!(resolved, Some(sibling)); - } - - #[test] - fn process_list_contains_running_qube_daemon_with_daemon_flag() { - let executable = Path::new("/usr/local/bin/qube-daemon"); - let ps_output = "qube-daemon /usr/local/bin/qube-daemon --daemon\n"; - assert!(process_list_contains_qube_daemon(ps_output, executable)); - } - - #[test] - fn process_list_ignores_non_daemon_qube_invocations() { - let executable = Path::new("/usr/local/bin/qube-daemon"); - let ps_output = "qube-daemon /usr/local/bin/qube-daemon --date 2026-04-21\n"; - assert!(!process_list_contains_qube_daemon(ps_output, executable)); - } - - #[test] - fn dashboard_requires_fresh_last_check() { - let now = DateTime::parse_from_rfc3339("2026-04-21T03:00:00+00:00") - .expect("parse time") - .with_timezone(&Utc); - assert!(is_last_check_fresh("2026-04-21T02:56:00+00:00", now)); - assert!(!is_last_check_fresh("2026-04-21T02:40:00+00:00", now)); - assert!(!is_last_check_fresh("", now)); - } - - #[test] - fn resolve_qube_daemon_executable_from_returns_none_when_nothing_resolves() { - let tmp = tempfile::TempDir::new().expect("tempdir"); - // Empty directory: no sibling, no PATH provided. - let current_exe = tmp.path().join("codescribe"); - std::fs::write(¤t_exe, "").expect("write fake current exe"); - assert_eq!( - resolve_qube_daemon_executable_from(¤t_exe, None), - None - ); - } - - #[test] - fn resolve_qube_daemon_executable_from_walks_path_when_no_sibling() { - let tmp = tempfile::TempDir::new().expect("tempdir"); - // No sibling: PATH lookup must find the binary. - let current_exe = tmp.path().join("nested/codescribe"); - std::fs::create_dir_all(current_exe.parent().expect("parent")).expect("mkdir nested"); - std::fs::write(¤t_exe, "").expect("write fake current exe"); - - let path_dir = tmp.path().join("path-bin"); - std::fs::create_dir_all(&path_dir).expect("mkdir path-bin"); - let qube_in_path = path_dir.join("qube-daemon"); - std::fs::write(&qube_in_path, "").expect("write fake daemon"); - - let path_env = std::ffi::OsString::from(path_dir.as_os_str()); - let resolved = resolve_qube_daemon_executable_from(¤t_exe, Some(&path_env)); - assert_eq!(resolved, Some(qube_in_path)); - } - - #[test] - fn lifecycle_state_is_running_only_for_running_variant() { - assert!( - !QubeLifecycleState::Disabled.is_running(), - "Disabled state must not advertise running" - ); - assert!(!QubeLifecycleState::Stopped.is_running()); - assert!( - !QubeLifecycleState::MissingBinary { - attempted: PathBuf::from("/nope") - } - .is_running() - ); - assert!( - !QubeLifecycleState::StartFailed { - message: "boom".into() - } - .is_running() - ); - assert!( - QubeLifecycleState::Running { - pid: Some(1234), - owned: true - } - .is_running() - ); - } -} diff --git a/app/ui/README.md b/app/ui/README.md deleted file mode 100644 index 23cc9720..00000000 --- a/app/ui/README.md +++ /dev/null @@ -1,266 +0,0 @@ -# UI Module - macOS Hold Badge & Caret Tracking - -This module provides native macOS functionality for displaying a floating badge indicator and tracking text caret positions. - -## Features - -### 1. Hold Badge Indicator - -A small, floating circular window that follows the cursor/caret position during recording: - -- **Appearance**: Customizable circular badge (default: 12px red circle) -- **Behavior**: Floats above all windows, ignores mouse events -- **Positioning**: Tracks cursor or text caret with configurable offset -- **Update Rate**: Configurable position update interval (default: 150ms) -- **Multi-Space**: Can appear on all macOS spaces - -### 2. Caret Tracking - -Uses macOS Accessibility API to find the text insertion point: - -- Queries focused UI element -- Checks if element accepts text input (text fields, text areas, etc.) -- Returns screen coordinates of caret position -- Falls back to cursor position if caret unavailable - -### 3. Cursor Position - -Simple mouse cursor tracking via NSEvent. - -## API - -### Basic Usage - -```rust -use codescribe::{show_hold_badge, hide_hold_badge}; - -// Show badge with default settings -show_hold_badge(); - -// Hide badge -hide_hold_badge(); -``` - -### Custom Configuration - -```rust -use codescribe::{show_hold_badge_with_config, HoldBadgeConfig}; - -let config = HoldBadgeConfig { - diameter: 20.0, // Badge size in pixels - offset: (-10.0, 10.0), // Offset from caret/cursor (x, y) - update_interval_ms: 100, // Position update frequency - color: (0.0, 1.0, 0.0, 0.9), // RGBA color (green, 90% opacity) -}; - -show_hold_badge_with_config(config); -``` - -### Utility Functions - -```rust -use codescribe::{ - focused_element_accepts_text, - get_caret_position, - get_cursor_position, -}; - -// Check if current focus is on a text input -if focused_element_accepts_text() { - println!("User is in a text field"); -} - -// Get caret position (returns None if not in text field) -if let Some((x, y)) = get_caret_position() { - println!("Caret at screen position: ({}, {})", x, y); -} - -// Get mouse cursor position (always available) -let (x, y) = get_cursor_position(); -println!("Cursor at: ({}, {})", x, y); -``` - -## Configuration Options - -### HoldBadgeConfig - -```rust -pub struct HoldBadgeConfig { - /// Diameter of the badge circle in pixels - pub diameter: f64, - - /// Offset from caret/cursor position (x, y) - pub offset: (f64, f64), - - /// Update interval in milliseconds - pub update_interval_ms: u64, - - /// Badge color (R, G, B, A) - values 0.0 to 1.0 - pub color: (f64, f64, f64, f64), -} -``` - -**Defaults:** - -- `diameter`: 12.0 pixels -- `offset`: (10.0, -10.0) - right and above -- `update_interval_ms`: 150ms -- `color`: (1.0, 0.0, 0.0, 0.8) - red with 80% opacity - -## Implementation Details - -### Thread Safety - -- Uses `Arc>` for shared state -- Window pointer stored as `usize` for `Send` compatibility -- Safe to call from any thread - -### Window Behavior - -- **Level**: NSStatusWindowLevel (25) - floats above regular windows -- **Style**: Borderless, transparent background -- **Mouse**: Ignores all mouse events (pass-through) -- **Spaces**: Can join all macOS spaces/desktops - -### Position Tracking - -The badge position is updated in a background thread: - -1. Check if caret position available via Accessibility API -2. Fall back to cursor position if caret not found -3. Apply configured offset -4. Update window position -5. Sleep for configured interval -6. Repeat while badge is visible - -### Accessibility API Usage - -The module queries: - -- **AXFocusedUIElement**: Get currently focused UI element -- **AXRole**: Check element type (text field, text area, etc.) -- **AXSelectedTextRange**: Get text selection/caret position -- **AXPosition** & **AXSize**: Convert to screen coordinates - -## Requirements - -### Dependencies - -```toml -[target.'cfg(target_os = "macos")'.dependencies] -cocoa = "0.25" -core-graphics = "0.23" -core-foundation = "0.10" -objc = "0.2" -lazy_static = "1.4" -``` - -### Permissions - -The application needs **Accessibility** permissions to track caret positions: - -1. System Settings → Privacy & Security → Accessibility -2. Add your application to the allowed list - -Without these permissions: - -- `focused_element_accepts_text()` returns `false` -- `get_caret_position()` returns `None` -- Badge falls back to cursor tracking - -## Examples - -### Recording Indicator - -```rust -// Show badge when recording starts -fn on_recording_start() { - show_hold_badge(); -} - -// Hide when recording stops -fn on_recording_stop() { - hide_hold_badge(); -} -``` - -### Smart Positioning - -```rust -// Use different offsets based on text field presence -let config = if focused_element_accepts_text() { - HoldBadgeConfig { - offset: (5.0, -20.0), // Near caret - ..Default::default() - } -} else { - HoldBadgeConfig { - offset: (15.0, 15.0), // Near cursor - ..Default::default() - } -}; - -show_hold_badge_with_config(config); -``` - -### Visual Feedback States - -```rust -// Different colors for different states -fn show_recording_badge() { - let config = HoldBadgeConfig { - color: (1.0, 0.0, 0.0, 0.8), // Red = recording - ..Default::default() - }; - show_hold_badge_with_config(config); -} - -fn show_processing_badge() { - let config = HoldBadgeConfig { - color: (1.0, 0.5, 0.0, 0.8), // Orange = processing - ..Default::default() - }; - show_hold_badge_with_config(config); -} -``` - -## Demo - -Run the included example: - -```bash -cargo run --example hold_badge_demo -``` - -This will: - -1. Check focused element text acceptance -2. Display cursor and caret positions -3. Show default badge for 3 seconds -4. Show custom colored badge with different size/offset -5. Clean up and exit - -## Limitations - -1. **macOS Only**: Uses Cocoa/AppKit APIs -2. **Accessibility Required**: Caret tracking needs system permissions -3. **No Window Retention**: Window pointer not retained across app restarts -4. **Single Badge**: Only one badge can be shown at a time (new one replaces old) - -## Future Enhancements - -Potential improvements: - -- [ ] Multiple simultaneous badges -- [ ] Animation support (pulse, fade, etc.) -- [ ] Custom shapes (not just circles) -- [ ] Smarter caret prediction in complex text layouts -- [ ] Cache focused element checks for performance - -## License - -FSL-1.1-ALv2 (same as parent project) - ---- - -Created by M&K (c)2025 The LibraxisAI Team diff --git a/app/ui/mod.rs b/app/ui/mod.rs deleted file mode 100644 index b77e3056..00000000 --- a/app/ui/mod.rs +++ /dev/null @@ -1,1068 +0,0 @@ -pub mod onboarding; -pub mod overlay; -pub mod settings; -pub mod shared; -pub mod tray; -pub mod voice_chat; -// macOS UI utilities for hold badge indicator and caret tracing -// - Displaying a floating red badge indicator during recording -// - Tracking text caret position via Accessibility API -// - Falling back to cursor position when caret is unavailable - -use core_foundation::base::TCFType; -use core_foundation::string::CFString; -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use dispatch::Queue; -use objc::runtime::Class; -use objc::runtime::Sel; -use objc::{msg_send, sel, sel_impl}; -use objc2_app_kit::{ - NSBackingStoreType, NSColor, NSEvent, NSWindowCollectionBehavior, NSWindowStyleMask, -}; -use std::ptr; - -use crate::ui::shared::helpers::{add_subview, window_close, window_show}; -use crate::ui_helpers::ns_string; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; -use tracing::{debug, warn}; - -// Type alias for Objective-C object pointers (compatible with objc crate msg_send!) -use crate::ui_helpers::Id; - -// Accessibility API bindings (use raw pointers compatible with C FFI) -type AXId = *mut std::ffi::c_void; - -#[link(name = "ApplicationServices", kind = "framework")] -unsafe extern "C" { - fn AXUIElementCopyAttributeValue(element: AXId, attribute: AXId, value: *mut AXId) -> i32; - fn AXUIElementCreateSystemWide() -> AXId; - fn AXValueGetValue(value: AXId, type_: i32, value_ptr: *mut std::ffi::c_void) -> bool; - fn CFRelease(cf: *const std::ffi::c_void); -} - -// AX constants -const AX_ERROR_SUCCESS: i32 = 0; -const AX_FOCUSED_UIELEMENT_ATTRIBUTE: &str = "AXFocusedUIElement"; -const AX_ROLE_ATTRIBUTE: &str = "AXRole"; -const AX_SELECTED_TEXT_ATTRIBUTE: &str = "AXSelectedText"; -const AX_SELECTED_TEXT_RANGE_ATTRIBUTE: &str = "AXSelectedTextRange"; -const AX_POSITION_ATTRIBUTE: &str = "AXPosition"; -const AX_SIZE_ATTRIBUTE: &str = "AXSize"; - -// AXValue types -const AX_VALUE_CGPOINT_TYPE: i32 = 1; -const AX_VALUE_CGSIZE_TYPE: i32 = 2; -const AX_VALUE_CFRANGE_TYPE: i32 = 3; - -// Window level constants -const NS_STATUS_WINDOW_LEVEL: i64 = 25; - -/// Badge display mode for different recording/processing states -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum BadgeMode { - /// Hold mode (Ctrl): Red, solid - "trzymaj palec!" - Hold, - /// Toggle mode (⌥⌥): Red, pulsing - "nagrywam hands-off" - Toggle, - /// Processing: Orange - "transkrybuję/formatuję" - Processing, - /// AI mode (Chat/Selection): Purple with glow - "AI słucha" - Assistive, -} - -impl BadgeMode { - /// Get the base color for this mode (RGBA) - pub fn color(&self) -> (f64, f64, f64, f64) { - match self { - BadgeMode::Hold => (1.0, 0.0, 0.0, 0.8), // Red - BadgeMode::Toggle => (1.0, 0.0, 0.0, 0.8), // Red (will pulse) - BadgeMode::Processing => (1.0, 0.5, 0.0, 0.85), // Orange - BadgeMode::Assistive => (0.6, 0.2, 0.9, 0.85), // Purple - } - } - - /// Whether this mode should pulse (opacity animation) - pub fn should_pulse(&self) -> bool { - matches!(self, BadgeMode::Toggle | BadgeMode::Processing) - } - - /// Whether this mode has glow effect - pub fn has_glow(&self) -> bool { - matches!(self, BadgeMode::Assistive) - } - - /// Get diameter multiplier for this mode - pub fn diameter_multiplier(&self) -> f64 { - match self { - BadgeMode::Assistive => 1.2, // Slightly larger for AI mode - _ => 1.0, - } - } -} - -/// Configuration for the hold badge -#[derive(Debug, Clone)] -pub struct HoldBadgeConfig { - /// Diameter of the badge circle in pixels - pub diameter: f64, - /// Offset from caret/cursor position (x, y) - pub offset: (f64, f64), - /// Update interval in milliseconds - pub update_interval_ms: u64, - /// Badge color (R, G, B, A) - pub color: (f64, f64, f64, f64), - /// Badge mode for animations - pub mode: BadgeMode, -} - -impl Default for HoldBadgeConfig { - fn default() -> Self { - Self { - diameter: 12.0, - offset: (10.0, -10.0), - update_interval_ms: 150, - color: (1.0, 0.0, 0.0, 0.8), // Red with 80% opacity - mode: BadgeMode::Hold, - } - } -} - -impl HoldBadgeConfig { - /// Create config from badge mode with appropriate colors - pub fn from_mode(mode: BadgeMode) -> Self { - let base = Self::default(); - Self { - diameter: base.diameter * mode.diameter_multiplier(), - color: mode.color(), - mode, - ..base - } - } -} - -/// Hold badge state -struct HoldBadgeState { - window: Option, // Store as usize to make it Send - timer_running: bool, - config: HoldBadgeConfig, - last_position: (f64, f64), -} - -lazy_static::lazy_static! { - static ref BADGE_STATE: Arc> = Arc::new(Mutex::new(HoldBadgeState { - window: None, - timer_running: false, - config: HoldBadgeConfig::default(), - last_position: (f64::NAN, f64::NAN), - })); -} - -/// Check if the currently focused element accepts text input -pub fn focused_element_accepts_text() -> bool { - unsafe { - let system_wide = AXUIElementCreateSystemWide(); - if system_wide.is_null() { - return false; - } - - let mut focused_element: AXId = ptr::null_mut(); - let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); - let result = AXUIElementCopyAttributeValue( - system_wide, - attr_name.as_concrete_TypeRef() as AXId, - &mut focused_element, - ); - - CFRelease(system_wide); - - if result != AX_ERROR_SUCCESS || focused_element.is_null() { - return false; - } - - // Get role attribute - let mut role_value: AXId = ptr::null_mut(); - let role_attr = CFString::new(AX_ROLE_ATTRIBUTE); - let role_result = AXUIElementCopyAttributeValue( - focused_element, - role_attr.as_concrete_TypeRef() as AXId, - &mut role_value, - ); - - CFRelease(focused_element); - - if role_result != AX_ERROR_SUCCESS || role_value.is_null() { - return false; - } - - // Convert role to string - let role_str = CFString::wrap_under_get_rule(role_value as *const _); - let role = role_str.to_string(); - CFRelease(role_value); - - // Check if role indicates text input - matches!( - role.as_str(), - "AXTextArea" | "AXTextField" | "AXComboBox" | "AXTextView" | "AXWebArea" - ) - } -} - -/// Get the current text caret position in screen coordinates -pub fn get_caret_position() -> Option<(f64, f64)> { - unsafe { - let system_wide = AXUIElementCreateSystemWide(); - if system_wide.is_null() { - return None; - } - - let mut focused_element: AXId = ptr::null_mut(); - let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); - let result = AXUIElementCopyAttributeValue( - system_wide, - attr_name.as_concrete_TypeRef() as AXId, - &mut focused_element, - ); - - CFRelease(system_wide); - - if result != AX_ERROR_SUCCESS || focused_element.is_null() { - return None; - } - - // Get selected text range - let mut range_value: AXId = ptr::null_mut(); - let range_attr = CFString::new(AX_SELECTED_TEXT_RANGE_ATTRIBUTE); - let range_result = AXUIElementCopyAttributeValue( - focused_element, - range_attr.as_concrete_TypeRef() as AXId, - &mut range_value, - ); - - if range_result != AX_ERROR_SUCCESS || range_value.is_null() { - CFRelease(focused_element); - return None; - } - - // Extract range - #[repr(C)] - struct CFRange { - location: i64, - length: i64, - } - - let mut cf_range = CFRange { - location: 0, - length: 0, - }; - - let range_ok = AXValueGetValue( - range_value, - AX_VALUE_CFRANGE_TYPE, - &mut cf_range as *mut _ as *mut std::ffi::c_void, - ); - - CFRelease(range_value); - - if !range_ok { - CFRelease(focused_element); - return None; - } - - // Try to get position and size of the focused element - let mut position_value: AXId = ptr::null_mut(); - let position_attr = CFString::new(AX_POSITION_ATTRIBUTE); - let position_result = AXUIElementCopyAttributeValue( - focused_element, - position_attr.as_concrete_TypeRef() as AXId, - &mut position_value, - ); - - let mut size_value: AXId = ptr::null_mut(); - let size_attr = CFString::new(AX_SIZE_ATTRIBUTE); - let size_result = AXUIElementCopyAttributeValue( - focused_element, - size_attr.as_concrete_TypeRef() as AXId, - &mut size_value, - ); - - CFRelease(focused_element); - - if position_result != AX_ERROR_SUCCESS - || position_value.is_null() - || size_result != AX_ERROR_SUCCESS - || size_value.is_null() - { - if !position_value.is_null() { - CFRelease(position_value); - } - if !size_value.is_null() { - CFRelease(size_value); - } - return None; - } - - // Extract position - let mut position = CGPoint { x: 0.0, y: 0.0 }; - let position_ok = AXValueGetValue( - position_value, - AX_VALUE_CGPOINT_TYPE, - &mut position as *mut _ as *mut std::ffi::c_void, - ); - - CFRelease(position_value); - - // Extract size - let mut size = CGSize { - width: 0.0, - height: 0.0, - }; - let size_ok = AXValueGetValue( - size_value, - AX_VALUE_CGSIZE_TYPE, - &mut size as *mut _ as *mut std::ffi::c_void, - ); - - CFRelease(size_value); - - if !position_ok || !size_ok { - return None; - } - - // Estimate caret position (top-left of element + small offset) - // For better accuracy, we'd need to parse the text layout, but this is a reasonable approximation - Some((position.x, position.y + size.height / 2.0)) - } -} - -/// Get currently selected text from the focused UI element (best-effort). -/// -/// Notes: -/// - Requires Accessibility permission. -/// - Many apps expose selected text via `AXSelectedText`, but not all. -/// - Returns `None` if there's no selection or the attribute isn't supported. -pub fn get_selected_text(max_chars: usize) -> Option { - unsafe { - let system_wide = AXUIElementCreateSystemWide(); - if system_wide.is_null() { - return None; - } - - let mut focused_element: AXId = ptr::null_mut(); - let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); - let result = AXUIElementCopyAttributeValue( - system_wide, - attr_name.as_concrete_TypeRef() as AXId, - &mut focused_element, - ); - - CFRelease(system_wide); - - if result != AX_ERROR_SUCCESS || focused_element.is_null() { - return None; - } - - let mut selected_value: AXId = ptr::null_mut(); - let selected_attr = CFString::new(AX_SELECTED_TEXT_ATTRIBUTE); - let selected_result = AXUIElementCopyAttributeValue( - focused_element, - selected_attr.as_concrete_TypeRef() as AXId, - &mut selected_value, - ); - - CFRelease(focused_element); - - if selected_result != AX_ERROR_SUCCESS || selected_value.is_null() { - return None; - } - - let selected_str = CFString::wrap_under_get_rule(selected_value as *const _).to_string(); - CFRelease(selected_value); - - let mut s = selected_str.trim().to_string(); - if s.is_empty() { - return None; - } - - let char_count = s.chars().count(); - if max_chars > 0 && char_count > max_chars { - s = s.chars().take(max_chars).collect(); - s.push('…'); - } - - Some(s) - } -} - -/// Get the current selected text length (range length) from the focused UI element. -/// -/// Returns: -/// - `Some(0)` if the element supports `AXSelectedTextRange` but there's no selection -/// - `Some(n>0)` if there's a selection -/// - `None` if the attribute isn't available or any step fails -pub fn get_selected_text_length() -> Option { - unsafe { - let system_wide = AXUIElementCreateSystemWide(); - if system_wide.is_null() { - return None; - } - - let mut focused_element: AXId = ptr::null_mut(); - let attr_name = CFString::new(AX_FOCUSED_UIELEMENT_ATTRIBUTE); - let result = AXUIElementCopyAttributeValue( - system_wide, - attr_name.as_concrete_TypeRef() as AXId, - &mut focused_element, - ); - - CFRelease(system_wide); - - if result != AX_ERROR_SUCCESS || focused_element.is_null() { - return None; - } - - let mut range_value: AXId = ptr::null_mut(); - let range_attr = CFString::new(AX_SELECTED_TEXT_RANGE_ATTRIBUTE); - let range_result = AXUIElementCopyAttributeValue( - focused_element, - range_attr.as_concrete_TypeRef() as AXId, - &mut range_value, - ); - - CFRelease(focused_element); - - if range_result != AX_ERROR_SUCCESS || range_value.is_null() { - return None; - } - - #[repr(C)] - struct CFRange { - location: i64, - length: i64, - } - - let mut cf_range = CFRange { - location: 0, - length: 0, - }; - - let ok = AXValueGetValue( - range_value, - AX_VALUE_CFRANGE_TYPE, - &mut cf_range as *mut _ as *mut std::ffi::c_void, - ); - CFRelease(range_value); - - if !ok { - return None; - } - - Some(cf_range.length.max(0) as usize) - } -} - -/// Get the current mouse cursor position in screen coordinates -pub fn get_cursor_position() -> (f64, f64) { - let mouse_location = NSEvent::mouseLocation(); - (mouse_location.x, mouse_location.y) -} - -/// Get the best available position for the badge (caret or cursor) -fn get_badge_position() -> (f64, f64) { - get_caret_position().unwrap_or_else(get_cursor_position) -} - -/// Create the hold badge window -unsafe fn create_badge_window(config: &HoldBadgeConfig) -> Id { - let ns_window = Class::get("NSWindow").unwrap(); - - // Get initial position - let (x, y) = get_badge_position(); - let adjusted_x = x + config.offset.0; - let adjusted_y = y + config.offset.1; - debug!( - "Badge position: raw=({:.1}, {:.1}), adjusted=({:.1}, {:.1}), diameter={}", - x, y, adjusted_x, adjusted_y, config.diameter - ); - - // Create window frame using CGRect (screen coordinates) - let window_frame = CGRect { - origin: CGPoint { - x: adjusted_x, - y: adjusted_y, - }, - size: CGSize { - width: config.diameter, - height: config.diameter, - }, - }; - - // Create window - let window: Id = msg_send![ns_window, alloc]; - let style_mask = NSWindowStyleMask::Borderless; - let backing = NSBackingStoreType::Buffered; - let window: Id = msg_send![ - window, - initWithContentRect: window_frame - styleMask: style_mask - backing: backing - defer: false - ]; - - // Configure window for floating transparent overlay - let clear_color = NSColor::clearColor(); - let clear_color_ptr = &*clear_color as *const _ as Id; - let _: () = msg_send![window, setOpaque: false]; - let _: () = msg_send![window, setBackgroundColor: clear_color_ptr]; - let _: () = msg_send![window, setIgnoresMouseEvents: true]; - let _: () = msg_send![window, setLevel: NS_STATUS_WINDOW_LEVEL]; - // Ensure any helper windows show up over fullscreen Spaces. - let collection_behavior = NSWindowCollectionBehavior::CanJoinAllSpaces - | NSWindowCollectionBehavior::FullScreenAuxiliary; - let _: () = msg_send![window, setCollectionBehavior: collection_behavior]; - - // Enable layer-backed views for better transparency/compositing - let content_view: Id = msg_send![window, contentView]; - let _: () = msg_send![content_view, setWantsLayer: true]; - - // Create badge view (circular red indicator) - // SAFETY: create_badge_view is unsafe, called from unsafe fn - let badge_view = unsafe { create_badge_view(config) }; - unsafe { - add_subview(content_view, badge_view); - } - - // Force the view to display - let _: () = msg_send![badge_view, setNeedsDisplay: true]; - - window -} - -/// Create the circular badge view using CALayer for reliable rendering -unsafe fn create_badge_view(config: &HoldBadgeConfig) -> Id { - // Use a plain NSView with a CALayer for drawing - let ns_view = Class::get("NSView").unwrap(); - let view: Id = msg_send![ns_view, alloc]; - let frame = CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width: config.diameter, - height: config.diameter, - }, - }; - let view: Id = msg_send![view, initWithFrame: frame]; - - // Enable layer-backing - let _: () = msg_send![view, setWantsLayer: true]; - - // Get the layer - let layer: Id = msg_send![view, layer]; - if layer.is_null() { - warn!("Badge layer is null - badge will not be visible"); - return view; - } - - // Configure the layer to draw a circle - // Set background color from config (default: red with 80% opacity) - // SAFETY: FFI calls to CoreGraphics - let cg_color = unsafe { - create_cg_color( - config.color.0, - config.color.1, - config.color.2, - config.color.3, - ) - }; - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - // SAFETY: Releasing CGColor we just created - unsafe { CGColorRelease(cg_color) }; - - // Make it circular by setting corner radius to half the diameter - let corner_radius = config.diameter / 2.0; - let _: () = msg_send![layer, setCornerRadius: corner_radius]; - - // Ensure the layer clips to bounds (for the circle shape) - let _: () = msg_send![layer, setMasksToBounds: true]; - - view -} - -// CGColor functions -#[link(name = "CoreGraphics", kind = "framework")] -unsafe extern "C" { - fn CGColorCreate( - space: *const std::ffi::c_void, - components: *const f64, - ) -> *const std::ffi::c_void; - fn CGColorSpaceCreateDeviceRGB() -> *const std::ffi::c_void; - fn CGColorSpaceRelease(space: *const std::ffi::c_void); - fn CGColorRelease(color: *const std::ffi::c_void); -} - -/// Create a CGColor from RGBA components -unsafe fn create_cg_color(r: f64, g: f64, b: f64, a: f64) -> *const std::ffi::c_void { - unsafe { - let color_space = CGColorSpaceCreateDeviceRGB(); - let components: [f64; 4] = [r, g, b, a]; - let color = CGColorCreate(color_space, components.as_ptr()); - CGColorSpaceRelease(color_space); - color - } -} - -/// Show the hold badge and start position tracking (default: Hold mode) -pub fn show_hold_badge() { - show_hold_badge_with_config(HoldBadgeConfig::default()); -} - -/// Show badge for specific mode with appropriate color/animation -pub fn show_badge_for_mode(mode: BadgeMode) { - show_hold_badge_with_config(HoldBadgeConfig::from_mode(mode)); -} - -/// Internal implementation that must run on the main thread -fn show_hold_badge_impl(config: HoldBadgeConfig) { - debug!("Showing hold badge (diameter={})", config.diameter); - unsafe { - // IMPORTANT: do not hold BADGE_STATE while calling `window_close`. - // Closing a window can trigger AppKit callbacks/notifications which may - // re-enter our code and attempt to lock BADGE_STATE again → deadlock. - let old_window_ptr = { - let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window.take() - }; - if let Some(window_ptr) = old_window_ptr { - window_close(window_ptr as Id); - } - - // Create new badge window (MUST be on main thread) - let window = create_badge_window(&config); - - // Make window visible - use orderFrontRegardless which works even when app is not active - window_show(window); - - // Force content view to redraw - let content_view: Id = msg_send![window, contentView]; - let _: () = msg_send![content_view, setNeedsDisplay: true]; - - // Update shared state and determine whether we need to start the updater thread. - let (update_interval, start_updater) = { - let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let was_running = state.timer_running; - state.window = Some(window as usize); - state.config = config.clone(); - state.timer_running = true; - (config.update_interval_ms, !was_running) - }; - - // Start a SINGLE position updater thread. Subsequent calls to `show_*` just update state. - if start_updater { - thread::spawn(move || { - let mut pulse_phase: f64 = 0.0; - let pulse_speed = 0.15; // Radians per update cycle - - loop { - thread::sleep(Duration::from_millis(update_interval)); - - // Snapshot state without blocking the main thread. Never hold the lock - // during AX queries (get_badge_position), which can be slow or stall. - let (window_ptr, config, should_pulse, last_position) = { - use std::sync::TryLockError; - let state = match BADGE_STATE.try_lock() { - Ok(state) => state, - Err(TryLockError::Poisoned(err)) => err.into_inner(), - Err(TryLockError::WouldBlock) => { - // Skip this tick if the state is busy; avoid blocking UI. - continue; - } - }; - if !state.timer_running { - break; - } - ( - state.window, - state.config.clone(), - state.config.mode.should_pulse(), - state.last_position, - ) - }; - - let Some(window_ptr) = window_ptr else { - continue; - }; - - // Calculate pulse opacity (sine wave from 0.4 to 1.0) - let pulse_opacity = if should_pulse { - pulse_phase += pulse_speed; - 0.7 + 0.3 * pulse_phase.sin() // Range: 0.4 to 1.0 - } else { - 1.0 - }; - - // Check if cursor actually moved (hysteresis: skip if < 2px) - let (new_x, new_y) = get_badge_position(); - let (lx, ly) = last_position; - let position_changed = - lx.is_nan() || (new_x - lx).abs() > 2.0 || (new_y - ly).abs() > 2.0; - - // Skip main-thread dispatch entirely when nothing visual changed - if !position_changed && !should_pulse { - continue; - } - - let adjusted_x = new_x + config.offset.0; - let adjusted_y = new_y + config.offset.1; - - // Position and opacity updates need main thread - Queue::main().exec_async(move || { - // Ensure state is still valid; do not block UI if busy. - { - use std::sync::TryLockError; - let mut state = match BADGE_STATE.try_lock() { - Ok(state) => state, - Err(TryLockError::Poisoned(err)) => err.into_inner(), - Err(TryLockError::WouldBlock) => return, - }; - if !state.timer_running || state.window != Some(window_ptr) { - return; - } - state.last_position = (new_x, new_y); - } - - let window = window_ptr as Id; - if position_changed { - let new_origin = CGPoint { - x: adjusted_x, - y: adjusted_y, - }; - let _: () = msg_send![window, setFrameOrigin: new_origin]; - } - - if should_pulse { - let content_view: Id = msg_send![window, contentView]; - if !content_view.is_null() { - let subviews: Id = msg_send![content_view, subviews]; - let count: usize = msg_send![subviews, count]; - if count > 0 { - let badge_view: Id = msg_send![subviews, objectAtIndex: 0usize]; - let layer: Id = msg_send![badge_view, layer]; - if !layer.is_null() { - let _: () = - msg_send![layer, setOpacity: pulse_opacity as f32]; - } - } - } - } - }); - } - }); - } - } -} - -/// Show the hold badge with custom configuration -/// This dispatches to the main thread for thread safety with NSWindow -pub fn show_hold_badge_with_config(config: HoldBadgeConfig) { - // Check if we're already on the main thread by checking thread name - // Note: exec_sync on main queue from main thread causes deadlock - let is_main_thread = std::thread::current().name() == Some("main"); - - if is_main_thread { - show_hold_badge_impl(config); - } else { - // Dispatch to main thread - NSWindow MUST be created on main thread - // Using exec_async to avoid deadlock when called from tokio runtime - Queue::main().exec_async(move || { - show_hold_badge_impl(config); - }); - } -} - -/// Hide the hold badge and stop position tracking -/// This dispatches to the main thread for thread safety with NSWindow -pub fn hide_hold_badge() { - debug!("Hiding hold badge"); - - // Stop the timer first (can be done on any thread) - let window_ptr = { - let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.timer_running = false; - state.window.take() - }; - - // Dispatch window close to main thread. Do NOT hold BADGE_STATE while closing. - Queue::main().exec_async(move || { - if let Some(window_ptr) = window_ptr { - unsafe { - window_close(window_ptr as Id); - } - } - }); -} - -/// Embedded icon for Dock (same as tray icon source) -const DOCK_ICON_BYTES: &[u8] = include_bytes!("../../assets/icon.png"); - -#[cfg(target_os = "macos")] -const NS_APP_ACTIVATION_POLICY_REGULAR: isize = 0; -#[cfg(target_os = "macos")] -const NS_APP_ACTIVATION_POLICY_ACCESSORY: isize = 1; - -#[cfg(target_os = "macos")] -fn dock_activation_policy(show_dock_icon: bool) -> isize { - if show_dock_icon { - NS_APP_ACTIVATION_POLICY_REGULAR - } else { - NS_APP_ACTIVATION_POLICY_ACCESSORY - } -} - -#[cfg(target_os = "macos")] -fn dock_activation_policy_name(policy: isize) -> &'static str { - match policy { - NS_APP_ACTIVATION_POLICY_REGULAR => "Regular", - NS_APP_ACTIVATION_POLICY_ACCESSORY => "Accessory", - _ => "Unknown", - } -} - -#[cfg(target_os = "macos")] -unsafe fn set_dock_icon_for_app(shared_app: Id) { - let Some(ns_data_class) = Class::get("NSData") else { - warn!("set_dock_icon: NSData class not found"); - return; - }; - let ns_data: Id = msg_send![ - ns_data_class, - dataWithBytes: DOCK_ICON_BYTES.as_ptr() - length: DOCK_ICON_BYTES.len() - ]; - - if ns_data.is_null() { - warn!("set_dock_icon: failed to create NSData from icon bytes"); - return; - } - - let Some(ns_image_class) = Class::get("NSImage") else { - warn!("set_dock_icon: NSImage class not found"); - return; - }; - let ns_image: Id = msg_send![ns_image_class, alloc]; - let ns_image: Id = msg_send![ns_image, initWithData: ns_data]; - - if ns_image.is_null() { - warn!("set_dock_icon: failed to create NSImage from icon data"); - return; - } - - let _: () = msg_send![shared_app, setApplicationIconImage: ns_image]; - debug!("Dock icon image set successfully"); -} - -/// Set the Dock icon programmatically (for unbundled binaries) -/// -/// This allows the app to show its custom icon in the Dock even when -/// running as a raw binary (not from a .app bundle). -pub fn set_dock_icon() { - debug!("Setting Dock icon programmatically"); - - Queue::main().exec_async(|| unsafe { - let Some(ns_app_class) = Class::get("NSApplication") else { - warn!("set_dock_icon: NSApplication class not found"); - return; - }; - let shared_app: Id = msg_send![ns_app_class, sharedApplication]; - - if shared_app.is_null() { - warn!("set_dock_icon: NSApplication sharedApplication is null"); - return; - } - - set_dock_icon_for_app(shared_app); - }); -} - -/// Apply Dock visibility preference at runtime (best effort on macOS). -/// -/// We switch NSApplication activation policy between: -/// - `Regular` (show Dock icon) -/// - `Accessory` (hide Dock icon, menu bar/tray style) -/// -/// Some launch modes can refuse policy transitions (for example strict `LSUIElement` -/// behavior in certain app bundle contexts). In that case we keep current behavior and -/// only log a warning instead of failing. -#[cfg(target_os = "macos")] -pub fn apply_dock_icon_visibility(show_dock_icon: bool) { - Queue::main().exec_async(move || unsafe { - let Some(ns_app_class) = Class::get("NSApplication") else { - warn!("apply_dock_icon_visibility: NSApplication class not found"); - return; - }; - let shared_app: Id = msg_send![ns_app_class, sharedApplication]; - if shared_app.is_null() { - warn!("apply_dock_icon_visibility: NSApplication sharedApplication is null"); - return; - } - - let target_policy = dock_activation_policy(show_dock_icon); - let current_policy: isize = msg_send![shared_app, activationPolicy]; - - if current_policy != target_policy { - let changed: bool = msg_send![shared_app, setActivationPolicy: target_policy]; - if !changed { - warn!( - "Show dock icon={} requested but activation policy change {} -> {} was refused. \ - Keeping current Dock behavior (likely launch-mode limitation).", - show_dock_icon, - dock_activation_policy_name(current_policy), - dock_activation_policy_name(target_policy), - ); - return; - } - - debug!( - "Dock activation policy changed: {} -> {}", - dock_activation_policy_name(current_policy), - dock_activation_policy_name(target_policy), - ); - } - - if show_dock_icon { - set_dock_icon_for_app(shared_app); - } - }); -} - -#[cfg(not(target_os = "macos"))] -pub fn apply_dock_icon_visibility(_show_dock_icon: bool) {} - -/// Install a minimal AppKit main menu with standard Edit key equivalents. -/// -/// CodeScribe runs as an `LSUIElement` agent app (no visible menu bar). In this mode AppKit still -/// relies on the app's `mainMenu` to resolve Command-key equivalents like Cmd+C / Cmd+V for text -/// controls (field editor). Without it, selectable text in bubbles and the Agent input field can -/// appear "dead" for copy/paste even though typing works. -/// -/// This is safe to call multiple times. -#[cfg(target_os = "macos")] -pub fn install_basic_edit_menu() { - use std::sync::Once; - - static INIT: Once = Once::new(); - INIT.call_once(|| { - Queue::main().exec_async(|| unsafe { - let ns_app_class = Class::get("NSApplication").expect("NSApplication class not found"); - let app: Id = msg_send![ns_app_class, sharedApplication]; - if app.is_null() { - warn!("install_basic_edit_menu: NSApplication sharedApplication is null"); - return; - } - - let ns_menu = Class::get("NSMenu").expect("NSMenu class not found"); - let ns_menu_item = Class::get("NSMenuItem").expect("NSMenuItem class not found"); - - let main_menu: Id = msg_send![ns_menu, alloc]; - let main_menu: Id = msg_send![main_menu, init]; - - // App menu (required for some key equivalent routing, even if hidden) - let app_item: Id = msg_send![ns_menu_item, alloc]; - let app_item: Id = msg_send![app_item, init]; - let app_menu: Id = msg_send![ns_menu, alloc]; - let app_menu: Id = msg_send![app_menu, init]; - - let quit_title = ns_string("Quit CodeScribe"); - let quit_key = ns_string("q"); - let quit_item: Id = msg_send![ns_menu_item, alloc]; - let quit_item: Id = - msg_send![quit_item, initWithTitle: quit_title action: sel!(terminate:) keyEquivalent: quit_key]; - let _: () = msg_send![app_menu, addItem: quit_item]; - let _: () = msg_send![app_item, setSubmenu: app_menu]; - let _: () = msg_send![main_menu, addItem: app_item]; - - // Edit menu with standard key equivalents - let edit_item: Id = msg_send![ns_menu_item, alloc]; - let edit_item: Id = msg_send![edit_item, init]; - let _: () = msg_send![edit_item, setTitle: ns_string("Edit")]; - - let edit_menu: Id = msg_send![ns_menu, alloc]; - let edit_menu: Id = msg_send![edit_menu, init]; - - let make_edit = |title: &str, sel: Sel, key: &str| -> Id { - let item: Id = msg_send![ns_menu_item, alloc]; - msg_send![ - item, - initWithTitle: ns_string(title) - action: sel - keyEquivalent: ns_string(key) - ] - }; - - let cut_item = make_edit("Cut", sel!(cut:), "x"); - let copy_item = make_edit("Copy", sel!(copy:), "c"); - let paste_item = make_edit("Paste", sel!(paste:), "v"); - let select_all_item = make_edit("Select All", sel!(selectAll:), "a"); - - let _: () = msg_send![edit_menu, addItem: cut_item]; - let _: () = msg_send![edit_menu, addItem: copy_item]; - let _: () = msg_send![edit_menu, addItem: paste_item]; - let _: () = msg_send![edit_menu, addItem: select_all_item]; - - let _: () = msg_send![edit_item, setSubmenu: edit_menu]; - let _: () = msg_send![main_menu, addItem: edit_item]; - - let _: () = msg_send![app, setMainMenu: main_menu]; - debug!("install_basic_edit_menu: mainMenu installed"); - }); - }); -} - -#[cfg(not(target_os = "macos"))] -pub fn install_basic_edit_menu() {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cursor_position() { - let (x, y) = get_cursor_position(); - // Just verify we get some coordinates - assert!(x >= 0.0); - assert!(y >= 0.0); - } - - #[test] - fn test_focused_element_check() { - // This will return false in test environment (no GUI) - // but verifies the function doesn't crash - let _ = focused_element_accepts_text(); - } - - #[test] - fn test_badge_config_default() { - let config = HoldBadgeConfig::default(); - assert_eq!(config.diameter, 12.0); - assert_eq!(config.offset, (10.0, -10.0)); - assert_eq!(config.update_interval_ms, 150); - } - - #[test] - #[cfg(target_os = "macos")] - fn test_dock_policy_mapping() { - assert_eq!( - dock_activation_policy(true), - NS_APP_ACTIVATION_POLICY_REGULAR - ); - assert_eq!( - dock_activation_policy(false), - NS_APP_ACTIVATION_POLICY_ACCESSORY - ); - } - - #[test] - fn test_apply_dock_icon_visibility_is_safe_to_call() { - apply_dock_icon_visibility(true); - apply_dock_icon_visibility(false); - } -} diff --git a/app/ui/onboarding/actions.rs b/app/ui/onboarding/actions.rs deleted file mode 100644 index ae64da3d..00000000 --- a/app/ui/onboarding/actions.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! Wizard flow control: button actions, step advance/retreat, auto-advance -//! scheduling, full-disk polling, persistence of user choices, and the -//! finish/teardown path. - -use std::thread; -use std::time::Duration; - -use dispatch::Queue; -use objc::{msg_send, sel, sel_impl}; -use tracing::{info, warn}; - -use crate::config::{Config, ShortcutBinding, UserSettings, WorkMode, keychain}; -use crate::os::hotkeys; -use crate::os::permissions::PermissionStatus; -use crate::ui::shared::helpers::{set_text_field_string, window_close}; - -use super::Id; -use super::permission_flow::{ - PermissionUiStatus, check_permission_state, permission_status, - reconcile_permission_runtime_after_grant, reconcile_runtime_after_onboarding_completion, - request_permission, should_wait_for_restart, -}; -use super::render::render_current_step; -use super::session::{mark_onboarding_done, release_onboarding_lock, save_onboarding_progress}; -use super::state::{HotkeyModeChoice, ONBOARDING_STATE}; -use super::steps::{PermissionKind, TOTAL_STEPS, WizardStep, step_for_index}; -use super::widgets::{get_text_field_string, system_green_color, system_red_color}; - -const FULL_DISK_STEP_INDEX: usize = 5; - -pub(super) fn handle_primary_action() { - let step = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - step_for_index(state.step_index) - }; - - match step { - WizardStep::Welcome => advance_step(), - WizardStep::Permission(kind) => handle_permission_primary(kind), - WizardStep::Language => { - save_language_choice(); - advance_step(); - } - WizardStep::ApiKey => { - if persist_api_key_from_field() { - advance_step(); - } - } - WizardStep::HotkeyMode => { - save_hotkey_mode(); - advance_step(); - } - WizardStep::Done => finish_onboarding(true), - } -} - -pub(super) fn handle_back_action() { - retreat_step(); -} - -pub(super) fn handle_skip_action() { - let step = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - step_for_index(state.step_index) - }; - - match step { - WizardStep::Permission(PermissionKind::FullDiskAccess) => { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.full_disk_polling = false; - drop(state); - advance_step(); - } - WizardStep::ApiKey => { - mark_api_key_skipped(); - advance_step(); - } - _ => finish_onboarding(false), - } -} - -fn handle_permission_primary(kind: PermissionKind) { - let idx = kind.index(); - let step_to_persist; - let already_requested; - - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let status = state.permission_states[idx]; - let requested = state.requested_permissions[idx]; - if status == PermissionUiStatus::Granted { - drop(state); - if should_wait_for_restart(kind, status, requested) { - finish_onboarding(false); - } else { - advance_step(); - } - return; - } - - already_requested = requested; - state.requested_permissions[idx] = true; - step_to_persist = state.step_index; - } - - if kind == PermissionKind::FullDiskAccess && already_requested { - let _ = request_permission(kind); - start_full_disk_polling(); - - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let requested = state.requested_permissions[idx]; - state.permission_states[idx] = check_permission_state(kind, requested); - } - - render_current_step(); - return; - } - - // Persist checkpoint before asking TCC in case macOS forces an app restart. - save_onboarding_progress(step_to_persist); - - if kind == PermissionKind::Microphone { - thread::spawn(move || { - let _ = request_permission(kind); - Queue::main().exec_async(move || { - let mut should_render = false; - let mut should_schedule = false; - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let requested = state.requested_permissions[idx]; - state.permission_states[idx] = check_permission_state(kind, requested); - if state.step_index == step_to_persist { - should_render = true; - if state.permission_states[idx] == PermissionUiStatus::Granted { - reconcile_permission_runtime_after_grant(kind); - should_schedule = true; - } - } - } - - if should_render { - render_current_step(); - } - if should_schedule { - maybe_schedule_auto_advance(step_to_persist); - } - }); - }); - - // Keep UI responsive while system prompt is in flight. - render_current_step(); - return; - } - - let _ = request_permission(kind); - - if kind == PermissionKind::FullDiskAccess { - start_full_disk_polling(); - } - - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let requested = state.requested_permissions[idx]; - state.permission_states[idx] = check_permission_state(kind, requested); - } - - if permission_status(kind) == PermissionStatus::Granted { - reconcile_permission_runtime_after_grant(kind); - } - - render_current_step(); -} - -fn advance_step() { - let mut should_render = false; - let mut new_step = None; - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.step_index + 1 < TOTAL_STEPS { - state.step_index += 1; - state.scheduled_auto_advance_step = None; - if state.step_index != FULL_DISK_STEP_INDEX { - state.full_disk_polling = false; - } - new_step = Some(state.step_index); - should_render = true; - } - } - if let Some(step) = new_step { - save_onboarding_progress(step); - } - - if should_render { - render_current_step(); - } -} - -fn retreat_step() { - let mut should_render = false; - let mut new_step = None; - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.step_index > 0 { - state.step_index -= 1; - state.scheduled_auto_advance_step = None; - if state.step_index != FULL_DISK_STEP_INDEX { - state.full_disk_polling = false; - } - new_step = Some(state.step_index); - should_render = true; - } - } - if let Some(step) = new_step { - save_onboarding_progress(step); - } - - if should_render { - render_current_step(); - } -} - -pub(super) fn maybe_schedule_auto_advance(step_index: usize) { - let should_schedule = { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.scheduled_auto_advance_step == Some(step_index) { - false - } else { - state.scheduled_auto_advance_step = Some(step_index); - true - } - }; - - if !should_schedule { - return; - } - - thread::spawn(move || { - thread::sleep(Duration::from_millis(800)); - Queue::main().exec_async(move || { - let mut should_advance = false; - - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.step_index == step_index - && let WizardStep::Permission(kind) = step_for_index(step_index) - { - let idx = kind.index(); - let requested = state.requested_permissions[idx]; - let status = check_permission_state(kind, requested); - state.permission_states[idx] = status; - if status == PermissionUiStatus::Granted - && !should_wait_for_restart(kind, status, requested) - { - should_advance = true; - } - } - state.scheduled_auto_advance_step = None; - } - - if should_advance { - advance_step(); - } else { - render_current_step(); - } - }); - }); -} - -fn start_full_disk_polling() { - let should_start = { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.full_disk_polling { - false - } else { - state.full_disk_polling = true; - true - } - }; - - if !should_start { - return; - } - - thread::spawn(|| { - loop { - thread::sleep(Duration::from_secs(2)); - - let keep_running = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.full_disk_polling - }; - - if !keep_running { - break; - } - - Queue::main().exec_async(|| { - let mut should_schedule = false; - let mut should_render = false; - - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if step_for_index(state.step_index) - == WizardStep::Permission(PermissionKind::FullDiskAccess) - { - let idx = PermissionKind::FullDiskAccess.index(); - state.permission_states[idx] = - check_permission_state(PermissionKind::FullDiskAccess, true); - let granted = state.permission_states[idx] == PermissionUiStatus::Granted; - should_schedule = granted - && !should_wait_for_restart( - PermissionKind::FullDiskAccess, - state.permission_states[idx], - state.requested_permissions[idx], - ); - if granted { - state.full_disk_polling = false; - } - should_render = true; - } else { - state.full_disk_polling = false; - } - } - - if should_render { - render_current_step(); - } - if should_schedule { - maybe_schedule_auto_advance(FULL_DISK_STEP_INDEX); - } - }); - } - }); -} - -fn save_language_choice() { - let language = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.language - }; - - let mut settings = UserSettings::load(); - settings.whisper_language = Some(language.value().to_string()); - if let Err(e) = settings.save() { - warn!( - "Onboarding: failed to persist language {}: {e}", - language.value() - ); - } - - unsafe { std::env::set_var("WHISPER_LANGUAGE", language.value()) }; - info!("Onboarding: language set to {}", language.value()); -} - -fn persist_api_key_from_field() -> bool { - let key = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state - .ui - .api_key_field - .map(|ptr| get_text_field_string(ptr as Id)) - .unwrap_or_default() - .trim() - .to_string() - }; - - if key.is_empty() { - mark_api_key_skipped(); - return true; - } - - match keychain::save_key("LLM_FORMATTING_API_KEY", &key).and_then(|_| { - keychain::save_key("LLM_ASSISTIVE_API_KEY", &key).inspect_err(|_| { - let _ = keychain::delete_key("LLM_FORMATTING_API_KEY"); - }) - }) { - Ok(()) => { - unsafe { - std::env::set_var("LLM_FORMATTING_API_KEY", &key); - std::env::set_var("LLM_ASSISTIVE_API_KEY", &key); - }; - let mut settings = UserSettings::load(); - settings.ai_formatting_enabled = Some(true); - if let Err(e) = settings.save() { - warn!("Onboarding: failed to persist AI formatting setting: {e}"); - } - - unsafe { std::env::set_var("AI_FORMATTING_ENABLED", "1") }; - - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.api_key_configured = true; - - if let Some(label_ptr) = state.ui.api_hint_label { - unsafe { - set_text_field_string(label_ptr as Id, "OpenAI API key saved to Keychain."); - let green = system_green_color(); - let _: () = msg_send![label_ptr as Id, setTextColor: green]; - } - } - true - } - Err(e) => { - warn!("Onboarding: failed to save API key: {e}"); - let state = ONBOARDING_STATE - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - if let Some(label_ptr) = state.ui.api_hint_label { - unsafe { - set_text_field_string(label_ptr as Id, "Failed to save key. Please try again."); - let red = system_red_color(); - let _: () = msg_send![label_ptr as Id, setTextColor: red]; - } - } - false - } - } -} - -fn mark_api_key_skipped() { - let mut settings = UserSettings::load(); - settings.ai_formatting_enabled = Some(false); - if let Err(e) = settings.save() { - warn!("Onboarding: failed to persist AI formatting disabled state: {e}"); - } - - unsafe { std::env::set_var("AI_FORMATTING_ENABLED", "0") }; - - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.api_key_configured = false; -} - -fn save_hotkey_mode() { - let mode = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hotkey_mode - }; - - let (dictation, formatting, assistive) = match mode { - HotkeyModeChoice::HoldToTalk => ( - ShortcutBinding::HoldFn, - ShortcutBinding::Disabled, - ShortcutBinding::Disabled, - ), - HotkeyModeChoice::Toggle => ( - ShortcutBinding::Disabled, - ShortcutBinding::DoubleLeftOption, - ShortcutBinding::DoubleRightOption, - ), - HotkeyModeChoice::Both => ( - ShortcutBinding::HoldFn, - ShortcutBinding::DoubleLeftOption, - ShortcutBinding::DoubleRightOption, - ), - }; - - let mut settings = UserSettings::load(); - settings.set_mode_binding(WorkMode::Dictation, dictation); - settings.set_mode_binding(WorkMode::Formatting, formatting); - settings.set_mode_binding(WorkMode::Assistive, assistive); - - hotkeys::apply_hotkey_config(&Config::load()); - - info!("Onboarding: hotkey mode set to {}", mode.label()); -} - -pub(super) fn finish_onboarding(completed: bool) { - if completed { - reconcile_runtime_after_onboarding_completion(); - mark_onboarding_done(); - } - - let window_ptr = { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.full_disk_polling = false; - state.scheduled_auto_advance_step = None; - state.closing_via_finish = true; - state.window.take() - }; - - if let Some(ptr) = window_ptr { - unsafe { window_close(ptr as Id) }; - } else { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.closing_via_finish = false; - drop(state); - release_onboarding_lock(); - } -} diff --git a/app/ui/onboarding/handlers.rs b/app/ui/onboarding/handlers.rs deleted file mode 100644 index 735873ee..00000000 --- a/app/ui/onboarding/handlers.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Objective-C bridge for the wizard: action handler and window delegate -//! class registration plus the `extern "C"` callbacks they dispatch to. - -use std::sync::OnceLock; - -use objc::declare::ClassDecl; -use objc::runtime::{Class, Object, Sel}; -use objc::{msg_send, sel, sel_impl}; - -use super::Id; -use super::actions::{ - finish_onboarding, handle_back_action, handle_primary_action, handle_skip_action, -}; -use super::render::render_current_step; -use super::session::release_onboarding_lock; -use super::state::{HotkeyModeChoice, LanguageChoice, ONBOARDING_STATE, UiRefs}; - -static ACTION_HANDLER_CLASS: OnceLock<&'static Class> = OnceLock::new(); -static WINDOW_DELEGATE_CLASS: OnceLock<&'static Class> = OnceLock::new(); - -pub(super) fn action_handler_class() -> &'static Class { - ACTION_HANDLER_CLASS.get_or_init(|| unsafe { - let superclass = Class::get("NSObject").expect("NSObject class missing"); - let mut decl = ClassDecl::new("CodeScribeOnboardingActionHandler", superclass) - .expect("Failed to create onboarding action handler class"); - - decl.add_method( - sel!(onPrimaryAction:), - on_primary_action as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onBackAction:), - on_back_action as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onSkipAction:), - on_skip_action as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onLanguageSelected:), - on_language_selected as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onHotkeySelected:), - on_hotkey_selected as extern "C" fn(&Object, Sel, Id), - ); - - decl.register() - }) -} - -pub(super) fn window_delegate_class() -> &'static Class { - WINDOW_DELEGATE_CLASS.get_or_init(|| unsafe { - let superclass = Class::get("NSObject").expect("NSObject class missing"); - let mut decl = ClassDecl::new("CodeScribeOnboardingWindowDelegate", superclass) - .expect("Failed to create onboarding window delegate class"); - decl.add_method( - sel!(windowShouldClose:), - on_window_should_close as extern "C" fn(&Object, Sel, Id) -> bool, - ); - decl.add_method( - sel!(windowWillClose:), - on_window_will_close as extern "C" fn(&Object, Sel, Id), - ); - decl.register() - }) -} - -extern "C" fn on_primary_action(_this: &Object, _sel: Sel, _sender: Id) { - handle_primary_action(); -} - -extern "C" fn on_back_action(_this: &Object, _sel: Sel, _sender: Id) { - handle_back_action(); -} - -extern "C" fn on_skip_action(_this: &Object, _sel: Sel, _sender: Id) { - handle_skip_action(); -} - -extern "C" fn on_language_selected(_this: &Object, _sel: Sel, sender: Id) { - unsafe { - let tag: isize = msg_send![sender, tag]; - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.language = if tag == 1 { - LanguageChoice::Polish - } else { - LanguageChoice::English - }; - } - render_current_step(); -} - -extern "C" fn on_hotkey_selected(_this: &Object, _sel: Sel, sender: Id) { - unsafe { - let tag: isize = msg_send![sender, tag]; - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hotkey_mode = match tag { - 1 => HotkeyModeChoice::Toggle, - 2 => HotkeyModeChoice::Both, - _ => HotkeyModeChoice::HoldToTalk, - }; - } - render_current_step(); -} - -extern "C" fn on_window_should_close(_this: &Object, _sel: Sel, _sender: Id) -> bool { - let closing_via_finish = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.closing_via_finish - }; - - if closing_via_finish { - return true; - } - - finish_onboarding(false); - false -} - -extern "C" fn on_window_will_close(_this: &Object, _sel: Sel, notification: Id) { - let window_ptr = unsafe { - if notification.is_null() { - None - } else { - let window: Id = msg_send![notification, object]; - if window.is_null() { - None - } else { - Some(window as usize) - } - } - }; - - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let delegate_ptr = state.window_delegate.take(); - let handler_ptr = state.action_handler.take(); - state.window = None; - state.ui = UiRefs::default(); - state.full_disk_polling = false; - state.scheduled_auto_advance_step = None; - state.closing_via_finish = false; - drop(state); - - release_onboarding_lock(); - - unsafe { - if let Some(ptr) = delegate_ptr { - let _: () = msg_send![ptr as Id, release]; - } - if let Some(ptr) = handler_ptr { - let _: () = msg_send![ptr as Id, release]; - } - if let Some(ptr) = window_ptr { - let _: () = msg_send![ptr as Id, release]; - } - } -} diff --git a/app/ui/onboarding/mod.rs b/app/ui/onboarding/mod.rs deleted file mode 100644 index adfa13be..00000000 --- a/app/ui/onboarding/mod.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! First-run onboarding wizard. -//! -//! Module layout (decomposed from a single 2259-LOC file): -//! - [`steps`] — step metadata: permission kinds, recovery strategies, flow order -//! - [`state`] — wizard state, UI element refs, initial-state probes -//! - [`session`] — on-disk markers, resume progress, flock(2) session lock -//! - [`permission_flow`] — TCC status mapping, requests, runtime reconciliation -//! - [`handlers`] — Objective-C action handler / window delegate bridge -//! - [`window`] — NSWindow construction and static UI build -//! - [`render`] — per-step rendering of the built UI -//! - [`actions`] — flow control, choice persistence, finish/teardown -//! - [`widgets`] — onboarding-local AppKit widget glue -//! -//! External contract: `should_show_onboarding` / `show_onboarding_wizard` -//! (re-exported from `app/lib.rs`), and the permission surface shared with -//! Settings (`PermissionKind`, `PERMISSION_ORDER`, `permission_status`, -//! `request_permission`, `open_permission_settings`, -//! `reconcile_permission_runtime_after_grant`). - -mod actions; -mod handlers; -mod permission_flow; -mod render; -mod session; -mod state; -mod steps; -#[cfg(test)] -mod tests; -mod widgets; -mod window; - -use dispatch::Queue; - -pub(crate) use self::permission_flow::{ - PERMISSION_ORDER, open_permission_settings, permission_status, - reconcile_permission_runtime_after_grant, request_permission, -}; -pub use self::session::should_show_onboarding; -pub(crate) use self::steps::PermissionKind; - -// Type alias for Objective-C object pointers -pub use crate::ui_helpers::Id; - -pub fn show_onboarding_wizard() { - if !should_show_onboarding() { - return; - } - if !session::acquire_onboarding_lock() { - return; - } - - if window::is_main_thread() { - window::launch_onboarding_window(); - } else { - Queue::main().exec_async(move || { - window::launch_onboarding_window(); - }); - } -} diff --git a/app/ui/onboarding/permission_flow.rs b/app/ui/onboarding/permission_flow.rs deleted file mode 100644 index 5319ccde..00000000 --- a/app/ui/onboarding/permission_flow.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Permission status mapping, TCC request/recovery flow, and the runtime -//! reconciliation that re-wires hotkeys/microphone after a grant. This is the -//! permission contract shared with Settings (`crate::ui::settings`). - -use tracing::{info, warn}; - -use crate::os::hotkeys; -use crate::os::permissions::{self, PermissionStatus}; - -use super::Id; -use super::state::OnboardingState; -use super::steps::{PermissionKind, PermissionRecoveryStrategy}; -use super::widgets::{system_green_color, system_red_color, system_secondary_color}; - -const STATUS_NOT_DETERMINED: &str = "\u{25CB} Not Enabled Yet"; -const STATUS_GRANTED: &str = "\u{25CF} Granted"; -const STATUS_DENIED: &str = "\u{2715} Denied"; -const STATUS_RESTART_REQUIRED: &str = "\u{25CF} Granted - Restart Required"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(super) enum PermissionUiStatus { - #[default] - NotDetermined, - Granted, - Denied, -} - -pub(crate) const PERMISSION_ORDER: [PermissionKind; 5] = [ - PermissionKind::Microphone, - PermissionKind::Accessibility, - PermissionKind::InputMonitoring, - PermissionKind::ScreenRecording, - PermissionKind::FullDiskAccess, -]; - -pub(super) fn should_wait_for_restart( - kind: PermissionKind, - status: PermissionUiStatus, - requested: bool, -) -> bool { - kind.recovery_strategy() == PermissionRecoveryStrategy::AppRestartRequired - && status == PermissionUiStatus::Granted - && requested -} - -pub(super) fn should_refresh_hotkey_runtime_after_grant( - kind: PermissionKind, - accessibility_status: PermissionStatus, - input_monitoring_status: PermissionStatus, -) -> bool { - matches!( - kind, - PermissionKind::Accessibility | PermissionKind::InputMonitoring - ) && accessibility_status == PermissionStatus::Granted - && input_monitoring_status == PermissionStatus::Granted -} - -pub(crate) fn reconcile_permission_runtime_after_grant(kind: PermissionKind) { - if permission_status(kind) != PermissionStatus::Granted { - return; - } - - match kind.recovery_strategy() { - PermissionRecoveryStrategy::LiveRecheck => { - if kind == PermissionKind::Microphone { - crate::controller::request_permission_runtime_reconcile(); - info!( - "Onboarding: rechecked {} live after permission grant", - kind.runtime_subsystem() - ); - } - } - PermissionRecoveryStrategy::LiveReinitialize => { - let accessibility_status = permissions::check_accessibility(); - let input_monitoring_status = permissions::check_input_monitoring(); - if permissions::hotkey_permissions_granted() - && should_refresh_hotkey_runtime_after_grant( - kind, - accessibility_status, - input_monitoring_status, - ) - { - // Dedup: if the global hotkey manager is already running (= a - // prior permission grant in this same onboarding flow already - // created it), skip the full teardown + restart cycle. CGEventTap - // is process-global and remains attached across TCC re-checks — - // the only case where we MUST refresh is the cold-start grant - // when the manager was never created. - if hotkeys::is_global_hotkey_manager_active() { - info!( - "Onboarding: {} granted; hotkey manager already running, skipping refresh (dedup)", - kind.runtime_subsystem() - ); - return; - } - match hotkeys::refresh_global_hotkey_manager() { - Ok(()) => info!( - "Onboarding: reinitialized {} after permission grant", - kind.runtime_subsystem() - ), - Err(error) => warn!( - "Onboarding: failed to reinitialize {} after permission grant: {error}", - kind.runtime_subsystem() - ), - } - } - } - PermissionRecoveryStrategy::AppRestartRequired => { - info!( - "Onboarding: {} still requires app restart after grant", - kind.runtime_subsystem() - ); - } - } -} - -pub(super) fn reconcile_runtime_after_onboarding_completion() { - for kind in [ - PermissionKind::Microphone, - PermissionKind::Accessibility, - PermissionKind::InputMonitoring, - ] { - reconcile_permission_runtime_after_grant(kind); - } -} - -pub(super) fn refresh_all_permission_states_locked(state: &mut OnboardingState) { - for kind in PERMISSION_ORDER { - let idx = kind.index(); - state.permission_states[idx] = - check_permission_state(kind, state.requested_permissions[idx]); - } -} - -pub(crate) fn permission_status(kind: PermissionKind) -> PermissionStatus { - match kind { - PermissionKind::Microphone => permissions::check_microphone(), - PermissionKind::Accessibility => permissions::check_accessibility(), - PermissionKind::InputMonitoring => permissions::check_input_monitoring(), - PermissionKind::ScreenRecording => permissions::check_screen_recording(), - PermissionKind::FullDiskAccess => permissions::check_full_disk_access(), - } -} - -pub(super) fn check_permission_state(kind: PermissionKind, requested: bool) -> PermissionUiStatus { - map_permission_status(kind, permission_status(kind), requested) -} - -fn map_permission_status( - kind: PermissionKind, - status: PermissionStatus, - requested: bool, -) -> PermissionUiStatus { - match status { - PermissionStatus::Granted => PermissionUiStatus::Granted, - PermissionStatus::Denied => PermissionUiStatus::Denied, - PermissionStatus::NotDetermined => { - if requested && kind != PermissionKind::FullDiskAccess { - PermissionUiStatus::Denied - } else { - PermissionUiStatus::NotDetermined - } - } - } -} - -fn permission_settings_deeplink(kind: PermissionKind) -> &'static str { - match kind { - PermissionKind::Microphone => "Privacy_Microphone", - PermissionKind::Accessibility => "Privacy_Accessibility", - PermissionKind::InputMonitoring => "Privacy_ListenEvent", - PermissionKind::ScreenRecording => "Privacy_ScreenCapture", - PermissionKind::FullDiskAccess => "Privacy_AllFiles", - } -} - -pub(crate) fn open_permission_settings(kind: PermissionKind) { - permissions::open_privacy_settings(permission_settings_deeplink(kind)); -} - -pub(super) fn should_open_settings_after_failed_request( - kind: PermissionKind, - granted: bool, -) -> bool { - !granted && kind != PermissionKind::FullDiskAccess -} - -pub(crate) fn request_permission(kind: PermissionKind) -> bool { - match kind { - PermissionKind::Microphone => { - let result = permissions::request_microphone(); - if should_open_settings_after_failed_request(kind, result) { - open_permission_settings(kind); - } - result - } - PermissionKind::Accessibility => { - let result = permissions::request_accessibility(); - if should_open_settings_after_failed_request(kind, result) { - open_permission_settings(kind); - } - result - } - PermissionKind::InputMonitoring => { - let result = permissions::request_input_monitoring(); - if should_open_settings_after_failed_request(kind, result) { - open_permission_settings(kind); - } - result - } - PermissionKind::ScreenRecording => { - let result = permissions::request_screen_recording(); - if should_open_settings_after_failed_request(kind, result) { - open_permission_settings(kind); - } - result - } - PermissionKind::FullDiskAccess => permissions::request_full_disk_access(), - } -} - -pub(super) fn permission_instruction_text( - kind: PermissionKind, - status: PermissionUiStatus, - requested: bool, -) -> Option<&'static str> { - match kind.recovery_strategy() { - PermissionRecoveryStrategy::AppRestartRequired => { - if should_wait_for_restart(kind, status, requested) { - Some( - "Permission granted. Restart CodeScribe to activate it. On relaunch onboarding will resume here automatically.", - ) - } else if status == PermissionUiStatus::Granted { - None - } else if kind == PermissionKind::FullDiskAccess { - Some( - "After enabling CodeScribe in System Settings > Privacy & Security > Full Disk Access, restart CodeScribe. On relaunch onboarding will resume here automatically.", - ) - } else { - Some( - "Enable this in System Settings, then restart CodeScribe. On relaunch onboarding will resume here automatically.", - ) - } - } - PermissionRecoveryStrategy::LiveReinitialize => { - if status == PermissionUiStatus::Denied { - Some( - "Enable this in System Settings. CodeScribe will reconnect global hotkeys live once Accessibility and Input Monitoring are both granted.", - ) - } else { - None - } - } - PermissionRecoveryStrategy::LiveRecheck => { - if status == PermissionUiStatus::Denied { - Some( - "This permission is required to continue onboarding. Enable it in System Settings, then click Try Again. CodeScribe rechecks microphone access live.", - ) - } else { - None - } - } - } -} - -pub(super) fn permission_status_text( - kind: PermissionKind, - status: PermissionUiStatus, - requested: bool, -) -> &'static str { - match (kind, status, requested) { - (_, PermissionUiStatus::Granted, true) - if kind.recovery_strategy() == PermissionRecoveryStrategy::AppRestartRequired => - { - STATUS_RESTART_REQUIRED - } - (_, PermissionUiStatus::NotDetermined, _) => STATUS_NOT_DETERMINED, - (_, PermissionUiStatus::Granted, _) => STATUS_GRANTED, - (_, PermissionUiStatus::Denied, _) => STATUS_DENIED, - } -} - -pub(super) fn permission_status_color(status: PermissionUiStatus) -> Id { - match status { - PermissionUiStatus::NotDetermined => system_secondary_color(), - PermissionUiStatus::Granted => system_green_color(), - PermissionUiStatus::Denied => system_red_color(), - } -} diff --git a/app/ui/onboarding/render.rs b/app/ui/onboarding/render.rs deleted file mode 100644 index 0f4e4bad..00000000 --- a/app/ui/onboarding/render.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Per-step rendering: headline copy, status pills, sidebar markers, and the -//! final summary view. Pure read-modify of already-built UI elements. - -use crate::ui::shared::helpers::color_label; - -use super::actions::maybe_schedule_auto_advance; -use super::permission_flow::{ - PERMISSION_ORDER, PermissionUiStatus, check_permission_state, permission_instruction_text, - permission_status_color, permission_status_text, refresh_all_permission_states_locked, - should_wait_for_restart, -}; -use super::state::{HotkeyModeChoice, LanguageChoice, ONBOARDING_STATE, UiRefs}; -use super::steps::{ - PermissionKind, PermissionRecoveryStrategy, TOTAL_STEPS, WizardStep, step_for_index, -}; -use super::widgets::{ - set_button_title_if_present, set_hidden_if_present, set_label_color_if_present, - set_text_if_present, sync_hotkey_radios, sync_language_radios, system_green_color, - system_red_color, system_secondary_color, -}; - -pub(super) fn render_current_step() { - let ( - step_index, - step, - language, - hotkey_mode, - api_key_configured, - permissions, - requested_permissions, - ui, - ) = { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let step = step_for_index(state.step_index); - match step { - WizardStep::Permission(kind) => { - let idx = kind.index(); - let requested = state.requested_permissions[idx]; - state.permission_states[idx] = check_permission_state(kind, requested); - } - WizardStep::Done => { - refresh_all_permission_states_locked(&mut state); - } - _ => {} - } - - ( - state.step_index, - step, - state.language, - state.hotkey_mode, - state.api_key_configured, - state.permission_states, - state.requested_permissions, - state.ui, - ) - }; - - set_text_if_present( - ui.step_counter_label, - &format!("Step {} of {}", step_index + 1, TOTAL_STEPS), - ); - - set_hidden_if_present(ui.status_label, true); - set_hidden_if_present(ui.instruction_label, true); - set_hidden_if_present(ui.language_view, true); - set_hidden_if_present(ui.api_view, true); - set_hidden_if_present(ui.hotkey_view, true); - set_hidden_if_present(ui.summary_view, true); - set_hidden_if_present(ui.skip_button, matches!(step, WizardStep::Done)); - if step != WizardStep::Done { - set_button_title_if_present(ui.skip_button, "Not Now"); - } - - set_hidden_if_present(ui.back_button, step_index == 0); - - sync_language_radios(ui, language); - sync_hotkey_radios(ui, hotkey_mode); - update_sidebar_step_labels(ui, step_index, permissions); - - match step { - WizardStep::Welcome => { - set_text_if_present(ui.icon_label, "WELCOME"); - set_text_if_present(ui.title_label, "Welcome to CodeScribe"); - set_text_if_present( - ui.description_label, - "We will wire permissions, choose your transcript defaults, and show how live preview, committed verdict, and AI help stay honest from first launch.", - ); - set_button_title_if_present(ui.primary_button, "Get Started"); - } - WizardStep::Permission(kind) => { - let idx = kind.index(); - let status = permissions[idx]; - let requested = requested_permissions[idx]; - set_text_if_present(ui.icon_label, kind.icon()); - set_text_if_present(ui.title_label, kind.title()); - set_text_if_present(ui.description_label, kind.reason()); - - set_hidden_if_present(ui.status_label, false); - set_text_if_present( - ui.status_label, - permission_status_text(kind, status, requested), - ); - set_label_color_if_present(ui.status_label, permission_status_color(status)); - - if status == PermissionUiStatus::Granted { - if should_wait_for_restart(kind, status, requested) { - set_button_title_if_present(ui.primary_button, "Close for Restart"); - } else { - set_button_title_if_present(ui.primary_button, "Continue"); - maybe_schedule_auto_advance(step_index); - } - } else if kind == PermissionKind::FullDiskAccess { - set_button_title_if_present(ui.primary_button, "Open Settings"); - set_hidden_if_present(ui.skip_button, false); - set_button_title_if_present( - ui.skip_button, - if requested { - "Continue Without It" - } else { - "Skip" - }, - ); - } else { - set_button_title_if_present( - ui.primary_button, - if kind.recovery_strategy() == PermissionRecoveryStrategy::AppRestartRequired - && requested - { - "Open Settings" - } else if status == PermissionUiStatus::Denied { - "Try Again" - } else { - "Grant Access" - }, - ); - } - - if let Some(text) = permission_instruction_text(kind, status, requested) { - set_hidden_if_present(ui.instruction_label, false); - set_text_if_present(ui.instruction_label, text); - } - } - WizardStep::Language => { - set_text_if_present(ui.icon_label, "LANG"); - set_text_if_present(ui.title_label, "Choose Language"); - set_text_if_present( - ui.description_label, - "Select the default transcript language. Live preview stays provisional, and the committed verdict will use this language unless you change it later in Settings.", - ); - set_hidden_if_present(ui.language_view, false); - set_button_title_if_present(ui.primary_button, "Continue"); - } - WizardStep::ApiKey => { - set_text_if_present(ui.icon_label, "API"); - set_text_if_present(ui.title_label, "Add OpenAI API Key"); - set_text_if_present( - ui.description_label, - "Put your OpenAI API key here to unlock formatting and the dictation-driven agent. Raw local transcript still works if you skip.", - ); - set_hidden_if_present(ui.api_view, false); - set_button_title_if_present(ui.primary_button, "Save & Continue"); - set_hidden_if_present(ui.skip_button, false); - set_button_title_if_present(ui.skip_button, "Skip OpenAI"); - } - WizardStep::HotkeyMode => { - set_text_if_present(ui.icon_label, "HOTKEY"); - set_text_if_present(ui.title_label, "Mode Shortcuts"); - set_text_if_present( - ui.description_label, - "Mode first, keys second. Dictation aims for a committed transcript verdict, Formatting upgrades text only when safe, and Assistive stays in the chat overlay instead of silent paste.", - ); - set_hidden_if_present(ui.hotkey_view, false); - set_button_title_if_present(ui.primary_button, "Continue"); - } - WizardStep::Done => { - set_text_if_present(ui.icon_label, "DONE"); - set_text_if_present(ui.title_label, "You're All Set"); - // Short copy here on purpose: the summary_view below renders 4 lines - // of permission status + config. With a longer description the two - // overlap (description y=268..372 vs summary first-row y≈332). - set_text_if_present( - ui.description_label, - "Truth model below — adjust later in Settings.", - ); - set_hidden_if_present(ui.summary_view, false); - set_hidden_if_present(ui.skip_button, true); - update_summary_view(ui, permissions, language, api_key_configured, hotkey_mode); - set_button_title_if_present(ui.primary_button, "Start CodeScribe"); - } - } -} - -fn sidebar_step_title(step: WizardStep) -> &'static str { - match step { - WizardStep::Welcome => "Welcome", - WizardStep::Permission(PermissionKind::Microphone) => "Microphone", - WizardStep::Permission(PermissionKind::Accessibility) => "Accessibility", - WizardStep::Permission(PermissionKind::InputMonitoring) => "Input Monitoring", - WizardStep::Permission(PermissionKind::ScreenRecording) => "Screen Recording", - WizardStep::Permission(PermissionKind::FullDiskAccess) => "Full Disk Access", - WizardStep::Language => "Language", - WizardStep::ApiKey => "API Key", - WizardStep::HotkeyMode => "Hotkeys", - WizardStep::Done => "Finish", - } -} - -fn update_sidebar_step_labels( - ui: UiRefs, - current_step_index: usize, - permissions: [PermissionUiStatus; 5], -) { - for idx in 0..TOTAL_STEPS { - let step = step_for_index(idx); - let (marker, color) = if idx == current_step_index { - if let WizardStep::Permission(kind) = step { - let status = permissions[kind.index()]; - if status == PermissionUiStatus::Denied { - ("\u{2715}", system_red_color()) - } else { - ("\u{25CF}", color_label()) - } - } else { - ("\u{25CF}", color_label()) - } - } else if idx < current_step_index { - if let WizardStep::Permission(PermissionKind::FullDiskAccess) = step { - if permissions[PermissionKind::FullDiskAccess.index()] - != PermissionUiStatus::Granted - { - ("\u{2013}", system_secondary_color()) - } else { - ("\u{2713}", system_green_color()) - } - } else { - ("\u{2713}", system_green_color()) - } - } else { - ("\u{25CB}", system_secondary_color()) - }; - - let text = format!("{marker} {}", sidebar_step_title(step)); - set_text_if_present(ui.sidebar_step_labels[idx], &text); - set_label_color_if_present(ui.sidebar_step_labels[idx], color); - } -} - -fn update_summary_view( - ui: UiRefs, - statuses: [PermissionUiStatus; 5], - language: LanguageChoice, - api_key_configured: bool, - hotkey_mode: HotkeyModeChoice, -) { - for kind in PERMISSION_ORDER { - let idx = kind.index(); - let text = if statuses[idx] == PermissionUiStatus::Granted { - format!("\u{2713} {}", kind.title()) - } else { - format!("\u{2715} {}", kind.title()) - }; - set_text_if_present(ui.summary_permission_labels[idx], &text); - - let color = if statuses[idx] == PermissionUiStatus::Granted { - system_green_color() - } else { - system_red_color() - }; - set_label_color_if_present(ui.summary_permission_labels[idx], color); - } - - let api_status = if api_key_configured { - "OpenAI key configured" - } else { - "OpenAI key not configured" - }; - - set_text_if_present( - ui.summary_config_label, - &format!( - "Language: {}\nOpenAI: {}\nMode profile: {}\nTruth model: Live preview stays local and provisional. CodeScribe only commits a final verdict after capture, and degraded fallback blocks silent auto-paste.", - language.label(), - api_status, - hotkey_mode.label() - ), - ); -} diff --git a/app/ui/onboarding/session.rs b/app/ui/onboarding/session.rs deleted file mode 100644 index ec0ebac2..00000000 --- a/app/ui/onboarding/session.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! Onboarding session lifecycle on disk: completion markers, resume -//! progress, legacy marker migration, and the flock(2)-based single-session -//! lock. - -use std::fs; -use std::fs::{File, OpenOptions}; -use std::io::{Seek, SeekFrom, Write}; -use std::os::fd::AsRawFd; -use std::path::PathBuf; -use std::sync::Mutex; - -use tracing::warn; - -use crate::config::Config; -use crate::os::permissions::PermissionStatus; - -use super::permission_flow::permission_status; -use super::steps::{PermissionKind, STEP_FLOW, TOTAL_STEPS, WizardStep}; - -fn setup_done_path() -> PathBuf { - Config::config_dir().join("setup_done") -} - -fn onboarding_done_path() -> PathBuf { - Config::config_dir().join("onboarding_done") -} - -fn legacy_bootstrap_done_path() -> PathBuf { - Config::config_dir().join("bootstrap_done") -} - -fn onboarding_progress_path() -> PathBuf { - Config::config_dir().join("onboarding_progress") -} - -fn onboarding_lock_path() -> PathBuf { - Config::config_dir().join("onboarding_session.lock") -} - -pub(super) fn load_onboarding_progress() -> usize { - let raw = fs::read_to_string(onboarding_progress_path()).ok(); - let step = raw - .as_deref() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - step.min(TOTAL_STEPS.saturating_sub(1)) -} - -pub(super) fn save_onboarding_progress(step_index: usize) { - let path = onboarding_progress_path(); - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent); - } - let _ = fs::write(path, step_index.to_string()); -} - -fn clear_onboarding_progress() { - let _ = fs::remove_file(onboarding_progress_path()); -} - -const REQUIRED_SETUP_PERMISSIONS: [PermissionKind; 4] = [ - PermissionKind::Microphone, - PermissionKind::Accessibility, - PermissionKind::InputMonitoring, - PermissionKind::ScreenRecording, -]; - -fn permission_step_index(kind: PermissionKind) -> Option { - STEP_FLOW - .iter() - .position(|step| *step == WizardStep::Permission(kind)) -} - -fn current_runtime_is_app_bundle() -> bool { - std::env::current_exe() - .map(|path| executable_is_app_bundle(&path)) - .unwrap_or(false) -} - -fn executable_is_app_bundle(path: &std::path::Path) -> bool { - path.to_string_lossy().contains(".app/Contents/MacOS/") -} - -fn permission_status_from_snapshot( - kind: PermissionKind, - microphone: PermissionStatus, - accessibility: PermissionStatus, - input_monitoring: PermissionStatus, - screen_recording: PermissionStatus, -) -> PermissionStatus { - match kind { - PermissionKind::Microphone => microphone, - PermissionKind::Accessibility => accessibility, - PermissionKind::InputMonitoring => input_monitoring, - PermissionKind::ScreenRecording => screen_recording, - PermissionKind::FullDiskAccess => PermissionStatus::Granted, - } -} - -pub(super) fn setup_done_refresh_target( - setup_done_exists: bool, - app_bundle_runtime: bool, - microphone: PermissionStatus, - accessibility: PermissionStatus, - input_monitoring: PermissionStatus, - screen_recording: PermissionStatus, -) -> Option { - if !setup_done_exists || !app_bundle_runtime { - return None; - } - - REQUIRED_SETUP_PERMISSIONS - .into_iter() - .find(|kind| { - permission_status_from_snapshot( - *kind, - microphone, - accessibility, - input_monitoring, - screen_recording, - ) != PermissionStatus::Granted - }) - .and_then(permission_step_index) -} - -fn invalidate_setup_done_if_permissions_missing() { - let setup_done = setup_done_path(); - if !setup_done.exists() { - return; - } - - let Some(resume_step) = setup_done_refresh_target( - true, - current_runtime_is_app_bundle(), - permission_status(PermissionKind::Microphone), - permission_status(PermissionKind::Accessibility), - permission_status(PermissionKind::InputMonitoring), - permission_status(PermissionKind::ScreenRecording), - ) else { - return; - }; - - match fs::remove_file(&setup_done) { - Ok(()) => { - save_onboarding_progress(resume_step); - warn!( - "Onboarding: removed stale setup_done because required permissions are missing; resuming at step {resume_step}" - ); - } - Err(error) => warn!( - "Onboarding: failed to remove stale setup_done despite missing required permissions: {error}" - ), - } -} - -/// Best-effort liveness probe for a PID via `kill(pid, 0)`. -/// -/// Retained for diagnostics and possible future tooling: the current lock -/// path uses `flock(2)` and no longer relies on PID liveness to gate access. -// FORGOTTEN-GEM(vc-prune 2026-06-10): kill(pid,0) liveness probe with no -// callers — likely intended for onboarding daemon checks that never landed. -// Wire it or delete it; operator decision tracked in forgotten-gems report. -#[allow(dead_code)] -fn process_is_alive(pid: u32) -> bool { - let result = unsafe { libc::kill(pid as i32, 0) }; - if result == 0 { - return true; - } - - std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) -} - -/// Holds the open `File` for the onboarding lock for the lifetime of the -/// process. Dropping the `File` closes the fd, which atomically releases the -/// `flock(2)` advisory lock — so we MUST keep it parked here. -static ONBOARDING_LOCK_FILE: Mutex> = Mutex::new(None); - -/// Acquire an exclusive, non-blocking advisory lock on the onboarding session -/// file using `flock(2)`. Returns `true` iff this process now holds the lock. -/// -/// Contract: -/// - Two simultaneous launches: exactly one wins, the other gets `false`. -/// - The lock is released automatically when the process exits (kernel closes -/// the fd) OR when [`release_onboarding_lock`] is called explicitly. -/// - The PID written to the file is informational only (for `ps`/log triage); -/// correctness comes from `flock`, not from the PID contents. -/// - Replaces an earlier check-then-create scheme that had a TOCTOU window -/// between liveness check and re-create — two launches could both pass and -/// both create the file. `flock` closes that window at the kernel level. -pub(super) fn acquire_onboarding_lock() -> bool { - let path = onboarding_lock_path(); - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent); - } - - let mut file = match OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&path) - { - Ok(file) => file, - Err(e) => { - warn!("Onboarding: failed to open lock file: {e}"); - return false; - } - }; - - // Non-blocking exclusive advisory lock. If another process holds it, - // `flock` returns -1 with errno EWOULDBLOCK and we bail out cleanly. - // SAFETY: `file.as_raw_fd()` is a valid borrowed fd for the lifetime of - // `file`, which outlives the `flock(2)` syscall. The flag bitmask is - // composed of libc-provided constants. No memory is read or written. - let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; - if rc != 0 { - let err = std::io::Error::last_os_error(); - if err.raw_os_error() == Some(libc::EWOULDBLOCK) { - // Try to read the holder PID for a useful diagnostic. Best-effort. - let holder_pid = fs::read_to_string(&path) - .ok() - .and_then(|raw| raw.trim().parse::().ok()); - match holder_pid { - Some(pid) => warn!( - "Onboarding: lock is held by live process pid={pid}, skipping duplicate wizard" - ), - None => { - warn!("Onboarding: lock is held by another process, skipping duplicate wizard") - } - } - } else { - warn!("Onboarding: failed to acquire lock via flock: {err}"); - } - return false; - } - - // We own the lock. Refresh the PID record for human diagnostics. Failures - // here do not affect correctness — the lock is what gates concurrency. - let pid = std::process::id(); - let _ = file.set_len(0); - let _ = file.seek(SeekFrom::Start(0)); - let _ = write!(file, "{pid}"); - let _ = file.flush(); - - // Park the file so the fd stays open and the lock persists for the - // process lifetime. Dropping the file would close the fd and release - // the kernel-level lock immediately. - let mut guard = match ONBOARDING_LOCK_FILE.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - *guard = Some(file); - true -} - -pub(super) fn release_onboarding_lock() { - let mut guard = match ONBOARDING_LOCK_FILE.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - if let Some(file) = guard.take() { - // Explicit unlock first; dropping the File closes the fd which would - // release the lock anyway, but explicit `LOCK_UN` is cheap insurance. - // SAFETY: `file.as_raw_fd()` is a valid borrowed fd for the lifetime - // of `file`, which is held until the explicit `drop(file)` below. - // `LOCK_UN` is a single libc constant. No memory is read or written. - let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; - drop(file); - } - // Best-effort cleanup so a stale lock file does not linger between runs. - let _ = fs::remove_file(onboarding_lock_path()); -} - -fn migrate_legacy_setup_done_marker() { - let setup_done = setup_done_path(); - if setup_done.exists() { - return; - } - - // Older builds tracked onboarding and settings completion separately. - // The current runtime only needs one canonical setup marker. - if onboarding_done_path().exists() && legacy_bootstrap_done_path().exists() { - if let Some(parent) = setup_done.parent() { - let _ = fs::create_dir_all(parent); - } - let _ = fs::write(setup_done, "done"); - } -} - -pub fn should_show_onboarding() -> bool { - migrate_legacy_setup_done_marker(); - invalidate_setup_done_if_permissions_missing(); - !setup_done_path().exists() -} - -pub(super) fn mark_onboarding_done() { - clear_onboarding_progress(); - let setup_done = setup_done_path(); - if let Some(parent) = setup_done.parent() { - let _ = fs::create_dir_all(parent); - } - let _ = fs::write(setup_done, "done"); -} diff --git a/app/ui/onboarding/state.rs b/app/ui/onboarding/state.rs deleted file mode 100644 index ffffe042..00000000 --- a/app/ui/onboarding/state.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Wizard state: user choices, UI element references, and the global -//! onboarding state cell, plus probes that derive initial state from -//! persisted settings. - -use std::sync::{LazyLock, Mutex}; - -use crate::config::{ShortcutBinding, UserSettings, WorkMode, keychain}; - -use super::permission_flow::PermissionUiStatus; -use super::steps::TOTAL_STEPS; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(super) enum LanguageChoice { - #[default] - English, - Polish, -} - -impl LanguageChoice { - pub(super) fn label(self) -> &'static str { - match self { - Self::English => "English", - Self::Polish => "Polish", - } - } - - pub(super) fn value(self) -> &'static str { - match self { - Self::English => "en", - Self::Polish => "pl", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(super) enum HotkeyModeChoice { - HoldToTalk, - Toggle, - #[default] - Both, -} - -impl HotkeyModeChoice { - pub(super) fn label(self) -> &'static str { - match self { - Self::HoldToTalk => "Dictation (Hold)", - Self::Toggle => "Hands-off (Toggle)", - Self::Both => "Hybrid", - } - } -} - -#[derive(Clone, Copy, Default)] -pub(super) struct UiRefs { - pub(super) sidebar_step_labels: [Option; TOTAL_STEPS], - pub(super) icon_label: Option, - pub(super) title_label: Option, - pub(super) description_label: Option, - pub(super) status_label: Option, - pub(super) instruction_label: Option, - pub(super) step_counter_label: Option, - pub(super) primary_button: Option, - pub(super) back_button: Option, - pub(super) skip_button: Option, - pub(super) language_view: Option, - pub(super) language_en_radio: Option, - pub(super) language_pl_radio: Option, - pub(super) api_view: Option, - pub(super) api_key_field: Option, - pub(super) api_hint_label: Option, - pub(super) hotkey_view: Option, - pub(super) hotkey_hold_radio: Option, - pub(super) hotkey_toggle_radio: Option, - pub(super) hotkey_both_radio: Option, - pub(super) summary_view: Option, - pub(super) summary_permission_labels: [Option; 5], - pub(super) summary_config_label: Option, -} - -pub(super) struct OnboardingState { - pub(super) window: Option, - pub(super) window_delegate: Option, - pub(super) action_handler: Option, - pub(super) step_index: usize, - pub(super) language: LanguageChoice, - pub(super) hotkey_mode: HotkeyModeChoice, - pub(super) requested_permissions: [bool; 5], - pub(super) permission_states: [PermissionUiStatus; 5], - pub(super) scheduled_auto_advance_step: Option, - pub(super) full_disk_polling: bool, - pub(super) closing_via_finish: bool, - pub(super) api_key_configured: bool, - pub(super) ui: UiRefs, -} - -impl Default for OnboardingState { - fn default() -> Self { - Self { - window: None, - window_delegate: None, - action_handler: None, - step_index: 0, - language: LanguageChoice::default(), - hotkey_mode: HotkeyModeChoice::default(), - requested_permissions: [false; 5], - permission_states: [PermissionUiStatus::NotDetermined; 5], - scheduled_auto_advance_step: None, - full_disk_polling: false, - closing_via_finish: false, - api_key_configured: false, - ui: UiRefs::default(), - } - } -} - -pub(super) static ONBOARDING_STATE: LazyLock> = - LazyLock::new(|| Mutex::new(OnboardingState::default())); - -pub(super) fn mode_api_key_configured() -> bool { - ["LLM_FORMATTING_API_KEY", "LLM_ASSISTIVE_API_KEY"] - .into_iter() - .any(|account| { - keychain::load_key(account) - .map(|k| !k.trim().is_empty()) - .unwrap_or(false) - }) -} - -pub(super) fn initial_language_choice() -> LanguageChoice { - let settings = UserSettings::load(); - match settings.whisper_language.as_deref() { - Some("pl") => LanguageChoice::Polish, - _ => LanguageChoice::English, - } -} - -pub(super) fn initial_hotkey_choice() -> HotkeyModeChoice { - let settings = UserSettings::load(); - let dictation = settings.mode_binding_for(WorkMode::Dictation); - let formatting = settings.mode_binding_for(WorkMode::Formatting); - let assistive = settings.mode_binding_for(WorkMode::Assistive); - - let hold_enabled = matches!( - dictation, - ShortcutBinding::HoldFn - | ShortcutBinding::HoldCtrl - | ShortcutBinding::HoldCtrlAlt - | ShortcutBinding::HoldCtrlShift - | ShortcutBinding::HoldCtrlCmd - ); - let toggle_enabled = matches!(dictation, ShortcutBinding::DoubleCtrl) - || formatting == ShortcutBinding::DoubleLeftOption - || assistive == ShortcutBinding::DoubleRightOption; - - match (hold_enabled, toggle_enabled) { - (true, true) => HotkeyModeChoice::Both, - (true, false) => HotkeyModeChoice::HoldToTalk, - (false, true) => HotkeyModeChoice::Toggle, - (false, false) => HotkeyModeChoice::Both, - } -} diff --git a/app/ui/onboarding/steps.rs b/app/ui/onboarding/steps.rs deleted file mode 100644 index 755db11b..00000000 --- a/app/ui/onboarding/steps.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Step metadata for first-run onboarding wizard. - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PermissionRecoveryStrategy { - LiveRecheck, - LiveReinitialize, - AppRestartRequired, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PermissionKind { - Microphone, - Accessibility, - InputMonitoring, - ScreenRecording, - FullDiskAccess, -} - -impl PermissionKind { - pub const fn index(self) -> usize { - match self { - Self::Microphone => 0, - Self::Accessibility => 1, - Self::InputMonitoring => 2, - Self::ScreenRecording => 3, - Self::FullDiskAccess => 4, - } - } - - pub const fn title(self) -> &'static str { - match self { - Self::Microphone => "Microphone Access", - Self::Accessibility => "Accessibility Access", - Self::InputMonitoring => "Input Monitoring Access", - Self::ScreenRecording => "Screen Recording Access", - Self::FullDiskAccess => "Full Disk Access", - } - } - - pub const fn reason(self) -> &'static str { - match self { - Self::Microphone => { - "Transcribe your voice into text. Audio is processed locally on your Mac." - } - Self::Accessibility => { - "Type transcribed text into any application and control text insertion." - } - Self::InputMonitoring => "Detect keyboard shortcuts to start and stop voice recording.", - Self::ScreenRecording => { - "Capture screen context to give the AI assistant visual awareness of what you're working on." - } - Self::FullDiskAccess => { - "Read project files for AI context. Optional - limits file-aware features if skipped." - } - } - } - - pub const fn icon(self) -> &'static str { - match self { - Self::Microphone => "MIC", - Self::Accessibility => "AX", - Self::InputMonitoring => "KEY", - Self::ScreenRecording => "SCR", - Self::FullDiskAccess => "FILE", - } - } - - pub const fn runtime_subsystem(self) -> &'static str { - match self { - Self::Microphone => "Microphone capture", - Self::Accessibility | Self::InputMonitoring => "Global hotkeys", - Self::ScreenRecording => "Screen capture", - Self::FullDiskAccess => "Protected file access", - } - } - - pub const fn recovery_strategy(self) -> PermissionRecoveryStrategy { - match self { - Self::Microphone => PermissionRecoveryStrategy::LiveRecheck, - Self::Accessibility | Self::InputMonitoring => { - PermissionRecoveryStrategy::LiveReinitialize - } - Self::ScreenRecording | Self::FullDiskAccess => { - PermissionRecoveryStrategy::AppRestartRequired - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WizardStep { - Welcome, - Permission(PermissionKind), - Language, - ApiKey, - HotkeyMode, - Done, -} - -pub const STEP_FLOW: [WizardStep; 10] = [ - WizardStep::Welcome, - WizardStep::Permission(PermissionKind::Microphone), - WizardStep::Permission(PermissionKind::Accessibility), - WizardStep::Permission(PermissionKind::InputMonitoring), - WizardStep::Permission(PermissionKind::ScreenRecording), - WizardStep::Permission(PermissionKind::FullDiskAccess), - WizardStep::Language, - WizardStep::ApiKey, - WizardStep::HotkeyMode, - WizardStep::Done, -]; - -pub const TOTAL_STEPS: usize = STEP_FLOW.len(); - -pub fn step_for_index(index: usize) -> WizardStep { - STEP_FLOW.get(index).copied().unwrap_or(WizardStep::Welcome) -} diff --git a/app/ui/onboarding/tests.rs b/app/ui/onboarding/tests.rs deleted file mode 100644 index e421330d..00000000 --- a/app/ui/onboarding/tests.rs +++ /dev/null @@ -1,226 +0,0 @@ -use serial_test::serial; -use tempfile::TempDir; - -use crate::config::Config; -use crate::os::permissions::PermissionStatus; - -use super::permission_flow::{ - PermissionUiStatus, should_open_settings_after_failed_request, - should_refresh_hotkey_runtime_after_grant, should_wait_for_restart, -}; -use super::session::{ - load_onboarding_progress, mark_onboarding_done, save_onboarding_progress, - setup_done_refresh_target, -}; -use super::should_show_onboarding; -use super::steps::{PermissionKind, PermissionRecoveryStrategy, WizardStep, step_for_index}; - -fn setup_test_env() -> TempDir { - let tmp = TempDir::new().expect("tempdir"); - unsafe { - std::env::set_var("CODESCRIBE_DATA_DIR", tmp.path()); - } - tmp -} - -#[test] -#[serial] -fn fresh_install_requires_onboarding() { - let _tmp = setup_test_env(); - assert!(should_show_onboarding()); -} - -#[test] -#[serial] -fn onboarding_completion_writes_canonical_setup_done() { - let _tmp = setup_test_env(); - - save_onboarding_progress(4); - mark_onboarding_done(); - - assert!(Config::config_dir().join("setup_done").exists()); - assert!(!Config::config_dir().join("onboarding_done").exists()); - assert!(!Config::config_dir().join("onboarding_progress").exists()); - assert!(!should_show_onboarding()); -} - -#[test] -#[serial] -fn onboarding_progress_round_trips_for_resume() { - let _tmp = setup_test_env(); - - save_onboarding_progress(3); - - assert_eq!(load_onboarding_progress(), 3); -} - -fn assert_resume_permission(step: Option, expected: PermissionKind) { - assert_eq!( - step.map(step_for_index), - Some(WizardStep::Permission(expected)) - ); -} - -#[test] -fn setup_done_refresh_target_ignores_non_app_runtime() { - assert_eq!( - setup_done_refresh_target( - true, - false, - PermissionStatus::Denied, - PermissionStatus::Granted, - PermissionStatus::Granted, - PermissionStatus::Granted, - ), - None - ); -} - -#[test] -fn setup_done_refresh_target_resumes_at_first_missing_required_permission() { - assert_resume_permission( - setup_done_refresh_target( - true, - true, - PermissionStatus::Denied, - PermissionStatus::Denied, - PermissionStatus::Denied, - PermissionStatus::NotDetermined, - ), - PermissionKind::Microphone, - ); - assert_resume_permission( - setup_done_refresh_target( - true, - true, - PermissionStatus::Granted, - PermissionStatus::Denied, - PermissionStatus::Denied, - PermissionStatus::NotDetermined, - ), - PermissionKind::Accessibility, - ); - assert_resume_permission( - setup_done_refresh_target( - true, - true, - PermissionStatus::Granted, - PermissionStatus::Granted, - PermissionStatus::Denied, - PermissionStatus::NotDetermined, - ), - PermissionKind::InputMonitoring, - ); - assert_resume_permission( - setup_done_refresh_target( - true, - true, - PermissionStatus::Granted, - PermissionStatus::Granted, - PermissionStatus::Granted, - PermissionStatus::NotDetermined, - ), - PermissionKind::ScreenRecording, - ); -} - -#[test] -fn setup_done_refresh_target_keeps_completed_marker_when_required_permissions_are_granted() { - assert_eq!( - setup_done_refresh_target( - true, - true, - PermissionStatus::Granted, - PermissionStatus::Granted, - PermissionStatus::Granted, - PermissionStatus::Granted, - ), - None - ); -} - -#[test] -fn runtime_recovery_strategy_maps_permissions_to_runtime_truth() { - assert_eq!( - PermissionKind::Microphone.recovery_strategy(), - PermissionRecoveryStrategy::LiveRecheck - ); - assert_eq!( - PermissionKind::Accessibility.recovery_strategy(), - PermissionRecoveryStrategy::LiveReinitialize - ); - assert_eq!( - PermissionKind::InputMonitoring.recovery_strategy(), - PermissionRecoveryStrategy::LiveReinitialize - ); - assert_eq!( - PermissionKind::ScreenRecording.recovery_strategy(), - PermissionRecoveryStrategy::AppRestartRequired - ); - assert_eq!( - PermissionKind::FullDiskAccess.recovery_strategy(), - PermissionRecoveryStrategy::AppRestartRequired - ); -} - -#[test] -fn restart_required_permissions_wait_for_relaunch_only_after_same_process_grant() { - assert!(should_wait_for_restart( - PermissionKind::ScreenRecording, - PermissionUiStatus::Granted, - true - )); - assert!(!should_wait_for_restart( - PermissionKind::ScreenRecording, - PermissionUiStatus::Granted, - false - )); - assert!(!should_wait_for_restart( - PermissionKind::Accessibility, - PermissionUiStatus::Granted, - true - )); -} - -#[test] -fn hotkey_runtime_refresh_waits_for_both_permissions() { - assert!(!should_refresh_hotkey_runtime_after_grant( - PermissionKind::Accessibility, - PermissionStatus::Granted, - PermissionStatus::Denied, - )); - assert!(!should_refresh_hotkey_runtime_after_grant( - PermissionKind::Microphone, - PermissionStatus::Granted, - PermissionStatus::Granted, - )); - assert!(should_refresh_hotkey_runtime_after_grant( - PermissionKind::InputMonitoring, - PermissionStatus::Granted, - PermissionStatus::Granted, - )); -} - -#[test] -fn failed_permission_retry_opens_system_settings_for_required_permissions() { - for kind in [ - PermissionKind::Microphone, - PermissionKind::Accessibility, - PermissionKind::InputMonitoring, - PermissionKind::ScreenRecording, - ] { - assert!( - should_open_settings_after_failed_request(kind, false), - "{kind:?} retry should fall back to System Settings when the system request does not grant access" - ); - } - - assert!( - !should_open_settings_after_failed_request(PermissionKind::FullDiskAccess, false), - "Full Disk Access opens System Settings inside its request implementation" - ); - assert!( - !should_open_settings_after_failed_request(PermissionKind::InputMonitoring, true), - "granted permissions should not reopen System Settings" - ); -} diff --git a/app/ui/onboarding/widgets.rs b/app/ui/onboarding/widgets.rs deleted file mode 100644 index 83cc6d3d..00000000 --- a/app/ui/onboarding/widgets.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Onboarding-local AppKit widget glue: radio buttons, label configuration, -//! optional-pointer setters, radio group sync, and system color lookups. - -use core_graphics::geometry::CGRect; -use objc::runtime::Class; -use objc::{msg_send, sel, sel_impl}; - -use crate::ui::shared::helpers::{ns_string, set_hidden, set_text_field_string}; - -use super::Id; -use super::state::{HotkeyModeChoice, LanguageChoice, UiRefs}; - -pub(super) fn configure_label(label: Id, centered: bool, multiline: bool) { - unsafe { - // NSTextAlignment: 0=left, 1=center, 2=right - let align = if centered { 1_isize } else { 0_isize }; - let _: () = msg_send![label, setAlignment: align]; - - if multiline { - let _: () = msg_send![label, setUsesSingleLineMode: false]; - let _: () = msg_send![label, setLineBreakMode: 0_isize]; - let cell: Id = msg_send![label, cell]; - if !cell.is_null() { - let _: () = msg_send![cell, setWraps: true]; - let _: () = msg_send![cell, setScrollable: false]; - let _: () = msg_send![cell, setLineBreakMode: 0_isize]; - } - } - } -} - -pub(super) fn create_radio_button(frame: CGRect, title: &str, selected: bool) -> Id { - unsafe { - let ns_button = Class::get("NSButton").unwrap(); - let button: Id = msg_send![ns_button, alloc]; - let button: Id = msg_send![button, initWithFrame: frame]; - let _: () = msg_send![button, setButtonType: 4_isize]; // NSRadioButton - let _: () = msg_send![button, setTitle: ns_string(title)]; - let _: () = msg_send![button, setState: if selected { 1_isize } else { 0_isize }]; - button - } -} - -pub(super) fn set_text_if_present(ptr: Option, text: &str) { - unsafe { - if let Some(value) = ptr { - set_text_field_string(value as Id, text); - } - } -} - -pub(super) fn set_button_title_if_present(ptr: Option, title: &str) { - unsafe { - if let Some(value) = ptr { - let _: () = msg_send![value as Id, setTitle: ns_string(title)]; - } - } -} - -pub(super) fn set_hidden_if_present(ptr: Option, hidden: bool) { - unsafe { - if let Some(value) = ptr { - set_hidden(value as Id, hidden); - } - } -} - -pub(super) fn set_label_color_if_present(ptr: Option, color: Id) { - unsafe { - if let Some(value) = ptr { - let _: () = msg_send![value as Id, setTextColor: color]; - } - } -} - -pub(super) fn sync_language_radios(ui: UiRefs, language: LanguageChoice) { - unsafe { - if let Some(en) = ui.language_en_radio { - let _: () = msg_send![en as Id, setState: if language == LanguageChoice::English { 1_isize } else { 0_isize }]; - } - if let Some(pl) = ui.language_pl_radio { - let _: () = msg_send![pl as Id, setState: if language == LanguageChoice::Polish { 1_isize } else { 0_isize }]; - } - } -} - -pub(super) fn sync_hotkey_radios(ui: UiRefs, mode: HotkeyModeChoice) { - unsafe { - if let Some(hold) = ui.hotkey_hold_radio { - let _: () = msg_send![hold as Id, setState: if mode == HotkeyModeChoice::HoldToTalk { 1_isize } else { 0_isize }]; - } - if let Some(toggle) = ui.hotkey_toggle_radio { - let _: () = msg_send![toggle as Id, setState: if mode == HotkeyModeChoice::Toggle { 1_isize } else { 0_isize }]; - } - if let Some(both) = ui.hotkey_both_radio { - let _: () = msg_send![both as Id, setState: if mode == HotkeyModeChoice::Both { 1_isize } else { 0_isize }]; - } - } -} - -pub(super) fn system_green_color() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, systemGreenColor] - } -} - -pub(super) fn system_red_color() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, systemRedColor] - } -} - -pub(super) fn system_secondary_color() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, secondaryLabelColor] - } -} - -pub(super) fn get_text_field_string(field: Id) -> String { - unsafe { - let value: Id = msg_send![field, stringValue]; - let c_str: *const std::ffi::c_char = msg_send![value, UTF8String]; - if c_str.is_null() { - return String::new(); - } - std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .to_string() - } -} diff --git a/app/ui/onboarding/window.rs b/app/ui/onboarding/window.rs deleted file mode 100644 index 89bc3e58..00000000 --- a/app/ui/onboarding/window.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! Wizard window construction: NSWindow setup, glass background, sidebar, -//! and all static UI elements built once per onboarding session. - -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use objc::runtime::Class; -use objc::{msg_send, sel, sel_impl}; -use objc2_app_kit::{ - NSBackingStoreType, NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, - NSWindowButton, NSWindowCollectionBehavior, NSWindowStyleMask, -}; -use tracing::warn; - -use crate::ui::shared::helpers::{ - LabelConfig, add_subview, button, button_set_action, color_clear, color_label, - color_secondary_label, create_glass_effect_view_with, create_label, create_secure_text_input, - ns_string, present_shared_shell_panel, -}; - -use super::Id; -use super::handlers::{action_handler_class, window_delegate_class}; -use super::permission_flow::{PERMISSION_ORDER, refresh_all_permission_states_locked}; -use super::render::render_current_step; -use super::session::{load_onboarding_progress, release_onboarding_lock, save_onboarding_progress}; -use super::state::{ - ONBOARDING_STATE, OnboardingState, UiRefs, initial_hotkey_choice, initial_language_choice, - mode_api_key_configured, -}; -use super::steps::TOTAL_STEPS; -use super::widgets::{configure_label, create_radio_button, system_secondary_color}; - -pub(super) const WINDOW_WIDTH: f64 = 720.0; -pub(super) const WINDOW_HEIGHT: f64 = 540.0; - -pub(super) fn launch_onboarding_window() { - // NOTE: this `catch_unwind` recovery is only effective under a profile that - // unwinds on panic (dev / `cargo test`/`run`). The release profile sets - // `panic="abort"` (Cargo.toml `[profile.release]`), so a panic in - // `show_onboarding_wizard_impl` aborts the process before this `Err` arm can - // run. In release the only panic diagnostic is the global panic hook - // installed in `main` (`install_panic_hook`), which logs payload + location - // + thread + backtrace before the abort. - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(show_onboarding_wizard_impl)) { - Ok(true) => {} - Ok(false) => { - warn!("Onboarding wizard did not open"); - release_onboarding_lock(); - } - Err(_) => { - warn!("Onboarding wizard terminated with panic"); - release_onboarding_lock(); - } - } -} - -pub(super) fn is_main_thread() -> bool { - unsafe { - let ns_thread = Class::get("NSThread").unwrap(); - msg_send![ns_thread, isMainThread] - } -} - -fn show_onboarding_wizard_impl() -> bool { - unsafe { - let existing = { - let state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window - }; - if let Some(window_ptr) = existing { - let window = window_ptr as Id; - let ns_window = Class::get("NSWindow").unwrap(); - let valid: bool = msg_send![window, isKindOfClass: ns_window]; - if valid { - present_shared_shell_panel(window); - return true; - } - } - - let Some(screen_class) = Class::get("NSScreen") else { - warn!("Onboarding: NSScreen class missing"); - return false; - }; - let screen: Id = msg_send![screen_class, mainScreen]; - if screen.is_null() { - warn!("Onboarding: No main screen"); - return false; - } - - let visible: CGRect = msg_send![screen, visibleFrame]; - let origin_x = visible.origin.x + (visible.size.width - WINDOW_WIDTH) * 0.5; - let origin_y = visible.origin.y + (visible.size.height - WINDOW_HEIGHT) * 0.5; - let frame = CGRect::new( - &CGPoint::new(origin_x, origin_y), - &CGSize::new(WINDOW_WIDTH, WINDOW_HEIGHT), - ); - - let ns_window = Class::get("NSWindow").unwrap(); - let window: Id = msg_send![ns_window, alloc]; - let style = NSWindowStyleMask::Titled | NSWindowStyleMask::FullSizeContentView; - let window: Id = msg_send![ - window, - initWithContentRect: frame - styleMask: style - backing: NSBackingStoreType::Buffered - defer: false - ]; - - let _: () = msg_send![window, setTitle: ns_string("Welcome to CodeScribe")]; - let _: () = msg_send![window, setTitleVisibility: 1_isize]; // NSWindowTitleHidden - let _: () = msg_send![window, setTitlebarAppearsTransparent: true]; - let _: () = msg_send![window, setOpaque: false]; - let _: () = msg_send![window, setBackgroundColor: color_clear()]; - let _: () = msg_send![window, setReleasedWhenClosed: false]; - let _: () = msg_send![window, setMovableByWindowBackground: true]; - let _: () = - msg_send![window, setCollectionBehavior: NSWindowCollectionBehavior::FullScreenNone]; - let size = CGSize::new(WINDOW_WIDTH, WINDOW_HEIGHT); - let _: () = msg_send![window, setContentMinSize: size]; - let _: () = msg_send![window, setContentMaxSize: size]; - - let close_btn: Id = msg_send![window, standardWindowButton: NSWindowButton::CloseButton]; - if !close_btn.is_null() { - let _: () = msg_send![close_btn, setHidden: false]; - let _: () = msg_send![close_btn, setEnabled: true]; - } - - let mini_btn: Id = - msg_send![window, standardWindowButton: NSWindowButton::MiniaturizeButton]; - if !mini_btn.is_null() { - let _: () = msg_send![mini_btn, setHidden: false]; - let _: () = msg_send![mini_btn, setEnabled: true]; - } - - let zoom_btn: Id = msg_send![window, standardWindowButton: NSWindowButton::ZoomButton]; - if !zoom_btn.is_null() { - let _: () = msg_send![zoom_btn, setHidden: true]; - let _: () = msg_send![zoom_btn, setEnabled: false]; - } - - let action_handler_class = action_handler_class(); - let action_handler: Id = msg_send![action_handler_class, new]; - - let delegate_class = window_delegate_class(); - let window_delegate: Id = msg_send![delegate_class, new]; - let _: () = msg_send![window, setDelegate: window_delegate]; - - let content_view: Id = msg_send![window, contentView]; - let content_bounds: CGRect = msg_send![content_view, bounds]; - let background = create_glass_effect_view_with( - content_bounds, - NSVisualEffectMaterial::HUDWindow, - NSVisualEffectBlendingMode::BehindWindow, - NSVisualEffectState::Active, - ); - let _: () = msg_send![background, setAutoresizingMask: 2_isize | 16_isize]; - add_subview(content_view, background); - - let ui = build_onboarding_ui(background, action_handler); - - let resume_step = load_onboarding_progress(); - - { - let mut state = ONBOARDING_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = OnboardingState { - window: Some(window as usize), - window_delegate: Some(window_delegate as usize), - action_handler: Some(action_handler as usize), - step_index: resume_step, - language: initial_language_choice(), - hotkey_mode: initial_hotkey_choice(), - requested_permissions: [false; 5], - permission_states: [super::permission_flow::PermissionUiStatus::NotDetermined; 5], - scheduled_auto_advance_step: None, - full_disk_polling: false, - closing_via_finish: false, - api_key_configured: mode_api_key_configured(), - ui, - }; - refresh_all_permission_states_locked(&mut state); - } - save_onboarding_progress(resume_step); - - render_current_step(); - present_shared_shell_panel(window); - true - } -} - -fn build_onboarding_ui(root: Id, action_handler: Id) -> UiRefs { - unsafe { - let ns_view = Class::get("NSView").unwrap(); - let mut ui = UiRefs::default(); - - const SIDEBAR_WIDTH: f64 = 204.0; - let content_left = SIDEBAR_WIDTH + 22.0; - let content_width = WINDOW_WIDTH - content_left - 22.0; - let content_center = content_left + (content_width * 0.5); - - let sidebar_bg = create_glass_effect_view_with( - CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(SIDEBAR_WIDTH, WINDOW_HEIGHT), - ), - NSVisualEffectMaterial::Sidebar, - NSVisualEffectBlendingMode::BehindWindow, - NSVisualEffectState::Active, - ); - let _: () = msg_send![ - sidebar_bg, - setAutoresizingMask: 16_isize | 2_isize // NSViewHeightSizable | NSViewWidthSizable - ]; - add_subview(root, sidebar_bg); - - let content_bg = create_glass_effect_view_with( - CGRect::new( - &CGPoint::new(SIDEBAR_WIDTH, 0.0), - &CGSize::new(WINDOW_WIDTH - SIDEBAR_WIDTH, WINDOW_HEIGHT), - ), - NSVisualEffectMaterial::HUDWindow, - NSVisualEffectBlendingMode::BehindWindow, - NSVisualEffectState::Active, - ); - let _: () = msg_send![ - content_bg, - setAutoresizingMask: 16_isize | 2_isize // NSViewHeightSizable | NSViewWidthSizable - ]; - add_subview(root, content_bg); - - let sidebar: Id = msg_send![ns_view, alloc]; - let sidebar: Id = msg_send![ - sidebar, - initWithFrame: CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(SIDEBAR_WIDTH, WINDOW_HEIGHT)) - ]; - let _: () = msg_send![sidebar, setAutoresizingMask: 16_isize | 2_isize]; // NSViewHeightSizable | NSViewWidthSizable - add_subview(root, sidebar); - - let sidebar_title = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(18.0, 494.0), - &CGSize::new(SIDEBAR_WIDTH - 28.0, 22.0), - ), - text: "Onboarding".to_string(), - font_size: 13.0, - bold: true, - text_color: color_label(), - ..Default::default() - }); - configure_label(sidebar_title, false, false); - add_subview(sidebar, sidebar_title); - - let divider = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(SIDEBAR_WIDTH - 1.0, 0.0), - &CGSize::new(1.0, WINDOW_HEIGHT), - ), - text: String::new(), - background_color: Some(system_secondary_color()), - ..Default::default() - }); - add_subview(root, divider); - - let mut sidebar_step_labels: [Option; TOTAL_STEPS] = [None; TOTAL_STEPS]; - let mut y = 460.0; - for slot in &mut sidebar_step_labels { - let label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(18.0, y), - &CGSize::new(SIDEBAR_WIDTH - 28.0, 20.0), - ), - text: String::new(), - font_size: 12.0, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(label, false, false); - add_subview(sidebar, label); - *slot = Some(label as usize); - y -= 34.0; - } - ui.sidebar_step_labels = sidebar_step_labels; - - let icon_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(content_center - 72.0, 410.0), - &CGSize::new(144.0, 34.0), - ), - text: String::new(), - font_size: 24.0, - bold: true, - text_color: color_label(), - ..Default::default() - }); - configure_label(icon_label, true, false); - add_subview(root, icon_label); - ui.icon_label = Some(icon_label as usize); - - let title_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(content_left, 378.0), - &CGSize::new(content_width, 34.0), - ), - text: String::new(), - font_size: 24.0, - bold: true, - text_color: color_label(), - ..Default::default() - }); - configure_label(title_label, true, false); - add_subview(root, title_label); - ui.title_label = Some(title_label as usize); - - let description_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(content_left + 8.0, 268.0), - &CGSize::new(content_width - 16.0, 104.0), - ), - text: String::new(), - font_size: 14.0, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(description_label, true, true); - add_subview(root, description_label); - ui.description_label = Some(description_label as usize); - - let status_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(content_center - 132.0, 244.0), - &CGSize::new(264.0, 22.0), - ), - text: String::new(), - font_size: 13.0, - bold: true, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(status_label, true, false); - add_subview(root, status_label); - ui.status_label = Some(status_label as usize); - - let instruction_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(content_left + 8.0, 196.0), - &CGSize::new(content_width - 16.0, 54.0), - ), - text: String::new(), - font_size: 12.0, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(instruction_label, true, true); - add_subview(root, instruction_label); - ui.instruction_label = Some(instruction_label as usize); - - let language_view: Id = msg_send![ns_view, alloc]; - let language_view: Id = msg_send![ - language_view, - initWithFrame: CGRect::new( - &CGPoint::new(content_center - 160.0, 186.0), - &CGSize::new(320.0, 88.0) - ) - ]; - add_subview(root, language_view); - ui.language_view = Some(language_view as usize); - - let language_en = create_radio_button( - CGRect::new(&CGPoint::new(10.0, 52.0), &CGSize::new(300.0, 24.0)), - "English", - true, - ); - let _: () = msg_send![language_en, setTag: 0_isize]; - button_set_action(language_en, action_handler, sel!(onLanguageSelected:)); - add_subview(language_view, language_en); - ui.language_en_radio = Some(language_en as usize); - - let language_pl = create_radio_button( - CGRect::new(&CGPoint::new(10.0, 22.0), &CGSize::new(300.0, 24.0)), - "Polish", - false, - ); - let _: () = msg_send![language_pl, setTag: 1_isize]; - button_set_action(language_pl, action_handler, sel!(onLanguageSelected:)); - add_subview(language_view, language_pl); - ui.language_pl_radio = Some(language_pl as usize); - - let api_view: Id = msg_send![ns_view, alloc]; - let api_view: Id = msg_send![ - api_view, - initWithFrame: CGRect::new( - &CGPoint::new(content_center - 190.0, 180.0), - &CGSize::new(380.0, 92.0) - ) - ]; - add_subview(root, api_view); - ui.api_view = Some(api_view as usize); - - let api_key_field = create_secure_text_input( - CGRect::new(&CGPoint::new(0.0, 46.0), &CGSize::new(380.0, 28.0)), - "Put your OpenAI API key", - ); - add_subview(api_view, api_key_field); - ui.api_key_field = Some(api_key_field as usize); - - let api_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(0.0, 8.0), &CGSize::new(380.0, 30.0)), - text: "Stored securely in macOS Keychain and used for Formatting plus Assistive." - .to_string(), - font_size: 11.0, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(api_hint, true, true); - add_subview(api_view, api_hint); - ui.api_hint_label = Some(api_hint as usize); - - let hotkey_view: Id = msg_send![ns_view, alloc]; - let hotkey_view: Id = msg_send![ - hotkey_view, - initWithFrame: CGRect::new( - &CGPoint::new(content_center - 200.0, 164.0), - &CGSize::new(400.0, 132.0) - ) - ]; - add_subview(root, hotkey_view); - ui.hotkey_view = Some(hotkey_view as usize); - - let hotkey_hold = create_radio_button( - CGRect::new(&CGPoint::new(0.0, 90.0), &CGSize::new(390.0, 24.0)), - "Dictation mode: Hold (Fn/Globe)", - false, - ); - let _: () = msg_send![hotkey_hold, setTag: 0_isize]; - button_set_action(hotkey_hold, action_handler, sel!(onHotkeySelected:)); - add_subview(hotkey_view, hotkey_hold); - ui.hotkey_hold_radio = Some(hotkey_hold as usize); - - let hotkey_toggle = create_radio_button( - CGRect::new(&CGPoint::new(0.0, 58.0), &CGSize::new(390.0, 24.0)), - "Hands-off mode: Toggle (Double-tap Option)", - false, - ); - let _: () = msg_send![hotkey_toggle, setTag: 1_isize]; - button_set_action(hotkey_toggle, action_handler, sel!(onHotkeySelected:)); - add_subview(hotkey_view, hotkey_toggle); - ui.hotkey_toggle_radio = Some(hotkey_toggle as usize); - - let hotkey_both = create_radio_button( - CGRect::new(&CGPoint::new(0.0, 26.0), &CGSize::new(390.0, 24.0)), - "Hybrid mode: Hold + Toggle", - true, - ); - let _: () = msg_send![hotkey_both, setTag: 2_isize]; - button_set_action(hotkey_both, action_handler, sel!(onHotkeySelected:)); - add_subview(hotkey_view, hotkey_both); - ui.hotkey_both_radio = Some(hotkey_both as usize); - - let summary_view: Id = msg_send![ns_view, alloc]; - const SUMMARY_WIDTH: f64 = 376.0; - const SUMMARY_HEIGHT: f64 = 212.0; - let summary_view: Id = msg_send![ - summary_view, - initWithFrame: CGRect::new( - &CGPoint::new(content_center - (SUMMARY_WIDTH * 0.5), 146.0), - &CGSize::new(SUMMARY_WIDTH, SUMMARY_HEIGHT) - ) - ]; - add_subview(root, summary_view); - ui.summary_view = Some(summary_view as usize); - - let mut summary_labels: [Option; 5] = [None; 5]; - let summary_line_height = 28.0; - let summary_top = SUMMARY_HEIGHT - 26.0; - for (idx, permission) in PERMISSION_ORDER.iter().enumerate() { - let y = summary_top - (idx as f64 * summary_line_height); - let label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(0.0, y), &CGSize::new(360.0, 22.0)), - text: permission.title().to_string(), - font_size: 12.0, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(label, false, false); - add_subview(summary_view, label); - summary_labels[idx] = Some(label as usize); - } - ui.summary_permission_labels = summary_labels; - - let summary_config = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(360.0, 50.0)), - text: String::new(), - font_size: 12.0, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(summary_config, false, true); - add_subview(summary_view, summary_config); - ui.summary_config_label = Some(summary_config as usize); - - let primary_w = 132.0; - let skip_w = 106.0; - let back_w = 90.0; - let button_y = 16.0; - let primary_x = WINDOW_WIDTH - 18.0 - primary_w; - let skip_x = primary_x - 8.0 - skip_w; - let back_x = skip_x - 8.0 - back_w; - - let primary_button = button( - CGRect::new( - &CGPoint::new(primary_x, button_y), - &CGSize::new(primary_w, 32.0), - ), - "Continue", - ); - button_set_action(primary_button, action_handler, sel!(onPrimaryAction:)); - add_subview(root, primary_button); - ui.primary_button = Some(primary_button as usize); - - let back_button = button( - CGRect::new(&CGPoint::new(back_x, button_y), &CGSize::new(back_w, 32.0)), - "Back", - ); - button_set_action(back_button, action_handler, sel!(onBackAction:)); - add_subview(root, back_button); - ui.back_button = Some(back_button as usize); - - let skip_button = button( - CGRect::new(&CGPoint::new(skip_x, button_y), &CGSize::new(skip_w, 32.0)), - "Skip", - ); - button_set_action(skip_button, action_handler, sel!(onSkipAction:)); - add_subview(root, skip_button); - ui.skip_button = Some(skip_button as usize); - - let step_counter = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(18.0, 20.0), - &CGSize::new(SIDEBAR_WIDTH - 28.0, 20.0), - ), - text: String::new(), - font_size: 12.0, - bold: true, - text_color: color_secondary_label(), - ..Default::default() - }); - configure_label(step_counter, false, false); - add_subview(sidebar, step_counter); - ui.step_counter_label = Some(step_counter as usize); - - ui - } -} diff --git a/app/ui/overlay/actions.rs b/app/ui/overlay/actions.rs deleted file mode 100644 index ccd0c949..00000000 --- a/app/ui/overlay/actions.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Objective-C action-handler bridge: button callbacks (Copy / Agent / -//! Format / Finish) and hover tracking, plus the action-contract text -//! snapshot used by the controller's commit path. - -use std::sync::Once; -use std::sync::atomic::Ordering; - -use objc::declare::ClassDecl; -use objc::runtime::{Class, Object, Sel}; -use objc::{msg_send, sel, sel_impl}; -use tracing::{info, warn}; - -use super::lifecycle::{hide_transcription_overlay, schedule_auto_hide}; -#[cfg(test)] -use super::state::TranscriptionOverlayState; -use super::state::{ - AUTO_HIDE_GENERATION, AUTO_HIDE_PENDING, FormatPhase, OVERLAY_STATE, OverlaySnapshot, - action_text_for_contract, apply_user_edit_to_state, -}; -use super::widgets::{set_action_buttons_visible_unlocked, set_status_message_unlocked}; -use crate::os::clipboard; -use crate::ui_helpers::{Id, get_text_view_string}; - -static ACTION_HANDLER_INIT: Once = Once::new(); -static mut ACTION_HANDLER_CLASS: *const Class = std::ptr::null(); - -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum AugmentAction { - CommitLiveSegment, - HandoffDecisionText(String), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum OverlayActionButtonRole { - FormatPaste, - Copy, - AgentClose, - Finish, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum OverlayButtonAction { - Format, - Copy, - Agent, - Close, - Finish, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct OverlayButtonRoute { - pub(super) action: OverlayButtonAction, - pub(super) selector_name: &'static str, -} - -pub(super) const SETTINGS_SELECTOR_NAME: &str = "onTabSettings:"; - -pub(super) fn overlay_button_route( - role: OverlayActionButtonRole, - phase: FormatPhase, -) -> OverlayButtonRoute { - match role { - OverlayActionButtonRole::FormatPaste => match phase { - FormatPhase::Formatted => OverlayButtonRoute { - action: OverlayButtonAction::Copy, - selector_name: "onCopyTranscript:", - }, - FormatPhase::Idle | FormatPhase::Formatting => OverlayButtonRoute { - action: OverlayButtonAction::Format, - selector_name: "onFormatTranscript:", - }, - }, - OverlayActionButtonRole::Copy => match phase { - FormatPhase::Formatted => OverlayButtonRoute { - action: OverlayButtonAction::Agent, - selector_name: "onAgentTranscript:", - }, - FormatPhase::Idle | FormatPhase::Formatting => OverlayButtonRoute { - action: OverlayButtonAction::Copy, - selector_name: "onCopyTranscript:", - }, - }, - OverlayActionButtonRole::AgentClose => match phase { - FormatPhase::Formatted => OverlayButtonRoute { - action: OverlayButtonAction::Close, - selector_name: "onCloseTranscript:", - }, - FormatPhase::Idle | FormatPhase::Formatting => OverlayButtonRoute { - action: OverlayButtonAction::Agent, - selector_name: "onAgentTranscript:", - }, - }, - OverlayActionButtonRole::Finish => OverlayButtonRoute { - action: OverlayButtonAction::Finish, - selector_name: "onCommitRecording:", - }, - } -} - -pub(super) fn overlay_button_selector(role: OverlayActionButtonRole, phase: FormatPhase) -> Sel { - let route = overlay_button_route(role, phase); - debug_assert_ne!(route.selector_name, SETTINGS_SELECTOR_NAME); - match route.action { - OverlayButtonAction::Format => sel!(onFormatTranscript:), - OverlayButtonAction::Copy => sel!(onCopyTranscript:), - OverlayButtonAction::Agent => sel!(onAgentTranscript:), - OverlayButtonAction::Close => sel!(onCloseTranscript:), - OverlayButtonAction::Finish => sel!(onCommitRecording:), - } -} - -pub(super) fn action_handler_class() -> *const Class { - ACTION_HANDLER_INIT.call_once(|| unsafe { - let superclass = Class::get("NSObject").unwrap(); - let mut decl = ClassDecl::new("TranscriptionOverlayActionHandler", superclass).unwrap(); - - decl.add_method( - sel!(onCopyTranscript:), - on_copy_transcript as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAgentTranscript:), - on_agent_transcript as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onFormatTranscript:), - on_format_transcript as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPasteTranscript:), - on_paste_transcript as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCloseTranscript:), - on_close_transcript as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCommitRecording:), - on_commit_recording as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(mouseEntered:), - on_mouse_entered as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(mouseExited:), - on_mouse_exited as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(textDidChange:), - on_text_did_change as extern "C" fn(&Object, Sel, Id), - ); - - ACTION_HANDLER_CLASS = decl.register(); - }); - unsafe { ACTION_HANDLER_CLASS } -} - -fn current_action_text_snapshot() -> (String, bool, OverlaySnapshot) { - let (fallback, decision_mode, snap) = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ( - action_text_for_contract(&state), - state.decision_mode, - OverlaySnapshot::from_state(&state), - ) - }; - - if decision_mode && let Some(text_view_ptr) = snap.text_view { - let edited = unsafe { get_text_view_string(text_view_ptr as Id) }; - return (edited, decision_mode, snap); - } - - (fallback, decision_mode, snap) -} - -#[cfg(test)] -pub(super) fn augment_action_for_state(state: &TranscriptionOverlayState) -> Option { - let text = action_text_for_contract(state); - if text.trim().is_empty() { - return None; - } - if state.decision_mode { - Some(AugmentAction::HandoffDecisionText(text)) - } else { - Some(AugmentAction::CommitLiveSegment) - } -} - -/// Returns the current action-contract text (Raw or AiFormat depending on -/// `state.action_contract_mode`). Used by controller's `commit_segment` to -/// read segment text for save without coupling button handlers to controller -/// state. Returns empty string if overlay state lock is poisoned (recoverable). -pub fn current_segment_text() -> String { - let (text, _, _) = current_action_text_snapshot(); - text -} - -/// Handler: Copy transcript using contract source of truth. -extern "C" fn on_copy_transcript(_this: &Object, _cmd: Sel, _sender: Id) { - let (text, _, snap) = current_action_text_snapshot(); - if text.is_empty() { - return; - } - if let Err(e) = clipboard::set_clipboard(&text) { - warn!("Failed to copy transcript: {}", e); - set_status_message_unlocked(&snap, "Copy failed", true); - return; - } - - info!("Copied transcript ({} chars)", text.len()); - if snap.format_phase == FormatPhase::Formatted { - set_status_message_unlocked(&snap, "Copied", false); - } else { - hide_transcription_overlay(); - } -} - -/// Handler: Agent = hand the whole transcript to the Agent (Emil). -/// -/// Decision-mode (post-recording): hands off the complete session transcript to -/// the voice-chat overlay as a single message. Live (mid-recording, legacy) clips -/// and commits the current segment, then augments. ADR 2026-05-28 Faza 1 renames -/// the former "Augment" action to "Agent" — same handoff, clearer contract. -extern "C" fn on_agent_transcript(_this: &Object, _cmd: Sel, _sender: Id) { - let (text, decision_mode, _) = current_action_text_snapshot(); - if text.trim().is_empty() { - return; - } - - if decision_mode { - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::handoff_transcript_to_chat(&text); - } else { - crate::controller::request_segment_commit_and_augment(); - } - hide_transcription_overlay(); -} - -/// Handler: Format = run AI formatting on the decision transcript in-place. -/// -/// ADR 2026-05-28 Faza 1: formatting is a post-recording CHOICE, not something the -/// dictation does mid-stream. Revision 2026-06-11 keeps the overlay open: Format -/// enters a disabled "Formatting..." phase, then returns editable text for -/// Copy / Agent / Close. -extern "C" fn on_format_transcript(_this: &Object, _cmd: Sel, _sender: Id) { - let (text, _, snap) = current_action_text_snapshot(); - if text.trim().is_empty() { - return; - } - - match snap.format_phase { - FormatPhase::Formatting => {} - FormatPhase::Formatted => { - on_copy_transcript(_this, _cmd, _sender); - } - FormatPhase::Idle => { - crate::ui::overlay::enter_overlay_formatting(); - crate::controller::request_format_for_overlay(text, |formatted_text| { - crate::ui::overlay::apply_overlay_format_result(&formatted_text); - }); - } - } -} - -/// Handler: Paste = paste the current editable formatted transcript. -extern "C" fn on_paste_transcript(_this: &Object, _cmd: Sel, _sender: Id) { - let (text, _, snap) = current_action_text_snapshot(); - if text.trim().is_empty() { - return; - } - - crate::controller::request_overlay_paste(text); - set_status_message_unlocked(&snap, "Pasted", false); -} - -/// Handler: Close = dismiss the formatted overlay without routing through Agent. -extern "C" fn on_close_transcript(_this: &Object, _cmd: Sel, _sender: Id) { - hide_transcription_overlay(); -} - -/// Handler: Commit segment = save WAV + transcript + Quick Notes WITHOUT -/// stopping the recorder. Recording continues; buffer offset advances so the -/// next segment starts from here. Overlay fades out. -extern "C" fn on_commit_recording(_this: &Object, _cmd: Sel, _sender: Id) { - crate::controller::request_segment_commit(); - hide_transcription_overlay(); -} - -extern "C" fn on_mouse_entered(_this: &Object, _cmd: Sel, _sender: Id) { - let (cancel_auto_hide, snap) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hover_active = true; - let dm = state.decision_mode; - (dm, OverlaySnapshot::from_state(&state)) - }; // Lock dropped before AppKit calls. - if cancel_auto_hide { - set_action_buttons_visible_unlocked(&snap, true); - AUTO_HIDE_GENERATION.fetch_add(1, Ordering::SeqCst); - AUTO_HIDE_PENDING.store(false, Ordering::SeqCst); - } -} - -extern "C" fn on_mouse_exited(_this: &Object, _cmd: Sel, _sender: Id) { - let (decision_mode, format_phase, snap) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hover_active = false; - ( - state.decision_mode, - state.format_phase, - OverlaySnapshot::from_state(&state), - ) - }; // Lock dropped before AppKit calls. - if decision_mode { - set_action_buttons_visible_unlocked(&snap, true); - if format_phase == FormatPhase::Idle { - schedule_auto_hide(); - } - } else { - set_action_buttons_visible_unlocked(&snap, false); - } -} - -extern "C" fn on_text_did_change(_this: &Object, _cmd: Sel, notification: Id) { - let text_view = unsafe { - let from_notification: Id = if notification.is_null() { - std::ptr::null_mut() - } else { - msg_send![notification, object] - }; - if !from_notification.is_null() { - from_notification - } else { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state - .text_view - .map(|ptr| ptr as Id) - .unwrap_or(std::ptr::null_mut()) - } - }; - if text_view.is_null() { - return; - } - - let edited_text = unsafe { get_text_view_string(text_view) }; - let snap = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - apply_user_edit_to_state(&mut state, edited_text); - OverlaySnapshot::from_state(&state) - }; - AUTO_HIDE_GENERATION.fetch_add(1, Ordering::SeqCst); - AUTO_HIDE_PENDING.store(false, Ordering::SeqCst); - set_status_message_unlocked(&snap, "Edited", false); -} diff --git a/app/ui/overlay/layout.rs b/app/ui/overlay/layout.rs deleted file mode 100644 index d2216856..00000000 --- a/app/ui/overlay/layout.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Geometry constants, layout metrics, and the unlocked window-resize path. -//! -//! `resize_overlay_unlocked` is the single place that reflows the overlay -//! window and all its chrome (header, status, hint, spinner, blur, buttons) -//! to fit the current text content. Call ONLY outside the `OVERLAY_STATE` -//! lock — see `state::OverlaySnapshot` for the deadlock rationale. - -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use objc::{msg_send, sel, sel_impl}; - -use super::state::OverlaySnapshot; -use crate::ui_helpers::Id; - -// Window level constants -pub(super) const NS_FLOATING_WINDOW_LEVEL: i64 = 3; - -pub(super) const OVERLAY_WINDOW_WIDTH: f64 = 420.0; -pub(super) const OVERLAY_WINDOW_MIN_WIDTH: f64 = 360.0; -pub(super) const OVERLAY_WINDOW_MAX_WIDTH: f64 = 760.0; -pub(super) const OVERLAY_WINDOW_MIN_HEIGHT: f64 = 180.0; -pub(super) const OVERLAY_WINDOW_MAX_HEIGHT_RATIO: f64 = 0.5; -pub(super) const OVERLAY_PADDING: f64 = 16.0; -pub(super) const OVERLAY_HEADER_HEIGHT: f64 = 20.0; -pub(super) const OVERLAY_STATUS_HEIGHT: f64 = 20.0; -pub(super) const OVERLAY_INFO_HEIGHT: f64 = 12.0; -pub(super) const OVERLAY_STATUS_WIDTH: f64 = 100.0; -pub(super) const OVERLAY_HEADER_GAP: f64 = 4.0; -pub(super) const OVERLAY_CONTENT_GAP: f64 = 8.0; -pub(super) const OVERLAY_TEXT_MIN_HEIGHT: f64 = 44.0; -pub(super) const OVERLAY_BUTTON_HEIGHT: f64 = 28.0; -pub(super) const OVERLAY_BUTTON_MARGIN: f64 = 8.0; - -pub(super) const OVERLAY_LAYOUT_THROTTLE_MS: u64 = 80; -pub(super) const OVERLAY_LAYOUT_HYSTERESIS_PX: f64 = 1.0; -pub(super) const NSVIEW_WIDTH_SIZABLE: isize = 2; -pub(super) const NSVIEW_MAX_X_MARGIN: isize = 4; -pub(super) const NSVIEW_MIN_Y_MARGIN: isize = 8; -pub(super) const NSVIEW_HEIGHT_SIZABLE: isize = 16; -pub(super) const NSVIEW_MAX_Y_MARGIN: isize = 32; - -#[repr(C)] -#[derive(Clone, Copy)] -struct NSRange { - location: usize, - length: usize, -} - -pub(super) fn overlay_top_reserved_height() -> f64 { - OVERLAY_PADDING - + OVERLAY_HEADER_HEIGHT - + OVERLAY_HEADER_GAP - + OVERLAY_INFO_HEIGHT - + OVERLAY_CONTENT_GAP -} - -pub(super) fn overlay_bottom_reserved_height() -> f64 { - OVERLAY_PADDING + OVERLAY_BUTTON_HEIGHT + OVERLAY_BUTTON_MARGIN -} - -#[derive(Debug, Clone, Copy)] -pub(super) struct OverlayLayoutMetrics { - pub(super) target_height: f64, - pub(super) text_viewport_height: f64, - pub(super) text_document_height: f64, - pub(super) needs_scroll: bool, -} - -pub(super) fn compute_overlay_layout_metrics( - text_content_height: f64, - min_height: f64, - max_height: f64, -) -> OverlayLayoutMetrics { - let clamped_content_height = text_content_height.max(OVERLAY_TEXT_MIN_HEIGHT); - let chrome_height = overlay_top_reserved_height() + overlay_bottom_reserved_height(); - let required_window_height = clamped_content_height + chrome_height; - let target_height = required_window_height.max(min_height).min(max_height); - let text_viewport_height = (target_height - chrome_height).max(OVERLAY_TEXT_MIN_HEIGHT); - let text_document_height = clamped_content_height.max(text_viewport_height); - let needs_scroll = text_document_height > text_viewport_height + 0.5; - - OverlayLayoutMetrics { - target_height, - text_viewport_height, - text_document_height, - needs_scroll, - } -} - -pub(super) fn measure_text_view_content_height(text_view: Id, width: f64) -> f64 { - unsafe { - let layout: Id = msg_send![text_view, layoutManager]; - let container: Id = msg_send![text_view, textContainer]; - if layout.is_null() || container.is_null() { - return 0.0; - } - let _: () = msg_send![container, setContainerSize: CGSize::new(width.max(1.0), f64::MAX)]; - let _: () = msg_send![layout, ensureLayoutForTextContainer: container]; - let used_rect: CGRect = msg_send![layout, usedRectForTextContainer: container]; - used_rect.size.height.max(0.0) - } -} - -pub(super) fn scroll_text_view_to_bottom(text_view: Id) { - unsafe { - let text: Id = msg_send![text_view, string]; - if text.is_null() { - return; - } - let len: usize = msg_send![text, length]; - if len == 0 { - return; - } - let range = NSRange { - location: len, - length: 0, - }; - let _: () = msg_send![text_view, scrollRangeToVisible: range]; - } -} - -/// Resize overlay window to fit text content. Call ONLY outside the `OVERLAY_STATE` lock. -/// Returns the new `last_applied_height` for write-back to state. -pub(super) fn resize_overlay_unlocked(snap: &OverlaySnapshot) -> f64 { - let (window_ptr, text_scroll_ptr, text_view_ptr) = - match (snap.window, snap.text_scroll_view, snap.text_view) { - (Some(w), Some(ts), Some(tv)) => (w as Id, ts as Id, tv as Id), - _ => return snap.last_applied_height, - }; - - unsafe { - let current_frame: CGRect = msg_send![window_ptr, frame]; - let window_width = current_frame - .size - .width - .clamp(OVERLAY_WINDOW_MIN_WIDTH, OVERLAY_WINDOW_MAX_WIDTH); - let text_width = (window_width - OVERLAY_PADDING * 2.0).max(120.0); - let text_content_height = measure_text_view_content_height(text_view_ptr, text_width); - let metrics = - compute_overlay_layout_metrics(text_content_height, snap.min_height, snap.max_height); - let top_y = current_frame.origin.y + current_frame.size.height; - let should_resize = - (snap.last_applied_height - metrics.target_height).abs() > OVERLAY_LAYOUT_HYSTERESIS_PX; - let applied_height = if should_resize { - let new_frame = CGRect { - origin: CGPoint { - x: current_frame.origin.x, - y: top_y - metrics.target_height, - }, - size: CGSize { - width: window_width, - height: metrics.target_height, - }, - }; - let _: () = msg_send![window_ptr, setFrame: new_frame display: true]; - metrics.target_height - } else { - current_frame.size.height - }; - let _: () = msg_send![window_ptr, setLevel: NS_FLOATING_WINDOW_LEVEL]; - - let text_frame = CGRect { - origin: CGPoint { - x: OVERLAY_PADDING, - y: overlay_bottom_reserved_height(), - }, - size: CGSize { - width: text_width, - height: metrics.text_viewport_height, - }, - }; - let _: () = msg_send![text_scroll_ptr, setFrame: text_frame]; - - let document_frame = CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width: text_width, - height: metrics.text_document_height, - }, - }; - let _: () = msg_send![text_view_ptr, setFrame: document_frame]; - let _: () = - msg_send![text_view_ptr, setMinSize: CGSize::new(0.0, metrics.text_viewport_height)]; - let _: () = msg_send![text_scroll_ptr, setHasVerticalScroller: metrics.needs_scroll]; - if metrics.needs_scroll { - scroll_text_view_to_bottom(text_view_ptr); - } - - let header_y = applied_height - OVERLAY_PADDING - OVERLAY_HEADER_HEIGHT; - let info_y = header_y - OVERLAY_HEADER_GAP - OVERLAY_INFO_HEIGHT; - let spinner_size = 14.0; - let spinner_x = window_width - OVERLAY_PADDING - spinner_size; - let status_gap = 6.0; - let status_max_x = spinner_x - status_gap; - let status_width = OVERLAY_STATUS_WIDTH.min((status_max_x - OVERLAY_PADDING).max(80.0)); - let status_x = (status_max_x - status_width).max(OVERLAY_PADDING); - let header_width = (status_x - OVERLAY_CONTENT_GAP - OVERLAY_PADDING).max(120.0); - - if let Some(header_ptr) = snap.header_label { - let header_frame = CGRect { - origin: CGPoint { - x: OVERLAY_PADDING, - y: header_y, - }, - size: CGSize { - width: header_width, - height: OVERLAY_HEADER_HEIGHT, - }, - }; - let _: () = msg_send![header_ptr as Id, setFrame: header_frame]; - } - - if let Some(status_ptr) = snap.status_field { - let status_frame = CGRect { - origin: CGPoint { - x: status_x, - y: header_y, - }, - size: CGSize { - width: status_width, - height: OVERLAY_STATUS_HEIGHT, - }, - }; - let _: () = msg_send![status_ptr as Id, setFrame: status_frame]; - } - - if let Some(auto_hide_ptr) = snap.auto_hide_label { - let hint_frame = CGRect { - origin: CGPoint { - x: OVERLAY_PADDING, - y: info_y, - }, - size: CGSize { - width: window_width - OVERLAY_PADDING * 2.0, - height: OVERLAY_INFO_HEIGHT, - }, - }; - let _: () = msg_send![auto_hide_ptr as Id, setFrame: hint_frame]; - } - - if let Some(spinner_ptr) = snap.progress_indicator { - let spinner_frame = CGRect { - origin: CGPoint { - x: spinner_x, - y: header_y + ((OVERLAY_HEADER_HEIGHT - spinner_size) / 2.0).max(0.0), - }, - size: CGSize { - width: spinner_size, - height: spinner_size, - }, - }; - let _: () = msg_send![spinner_ptr as Id, setFrame: spinner_frame]; - } - - if let Some(blur_ptr) = snap.blur_view { - let blur_frame = CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width: window_width, - height: applied_height, - }, - }; - let _: () = msg_send![blur_ptr as Id, setFrame: blur_frame]; - } - - let button_width = 100.0; - let button_gap = 10.0; - let row_width = button_width * 3.0 + button_gap * 2.0; - let row_x = (window_width - row_width) / 2.0; - let save_frame = CGRect { - origin: CGPoint { - x: row_x, - y: OVERLAY_PADDING, - }, - size: CGSize { - width: button_width, - height: OVERLAY_BUTTON_HEIGHT, - }, - }; - let copy_frame = CGRect { - origin: CGPoint { - x: row_x + button_width + button_gap, - y: OVERLAY_PADDING, - }, - size: CGSize { - width: button_width, - height: OVERLAY_BUTTON_HEIGHT, - }, - }; - let augment_frame = CGRect { - origin: CGPoint { - x: row_x + (button_width + button_gap) * 2.0, - y: OVERLAY_PADDING, - }, - size: CGSize { - width: button_width, - height: OVERLAY_BUTTON_HEIGHT, - }, - }; - - if let Some(save_ptr) = snap.save_button { - let _: () = msg_send![save_ptr as Id, setFrame: save_frame]; - } - if let Some(copy_ptr) = snap.copy_button { - let _: () = msg_send![copy_ptr as Id, setFrame: copy_frame]; - } - if let Some(augment_ptr) = snap.augment_button { - let _: () = msg_send![augment_ptr as Id, setFrame: augment_frame]; - } - if let Some(commit_ptr) = snap.commit_button { - let commit_frame = CGRect { - origin: CGPoint { - x: (window_width - button_width) / 2.0, - y: OVERLAY_PADDING, - }, - size: CGSize { - width: button_width, - height: OVERLAY_BUTTON_HEIGHT, - }, - }; - let _: () = msg_send![commit_ptr as Id, setFrame: commit_frame]; - } - - applied_height - } -} diff --git a/app/ui/overlay/lifecycle.rs b/app/ui/overlay/lifecycle.rs deleted file mode 100644 index 560d2a91..00000000 --- a/app/ui/overlay/lifecycle.rs +++ /dev/null @@ -1,542 +0,0 @@ -//! Public overlay lifecycle API: status updates, streaming deltas, text and -//! action-contract payloads, mode transitions (recording / processing / -//! decision), auto-hide scheduling, and teardown. -//! -//! Every entry point hops to the main queue and follows the -//! extract-snapshot / drop-lock / AppKit-call pattern documented on -//! `state::OverlaySnapshot`. - -use std::sync::atomic::Ordering; -use std::time::{Duration, Instant}; - -use dispatch::Queue; -use objc::{msg_send, sel, sel_impl}; -use tracing::debug; - -use super::layout::{ - OVERLAY_LAYOUT_THROTTLE_MS, OVERLAY_WINDOW_MIN_HEIGHT, resize_overlay_unlocked, -}; -use super::preview::display_text_for_state; -use super::state::{ - AUTO_HIDE_GENERATION, AUTO_HIDE_PENDING, FormatPhase, OVERLAY_STATE, OverlaySnapshot, - TranscriptionActionContractMode, auto_hide_delay_secs, -}; -use super::widgets::{ - refresh_action_contract_ui_unlocked, reset_overlay_to_idle_unlocked, - set_action_buttons_visible_unlocked, set_auto_hide_hint_visible_unlocked, - set_format_phase_ui_unlocked, set_recording_button_visible_unlocked, - set_recording_status_unlocked, set_status_message_unlocked, set_text_view_editable_unlocked, - transcript_text_view_editable, update_overlay_text_unlocked, -}; -use crate::ui_helpers::{Id, animate_fade, set_hidden}; - -/// Update the status text in the overlay -pub fn update_transcription_status(status: &str) { - let status_owned = status.to_string(); - Queue::main().exec_async(move || { - update_transcription_status_impl(&status_owned); - }); -} - -fn update_transcription_status_impl(status: &str) { - let snap = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - OverlaySnapshot::from_state(&state) - }; - set_status_message_unlocked(&snap, status, true); -} - -/// Append a delta (streaming token) to the overlay text -pub fn append_transcription_delta(delta: &str) { - let delta_owned = delta.to_string(); - Queue::main().exec_async(move || { - append_transcription_delta_impl(&delta_owned); - }); -} - -pub(super) fn append_transcription_delta_impl(delta: &str) { - // Extract text + snapshot under lock, then drop before AppKit calls. - let (visible_text, snap, needs_resize) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.user_edited { - return; - } - let len_before = state.accumulated_text.len(); - codescribe_core::pipeline::contracts::TranscriptDelta::from_raw(delta) - .apply(&mut state.accumulated_text); - let len_after = state.accumulated_text.len(); - let visible = display_text_for_state(&state); - let snap = OverlaySnapshot::from_state(&state); - - // Throttled resize: trigger immediately on structural changes (newlines, - // backspace/deletion that shortens text), otherwise throttle by time. - let now = Instant::now(); - let structural_change = delta.contains('\n') || len_after < len_before; - let needs_resize = structural_change - || now.duration_since(state.last_layout_resize_at).as_millis() - >= OVERLAY_LAYOUT_THROTTLE_MS as u128; - if needs_resize { - state.last_layout_resize_at = now; - state.pending_layout_resize = false; - } else { - state.pending_layout_resize = true; - } - (visible, snap, needs_resize) - }; // Lock dropped. - - update_overlay_text_unlocked(snap.text_view, &visible_text); - if needs_resize { - let new_h = resize_overlay_unlocked(&snap); - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_applied_height = new_h; - } -} - -/// Set the full text in the overlay -pub fn set_transcription_text(text: &str) { - let text_owned = text.to_string(); - Queue::main().exec_async(move || { - set_transcription_text_impl(&text_owned); - }); -} - -fn set_transcription_text_impl(text: &str) { - let (visible_text, snap) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text = text.to_string(); - state.last_pass_text = text.to_string(); - state.user_edited = false; - let visible = display_text_for_state(&state); - let snap = OverlaySnapshot::from_state(&state); - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - (visible, snap) - }; // Lock dropped. - - update_overlay_text_unlocked(snap.text_view, &visible_text); - let new_h = resize_overlay_unlocked(&snap); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_applied_height = new_h; - } -} - -/// Set decision-mode action contract payload. -/// -/// `mode` defines whether `Copy`/`Augment` use RAW or last-pass text. -pub fn set_transcription_action_contract( - raw_text: &str, - last_pass_text: &str, - mode: TranscriptionActionContractMode, - display_status: String, -) { - let raw_text_owned = raw_text.to_string(); - let last_pass_owned = last_pass_text.to_string(); - let mode_copy = mode; - Queue::main().exec_async(move || { - let (visible_text, snap, decision_mode) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text.clear(); - state.raw_text = raw_text_owned; - state.last_pass_text = last_pass_owned; - state.user_edited = false; - state.action_contract_mode = mode_copy; - state.format_phase = FormatPhase::Idle; - state.display_status = display_status; - let visible = display_text_for_state(&state); - let dm = state.decision_mode; - let snap = OverlaySnapshot::from_state(&state); - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - (visible, snap, dm) - }; // Lock dropped. - - refresh_action_contract_ui_unlocked(&snap, mode_copy, decision_mode); - set_format_phase_ui_unlocked(&snap, mode_copy); - set_text_view_editable_unlocked( - &snap, - transcript_text_view_editable(decision_mode, snap.format_phase), - ); - update_overlay_text_unlocked(snap.text_view, &visible_text); - let new_h = resize_overlay_unlocked(&snap); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_applied_height = new_h; - } - }); -} - -/// Get the accumulated text from the overlay -pub fn get_transcription_text() -> String { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text.clone() -} - -/// Clear the text content of the overlay -pub fn clear_transcription_text() { - Queue::main().exec_async(|| { - clear_transcription_text_impl(); - }); -} - -fn clear_transcription_text_impl() { - let snap = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text.clear(); - state.raw_text.clear(); - state.last_pass_text.clear(); - state.user_edited = false; - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.format_phase = FormatPhase::Idle; - state.display_status.clear(); - state.decision_mode = false; - state.hover_active = false; - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - OverlaySnapshot::from_state(&state) - }; // Lock dropped before AppKit calls. - - update_overlay_text_unlocked(snap.text_view, ""); - let new_h = resize_overlay_unlocked(&snap); - set_action_buttons_visible_unlocked(&snap, false); - set_recording_button_visible_unlocked(&snap, false); - set_auto_hide_hint_visible_unlocked(&snap, TranscriptionActionContractMode::Raw, false); - set_text_view_editable_unlocked(&snap, false); - if let Some(spinner_ptr) = snap.progress_indicator { - unsafe { - set_hidden(spinner_ptr as Id, true); - } - } - reset_overlay_to_idle_unlocked(&snap); - - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_applied_height = new_h; - } -} - -/// Check if the transcription overlay is currently visible -pub fn is_transcription_overlay_visible() -> bool { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window.is_some() -} - -/// Schedule auto-hide after delay (call this when recording finishes) -pub fn schedule_auto_hide() { - let generation = AUTO_HIDE_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; - AUTO_HIDE_PENDING.store(true, Ordering::SeqCst); - - Queue::main().exec_async(|| { - let (snap, mode) = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ( - OverlaySnapshot::from_state(&state), - state.action_contract_mode, - ) - }; // Lock dropped. - set_auto_hide_hint_visible_unlocked(&snap, mode, true); - }); - - std::thread::spawn(move || { - std::thread::sleep(Duration::from_secs(auto_hide_delay_secs())); - - if should_auto_hide(generation) { - hide_transcription_overlay(); - debug!( - "Transcription overlay auto-hidden after {}s", - auto_hide_delay_secs() - ); - } else { - debug!("Auto-hide skipped"); - } - }); -} - -pub(super) fn should_auto_hide(expected_generation: u64) -> bool { - if AUTO_HIDE_GENERATION.load(Ordering::SeqCst) != expected_generation - || !AUTO_HIDE_PENDING.load(Ordering::SeqCst) - { - return false; - } - - let hovered = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.format_phase == FormatPhase::Formatting { - return false; - } - state.hover_active - }; - - !hovered -} - -pub fn enter_overlay_formatting() { - AUTO_HIDE_GENERATION.fetch_add(1, Ordering::SeqCst); - AUTO_HIDE_PENDING.store(false, Ordering::SeqCst); - - Queue::main().exec_async(|| { - let (snap, mode) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.format_phase = FormatPhase::Formatting; - state.display_status = "Formatting...".to_string(); - ( - OverlaySnapshot::from_state(&state), - state.action_contract_mode, - ) - }; - - set_action_buttons_visible_unlocked(&snap, true); - set_auto_hide_hint_visible_unlocked(&snap, mode, false); - set_format_phase_ui_unlocked(&snap, mode); - set_text_view_editable_unlocked(&snap, false); - set_status_message_unlocked(&snap, "Formatting...", true); - }); -} - -pub fn apply_overlay_format_result(formatted_text: &str) { - let formatted_text = formatted_text.to_string(); - AUTO_HIDE_GENERATION.fetch_add(1, Ordering::SeqCst); - AUTO_HIDE_PENDING.store(false, Ordering::SeqCst); - - Queue::main().exec_async(move || { - let (visible_text, snap, mode) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text = formatted_text.clone(); - state.last_pass_text = formatted_text; - state.user_edited = false; - state.decision_mode = true; - state.action_contract_mode = TranscriptionActionContractMode::AiFormat; - state.format_phase = FormatPhase::Formatted; - state.display_status = "Formatted".to_string(); - let visible = display_text_for_state(&state); - let snap = OverlaySnapshot::from_state(&state); - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - (visible, snap, state.action_contract_mode) - }; - - refresh_action_contract_ui_unlocked(&snap, mode, true); - set_auto_hide_hint_visible_unlocked(&snap, mode, true); - set_format_phase_ui_unlocked(&snap, mode); - set_action_buttons_visible_unlocked(&snap, true); - set_recording_button_visible_unlocked(&snap, false); - set_text_view_editable_unlocked(&snap, true); - set_status_message_unlocked(&snap, "Formatted", false); - update_overlay_text_unlocked(snap.text_view, &visible_text); - let new_h = resize_overlay_unlocked(&snap); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_applied_height = new_h; - } - schedule_auto_hide(); - }); -} - -/// Enter decision mode: show actions on hover for the current transcript -pub fn enter_decision_mode() { - Queue::main().exec_async(|| { - let (snap, mode) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = true; - if state.format_phase != FormatPhase::Formatted { - state.format_phase = FormatPhase::Idle; - } - ( - OverlaySnapshot::from_state(&state), - state.action_contract_mode, - ) - }; // Lock dropped before AppKit calls. - set_action_buttons_visible_unlocked(&snap, true); - set_auto_hide_hint_visible_unlocked(&snap, mode, true); - set_format_phase_ui_unlocked(&snap, mode); - set_recording_button_visible_unlocked(&snap, false); - set_recording_status_unlocked(&snap, false); - set_text_view_editable_unlocked( - &snap, - transcript_text_view_editable(true, snap.format_phase), - ); - }); -} - -/// Enter recording mode: hide actions, show recording indicator -pub fn enter_recording_mode() { - Queue::main().exec_async(|| { - let (snap, mode) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = false; - state.hover_active = false; - state.format_phase = FormatPhase::Idle; - state.user_edited = false; - ( - OverlaySnapshot::from_state(&state), - state.action_contract_mode, - ) - }; // Lock dropped before AppKit calls. - set_action_buttons_visible_unlocked(&snap, false); - set_auto_hide_hint_visible_unlocked(&snap, mode, false); - set_format_phase_ui_unlocked(&snap, mode); - set_recording_button_visible_unlocked(&snap, true); - set_text_view_editable_unlocked(&snap, false); - // Show recording indicator (red dot + text), no spinner - set_recording_status_unlocked(&snap, true); - }); -} - -/// Enter processing mode: recording has stopped, but final transcription / -/// formatting work is still running. -pub fn enter_processing_mode() { - Queue::main().exec_async(|| { - let (snap, mode) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = false; - state.hover_active = false; - state.format_phase = FormatPhase::Idle; - state.user_edited = false; - ( - OverlaySnapshot::from_state(&state), - state.action_contract_mode, - ) - }; // Lock dropped before AppKit calls. - set_action_buttons_visible_unlocked(&snap, false); - set_auto_hide_hint_visible_unlocked(&snap, mode, false); - set_format_phase_ui_unlocked(&snap, mode); - set_recording_button_visible_unlocked(&snap, false); - set_text_view_editable_unlocked(&snap, false); - set_status_message_unlocked(&snap, "Thinking", true); - }); -} - -/// Hide the transcription overlay window (with fade-out animation) -pub fn hide_transcription_overlay() { - // Cancel any pending auto-hide - AUTO_HIDE_PENDING.store(false, Ordering::SeqCst); - - Queue::main().exec_async(|| { - hide_transcription_overlay_impl(); - }); -} - -/// Closes a window by raw pointer (used for delayed close after animation). -/// -/// Sends `release` after `close` because the shared shell policy sets -/// `released_when_closed = false`, so AppKit no longer auto-releases the -/// initial alloc/init retain. Without this the NSWindow itself would leak. -/// -/// The teardown sequence is wrapped in an explicit `objc2::rc::autoreleasepool` -/// scope so that autoreleased temporaries spawned during AppKit's -/// `windowWillClose` / `removeFromSuperview` / CoreAnimation cleanup chain -/// drain in-scope, before this function returns. Without the scope, -/// pendingowe autoreleases survive into the next runloop tick's pool pop -/// and can hit pointers freed by this same teardown, producing -/// `EXC_BAD_ACCESS` in `objc_release` during `_CFAutoreleasePoolPop` -/// (observed as SIGSEGV on macOS Tahoe beta, 2026-05-10 and 2026-05-13). -fn close_window_by_ptr(window_ptr: usize) { - debug!( - "close_window_by_ptr: tearing down NSWindow (ptr={:#x})", - window_ptr - ); - objc2::rc::autoreleasepool(|_pool| unsafe { - crate::ui_helpers::window_discard(window_ptr as Id); - }); -} - -fn hide_transcription_overlay_impl() { - // Operator's screencast 2026-05-26 (75-89s): "skończyłem dyktować ale i tak czerwono". - // Overlay teardown is the last surface that touches tray status — no subscriber maps - // controller State::Idle → TrayStatus::Idle automatically. Reset here so the menu bar - // indicator returns to green whenever the overlay disappears. - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Idle); - - // DEADLOCK PREVENTION: extract handles and clear state under lock, - // then drop lock before the animate_fade AppKit call. - let ( - window_ptr, - tracking_area_ptr, - action_handler_ptr, - text_view_ptr, - copy_button_ptr, - augment_button_ptr, - save_button_ptr, - commit_button_ptr, - ) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let wp = state.window.take(); - let tap = state.tracking_area.take(); - let ahp = state.action_handler.take(); - let tvp = state.text_view.take(); - let cbp = state.copy_button.take(); - let abp = state.augment_button.take(); - let sbp = state.save_button.take(); - let cmp = state.commit_button.take(); - state.header_label = None; - state.text_scroll_view = None; - state.status_field = None; - state.auto_hide_label = None; - state.blur_view = None; - state.progress_indicator = None; - state.decision_mode = false; - state.hover_active = false; - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.format_phase = FormatPhase::Idle; - state.user_edited = false; - state.last_applied_height = OVERLAY_WINDOW_MIN_HEIGHT; - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - // Note: accumulated_text is NOT cleared here - it's needed for clipboard copy - (wp, tap, ahp, tvp, cbp, abp, sbp, cmp) - }; // Lock dropped. - - if let Some(window_ptr) = window_ptr { - let window = window_ptr as Id; - - // Fade out animation (0.15s) - unsafe { - animate_fade(window, 0.0, 0.15); - } - - // Release the tracking area and the action target before the window - // tears down. Detach every unretained target first so no queued AppKit - // control/tracking callback can fire on a freed pointer during fade-out. - unsafe { - let nil_target: Id = std::ptr::null_mut(); - if let Some(tv_ptr) = text_view_ptr { - let _: () = msg_send![tv_ptr as Id, setDelegate: nil_target]; - } - for button_ptr in [ - copy_button_ptr, - augment_button_ptr, - save_button_ptr, - commit_button_ptr, - ] - .into_iter() - .flatten() - { - let button = button_ptr as Id; - let _: () = msg_send![button, setTarget: nil_target]; - } - if let Some(ta_ptr) = tracking_area_ptr { - let ta = ta_ptr as Id; - let content_view: Id = msg_send![window, contentView]; - if !content_view.is_null() { - let _: () = msg_send![content_view, removeTrackingArea: ta]; - } - let _: () = msg_send![ta, release]; - } - if let Some(ah_ptr) = action_handler_ptr { - let _: () = msg_send![ah_ptr as Id, release]; - } - } - - // Close window after brief delay for animation. `close_window_by_ptr` - // sends `release` after `close` to balance the alloc/init retain - // (released_when_closed = false in the shared shell policy). - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(200)); - Queue::main().exec_async(move || { - close_window_by_ptr(window_ptr); - }); - }); - - debug!("Transcription overlay hidden"); - } -} diff --git a/app/ui/overlay/mod.rs b/app/ui/overlay/mod.rs deleted file mode 100644 index a9138094..00000000 --- a/app/ui/overlay/mod.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Simple transcription overlay for non-assistive modes. -//! -//! This module provides a minimal floating overlay window that: -//! - Shows status during recording (Recording..., Processing...) -//! - Displays live streaming transcription text -//! - Supports explicit decision actions (Save/Copy/Augment) after recording -//! - Auto-hides after recording completion -//! -//! Use this for: Ctrl hold (raw), Left ⌥⌥ toggle (normal) -//! For agent chat conversations, use the `ui::voice_chat` overlay. -//! -//! Design: macOS Tahoe Liquid Glass (NSVisualEffectView, HudWindow material) -//! -//! Module layout (decomposed from a single 2456-LOC file): -//! - [`state`] — overlay state cell, AppKit pointer snapshot, auto-hide -//! bookkeeping, action-contract source selection -//! - [`preview`] — live-preview text filtering (word-boundary stabilization) -//! - [`layout`] — geometry constants, layout metrics, unlocked window resize -//! - [`widgets`] — unlocked AppKit widget mutators (buttons, tooltips, status) -//! - [`actions`] — Objective-C action-handler bridge for button callbacks -//! and hover tracking -//! - [`window`] — NSWindow construction and static UI build -//! - [`lifecycle`] — public lifecycle API: deltas, text, modes, auto-hide, -//! teardown -//! -//! External contract: everything re-exported below stays addressable as -//! `crate::ui::overlay::` — controller, settings, and voice_chat call -//! through this facade. - -mod actions; -mod layout; -mod lifecycle; -mod preview; -mod state; -#[cfg(test)] -mod tests; -mod widgets; -mod window; - -pub use self::actions::current_segment_text; -pub use self::lifecycle::{ - append_transcription_delta, apply_overlay_format_result, clear_transcription_text, - enter_decision_mode, enter_overlay_formatting, enter_processing_mode, enter_recording_mode, - get_transcription_text, hide_transcription_overlay, is_transcription_overlay_visible, - schedule_auto_hide, set_transcription_action_contract, set_transcription_text, - update_transcription_status, -}; -pub use self::state::{TranscriptionActionContractMode, TranscriptionOverlayConfig}; -pub use self::window::show_transcription_overlay; diff --git a/app/ui/overlay/preview.rs b/app/ui/overlay/preview.rs deleted file mode 100644 index 164a675b..00000000 --- a/app/ui/overlay/preview.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Live-preview text filtering: decision-mode passthrough versus -//! word-boundary-stabilized streaming preview. - -use super::state::{TranscriptionOverlayState, action_text_for_contract}; - -pub(super) fn display_text_for_state(state: &TranscriptionOverlayState) -> String { - let text = if state.accumulated_text.trim().is_empty() { - action_text_for_contract(state) - } else { - state.accumulated_text.clone() - }; - overlay_visible_text(&text, state.decision_mode).to_string() -} - -pub(super) fn overlay_visible_text(text: &str, decision_mode: bool) -> &str { - if decision_mode || !overlay_live_preview_uses_stable_text() { - // Decision mode must show exact contract payload without preview filtering. - text - } else { - // Live preview shows only complete word boundaries to avoid jittery partial tails. - stable_overlay_preview_text(text) - } -} - -fn overlay_live_preview_uses_stable_text() -> bool { - std::env::var("CODESCRIBE_OVERLAY_STABLE_PREVIEW") - .ok() - .map(|v| { - matches!( - v.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) - .unwrap_or(false) -} - -pub(super) fn stable_overlay_preview_text(text: &str) -> &str { - if text.is_empty() { - return text; - } - - let ends_stable = text - .chars() - .last() - .map(is_preview_boundary_char) - .unwrap_or(false); - if ends_stable { - return text; - } - - let mut last_boundary_idx = None; - for (idx, ch) in text.char_indices() { - if is_preview_boundary_char(ch) { - last_boundary_idx = Some(idx + ch.len_utf8()); - } - } - - match last_boundary_idx { - Some(idx) => &text[..idx], - None => text, - } -} - -fn is_preview_boundary_char(ch: char) -> bool { - ch.is_whitespace() - || matches!( - ch, - '.' | ',' - | ';' - | ':' - | '!' - | '?' - | ')' - | '(' - | ']' - | '[' - | '}' - | '{' - | '"' - | '\'' - | '…' - | '—' - | '-' - ) -} diff --git a/app/ui/overlay/state.rs b/app/ui/overlay/state.rs deleted file mode 100644 index dc213155..00000000 --- a/app/ui/overlay/state.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! Overlay state cell, AppKit pointer snapshot, auto-hide bookkeeping, and -//! the action-contract source-of-truth selection. - -use std::sync::atomic::AtomicBool; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; - -use super::layout::OVERLAY_WINDOW_MIN_HEIGHT; - -// Auto-hide delay after recording completes (configurable via env) -pub(super) const DEFAULT_AUTO_HIDE_DELAY_SECS: u64 = 15; -pub(super) const MIN_AUTO_HIDE_DELAY_SECS: u64 = 3; -pub(super) const MAX_AUTO_HIDE_DELAY_SECS: u64 = 60; - -pub(super) fn parse_auto_hide_delay_secs(raw: Option<&str>) -> u64 { - raw.and_then(|v| v.parse::().ok()) - .map(|v| v.clamp(MIN_AUTO_HIDE_DELAY_SECS, MAX_AUTO_HIDE_DELAY_SECS)) - .unwrap_or(DEFAULT_AUTO_HIDE_DELAY_SECS) -} - -pub(super) fn auto_hide_delay_secs() -> u64 { - static DELAY: OnceLock = OnceLock::new(); - *DELAY.get_or_init(|| { - parse_auto_hide_delay_secs( - std::env::var("TRANSCRIPTION_OVERLAY_AUTO_HIDE_SECS") - .ok() - .as_deref(), - ) - }) -} - -/// Configuration for the transcription overlay -#[derive(Debug, Clone)] -pub struct TranscriptionOverlayConfig { - /// Width of the overlay window in pixels - pub width: f64, - /// Height of the overlay window in pixels - pub height: f64, -} - -impl Default for TranscriptionOverlayConfig { - fn default() -> Self { - Self { - width: 420.0, - height: 180.0, - } - } -} - -/// Source-of-truth mode for transcription overlay actions in decision mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TranscriptionActionContractMode { - /// Copy/Augment use raw transcript captured from STT. - Raw, - /// Copy/Augment use last-pass/formatted transcript. - AiFormat, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum FormatPhase { - Idle, - Formatting, - Formatted, -} - -/// Transcription overlay state -pub(super) struct TranscriptionOverlayState { - pub(super) window: Option, - pub(super) header_label: Option, - pub(super) text_scroll_view: Option, - pub(super) text_view: Option, - pub(super) status_field: Option, - pub(super) auto_hide_label: Option, - pub(super) blur_view: Option, - pub(super) copy_button: Option, - pub(super) augment_button: Option, - pub(super) save_button: Option, - pub(super) commit_button: Option, - pub(super) progress_indicator: Option, - pub(super) tracking_area: Option, - pub(super) decision_mode: bool, - pub(super) hover_active: bool, - pub(super) action_handler: Option, - pub(super) action_contract_mode: TranscriptionActionContractMode, - pub(super) format_phase: FormatPhase, - pub(super) display_status: String, - pub(super) raw_text: String, - pub(super) last_pass_text: String, - pub(super) accumulated_text: String, - pub(super) user_edited: bool, - pub(super) min_height: f64, - pub(super) max_height: f64, - pub(super) last_applied_height: f64, - pub(super) last_layout_resize_at: Instant, - pub(super) pending_layout_resize: bool, -} - -lazy_static::lazy_static! { - pub(super) static ref OVERLAY_STATE: Mutex = Mutex::new(TranscriptionOverlayState { - window: None, - header_label: None, - text_scroll_view: None, - text_view: None, - status_field: None, - auto_hide_label: None, - blur_view: None, - copy_button: None, - augment_button: None, - save_button: None, - commit_button: None, - progress_indicator: None, - tracking_area: None, - decision_mode: false, - hover_active: false, - action_handler: None, - action_contract_mode: TranscriptionActionContractMode::Raw, - format_phase: FormatPhase::Idle, - display_status: String::new(), - raw_text: String::new(), - last_pass_text: String::new(), - accumulated_text: String::new(), - user_edited: false, - min_height: OVERLAY_WINDOW_MIN_HEIGHT, - max_height: OVERLAY_WINDOW_MIN_HEIGHT, - last_applied_height: OVERLAY_WINDOW_MIN_HEIGHT, - last_layout_resize_at: Instant::now(), - pending_layout_resize: false, - }); -} - -/// Snapshot of widget pointers + layout params for AppKit calls outside lock scope. -/// -/// DEADLOCK PREVENTION: extract this while holding `OVERLAY_STATE`, then -/// **drop the lock** before using the pointers in AppKit `msg_send!` calls. -/// AppKit can spin a nested run-loop during `setFrame:display:`, `orderFront:`, -/// etc., and pending `Queue::main().exec_async` blocks that also lock -/// `OVERLAY_STATE` will deadlock on the non-reentrant `std::sync::Mutex`. -#[derive(Clone)] -pub(super) struct OverlaySnapshot { - pub(super) window: Option, - pub(super) header_label: Option, - pub(super) text_scroll_view: Option, - pub(super) text_view: Option, - pub(super) status_field: Option, - pub(super) auto_hide_label: Option, - pub(super) blur_view: Option, - pub(super) copy_button: Option, - pub(super) augment_button: Option, - pub(super) save_button: Option, - pub(super) commit_button: Option, - pub(super) progress_indicator: Option, - pub(super) action_handler: Option, - pub(super) format_phase: FormatPhase, - pub(super) display_status: String, - pub(super) min_height: f64, - pub(super) max_height: f64, - pub(super) last_applied_height: f64, -} - -impl OverlaySnapshot { - pub(super) fn from_state(state: &TranscriptionOverlayState) -> Self { - Self { - window: state.window, - header_label: state.header_label, - text_scroll_view: state.text_scroll_view, - text_view: state.text_view, - status_field: state.status_field, - auto_hide_label: state.auto_hide_label, - blur_view: state.blur_view, - copy_button: state.copy_button, - augment_button: state.augment_button, - save_button: state.save_button, - commit_button: state.commit_button, - progress_indicator: state.progress_indicator, - action_handler: state.action_handler, - format_phase: state.format_phase, - display_status: state.display_status.clone(), - min_height: state.min_height, - max_height: state.max_height, - last_applied_height: state.last_applied_height, - } - } -} - -/// Flag to track if auto-hide timer is pending -pub(super) static AUTO_HIDE_PENDING: AtomicBool = AtomicBool::new(false); -/// Counter to invalidate old timers -pub(super) static AUTO_HIDE_GENERATION: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(0); - -pub(super) fn action_text_for_contract(state: &TranscriptionOverlayState) -> String { - match state.action_contract_mode { - TranscriptionActionContractMode::Raw => state.raw_text.clone(), - TranscriptionActionContractMode::AiFormat => state.last_pass_text.clone(), - } -} - -pub(super) fn apply_user_edit_to_state(state: &mut TranscriptionOverlayState, text: String) { - state.user_edited = true; - state.accumulated_text = text.clone(); - match state.action_contract_mode { - TranscriptionActionContractMode::Raw => { - state.raw_text = text; - } - TranscriptionActionContractMode::AiFormat => { - state.last_pass_text = text; - } - } -} diff --git a/app/ui/overlay/tests.rs b/app/ui/overlay/tests.rs deleted file mode 100644 index 6316055b..00000000 --- a/app/ui/overlay/tests.rs +++ /dev/null @@ -1,771 +0,0 @@ -//! Overlay unit + opt-in real-flow E2E tests (decomposed alongside the -//! `mod.rs` split; semantics unchanged). - -use std::sync::atomic::Ordering; -use std::time::Instant; - -use super::actions::{ - AugmentAction, OverlayActionButtonRole, OverlayButtonAction, SETTINGS_SELECTOR_NAME, - augment_action_for_state, overlay_button_route, -}; -use super::layout::{ - OVERLAY_TEXT_MIN_HEIGHT, OVERLAY_WINDOW_MIN_HEIGHT, compute_overlay_layout_metrics, -}; -use super::lifecycle::{append_transcription_delta_impl, should_auto_hide}; -use super::preview::{display_text_for_state, overlay_visible_text, stable_overlay_preview_text}; -use super::state::{ - AUTO_HIDE_GENERATION, AUTO_HIDE_PENDING, DEFAULT_AUTO_HIDE_DELAY_SECS, FormatPhase, - MAX_AUTO_HIDE_DELAY_SECS, MIN_AUTO_HIDE_DELAY_SECS, OVERLAY_STATE, action_text_for_contract, - apply_user_edit_to_state, parse_auto_hide_delay_secs, -}; -use super::widgets::{decision_hint_text, overlay_status_label, transcript_text_view_editable}; -use super::{ - TranscriptionActionContractMode, TranscriptionOverlayConfig, current_segment_text, - get_transcription_text, is_transcription_overlay_visible, -}; -use crate::presentation::emitter::PresentationEmitter; -use crate::ui::shared::status::status_from_detail; -use codescribe_core::audio::load_audio_file; -use codescribe_core::pipeline::contracts::{DeltaSink, EngineEvent, EventSink, TranscriptDelta}; -use codescribe_core::pipeline::streaming::collect_buffered_engine_events; -use serial_test::serial; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex as StdMutex}; -use tokio::sync::Mutex; - -const OVERLAY_REAL_FLOW_OPT_IN_ENV: &str = "CODESCRIBE_E2E_STT"; - -fn overlay_real_flow_enabled() -> bool { - std::env::var(OVERLAY_REAL_FLOW_OPT_IN_ENV) - .ok() - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false) -} - -fn canonical_data_assets_dir() -> Option { - let home = std::env::var("HOME").ok()?; - let dir = PathBuf::from(home).join(".codescribe/data_assets"); - if dir.exists() { Some(dir) } else { None } -} - -fn canonical_overlay_cases() -> Vec<(PathBuf, PathBuf)> { - let Some(dir) = canonical_data_assets_dir() else { - return Vec::new(); - }; - - let mut out = Vec::new(); - let entries = match fs::read_dir(&dir) { - Ok(entries) => entries, - Err(_) => return Vec::new(), - }; - - for entry in entries.flatten() { - let wav = entry.path(); - if wav.extension().and_then(|ext| ext.to_str()) != Some("wav") { - continue; - } - - let Some(stem) = wav.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - let reference = dir.join(format!( - "{stem}_codescribe_raw_human_transcription_from_wav.txt" - )); - if reference.exists() { - out.push((wav, reference)); - } - } - - out.sort(); - out -} - -fn append_utterance_text(rendered: &mut String, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - if !rendered.is_empty() { - rendered.push(' '); - } - rendered.push_str(trimmed); -} - -fn final_transcript_from_events(events: &[EngineEvent]) -> String { - let mut transcript = String::new(); - for event in events { - if let EngineEvent::UtteranceFinal { text, .. } = event { - append_utterance_text(&mut transcript, text); - } - } - transcript -} - -fn normalize_overlay_text(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} - -fn human_reference_excerpt(path: &Path) -> String { - fs::read_to_string(path) - .map(|text| { - text.split_whitespace() - .take(24) - .collect::>() - .join(" ") - }) - .unwrap_or_default() -} - -fn reset_overlay_state_for_test() { - // Test fixture: pointers may be invalid (tests don't always wire a - // real AppKit window), so we intentionally do NOT send `release` for - // window / tracking_area / action_handler here. Production teardown - // lives in `hide_transcription_overlay_impl` + `close_window_by_ptr`. - AUTO_HIDE_PENDING.store(false, Ordering::SeqCst); - AUTO_HIDE_GENERATION.store(0, Ordering::SeqCst); - - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window = None; - state.header_label = None; - state.text_scroll_view = None; - state.text_view = None; - state.status_field = None; - state.auto_hide_label = None; - state.blur_view = None; - state.copy_button = None; - state.augment_button = None; - state.save_button = None; - state.commit_button = None; - state.progress_indicator = None; - state.tracking_area = None; - state.decision_mode = false; - state.hover_active = false; - state.action_handler = None; - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.format_phase = FormatPhase::Idle; - state.display_status.clear(); - state.raw_text.clear(); - state.last_pass_text.clear(); - state.accumulated_text.clear(); - state.user_edited = false; - state.min_height = OVERLAY_WINDOW_MIN_HEIGHT; - state.max_height = OVERLAY_WINDOW_MIN_HEIGHT; - state.last_applied_height = OVERLAY_WINDOW_MIN_HEIGHT; - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; -} - -fn overlay_visible_text_now() -> String { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - display_text_for_state(&state) -} - -fn has_one_to_three_word_collapse(snapshots: &[String]) -> bool { - let mut saw_substantial_text = false; - - for snapshot in snapshots { - let words = snapshot.split_whitespace().count(); - if words >= 6 || snapshot.chars().count() >= 30 { - saw_substantial_text = true; - } - - if saw_substantial_text && (1..=3).contains(&words) { - return true; - } - } - - false -} - -struct OverlayReplaySink { - snapshots: Arc>>, -} - -impl DeltaSink for OverlayReplaySink { - fn apply(&self, delta: &TranscriptDelta) { - append_transcription_delta_impl(&delta.delta); - let visible = overlay_visible_text_now(); - self.snapshots - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(visible); - } -} - -#[test] -fn test_overlay_button_routes_are_distinct_from_settings_handler() { - let cases = [ - ( - OverlayActionButtonRole::FormatPaste, - FormatPhase::Idle, - OverlayButtonAction::Format, - "onFormatTranscript:", - ), - ( - OverlayActionButtonRole::FormatPaste, - FormatPhase::Formatted, - OverlayButtonAction::Copy, - "onCopyTranscript:", - ), - ( - OverlayActionButtonRole::Copy, - FormatPhase::Idle, - OverlayButtonAction::Copy, - "onCopyTranscript:", - ), - ( - OverlayActionButtonRole::Copy, - FormatPhase::Formatted, - OverlayButtonAction::Agent, - "onAgentTranscript:", - ), - ( - OverlayActionButtonRole::AgentClose, - FormatPhase::Idle, - OverlayButtonAction::Agent, - "onAgentTranscript:", - ), - ( - OverlayActionButtonRole::AgentClose, - FormatPhase::Formatted, - OverlayButtonAction::Close, - "onCloseTranscript:", - ), - ( - OverlayActionButtonRole::Finish, - FormatPhase::Idle, - OverlayButtonAction::Finish, - "onCommitRecording:", - ), - ]; - - for (role, phase, expected_action, expected_selector) in cases { - let route = overlay_button_route(role, phase); - assert_eq!(route.action, expected_action); - assert_eq!(route.selector_name, expected_selector); - assert_ne!(route.selector_name, SETTINGS_SELECTOR_NAME); - } - - assert_eq!( - [ - overlay_button_route(OverlayActionButtonRole::FormatPaste, FormatPhase::Idle).action, - overlay_button_route(OverlayActionButtonRole::Copy, FormatPhase::Idle).action, - overlay_button_route(OverlayActionButtonRole::AgentClose, FormatPhase::Idle).action, - ], - [ - OverlayButtonAction::Format, - OverlayButtonAction::Copy, - OverlayButtonAction::Agent - ] - ); - assert_eq!( - [ - overlay_button_route(OverlayActionButtonRole::FormatPaste, FormatPhase::Formatted) - .action, - overlay_button_route(OverlayActionButtonRole::Copy, FormatPhase::Formatted).action, - overlay_button_route(OverlayActionButtonRole::AgentClose, FormatPhase::Formatted) - .action, - ], - [ - OverlayButtonAction::Copy, - OverlayButtonAction::Agent, - OverlayButtonAction::Close - ] - ); - assert_eq!( - decision_hint_text( - TranscriptionActionContractMode::AiFormat, - FormatPhase::Formatted, - "", - true - ), - "Dictation overlay | FORMATTED | Copy · Agent · Close" - ); -} - -#[test] -fn test_transcript_text_view_editability_policy() { - assert!(transcript_text_view_editable(true, FormatPhase::Idle)); - assert!(!transcript_text_view_editable(false, FormatPhase::Idle)); - assert!(!transcript_text_view_editable( - true, - FormatPhase::Formatting - )); - assert!(transcript_text_view_editable(false, FormatPhase::Formatted)); -} - -#[test] -fn test_transcription_text() { - // Just verify the function doesn't panic - let _ = get_transcription_text(); -} - -/// The dictation overlay is a borderless floating window. A plain borderless -/// `NSWindow` returns `canBecomeKeyWindow = NO`, which silently blocks all -/// keyboard input to the transcript `NSTextView` — so `setEditable: true` would -/// be a visual lie. Verify the overlay window subclass opts into key/main -/// status so the transcript is genuinely editable. -#[test] -fn overlay_window_subclass_is_keyable() { - use objc::runtime::Sel; - use objc::{msg_send, sel, sel_impl}; - - let class = super::window::overlay_window_class(); - assert!(!class.is_null(), "overlay window class should register"); - - let key_sel: Sel = sel!(canBecomeKeyWindow); - let main_sel: Sel = sel!(canBecomeMainWindow); - // SAFETY: querying the runtime whether instances respond to a selector. - let responds_key: bool = unsafe { msg_send![class, instancesRespondToSelector: key_sel] }; - let responds_main: bool = unsafe { msg_send![class, instancesRespondToSelector: main_sel] }; - assert!( - responds_key, - "overlay window must override canBecomeKeyWindow" - ); - assert!( - responds_main, - "overlay window must override canBecomeMainWindow" - ); -} - -#[test] -#[serial] -fn test_current_segment_text_smoke() { - // current_segment_text() is the pub accessor used by controller's - // commit_segment to read overlay action-contract text. This smoke test - // verifies the lock-acquire + Raw/AiFormat branch path doesn't panic - // on default state, and that the returned String matches whatever the - // private action_text_for_contract reads under the same lock. - let direct = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - action_text_for_contract(&state) - }; - let via_pub = current_segment_text(); - assert_eq!(direct, via_pub); -} - -#[test] -fn test_overlay_config_default() { - let config = TranscriptionOverlayConfig::default(); - assert_eq!(config.width, 420.0); - assert_eq!(config.height, 180.0); -} - -#[test] -fn test_is_overlay_visible_returns_bool() { - // Just verify the function returns a bool without panic - let visible = is_transcription_overlay_visible(); - let _ = visible; -} - -#[test] -#[serial] -fn test_auto_hide_generation() { - // Test that generation counter increments - let gen1 = AUTO_HIDE_GENERATION.load(Ordering::SeqCst); - AUTO_HIDE_GENERATION.fetch_add(1, Ordering::SeqCst); - let gen2 = AUTO_HIDE_GENERATION.load(Ordering::SeqCst); - assert_eq!(gen2, gen1 + 1); -} - -#[test] -fn test_auto_hide_delay_seconds() { - assert_eq!( - parse_auto_hide_delay_secs(None), - DEFAULT_AUTO_HIDE_DELAY_SECS - ); - assert_eq!( - parse_auto_hide_delay_secs(Some("2")), - MIN_AUTO_HIDE_DELAY_SECS - ); - assert_eq!( - parse_auto_hide_delay_secs(Some("999")), - MAX_AUTO_HIDE_DELAY_SECS - ); - assert_eq!(parse_auto_hide_delay_secs(Some("18")), 18); -} - -#[test] -#[serial] -fn test_auto_hide_hover_guard() { - AUTO_HIDE_GENERATION.store(42, Ordering::SeqCst); - AUTO_HIDE_PENDING.store(true, Ordering::SeqCst); - - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hover_active = true; - } - assert!(!should_auto_hide(42)); - - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hover_active = false; - } - assert!(should_auto_hide(42)); -} - -#[test] -#[serial] -fn test_auto_hide_suppresses_only_in_progress_formatting() { - reset_overlay_state_for_test(); - AUTO_HIDE_GENERATION.store(7, Ordering::SeqCst); - AUTO_HIDE_PENDING.store(true, Ordering::SeqCst); - - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hover_active = false; - state.format_phase = FormatPhase::Formatting; - } - assert!(!should_auto_hide(7)); - - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.format_phase = FormatPhase::Formatted; - } - assert!(should_auto_hide(7)); - - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.format_phase = FormatPhase::Idle; - } - assert!(should_auto_hide(7)); -} - -#[test] -fn test_layout_metrics_scroll_transition() { - let min_height = OVERLAY_WINDOW_MIN_HEIGHT; - let max_height = min_height + 80.0; - - let compact = compute_overlay_layout_metrics(40.0, min_height, max_height); - assert!(!compact.needs_scroll); - assert!(compact.target_height >= min_height); - - let grown = compute_overlay_layout_metrics(120.0, min_height, max_height); - assert!(!grown.needs_scroll); - assert!(grown.target_height > compact.target_height); - - let overflow = compute_overlay_layout_metrics(420.0, min_height, max_height); - assert!((overflow.target_height - max_height).abs() < f64::EPSILON); - assert!(overflow.needs_scroll); - assert!(overflow.text_document_height > overflow.text_viewport_height); -} - -#[test] -fn test_layout_metrics_mobile_like_compact_window() { - let min_height = OVERLAY_WINDOW_MIN_HEIGHT; - let max_height = min_height + 24.0; - - let compact = compute_overlay_layout_metrics(360.0, min_height, max_height); - assert!((compact.target_height - max_height).abs() < f64::EPSILON); - assert!(compact.needs_scroll); - assert!(compact.text_viewport_height >= OVERLAY_TEXT_MIN_HEIGHT); -} - -#[test] -fn test_overlay_status_labels_are_canonical() { - assert_eq!( - overlay_status_label(status_from_detail("Listening...")), - "Listening" - ); - assert_eq!( - overlay_status_label(status_from_detail("Thinking...")), - "Thinking" - ); - assert_eq!(overlay_status_label(status_from_detail("Idle")), "Idle"); - assert_eq!( - overlay_status_label(status_from_detail("Backend failed")), - "Error" - ); - assert_eq!(overlay_status_label(status_from_detail("??")), "Idle"); -} - -#[test] -#[serial] -fn test_action_text_uses_raw_contract_source_in_raw_mode() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.raw_text = "raw transcript".to_string(); - state.accumulated_text = "overlay preview".to_string(); - state.last_pass_text = "final last-pass".to_string(); - - let text = action_text_for_contract(&state); - assert_eq!(text, "raw transcript"); -} - -#[test] -#[serial] -fn test_augment_decision_mode_hands_off_existing_transcript() { - reset_overlay_state_for_test(); - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = true; - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.raw_text = "saved decision transcript".to_string(); - - assert_eq!( - augment_action_for_state(&state), - Some(AugmentAction::HandoffDecisionText( - "saved decision transcript".to_string() - )) - ); -} - -#[test] -#[serial] -fn test_augment_live_recording_commits_current_segment() { - reset_overlay_state_for_test(); - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = false; - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.raw_text = "live segment transcript".to_string(); - - assert_eq!( - augment_action_for_state(&state), - Some(AugmentAction::CommitLiveSegment) - ); -} - -#[test] -#[serial] -fn test_action_text_uses_last_pass_contract_source_in_ai_mode() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.action_contract_mode = TranscriptionActionContractMode::AiFormat; - state.raw_text = "raw transcript".to_string(); - state.accumulated_text = "overlay preview".to_string(); - state.last_pass_text = "final last-pass".to_string(); - - let text = action_text_for_contract(&state); - assert_eq!(text, "final last-pass"); -} - -#[test] -#[serial] -fn test_action_text_ai_mode_returns_empty_when_last_pass_empty() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.action_contract_mode = TranscriptionActionContractMode::AiFormat; - state.raw_text = "raw transcript".to_string(); - state.accumulated_text = "overlay preview".to_string(); - state.last_pass_text.clear(); - - let text = action_text_for_contract(&state); - assert!(text.is_empty()); -} - -#[test] -#[serial] -fn test_manual_overlay_edit_updates_raw_contract_and_blocks_deltas() { - reset_overlay_state_for_test(); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.accumulated_text = "server text".to_string(); - state.raw_text = "server text".to_string(); - apply_user_edit_to_state(&mut state, "manual edit".to_string()); - } - - append_transcription_delta_impl(" overwritten"); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert!(state.user_edited); - assert_eq!(state.accumulated_text, "manual edit"); - assert_eq!(state.raw_text, "manual edit"); -} - -#[test] -#[serial] -fn test_manual_overlay_edit_updates_ai_contract() { - reset_overlay_state_for_test(); - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.action_contract_mode = TranscriptionActionContractMode::AiFormat; - state.last_pass_text = "formatted".to_string(); - - apply_user_edit_to_state(&mut state, "formatted manual edit".to_string()); - - assert_eq!(state.last_pass_text, "formatted manual edit"); - assert!(state.raw_text.is_empty()); - assert_eq!(action_text_for_contract(&state), "formatted manual edit"); -} - -#[test] -#[serial] -fn test_display_text_prefers_live_preview_over_action_contract() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = true; - state.action_contract_mode = TranscriptionActionContractMode::AiFormat; - state.raw_text = "raw transcript".to_string(); - state.accumulated_text = "overlay preview".to_string(); - state.last_pass_text = "final last-pass".to_string(); - - let text = display_text_for_state(&state); - assert_eq!(text, "overlay preview"); -} - -#[test] -#[serial] -fn test_display_text_falls_back_to_action_contract_when_preview_empty() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = true; - state.action_contract_mode = TranscriptionActionContractMode::AiFormat; - state.raw_text = "raw transcript".to_string(); - state.accumulated_text.clear(); - state.last_pass_text = "final last-pass".to_string(); - - let text = display_text_for_state(&state); - assert_eq!(text, "final last-pass"); -} - -#[test] -fn test_stable_overlay_preview_text_keeps_complete_tail() { - let text = "To jest stabilne zdanie."; - assert_eq!(stable_overlay_preview_text(text), text); -} - -#[test] -fn test_stable_overlay_preview_text_trims_partial_tail_word() { - let text = "To jest stabilne zda"; - assert_eq!(stable_overlay_preview_text(text), "To jest stabilne "); -} - -#[test] -fn test_stable_overlay_preview_text_without_boundary_returns_text() { - assert_eq!(stable_overlay_preview_text("partial"), "partial"); -} - -/// Scoped env var guard — saves the prior value and restores it on Drop. -/// -/// Required because `CODESCRIBE_OVERLAY_STABLE_PREVIEW` is read by -/// `overlay_live_preview_uses_stable_text()` as process-global state, so -/// parallel tests without isolation can observe values left over by siblings. -struct OverlayStablePreviewEnvGuard { - prev: Option, -} - -impl OverlayStablePreviewEnvGuard { - fn unset() -> Self { - let prev = std::env::var("CODESCRIBE_OVERLAY_STABLE_PREVIEW").ok(); - // SAFETY: `#[serial]` on every caller enforces single-threaded access to - // this env var for the duration of the test, and Drop restores the prior - // value before any other test resumes. - unsafe { std::env::remove_var("CODESCRIBE_OVERLAY_STABLE_PREVIEW") }; - Self { prev } - } -} - -impl Drop for OverlayStablePreviewEnvGuard { - fn drop(&mut self) { - match &self.prev { - // SAFETY: see OverlayStablePreviewEnvGuard::unset — serial test scope. - Some(v) => unsafe { std::env::set_var("CODESCRIBE_OVERLAY_STABLE_PREVIEW", v) }, - // SAFETY: see OverlayStablePreviewEnvGuard::unset — serial test scope. - None => unsafe { std::env::remove_var("CODESCRIBE_OVERLAY_STABLE_PREVIEW") }, - } - } -} - -#[test] -#[serial] -fn test_overlay_visible_text_decision_mode_uses_exact_text() { - let _guard = OverlayStablePreviewEnvGuard::unset(); - let text = "pełny tekst kontraktu bez trimowania"; - assert_eq!(overlay_visible_text(text, true), text); -} - -#[test] -#[serial] -fn test_overlay_visible_text_live_mode_defaults_to_exact_text() { - let _guard = OverlayStablePreviewEnvGuard::unset(); - let text = "To jest stabilne zda"; - assert_eq!(overlay_visible_text(text, false), text); -} - -#[tokio::test] -#[serial] -async fn overlay_real_flow_from_canonical_assets_never_collapses_to_1_3_words() { - if !overlay_real_flow_enabled() { - eprintln!( - "Skipping overlay real-flow E2E (set {}=1 to enable)", - OVERLAY_REAL_FLOW_OPT_IN_ENV - ); - return; - } - - if let Err(err) = codescribe_core::stt::whisper::singleton::get_model_path() { - eprintln!("Skipping overlay real-flow E2E: local Whisper model unavailable: {err}"); - return; - } - - let cases = canonical_overlay_cases(); - if cases.is_empty() { - eprintln!("Skipping overlay real-flow E2E: no canonical data assets found"); - return; - } - - let previous_stable_preview = std::env::var("CODESCRIBE_OVERLAY_STABLE_PREVIEW").ok(); - unsafe { - std::env::set_var("CODESCRIBE_OVERLAY_STABLE_PREVIEW", "0"); - } - - for (audio_path, reference_path) in cases { - reset_overlay_state_for_test(); - - let (samples, sample_rate) = - load_audio_file(&audio_path).expect("load canonical audio asset"); - let events = collect_buffered_engine_events(&samples, sample_rate, Some("pl".to_string())) - .await - .expect("collect engine events for overlay replay"); - - assert!( - events - .iter() - .any(|event| matches!(event, EngineEvent::Preview { .. })), - "expected Preview events for canonical asset {}", - audio_path.display() - ); - - let expected_final = final_transcript_from_events(&events); - assert!( - !expected_final.trim().is_empty(), - "expected non-empty final transcript for {}", - audio_path.display() - ); - - let transcript_buffer = Arc::new(Mutex::new(String::new())); - let snapshots = Arc::new(StdMutex::new(Vec::::new())); - let sink: Arc = Arc::new(OverlayReplaySink { - snapshots: Arc::clone(&snapshots), - }); - let mut emitter = PresentationEmitter::new(transcript_buffer, Some(sink), None); - - for event in &events { - emitter.on_event(event); - } - emitter.finish().await; - - let final_visible = overlay_visible_text_now(); - let snapshot_list = snapshots.lock().unwrap_or_else(|e| e.into_inner()).clone(); - - assert!( - !snapshot_list.is_empty(), - "expected visible overlay snapshots for {}", - audio_path.display() - ); - assert!( - !has_one_to_three_word_collapse(&snapshot_list), - "overlay collapsed to 1-3 words for {} (reference excerpt: {})", - audio_path.display(), - human_reference_excerpt(&reference_path) - ); - assert_eq!( - normalize_overlay_text(&final_visible), - normalize_overlay_text(&expected_final), - "overlay final visible transcript diverged for {}", - audio_path.display() - ); - } - - match previous_stable_preview { - Some(value) => unsafe { std::env::set_var("CODESCRIBE_OVERLAY_STABLE_PREVIEW", value) }, - None => unsafe { std::env::remove_var("CODESCRIBE_OVERLAY_STABLE_PREVIEW") }, - } - reset_overlay_state_for_test(); -} diff --git a/app/ui/overlay/widgets.rs b/app/ui/overlay/widgets.rs deleted file mode 100644 index 7a5a6500..00000000 --- a/app/ui/overlay/widgets.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! Unlocked AppKit widget mutators: action buttons, tooltips, hint label, -//! status field, spinner, and text-view editability. -//! -//! Every function here takes an `OverlaySnapshot` and MUST be called outside -//! the `OVERLAY_STATE` lock — see `state::OverlaySnapshot` for the deadlock -//! rationale. - -use objc::runtime::Object; -use objc::{msg_send, sel, sel_impl}; - -use super::actions::{OverlayActionButtonRole, overlay_button_selector}; -use super::state::{ - FormatPhase, OverlaySnapshot, TranscriptionActionContractMode, auto_hide_delay_secs, -}; -use crate::ui::shared::status::{UiStatus, status_from_detail}; -use crate::ui_helpers::{Id, ns_string, set_hidden, set_text, set_text_view_string, set_tooltip}; - -/// Show/hide action buttons. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn set_action_buttons_visible_unlocked(snap: &OverlaySnapshot, visible: bool) { - if let Some(copy_ptr) = snap.copy_button { - unsafe { - set_hidden(copy_ptr as Id, !visible); - } - } - if let Some(augment_ptr) = snap.augment_button { - unsafe { - set_hidden(augment_ptr as Id, !visible); - } - } - if let Some(save_ptr) = snap.save_button { - unsafe { - set_hidden(save_ptr as Id, !visible); - } - } -} - -/// Show/hide commit button. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn set_recording_button_visible_unlocked(snap: &OverlaySnapshot, visible: bool) { - if let Some(commit_ptr) = snap.commit_button { - unsafe { - set_hidden(commit_ptr as Id, !visible); - } - } -} - -fn action_contract_source_label(mode: TranscriptionActionContractMode) -> &'static str { - match mode { - TranscriptionActionContractMode::Raw => "RAW", - TranscriptionActionContractMode::AiFormat => "AI-FORMAT", - } -} - -pub(super) fn copy_action_tooltip(mode: TranscriptionActionContractMode) -> &'static str { - match mode { - TranscriptionActionContractMode::Raw => "Copy RAW transcript", - TranscriptionActionContractMode::AiFormat => "Copy current editable transcript", - } -} - -pub(super) fn augment_action_tooltip( - mode: TranscriptionActionContractMode, - _phase: FormatPhase, -) -> &'static str { - match mode { - TranscriptionActionContractMode::Raw => "Open Agent overlay and hand off RAW transcript", - TranscriptionActionContractMode::AiFormat => "Open Agent overlay and hand off transcript", - } -} - -fn close_action_tooltip() -> &'static str { - "Close the formatted transcript overlay" -} - -/// Tooltip for the `[Format]` action (decision-mode). ADR 2026-05-28 Faza 1: -/// Format is an on-demand AI polish into the overlay — it works on the RAW -/// transcript too, it is NOT the old "Save closes" no-op. (Completes the Save->Format rename: the -/// creation-time tooltip was being clobbered back to the old Save text on every -/// action-contract refresh.) -fn format_action_tooltip(phase: FormatPhase) -> &'static str { - match phase { - FormatPhase::Idle => "Format the transcript with AI in the overlay", - FormatPhase::Formatting => "Formatting in progress", - FormatPhase::Formatted => "Copy current editable transcript", - } -} - -pub(super) fn transcript_text_view_editable(decision_mode: bool, phase: FormatPhase) -> bool { - match phase { - FormatPhase::Formatting => false, - FormatPhase::Formatted => true, - FormatPhase::Idle => decision_mode, - } -} - -pub(super) fn decision_hint_text( - mode: TranscriptionActionContractMode, - phase: FormatPhase, - display_status: &str, - include_auto_hide: bool, -) -> String { - let mode_label = action_contract_source_label(mode); - // Action-driven contract (ADR 2026-05-28 Faza 1): [Format] polishes via AI, - // [Copy] copies, [Agent] hands off. No more "Save closes" — Format is a real action. - let actions = match phase { - FormatPhase::Idle => "Format · Copy · Agent", - FormatPhase::Formatting => "Formatting...", - FormatPhase::Formatted => "Copy · Agent · Close", - }; - let base = match (display_status.is_empty(), phase) { - (_, FormatPhase::Formatted) => format!("Dictation overlay | FORMATTED | {actions}"), - (true, _) => format!("Dictation overlay | {} | {actions}", mode_label), - (false, _) => format!( - "Dictation overlay | {} | {} | {actions}", - mode_label, display_status - ), - }; - if include_auto_hide && phase == FormatPhase::Idle { - format!("{base} | Auto-hide {}s", auto_hide_delay_secs()) - } else { - base - } -} - -/// Refresh action contract tooltips/hints. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn refresh_action_contract_ui_unlocked( - snap: &OverlaySnapshot, - mode: TranscriptionActionContractMode, - include_auto_hide_hint: bool, -) { - if let Some(copy_ptr) = snap.copy_button { - unsafe { - set_tooltip(copy_ptr as Id, copy_action_tooltip(mode)); - } - } - if let Some(augment_ptr) = snap.augment_button { - unsafe { - let tooltip = if snap.format_phase == FormatPhase::Formatted { - close_action_tooltip() - } else { - augment_action_tooltip(mode, snap.format_phase) - }; - set_tooltip(augment_ptr as Id, tooltip); - } - } - if let Some(save_ptr) = snap.save_button { - // `save_button` slot now holds the [Format] button (ADR 2026-05-28 Faza 1). - // It must advertise the format action, not the old Save "close" semantics — - // otherwise this refresh clobbers the creation-time tooltip back to "Save". - unsafe { - set_tooltip(save_ptr as Id, format_action_tooltip(snap.format_phase)); - } - } - if let Some(label_ptr) = snap.auto_hide_label { - unsafe { - if include_auto_hide_hint { - let hint = decision_hint_text(mode, snap.format_phase, &snap.display_status, true); - set_text(label_ptr as Id, &hint); - set_tooltip(label_ptr as Id, "Transcription overlay action contract"); - set_hidden(label_ptr as Id, false); - } else { - set_hidden(label_ptr as Id, true); - } - } - } -} - -fn set_button_title(button: Id, title: &str) { - unsafe { - let title = ns_string(title); - let _: () = msg_send![button, setTitle: title]; - } -} - -fn set_button_enabled(button: Id, enabled: bool) { - unsafe { - let _: () = msg_send![button, setEnabled: enabled]; - } -} - -fn set_button_route( - button: Id, - target: Option, - role: OverlayActionButtonRole, - phase: FormatPhase, -) { - let action = overlay_button_selector(role, phase); - unsafe { - if let Some(target_ptr) = target { - let _: () = msg_send![button, setTarget: target_ptr as Id]; - } - let _: () = msg_send![button, setAction: action]; - } -} - -pub(super) fn set_format_phase_ui_unlocked( - snap: &OverlaySnapshot, - mode: TranscriptionActionContractMode, -) { - if let Some(format_ptr) = snap.save_button { - let title = match snap.format_phase { - FormatPhase::Idle => "Format", - FormatPhase::Formatting => "Formatting...", - FormatPhase::Formatted => "Copy", - }; - let button = format_ptr as Id; - set_button_title(button, title); - set_button_enabled(button, snap.format_phase != FormatPhase::Formatting); - set_button_route( - button, - snap.action_handler, - OverlayActionButtonRole::FormatPaste, - snap.format_phase, - ); - unsafe { - set_tooltip(button, format_action_tooltip(snap.format_phase)); - } - } - - if let Some(copy_ptr) = snap.copy_button { - let button = copy_ptr as Id; - let title = if snap.format_phase == FormatPhase::Formatted { - "Agent" - } else { - "Copy" - }; - set_button_title(button, title); - set_button_enabled(button, snap.format_phase != FormatPhase::Formatting); - set_button_route( - button, - snap.action_handler, - OverlayActionButtonRole::Copy, - snap.format_phase, - ); - unsafe { - let tooltip = if snap.format_phase == FormatPhase::Formatted { - augment_action_tooltip(mode, snap.format_phase) - } else { - copy_action_tooltip(mode) - }; - set_tooltip(button, tooltip); - } - } - - if let Some(augment_ptr) = snap.augment_button { - let button = augment_ptr as Id; - let title = if snap.format_phase == FormatPhase::Formatted { - "Close" - } else { - "Agent" - }; - set_button_title(button, title); - set_button_enabled(button, snap.format_phase != FormatPhase::Formatting); - set_button_route( - button, - snap.action_handler, - OverlayActionButtonRole::AgentClose, - snap.format_phase, - ); - unsafe { - let tooltip = if snap.format_phase == FormatPhase::Formatted { - close_action_tooltip() - } else { - augment_action_tooltip(mode, snap.format_phase) - }; - set_tooltip(button, tooltip); - } - } -} - -/// Show/hide auto-hide hint. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn set_auto_hide_hint_visible_unlocked( - snap: &OverlaySnapshot, - mode: TranscriptionActionContractMode, - visible: bool, -) { - refresh_action_contract_ui_unlocked(snap, mode, visible); -} - -pub(super) fn overlay_status_label(kind: UiStatus) -> &'static str { - match kind { - UiStatus::Idle => "Idle", - UiStatus::Listening => "Listening", - UiStatus::Processing => "Thinking", - UiStatus::Error => "Error", - } -} - -/// Update status label + spinner. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn set_status_message_unlocked(snap: &OverlaySnapshot, msg: &str, allow_spinner: bool) { - let status_kind = status_from_detail(msg); - let status_text = overlay_status_label(status_kind); - - if let Some(status_ptr) = snap.status_field { - unsafe { - set_text(status_ptr as Id, status_text); - set_hidden(status_ptr as Id, false); - let status_color = status_kind.text_color(); - let _: () = msg_send![status_ptr as Id, setTextColor: status_color]; - - let detail = if msg.trim().is_empty() { - "Status: Idle".to_string() - } else { - format!("Status: {}", msg.trim()) - }; - set_tooltip(status_ptr as Id, &detail); - } - } - - let _ = crate::tray::update_tray_status(status_kind.to_tray()); - - let show_spinner = allow_spinner && status_kind == UiStatus::Processing; - if let Some(spinner_ptr) = snap.progress_indicator { - unsafe { - set_hidden(spinner_ptr as Id, !show_spinner); - if show_spinner { - let _: () = - msg_send![spinner_ptr as Id, startAnimation: std::ptr::null::()]; - } else { - let _: () = msg_send![spinner_ptr as Id, stopAnimation: std::ptr::null::()]; - } - } - } -} - -pub(super) fn set_text_view_editable_unlocked(snap: &OverlaySnapshot, editable: bool) { - if let Some(text_view_ptr) = snap.text_view { - unsafe { - let text_view = text_view_ptr as Id; - let _: () = msg_send![text_view, setEditable: editable]; - let _: () = msg_send![text_view, setSelectable: true]; - } - } -} - -/// Update the overlay text content. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn update_overlay_text_unlocked(text_view_ptr: Option, visible_text: &str) { - if let Some(tv_ptr) = text_view_ptr { - unsafe { - set_text_view_string(tv_ptr as Id, visible_text); - } - } -} - -// NOTE: update_overlay_text_and_layout and maybe_resize_overlay_layout were removed. -// Their logic is now inlined into callers using the extract-drop-execute pattern -// to prevent deadlocks. See append_transcription_delta_impl, set_transcription_text_impl, etc. - -/// Reset status to idle. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn reset_overlay_to_idle_unlocked(snap: &OverlaySnapshot) { - set_status_message_unlocked(snap, "Idle", false); -} - -/// Toggle recording status indicator. Call ONLY outside the `OVERLAY_STATE` lock. -pub(super) fn set_recording_status_unlocked(snap: &OverlaySnapshot, show: bool) { - if show { - set_status_message_unlocked(snap, "Listening", false); - return; - } - reset_overlay_to_idle_unlocked(snap); -} diff --git a/app/ui/overlay/window.rs b/app/ui/overlay/window.rs deleted file mode 100644 index b590b5cf..00000000 --- a/app/ui/overlay/window.rs +++ /dev/null @@ -1,594 +0,0 @@ -//! NSWindow construction and static UI build for the transcription overlay: -//! Tahoe Liquid Glass blur, header/status/hint labels, spinner, scrollable -//! text view, hover tracking area, and the decision-action button row. - -use std::sync::atomic::Ordering; -use std::time::Instant; - -use codescribe_core::config::{Config, OverlayPositionMode}; -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use dispatch::Queue; -use objc::runtime::{Class, Object}; -use objc::{msg_send, sel, sel_impl}; -use objc2_app_kit::{ - NSBackingStoreType, NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, - NSWindowCollectionBehavior, NSWindowStyleMask, -}; -use tracing::{info, warn}; - -use super::actions::{OverlayActionButtonRole, action_handler_class, overlay_button_selector}; -use super::layout::{ - NS_FLOATING_WINDOW_LEVEL, NSVIEW_HEIGHT_SIZABLE, NSVIEW_MAX_X_MARGIN, NSVIEW_MAX_Y_MARGIN, - NSVIEW_MIN_Y_MARGIN, NSVIEW_WIDTH_SIZABLE, OVERLAY_BUTTON_HEIGHT, OVERLAY_CONTENT_GAP, - OVERLAY_HEADER_GAP, OVERLAY_HEADER_HEIGHT, OVERLAY_INFO_HEIGHT, OVERLAY_PADDING, - OVERLAY_STATUS_HEIGHT, OVERLAY_STATUS_WIDTH, OVERLAY_WINDOW_MAX_HEIGHT_RATIO, - OVERLAY_WINDOW_MAX_WIDTH, OVERLAY_WINDOW_MIN_HEIGHT, OVERLAY_WINDOW_MIN_WIDTH, - OVERLAY_WINDOW_WIDTH, compute_overlay_layout_metrics, overlay_bottom_reserved_height, - resize_overlay_unlocked, -}; -use super::state::{ - AUTO_HIDE_GENERATION, AUTO_HIDE_PENDING, FormatPhase, OVERLAY_STATE, OverlaySnapshot, - TranscriptionActionContractMode, -}; -use super::widgets::{ - augment_action_tooltip, copy_action_tooltip, decision_hint_text, - refresh_action_contract_ui_unlocked, reset_overlay_to_idle_unlocked, -}; -use crate::ui_helpers::{ - Id, add_subview, animate_fade, button_set_action, button_style, clamp_overlay_position, - create_button, create_glass_effect_view_with, create_label, create_scrollable_text_view, - ns_string, set_glass_effect_content_view, set_hidden, set_text_view_string, set_tooltip, - ui_colors, ui_tokens, window_discard, window_set_alpha, window_show, -}; - -const NS_PROGRESS_INDICATOR_STYLE_SPINNING: i64 = 1; -const NSTRACKING_MOUSE_ENTERED_AND_EXITED: u64 = 1 << 0; -const NSTRACKING_ACTIVE_ALWAYS: u64 = 1 << 7; -const NSTRACKING_IN_VISIBLE_RECT: u64 = 1 << 9; - -const OVERLAY_HEADER_LABEL: &str = "CodeScribe - Dictation Overlay"; - -static OVERLAY_WINDOW_INIT: std::sync::Once = std::sync::Once::new(); -static mut OVERLAY_WINDOW_CLASS: *const Class = std::ptr::null(); - -extern "C" fn overlay_can_become_key(_this: &Object, _cmd: objc::runtime::Sel) -> bool { - true -} - -/// NSWindow subclass for the dictation overlay. -/// -/// The overlay is a borderless floating window. On macOS a borderless -/// `NSWindow` returns `canBecomeKeyWindow = NO` by default, which means its -/// `NSTextView` can never become first responder — so `setEditable: true` is a -/// visual lie: a caret never appears and keystrokes are dropped. Overriding -/// `canBecomeKeyWindow`/`canBecomeMainWindow` lets the user click into the -/// transcript and actually edit it (mirrors `VoiceChatOverlayWindow`). -pub(super) fn overlay_window_class() -> *const Class { - unsafe { - OVERLAY_WINDOW_INIT.call_once(|| { - let superclass = Class::get("NSWindow").expect("NSWindow class missing"); - let mut decl = - objc::declare::ClassDecl::new("CodeScribeDictationOverlayWindow", superclass) - .expect("Failed to declare dictation overlay window class"); - decl.add_method( - sel!(canBecomeKeyWindow), - overlay_can_become_key as extern "C" fn(&Object, objc::runtime::Sel) -> bool, - ); - decl.add_method( - sel!(canBecomeMainWindow), - overlay_can_become_key as extern "C" fn(&Object, objc::runtime::Sel) -> bool, - ); - OVERLAY_WINDOW_CLASS = decl.register(); - }); - OVERLAY_WINDOW_CLASS - } -} - -/// Show the transcription overlay window -pub fn show_transcription_overlay() { - // Cancel any pending auto-hide - AUTO_HIDE_GENERATION.fetch_add(1, Ordering::SeqCst); - AUTO_HIDE_PENDING.store(false, Ordering::SeqCst); - - Queue::main().exec_async(|| { - show_transcription_overlay_impl(); - }); -} - -fn show_transcription_overlay_impl() { - unsafe { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - - // Reuse existing window if any - if let Some(window_ptr) = state.window { - // DEADLOCK PREVENTION: extract snapshot, drop lock before AppKit calls. - let snap = OverlaySnapshot::from_state(&state); - drop(state); - - let window = window_ptr as Id; - let _: () = msg_send![window, setLevel: NS_FLOATING_WINDOW_LEVEL]; - window_show(window); - let new_h = resize_overlay_unlocked(&snap); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_applied_height = new_h; - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - } - info!("Transcription overlay reused"); - return; - } - - state.accumulated_text.clear(); - state.raw_text.clear(); - state.last_pass_text.clear(); - state.user_edited = false; - state.action_contract_mode = TranscriptionActionContractMode::Raw; - state.format_phase = FormatPhase::Idle; - drop(state); // Release lock BEFORE heavy AppKit widget creation. - - // Get classes - let ns_screen_class = Class::get("NSScreen"); - let ns_color_class = Class::get("NSColor"); - let ns_progress_class = Class::get("NSProgressIndicator"); - let ns_tracking_area_class = Class::get("NSTrackingArea"); - - // Defensive checks for Cocoa classes - if ns_screen_class.is_none() - || ns_color_class.is_none() - || ns_progress_class.is_none() - || ns_tracking_area_class.is_none() - { - warn!("Failed to get required Cocoa classes"); - return; - } - - // Keyable borderless subclass so the transcript NSTextView accepts edits. - let ns_window = overlay_window_class(); - let ns_screen = ns_screen_class.unwrap(); - let ns_color = ns_color_class.unwrap(); - let ns_progress = ns_progress_class.unwrap(); - let ns_tracking_area = ns_tracking_area_class.unwrap(); - - // Get screen size to position the overlay - let main_screen: Id = msg_send![ns_screen, mainScreen]; - if main_screen.is_null() { - warn!("No main screen available"); - return; - } - let visible_frame: CGRect = msg_send![main_screen, visibleFrame]; - - // Load config for position logic - let config = Config::load(); - - // Modern compact dimensions for Tahoe-style overlay - let window_width = OVERLAY_WINDOW_WIDTH; - let window_height = OVERLAY_WINDOW_MIN_HEIGHT; - let margin = 20.0; - let corner_radius = ui_tokens::SURFACE_RADIUS; - let max_height = - (visible_frame.size.height * OVERLAY_WINDOW_MAX_HEIGHT_RATIO).max(window_height); - - let (raw_x, raw_y) = match config.overlay_position_mode { - OverlayPositionMode::SnappedTopRight => { - let right_x = visible_frame.origin.x + visible_frame.size.width; - let top_y = visible_frame.origin.y + visible_frame.size.height; - ( - right_x - window_width - margin, - top_y - window_height - margin, - ) - } - OverlayPositionMode::Custom => { - let right_x = visible_frame.origin.x + visible_frame.size.width; - let top_y = visible_frame.origin.y + visible_frame.size.height; - let def_x = right_x - window_width - margin; - let def_y = top_y - window_height - margin; - ( - config.overlay_custom_x.unwrap_or(def_x), - config.overlay_custom_y.unwrap_or(def_y), - ) - } - }; - let (x, y) = clamp_overlay_position( - visible_frame, - window_width, - window_height, - margin, - raw_x, - raw_y, - ); - - let frame = CGRect { - origin: CGPoint { x, y }, - size: CGSize { - width: window_width, - height: window_height, - }, - }; - - // Create borderless window for modern look - let window: Id = msg_send![ns_window, alloc]; - if window.is_null() { - warn!("Failed to alloc NSWindow"); - return; - } - - // Borderless + FullSizeContentView for true vibrancy effect; Resizable - // gives decision mode a native drag affordance without changing chrome. - let style_mask = NSWindowStyleMask::Borderless - | NSWindowStyleMask::FullSizeContentView - | NSWindowStyleMask::Resizable; - let backing = NSBackingStoreType::Buffered; - let window: Id = msg_send![ - window, - initWithContentRect: frame - styleMask: style_mask - backing: backing - defer: false - ]; - if window.is_null() { - warn!("Failed to init NSWindow"); - return; - } - - // Configure window for floating overlay - let _: () = msg_send![window, setOpaque: false]; - let clear_color: Id = msg_send![ns_color, clearColor]; - let _: () = msg_send![window, setBackgroundColor: clear_color]; - let _: () = msg_send![window, setLevel: NS_FLOATING_WINDOW_LEVEL]; - let _: () = msg_send![window, setMovableByWindowBackground: true]; - let _: () = msg_send![window, setHasShadow: true]; - let _: () = msg_send![window, setReleasedWhenClosed: false]; - let _: () = msg_send![ - window, - setMinSize: CGSize::new(OVERLAY_WINDOW_MIN_WIDTH, OVERLAY_WINDOW_MIN_HEIGHT) - ]; - let _: () = - msg_send![window, setMaxSize: CGSize::new(OVERLAY_WINDOW_MAX_WIDTH, max_height)]; - - // Join all spaces (follow focus) - // Make sure the overlay shows up even when the user is in a fullscreen Space. - let collection_behavior = NSWindowCollectionBehavior::CanJoinAllSpaces - | NSWindowCollectionBehavior::FullScreenAuxiliary; - let _: () = msg_send![window, setCollectionBehavior: collection_behavior]; - - // Get content view - let window_content_view: Id = msg_send![window, contentView]; - if window_content_view.is_null() { - warn!("Failed to get content view"); - return; - } - - // === Tahoe Liquid Glass blur === - let blur_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(window_width, window_height), - ); - let blur_view: Id = create_glass_effect_view_with( - blur_frame, - NSVisualEffectMaterial::HUDWindow, - NSVisualEffectBlendingMode::BehindWindow, - NSVisualEffectState::Active, - ); - let layer: Id = msg_send![blur_view, layer]; - if !layer.is_null() { - let _: () = msg_send![layer, setCornerRadius: corner_radius]; - let _: () = msg_send![layer, setMasksToBounds: true]; - let border = ui_colors::overlay_sheet_border(); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![layer, setBorderColor: cg_border]; - let _: () = msg_send![layer, setBorderWidth: 1.0f64]; - } - let _: () = - msg_send![blur_view, setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE]; - - // Add blur view as background, then mount overlay controls via glass `contentView`. - add_subview(window_content_view, blur_view); - let content_view: Id = msg_send![Class::get("NSView").unwrap(), alloc]; - let content_view: Id = msg_send![content_view, initWithFrame: blur_frame]; - let _: () = msg_send![content_view, setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE]; - let _: bool = set_glass_effect_content_view(blur_view, content_view); - - let _: () = msg_send![window, setTitle: ns_string(OVERLAY_HEADER_LABEL)]; - - let padding = OVERLAY_PADDING; - let button_height = OVERLAY_BUTTON_HEIGHT; - let initial_layout = compute_overlay_layout_metrics(0.0, window_height, max_height); - let header_y = initial_layout.target_height - OVERLAY_PADDING - OVERLAY_HEADER_HEIGHT; - let info_y = header_y - OVERLAY_HEADER_GAP - OVERLAY_INFO_HEIGHT; - let spinner_size = 14.0; - let spinner_x = window_width - OVERLAY_PADDING - spinner_size; - let status_gap = 6.0; - let status_max_x = spinner_x - status_gap; - let status_width = OVERLAY_STATUS_WIDTH.min((status_max_x - OVERLAY_PADDING).max(80.0)); - let status_x = (status_max_x - status_width).max(OVERLAY_PADDING); - let header_width = (status_x - OVERLAY_CONTENT_GAP - OVERLAY_PADDING).max(120.0); - - let header_label = create_label(crate::ui_helpers::LabelConfig { - frame: CGRect::new( - &CGPoint::new(OVERLAY_PADDING, header_y), - &CGSize::new(header_width, OVERLAY_HEADER_HEIGHT), - ), - text: OVERLAY_HEADER_LABEL.to_string(), - font_size: ui_tokens::BODY_FONT_SIZE, - bold: true, - text_color: ui_colors::overlay_text(), - background_color: None, - selectable: false, - editable: false, - }); - let _: () = msg_send![header_label, setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MIN_Y_MARGIN]; - add_subview(content_view, header_label); - - let status_field = create_label(crate::ui_helpers::LabelConfig { - frame: CGRect::new( - &CGPoint::new(status_x, header_y), - &CGSize::new(status_width, OVERLAY_STATUS_HEIGHT), - ), - text: "Idle".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: ui_colors::overlay_hint_text(), - background_color: None, - selectable: false, - editable: false, - }); - let _: () = msg_send![status_field, setAlignment: 2_isize]; - let _: () = - msg_send![status_field, setAutoresizingMask: NSVIEW_MAX_X_MARGIN | NSVIEW_MIN_Y_MARGIN]; - add_subview(content_view, status_field); - - let auto_hide_label = create_label(crate::ui_helpers::LabelConfig { - frame: CGRect::new( - &CGPoint::new(OVERLAY_PADDING, info_y), - &CGSize::new(window_width - OVERLAY_PADDING * 2.0, OVERLAY_INFO_HEIGHT), - ), - text: decision_hint_text( - TranscriptionActionContractMode::Raw, - FormatPhase::Idle, - "", - true, - ), - font_size: ui_tokens::MICRO_FONT_SIZE, - bold: false, - text_color: ui_colors::overlay_hint_text(), - background_color: None, - selectable: false, - editable: false, - }); - let _: () = msg_send![ - auto_hide_label, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(content_view, auto_hide_label); - set_hidden(auto_hide_label, true); - - let spinner_frame = CGRect::new( - &CGPoint::new( - spinner_x, - header_y + ((OVERLAY_HEADER_HEIGHT - spinner_size) / 2.0).max(0.0), - ), - &CGSize::new(spinner_size, spinner_size), - ); - let spinner: Id = msg_send![ns_progress, alloc]; - let spinner: Id = msg_send![spinner, initWithFrame: spinner_frame]; - let _: () = - msg_send![spinner, setAutoresizingMask: NSVIEW_MAX_X_MARGIN | NSVIEW_MIN_Y_MARGIN]; - let _: () = msg_send![spinner, setStyle: NS_PROGRESS_INDICATOR_STYLE_SPINNING]; - let _: () = msg_send![spinner, setIndeterminate: true]; - let _: () = msg_send![spinner, setDisplayedWhenStopped: false]; - add_subview(content_view, spinner); - set_hidden(spinner, true); - - // === Scrollable text view for transcription (main area) === - let text_frame = CGRect::new( - &CGPoint::new(OVERLAY_PADDING, overlay_bottom_reserved_height()), - &CGSize::new( - (window_width - OVERLAY_PADDING * 2.0).max(120.0), - initial_layout.text_viewport_height, - ), - ); - let (text_scroll_view, text_view) = create_scrollable_text_view(text_frame, false); - let _: () = msg_send![ - text_scroll_view, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let ns_font_class = Class::get("NSFont").unwrap(); - let system_font: Id = msg_send![ns_font_class, systemFontOfSize: 14.0f64]; - let _: () = msg_send![text_view, setFont: system_font]; - let text_color = ui_colors::overlay_text(); - let _: () = msg_send![text_view, setTextColor: text_color]; - let _: () = msg_send![text_view, setRichText: false]; - let _: () = - msg_send![text_view, setMinSize: CGSize::new(0.0, initial_layout.text_viewport_height)]; - let _: () = msg_send![ - text_view, - setMaxSize: CGSize::new((window_width - OVERLAY_PADDING * 2.0).max(120.0), f64::MAX) - ]; - let container: Id = msg_send![text_view, textContainer]; - if !container.is_null() { - let _: () = msg_send![container, setLineFragmentPadding: 0.0f64]; - } - set_text_view_string(text_view, ""); - add_subview(content_view, text_scroll_view); - - // Create action handler instance - let handler_class = action_handler_class(); - let action_handler: Id = msg_send![handler_class, alloc]; - let action_handler: Id = msg_send![action_handler, init]; - let _: () = msg_send![text_view, setDelegate: action_handler]; - - // Track hover on the overlay (show actions only on hover in decision mode) - let tracking_opts = NSTRACKING_MOUSE_ENTERED_AND_EXITED - | NSTRACKING_ACTIVE_ALWAYS - | NSTRACKING_IN_VISIBLE_RECT; - let tracking_area: Id = msg_send![ns_tracking_area, alloc]; - let tracking_area: Id = msg_send![ - tracking_area, - initWithRect: blur_frame - options: tracking_opts - owner: action_handler - userInfo: std::ptr::null::() - ]; - let _: () = msg_send![content_view, addTrackingArea: tracking_area]; - - // === Decision buttons (hidden during recording; show on hover) === - let button_width = 100.0; - let button_gap = 10.0; - let row_width = button_width * 3.0 + button_gap * 2.0; - let row_x = (window_width - row_width) / 2.0; - let commit_x = (window_width - button_width) / 2.0; - - let save_frame = CGRect { - origin: CGPoint { - x: row_x, - y: padding, - }, - size: CGSize { - width: button_width, - height: button_height, - }, - }; - let copy_frame = CGRect { - origin: CGPoint { - x: row_x + button_width + button_gap, - y: padding, - }, - size: CGSize { - width: button_width, - height: button_height, - }, - }; - let augment_frame = CGRect { - origin: CGPoint { - x: row_x + (button_width + button_gap) * 2.0, - y: padding, - }, - size: CGSize { - width: button_width, - height: button_height, - }, - }; - let commit_frame = CGRect { - origin: CGPoint { - x: commit_x, - y: padding, - }, - size: CGSize { - width: button_width, - height: button_height, - }, - }; - - // Decision-mode action contract (ADR 2026-05-28 Faza 1): one hands-off - // recording → three post-recording actions. [Format] polishes via AI + pastes, - // [Copy] copies the transcript, [Agent] hands the whole session to Emil. - // ROUNDED (not GLASS): Format is an active peer action alongside Copy/Agent. - // GLASS rendered translucent → read as disabled/"wyszarzony" by the operator. - let format_button = create_button(save_frame, "Format", button_style::ROUNDED); - let copy_button = create_button(copy_frame, "Copy", button_style::ROUNDED); - let agent_button = create_button(augment_frame, "Agent", button_style::ROUNDED); - let commit_button = create_button(commit_frame, "Finish", button_style::GLASS); - let button_autoresize = NSVIEW_MAX_Y_MARGIN; - let _: () = msg_send![format_button, setAutoresizingMask: button_autoresize]; - let _: () = msg_send![copy_button, setAutoresizingMask: button_autoresize]; - let _: () = msg_send![agent_button, setAutoresizingMask: button_autoresize]; - let _: () = msg_send![commit_button, setAutoresizingMask: button_autoresize]; - set_tooltip( - copy_button, - copy_action_tooltip(TranscriptionActionContractMode::Raw), - ); - set_tooltip( - agent_button, - augment_action_tooltip(TranscriptionActionContractMode::Raw, FormatPhase::Idle), - ); - set_tooltip( - format_button, - "Format the transcript with AI in the overlay", - ); - set_tooltip(commit_button, "Stop recording and enter decision mode"); - - button_set_action( - format_button, - action_handler, - overlay_button_selector(OverlayActionButtonRole::FormatPaste, FormatPhase::Idle), - ); - button_set_action( - copy_button, - action_handler, - overlay_button_selector(OverlayActionButtonRole::Copy, FormatPhase::Idle), - ); - button_set_action( - agent_button, - action_handler, - overlay_button_selector(OverlayActionButtonRole::AgentClose, FormatPhase::Idle), - ); - button_set_action( - commit_button, - action_handler, - overlay_button_selector(OverlayActionButtonRole::Finish, FormatPhase::Idle), - ); - - add_subview(content_view, format_button); - add_subview(content_view, copy_button); - add_subview(content_view, agent_button); - add_subview(content_view, commit_button); - - set_hidden(format_button, true); - set_hidden(copy_button, true); - set_hidden(agent_button, true); - set_hidden(commit_button, true); - - // Show the window with fade-in animation - window_set_alpha(window, 0.0); - window_show(window); - animate_fade(window, 1.0, 0.2); - - // Re-acquire lock to store widget pointers (quick write, no AppKit calls). - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - // Guard: if another path filled window while we were creating, abandon ours. - if state.window.is_some() { - drop(state); - warn!("Overlay window created concurrently; discarding duplicate"); - window_discard(window); - return; - } - state.window = Some(window as usize); - state.header_label = Some(header_label as usize); - state.text_scroll_view = Some(text_scroll_view as usize); - state.text_view = Some(text_view as usize); - state.status_field = Some(status_field as usize); - state.auto_hide_label = Some(auto_hide_label as usize); - state.blur_view = Some(blur_view as usize); - state.copy_button = Some(copy_button as usize); - state.augment_button = Some(agent_button as usize); - state.save_button = Some(format_button as usize); - state.commit_button = Some(commit_button as usize); - state.progress_indicator = Some(spinner as usize); - state.tracking_area = Some(tracking_area as usize); - state.decision_mode = false; - state.hover_active = false; - state.action_handler = Some(action_handler as usize); - state.min_height = window_height; - state.max_height = max_height; - state.last_applied_height = window_height; - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - - // DEADLOCK PREVENTION: snapshot + drop before AppKit layout calls. - let snap = OverlaySnapshot::from_state(&state); - drop(state); - - refresh_action_contract_ui_unlocked(&snap, TranscriptionActionContractMode::Raw, false); - reset_overlay_to_idle_unlocked(&snap); - let new_h = resize_overlay_unlocked(&snap); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_applied_height = new_h; - state.last_layout_resize_at = Instant::now(); - state.pending_layout_resize = false; - } - - info!("Transcription overlay shown (Tahoe-style with HudWindow vibrancy)"); - } -} diff --git a/app/ui/settings/actions.rs b/app/ui/settings/actions.rs deleted file mode 100644 index d9163b3c..00000000 --- a/app/ui/settings/actions.rs +++ /dev/null @@ -1,856 +0,0 @@ -//! `extern "C"` action trampolines registered on the settings action handler class. - -use super::*; - -pub(super) extern "C" fn on_mode_binding_change( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let tag: isize = msg_send![sender, tag]; - if mode_from_double_ctrl_tag(tag) { - apply_mode_binding(WorkMode::Dictation, ShortcutBinding::DoubleCtrl); - return; - } - if let Some(mode) = mode_from_disable_tag(tag) { - apply_mode_binding(mode, ShortcutBinding::Disabled); - return; - } - if let Some(mode) = mode_from_tag(tag) { - start_mode_binding_recorder(mode); - } - } -} - -pub(super) extern "C" fn on_show_hotkey_conflicts( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - show_hotkey_conflicts_sheet(); -} - -pub(super) extern "C" fn on_language_changed(_this: &Object, _cmd: objc::runtime::Sel, sender: Id) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let idx: isize = msg_send![sender, indexOfSelectedItem]; - let lang = match idx { - 0 => "pl", - 1 => "en", - _ => "pl", - }; - info!("Settings: language -> {}", lang); - let config = Config::load(); - let _ = config.save_to_env("WHISPER_LANGUAGE", lang); - } -} - -pub(super) extern "C" fn on_formatting_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: AI formatting -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env("AI_FORMATTING_ENABLED", if enabled { "1" } else { "0" }); - } -} - -pub(super) extern "C" fn on_transcript_tagging_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: transcript tagging -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env( - "TRANSCRIPT_TAGGING_ENABLED", - if enabled { "1" } else { "0" }, - ); - } -} - -pub(super) extern "C" fn on_formatting_level_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let idx: isize = msg_send![sender, indexOfSelectedItem]; - let level = match idx { - 0 => "raw", - 1 => "medium", - 2 => "creative", - _ => "medium", - }; - info!("Settings: Formatting level -> {}", level); - let config = Config::load(); - let _ = config.save_to_env("FORMATTING_LEVEL", level); - } -} - -pub(super) extern "C" fn on_llm_endpoint_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr).to_string_lossy().to_string(); - info!("Settings: formatting endpoint -> {}", value); - let config = Config::load(); - let _ = config.save_to_env("LLM_FORMATTING_ENDPOINT", &value); - } -} - -pub(super) extern "C" fn on_llm_model_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr).to_string_lossy().to_string(); - info!("Settings: formatting model -> {}", value); - let config = Config::load(); - let _ = config.save_to_env("LLM_FORMATTING_MODEL", &value); - } -} - -pub(super) extern "C" fn on_llm_key_changed(_this: &Object, _cmd: objc::runtime::Sel, sender: Id) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr).to_string_lossy().to_string(); - if !value.is_empty() { - info!("Settings: formatting API key updated (stored in Keychain)"); - let config = Config::load(); - let _ = config.save_to_env("LLM_FORMATTING_API_KEY", &value); - update_keychain_status_labels(); - } - } -} - -pub(super) extern "C" fn on_clear_llm_key(_this: &Object, _cmd: objc::runtime::Sel, _sender: Id) { - let field_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.llm_key_field - }; - clear_keychain_entry("LLM_FORMATTING_API_KEY", field_ptr); -} - -pub(super) extern "C" fn on_save_api_settings( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - let (llm_endpoint, llm_model, llm_key, assist_endpoint, assist_model, assist_key) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.llm_endpoint_field, - state.llm_model_field, - state.llm_key_field, - state.assistive_endpoint_field, - state.assistive_model_field, - state.assistive_key_field, - ) - }; - - let mut entries: Vec<(&str, String)> = Vec::new(); - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - if let Some(ptr) = llm_endpoint { - let value = crate::ui_helpers::get_text_field_string(ptr as Id); - entries.push(("LLM_FORMATTING_ENDPOINT", value.trim().to_string())); - } - if let Some(ptr) = llm_model { - let value = crate::ui_helpers::get_text_field_string(ptr as Id); - entries.push(("LLM_FORMATTING_MODEL", value.trim().to_string())); - } - if let Some(ptr) = llm_key { - let value = crate::ui_helpers::get_text_field_string(ptr as Id); - if !value.trim().is_empty() { - entries.push(("LLM_FORMATTING_API_KEY", value.trim().to_string())); - } - } - if let Some(ptr) = assist_endpoint { - let value = crate::ui_helpers::get_text_field_string(ptr as Id); - entries.push(("LLM_ASSISTIVE_ENDPOINT", value.trim().to_string())); - } - if let Some(ptr) = assist_model { - let value = crate::ui_helpers::get_text_field_string(ptr as Id); - entries.push(("LLM_ASSISTIVE_MODEL", value.trim().to_string())); - } - if let Some(ptr) = assist_key { - let value = crate::ui_helpers::get_text_field_string(ptr as Id); - if !value.trim().is_empty() { - entries.push(("LLM_ASSISTIVE_API_KEY", value.trim().to_string())); - } - } - } - if !entries.is_empty() { - let config = Config::load(); - let borrowed: Vec<(&str, &str)> = entries.iter().map(|(k, v)| (*k, v.as_str())).collect(); - let _ = config.save_to_env_many(&borrowed); - } - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - if let Some(ptr) = llm_key { - set_text_field_string(ptr as Id, ""); - } - if let Some(ptr) = assist_key { - set_text_field_string(ptr as Id, ""); - } - } - update_keychain_status_labels(); - info!("Settings: API settings saved"); -} - -pub(super) extern "C" fn on_prompt_type_changed( - this: &Object, - cmd: objc::runtime::Sel, - sender: Id, -) { - refresh_prompt_editor_labels(); - on_prompt_load(this, cmd, sender); -} - -pub(super) extern "C" fn on_prompt_load(_this: &Object, _cmd: objc::runtime::Sel, _sender: Id) { - let prompt_type = selected_prompt_type(); - match load_prompt_content(prompt_type) { - Ok(content) => { - set_prompt_editor_content(&content); - set_prompt_editor_status( - &format!("{} prompt loaded.", prompt_display_name(prompt_type)), - false, - ); - } - Err(err) => { - set_prompt_editor_status(&format!("Failed to load prompt: {err}"), true); - } - } - refresh_prompt_editor_labels(); -} - -pub(super) extern "C" fn on_prompt_save(_this: &Object, _cmd: objc::runtime::Sel, _sender: Id) { - let prompt_type = selected_prompt_type(); - let content = read_prompt_editor_content(); - if content.trim().is_empty() { - set_prompt_editor_status("Prompt is empty. Add content before saving.", true); - return; - } - - match save_prompt_content(prompt_type, &content) { - Ok(()) => { - set_prompt_editor_status( - &format!("{} prompt saved.", prompt_display_name(prompt_type)), - false, - ); - } - Err(err) => { - set_prompt_editor_status(&format!("Failed to save prompt: {err}"), true); - } - } - refresh_prompt_editor_labels(); -} - -pub(super) extern "C" fn on_prompt_reset(_this: &Object, _cmd: objc::runtime::Sel, _sender: Id) { - let prompt_type = selected_prompt_type(); - match reset_prompt_content(prompt_type) { - Ok(()) => match load_prompt_content(prompt_type) { - Ok(content) => { - set_prompt_editor_content(&content); - set_prompt_editor_status( - &format!( - "{} prompt reset to default.", - prompt_display_name(prompt_type) - ), - false, - ); - } - Err(err) => { - set_prompt_editor_status(&format!("Prompt reset but reload failed: {err}"), true); - } - }, - Err(err) => { - set_prompt_editor_status(&format!("Failed to reset prompt: {err}"), true); - } - } - refresh_prompt_editor_labels(); -} - -pub(super) extern "C" fn on_quality_refresh(_this: &Object, _cmd: objc::runtime::Sel, _sender: Id) { - refresh_quality_dashboard(); -} - -pub(super) extern "C" fn on_open_qube_report( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - if !crate::qube_daemon::open_latest_report() { - warn!("Settings: no quality report available"); - } - refresh_quality_dashboard(); -} - -pub(super) extern "C" fn on_diagnostics_refresh( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - refresh_permission_indicators(); - refresh_diagnostics_dashboard(); -} - -pub(super) extern "C" fn on_copy_diagnostics( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - let report = crate::os::permissions::diagnostics_report(); - let (status_ptr, secondary) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.diagnostics_status_label, - crate::ui_helpers::color_secondary_label(), - ) - }; - match crate::os::clipboard::set_clipboard(&report) { - Ok(()) => { - if let Some(ptr) = status_ptr { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let label = ptr as Id; - set_text_field_string(label, "Diagnostics copied to clipboard."); - let _: () = msg_send![label, setTextColor: secondary]; - } - } - } - Err(err) => { - if let Some(ptr) = status_ptr { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let label = ptr as Id; - set_text_field_string(label, &format!("Failed to copy diagnostics: {err}")); - let _: () = msg_send![label, setTextColor: ui_colors::bubble_error_text()]; - } - } - } - } -} - -pub(super) extern "C" fn on_delay_changed(_this: &Object, _cmd: objc::runtime::Sel, sender: Id) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let value: f64 = msg_send![sender, doubleValue]; - let ms = value.round() as u64; - info!("Settings: hold delay -> {}ms", ms); - let config = Config::load(); - let _ = config.save_to_env("HOLD_START_DELAY_MS", &ms.to_string()); - let mut runtime_config = hotkeys::HotkeyRuntimeConfig::from(&config); - runtime_config.hold_start_delay_ms = ms; - hotkeys::apply_hotkey_runtime_config(runtime_config); - let label_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.hold_delay_value_label - }; - if let Some(ptr) = label_ptr { - set_text_field_string(ptr as Id, &format!("{ms} ms")); - } - sync_runtime_config_via_ipc(); - } -} - -pub(super) extern "C" fn on_double_tap_interval_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let value: f64 = msg_send![sender, doubleValue]; - let ms = value.round() as u64; - info!("Settings: double-tap interval -> {}ms", ms); - let config = Config::load(); - let _ = config.save_to_env("DOUBLE_TAP_INTERVAL_MS", &ms.to_string()); - let mut runtime_config = hotkeys::HotkeyRuntimeConfig::from(&config); - runtime_config.double_tap_interval_ms = ms; - hotkeys::apply_hotkey_runtime_config(runtime_config); - let label_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.double_tap_value_label - }; - if let Some(ptr) = label_ptr { - set_text_field_string(ptr as Id, &format!("{ms} ms")); - } - sync_runtime_config_via_ipc(); - } -} - -pub(super) extern "C" fn on_beep_toggled(_this: &Object, _cmd: objc::runtime::Sel, sender: Id) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: beep on start -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env("BEEP_ON_START", if enabled { "1" } else { "0" }); - sync_runtime_config_via_ipc(); - } -} - -pub(super) extern "C" fn on_enter_send_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: agent enter sends -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env("AGENT_ENTER_SENDS", if enabled { "1" } else { "0" }); - } -} - -pub(super) extern "C" fn on_show_dock_icon_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: show dock icon -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env("SHOW_DOCK_ICON", if enabled { "1" } else { "0" }); - crate::apply_dock_icon_visibility(enabled); - } -} - -pub(super) extern "C" fn on_transcription_overlay_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: transcription overlay -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env( - "TRANSCRIPTION_OVERLAY_ENABLED", - if enabled { "1" } else { "0" }, - ); - sync_runtime_config_via_ipc(); - if !enabled { - crate::ui::overlay::hide_transcription_overlay(); - } - refresh_transcription_preview_panel(); - } -} - -pub(super) extern "C" fn on_preview_preset_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let selected: isize = msg_send![sender, selectedSegment]; - let preset = PreviewTimingPreset::from_segment_index(selected); - { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.preview_advanced_expanded = matches!(preset, PreviewTimingPreset::Custom); - state.preview_timing_forced_custom = matches!(preset, PreviewTimingPreset::Custom); - } - - if matches!(preset, PreviewTimingPreset::Custom) { - refresh_preview_advanced_visibility(); - refresh_transcription_preview_panel(); - return; - } - - let config = Config::load(); - if matches!(preset, PreviewTimingPreset::Off) { - info!("Settings: preview preset -> Off"); - if let Err(err) = config.save_to_env("TRANSCRIPTION_OVERLAY_ENABLED", "0") { - warn!("Settings: failed to save preview preset Off: {err}"); - } - } else if let Some(values) = preset_values(preset) { - info!("Settings: preview preset -> {:?}", preset); - let entries: Vec<(&str, String)> = vec![ - ("TRANSCRIPTION_OVERLAY_ENABLED", "1".to_string()), - ( - "CODESCRIBE_BUFFER_DELAY_MS", - values.buffer_delay_ms.to_string(), - ), - ("CODESCRIBE_TYPING_CPS", format!("{:.1}", values.typing_cps)), - ( - "CODESCRIBE_EMIT_WORDS_MAX", - values.emit_words_max.to_string(), - ), - ( - "CODESCRIBE_BUFFERED_INTERIM_SEC", - format!("{:.1}", values.interim_sec), - ), - ]; - let borrowed: Vec<(&str, &str)> = entries - .iter() - .map(|(key, value)| (*key, value.as_str())) - .collect(); - if let Err(err) = config.save_to_env_many(&borrowed) { - warn!( - "Settings: failed to save preview preset {:?}: {err}", - preset - ); - } - } - - sync_runtime_config_via_ipc(); - refresh_transcription_preview_panel(); - } -} - -pub(super) extern "C" fn on_preview_advanced_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.preview_advanced_expanded = !state.preview_advanced_expanded; - } - refresh_preview_advanced_visibility(); -} - -fn mark_preview_timing_custom() { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.preview_advanced_expanded = true; - state.preview_timing_forced_custom = true; -} - -pub(super) extern "C" fn on_preview_buffer_delay_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let value: f64 = msg_send![sender, doubleValue]; - let ms = value.round() as u64; - info!("Settings: preview buffer delay -> {}ms", ms); - let config = Config::load(); - let _ = config.save_to_env("CODESCRIBE_BUFFER_DELAY_MS", &ms.to_string()); - sync_runtime_config_via_ipc(); - mark_preview_timing_custom(); - refresh_transcription_preview_panel(); - } -} - -pub(super) extern "C" fn on_preview_typing_cps_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let value: f64 = msg_send![sender, doubleValue]; - let cps = value.max(5.0) as f32; - info!("Settings: preview typing cps -> {:.1}", cps); - let config = Config::load(); - let _ = config.save_to_env("CODESCRIBE_TYPING_CPS", &format!("{cps:.1}")); - sync_runtime_config_via_ipc(); - mark_preview_timing_custom(); - refresh_transcription_preview_panel(); - } -} - -pub(super) extern "C" fn on_preview_emit_words_max_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let value: f64 = msg_send![sender, doubleValue]; - let words = value.round().clamp(1.0, 10.0) as u64; - info!("Settings: preview emit words max -> {}", words); - let config = Config::load(); - let _ = config.save_to_env("CODESCRIBE_EMIT_WORDS_MAX", &words.to_string()); - sync_runtime_config_via_ipc(); - mark_preview_timing_custom(); - refresh_transcription_preview_panel(); - } -} - -pub(super) extern "C" fn on_preview_interim_cadence_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let value: f64 = msg_send![sender, doubleValue]; - let secs = value.clamp(1.0, 12.0) as f32; - info!("Settings: preview interim cadence -> {:.1}s", secs); - let config = Config::load(); - let _ = config.save_to_env("CODESCRIBE_BUFFERED_INTERIM_SEC", &format!("{secs:.1}")); - sync_runtime_config_via_ipc(); - mark_preview_timing_custom(); - refresh_transcription_preview_panel(); - } -} - -pub(super) extern "C" fn on_stt_provider_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let selected_idx: isize = msg_send![sender, indexOfSelectedItem]; - let use_local_stt = selected_idx == 0; - info!( - "Settings: final transcript path -> {}", - if use_local_stt { "local" } else { "cloud" } - ); - let config = Config::load(); - let _ = config.save_to_env("USE_LOCAL_STT", if use_local_stt { "1" } else { "0" }); - sync_runtime_config_via_ipc(); - } -} - -pub(super) extern "C" fn on_stt_endpoint_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr) - .to_string_lossy() - .trim() - .to_string(); - info!("Settings: STT endpoint -> {}", value); - let config = Config::load(); - let _ = config.save_to_env("STT_ENDPOINT", &value); - sync_runtime_config_via_ipc(); - } -} - -pub(super) extern "C" fn on_stt_key_changed(_this: &Object, _cmd: objc::runtime::Sel, sender: Id) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr) - .to_string_lossy() - .trim() - .to_string(); - if value.is_empty() { - info!("Settings: clearing cloud STT API key from Keychain"); - if let Err(e) = keychain::delete_key("STT_API_KEY") { - warn!("Failed to delete STT_API_KEY from Keychain: {e}"); - } - std::env::remove_var("STT_API_KEY"); - } else { - info!("Settings: cloud STT API key updated (stored in Keychain)"); - let config = Config::load(); - let _ = config.save_to_env("STT_API_KEY", &value); - } - sync_runtime_config_via_ipc(); - } -} - -pub(super) extern "C" fn on_volume_changed(_this: &Object, _cmd: objc::runtime::Sel, sender: Id) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let value: f64 = msg_send![sender, doubleValue]; - info!("Settings: sound volume -> {:.2}", value); - let config = Config::load(); - let _ = config.save_to_env("SOUND_VOLUME", &format!("{:.2}", value)); - sync_runtime_config_via_ipc(); - } -} - -// ============================================================================ -// Assistive AI + Quality daemon + Permissions handlers -// ============================================================================ - -pub(super) extern "C" fn on_assistive_endpoint_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr).to_string_lossy().to_string(); - info!("Settings: assistive endpoint -> {}", value); - let config = Config::load(); - let _ = config.save_to_env("LLM_ASSISTIVE_ENDPOINT", &value); - } -} - -pub(super) extern "C" fn on_assistive_model_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr).to_string_lossy().to_string(); - info!("Settings: assistive model -> {}", value); - let config = Config::load(); - let _ = config.save_to_env("LLM_ASSISTIVE_MODEL", &value); - } -} - -pub(super) extern "C" fn on_assistive_key_changed( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_val: Id = msg_send![sender, stringValue]; - let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; - let value = std::ffi::CStr::from_ptr(cstr).to_string_lossy().to_string(); - if !value.is_empty() { - info!("Settings: assistive API key updated (stored in Keychain)"); - let config = Config::load(); - let _ = config.save_to_env("LLM_ASSISTIVE_API_KEY", &value); - update_keychain_status_labels(); - } - } -} - -pub(super) extern "C" fn on_clear_assistive_key( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - let field_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.assistive_key_field - }; - clear_keychain_entry("LLM_ASSISTIVE_API_KEY", field_ptr); -} - -pub(super) extern "C" fn on_qube_daemon_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: quality daemon autostart -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env("QUBE_DAEMON_AUTOSTART", if enabled { "1" } else { "0" }); - if enabled { - let _ = crate::qube_lifecycle::start_managed(); - } else { - let _ = crate::qube_lifecycle::stop_managed(); - } - refresh_quality_dashboard(); - crate::ui::tray::update_quality_label(); - } -} - -pub(super) extern "C" fn on_ultra_quality_toggled( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let state: isize = msg_send![sender, state]; - let enabled = state == 1; - info!("Settings: ultra quality final pass -> {}", enabled); - let config = Config::load(); - let _ = config.save_to_env( - "CODESCRIBE_LOCAL_STT_FINAL_PASS", - if enabled { "1" } else { "0" }, - ); - refresh_quality_dashboard(); - } -} - -pub(super) extern "C" fn on_permission_action( - _this: &Object, - _cmd: objc::runtime::Sel, - sender: Id, -) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let tag: isize = msg_send![sender, tag]; - if let Some(kind) = permission_kind_from_tag(tag) { - info!("Settings: permission action for {:?}", kind); - handle_permission_action(kind); - } - } -} - -pub(super) extern "C" fn on_open_system_settings( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - info!("Settings: opening System Settings"); - open_system_settings_security(); -} - -pub(super) extern "C" fn on_refresh_permissions( - _this: &Object, - _cmd: objc::runtime::Sel, - _sender: Id, -) { - info!("Settings: refreshing permission indicators"); - refresh_permission_indicators(); -} diff --git a/app/ui/settings/ai_prompts_tab.rs b/app/ui/settings/ai_prompts_tab.rs deleted file mode 100644 index 2ca5dd1c..00000000 --- a/app/ui/settings/ai_prompts_tab.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! AppKit builder for the AI & Prompts settings tab. - -use super::*; - -pub(super) unsafe fn build_ai_prompts_tab( - action_handler: Id, - frame: core_graphics::geometry::CGRect, - _config: &Config, - state: &mut SettingsWindowState, -) -> Id { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_view = objc_class("NSView"); - let ns_popup = objc_class("NSPopUpButton"); - let container: Id = msg_send![ns_view, alloc]; - let container: Id = msg_send![container, initWithFrame: frame]; - - let pad = ui_tokens::EDGE_PADDING; - let content_w = frame.size.width - pad * 2.0; - let gap = ui_tokens::DENSITY_COMFORTABLE; - let mut y = frame.size.height - (24.0 + gap); - let primary = crate::ui_helpers::color_label(); - let secondary = crate::ui_helpers::color_secondary_label(); - let title = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 24.0)), - text: "User".to_string(), - font_size: ui_tokens::TITLE_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, title); - y -= 24.0 + gap; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= gap; - - let subtitle = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Slow-moving user choices, AI formatting preference, and prompt editing." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, subtitle); - y -= 16.0 + gap; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 198.0); - let user_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "User Toggles".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, user_header); - y -= 18.0 + gap; - - let _dock_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "Show Dock icon", - checked: _config.show_dock_icon, - action: sel!(onShowDockIconToggled:), - description: Some("Keep CodeScribe in the Dock after windows close."), - tag: None, - gap, - }, - ); - - let quality_on = UserSettings::load() - .qube_daemon_autostart - .unwrap_or_else(|| { - std::env::var("QUBE_DAEMON_AUTOSTART") - .map(|v| parse_env_bool(&v)) - .unwrap_or(false) - }); - let quality_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "Start quality daemon automatically", - checked: quality_on, - action: sel!(onQubeDaemonToggled:), - description: Some( - "Starts bundled `qube-daemon --daemon` immediately and on next CodeScribe launch when the binary is installed.", - ), - tag: None, - gap, - }, - ); - state.qube_daemon_checkbox = Some(quality_check as usize); - - let ultra_on = std::env::var("CODESCRIBE_LOCAL_STT_FINAL_PASS") - .map(|v| parse_env_bool(&v)) - .unwrap_or(false); - let ultra_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "Local file-based final pass", - checked: ultra_on, - action: sel!(onUltraQualityToggled:), - description: Some( - "Re-runs local Whisper on saved audio after capture ends to strengthen or downgrade the committed verdict.", - ), - tag: None, - gap, - }, - ); - state.ultra_quality_checkbox = Some(ultra_check as usize); - - let _tagging_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "Tag transcripts for AI agents", - checked: _config.transcript_tagging_enabled, - action: sel!(onTranscriptTaggingToggled:), - description: Some( - "Pastes speech as ... for agent-aware dictated input.", - ), - tag: None, - gap, - }, - ); - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 104.0); - let fmt_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Formatting AI (optional)".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: secondary, - ..Default::default() - }); - add_subview(container, fmt_header); - y -= 18.0 + gap; - - let _fmt_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "AI Formatting", - checked: _config.ai_formatting_enabled, - action: sel!(onFormattingToggled:), - description: Some( - "Uses the formatting model to clean up the committed transcript; raw transcript is preserved on fallback.", - ), - tag: None, - gap, - }, - ); - - let fmt_level_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(120.0, 18.0)), - text: "Formatting level:".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, fmt_level_label); - - let fmt_popup: Id = msg_send![ns_popup, alloc]; - let fmt_popup: Id = msg_send![fmt_popup, initWithFrame: - CGRect::new(&CGPoint::new(pad + 124.0, y - 2.0), &CGSize::new(240.0, 24.0)) - pullsDown: false - ]; - let _: () = msg_send![fmt_popup, addItemWithTitle: ns_string("Raw")]; - let _: () = msg_send![fmt_popup, addItemWithTitle: ns_string("Medium")]; - let _: () = msg_send![fmt_popup, addItemWithTitle: ns_string("Creative")]; - let current_level = std::env::var("FORMATTING_LEVEL").unwrap_or_default(); - let sel_idx: isize = match current_level.as_str() { - "raw" => 0, - "medium" => 1, - "creative" => 2, - _ => 1, - }; - let _: () = msg_send![fmt_popup, selectItemAtIndex: sel_idx]; - button_set_action(fmt_popup, action_handler, sel!(onFormattingLevelChanged:)); - add_subview(container, fmt_popup); - y -= 24.0 + gap; - - let section_divider = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 1.0)), - text: String::new(), - background_color: Some(ui_colors::surface_border()), - ..Default::default() - }); - let _: () = msg_send![section_divider, setAlphaValue: 0.9f64]; - add_subview(container, section_divider); - y -= ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 312.0); - let prompt_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Prompt Editor".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, prompt_header); - y -= 18.0 + gap; - - let prompt_subtitle = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: - "Primary flow is fully in-app: switch prompt type, edit, save, or reset to defaults." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, prompt_subtitle); - y -= 16.0 + gap; - - let prompt_type_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(106.0, 20.0)), - text: "Prompt type:".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, prompt_type_label); - - let prompt_type_popup: Id = msg_send![ns_popup, alloc]; - let prompt_type_popup: Id = msg_send![prompt_type_popup, initWithFrame: - CGRect::new(&CGPoint::new(pad + 108.0, y - 2.0), &CGSize::new(208.0, 24.0)) - pullsDown: false - ]; - let _: () = msg_send![prompt_type_popup, addItemWithTitle: ns_string("Formatting prompt")]; - let _: () = msg_send![prompt_type_popup, addItemWithTitle: ns_string("Assistive prompt")]; - let _: () = msg_send![prompt_type_popup, selectItemAtIndex: 0_isize]; - button_set_action( - prompt_type_popup, - action_handler, - sel!(onPromptTypeChanged:), - ); - add_subview(container, prompt_type_popup); - state.prompt_type_popup = Some(prompt_type_popup as usize); - - let reset_btn_w = 112.0; - let save_prompt_btn_w = 98.0; - let load_btn_w = 82.0; - let reset_btn_x = pad + content_w - reset_btn_w; - let save_prompt_btn_x = reset_btn_x - 8.0 - save_prompt_btn_w; - let load_btn_x = save_prompt_btn_x - 8.0 - load_btn_w; - - let load_btn = create_button( - CGRect::new( - &CGPoint::new(load_btn_x, y - 2.0), - &CGSize::new(load_btn_w, 24.0), - ), - "Load", - button_style::GLASS, - ); - button_set_action(load_btn, action_handler, sel!(onPromptLoad:)); - add_subview(container, load_btn); - - let save_prompt_btn = create_button( - CGRect::new( - &CGPoint::new(save_prompt_btn_x, y - 2.0), - &CGSize::new(save_prompt_btn_w, 24.0), - ), - "Save Prompt", - button_style::GLASS, - ); - button_set_action(save_prompt_btn, action_handler, sel!(onPromptSave:)); - add_subview(container, save_prompt_btn); - - let reset_prompt_btn = create_button( - CGRect::new( - &CGPoint::new(reset_btn_x, y - 2.0), - &CGSize::new(reset_btn_w, 24.0), - ), - "Reset Default", - button_style::GLASS, - ); - button_set_action(reset_prompt_btn, action_handler, sel!(onPromptReset:)); - add_subview(container, reset_prompt_btn); - y -= 24.0 + gap; - - let initial_type = "formatting"; - let initial_path = prompt_path_text(initial_type); - let path_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: format!("Path: {initial_path}"), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, path_label); - state.prompt_path_label = Some(path_label as usize); - y -= 16.0 + gap; - - let prompt_editor_layout = compute_prompt_editor_layout(y, gap); - let editor_height = prompt_editor_layout.editor_height; - let editor_y = prompt_editor_layout.editor_y; - let (editor_scroll, editor_text_view) = create_scrollable_text_view( - CGRect::new( - &CGPoint::new(pad, editor_y), - &CGSize::new(content_w, editor_height), - ), - true, - ); - let editor_font = crate::ui_helpers::monospace_font(ui_tokens::SMALL_FONT_SIZE); - let _: () = msg_send![editor_text_view, setFont: editor_font]; - let _: () = msg_send![editor_text_view, setRichText: false]; - let _: () = msg_send![editor_scroll, setDrawsBackground: true]; - let editor_bg = settings_input_paper_bg(); - let _: () = msg_send![editor_scroll, setBackgroundColor: editor_bg]; - add_subview(container, editor_scroll); - state.prompt_editor_text_view = Some(editor_text_view as usize); - - let initial_prompt = load_prompt_content(initial_type).unwrap_or_else(|err| { - warn!("Settings: failed to load initial prompt: {err}"); - String::new() - }); - set_text_view_string(editor_text_view, &initial_prompt); - - let prompt_status = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, prompt_editor_layout.status_y), - &CGSize::new(content_w, PROMPT_EDITOR_STATUS_HEIGHT), - ), - text: "Formatting prompt loaded.".to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, prompt_status); - state.prompt_status_label = Some(prompt_status as usize); - - container - } -} - -// ============================================================================ -// Audio & Input tab -// ============================================================================ diff --git a/app/ui/settings/audio_input_tab.rs b/app/ui/settings/audio_input_tab.rs deleted file mode 100644 index d5d0d4b3..00000000 --- a/app/ui/settings/audio_input_tab.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! AppKit builder for the Audio Input settings tab. - -use super::*; - -pub(super) unsafe fn build_audio_input_tab( - action_handler: Id, - frame: core_graphics::geometry::CGRect, - config: &Config, -) -> Id { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_view = objc_class("NSView"); - let ns_popup = objc_class("NSPopUpButton"); - - let container: Id = msg_send![ns_view, alloc]; - let container: Id = msg_send![container, initWithFrame: frame]; - - let pad = ui_tokens::EDGE_PADDING; - let content_w = frame.size.width - pad * 2.0; - let gap = ui_tokens::DENSITY_COMFORTABLE; - let mut y = frame.size.height - (24.0 + gap); - let primary = crate::ui_helpers::color_label(); - let secondary = crate::ui_helpers::color_secondary_label(); - - let title = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 24.0)), - text: "Audio".to_string(), - font_size: ui_tokens::TITLE_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, title); - y -= 24.0 + gap; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= gap; - - let subtitle = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Speech capture defaults, recorder feedback, and simple input toggles." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, subtitle); - y -= 16.0 + gap; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 284.0); - let capture_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Capture Defaults".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, capture_header); - y -= 18.0 + gap; - - let capture_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Language, capture feedback, overlay visibility, and agent send behavior." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, capture_hint); - y -= 16.0 + gap; - - // Language dropdown - let lang_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(130.0, 18.0)), - text: "Whisper language:".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, lang_label); - - let lang_popup: Id = msg_send![ns_popup, alloc]; - let lang_popup: Id = msg_send![lang_popup, initWithFrame: - CGRect::new(&CGPoint::new(pad + 134.0, y - 2.0), &CGSize::new(180.0, 24.0)) - pullsDown: false - ]; - let _: () = msg_send![lang_popup, addItemWithTitle: ns_string("Polish (pl)")]; - let _: () = msg_send![lang_popup, addItemWithTitle: ns_string("English (en)")]; - let lang_idx: isize = match config.whisper_language.as_str() { - "pl" => 0, - "en" => 1, - _ => 0, - }; - let _: () = msg_send![lang_popup, selectItemAtIndex: lang_idx]; - button_set_action(lang_popup, action_handler, sel!(onLanguageChanged:)); - add_subview(container, lang_popup); - y -= 24.0 + gap; - - // Beep on start toggle - let _beep_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "Beep on recording start", - checked: config.beep_on_start, - action: sel!(onBeepToggled:), - description: None, - tag: None, - gap, - }, - ); - // Agent: Enter to send toggle - let _enter_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "Enter to send (⌘⏎ for newline)", - checked: config.agent_enter_sends, - action: sel!(onEnterSendToggled:), - description: None, - tag: None, - gap, - }, - ); - let _overlay_check = add_toggle_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - ToggleRowSpec { - title: "Transcription overlay", - checked: config.transcription_overlay_enabled, - action: sel!(onTranscriptionOverlayToggled:), - description: Some( - "On: live floating preview with fast partials. Off: no overlay and buffered partials for lower local load.", - ), - tag: None, - gap, - }, - ); - // Sound volume slider - let vol_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(120.0, 20.0)), - text: "Sound volume:".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, vol_label); - - let vol_slider = create_slider( - CGRect::new( - &CGPoint::new(pad + 124.0, y), - &CGSize::new(content_w - 124.0, 20.0), - ), - 0.0, - 1.0, - config.sound_volume as f64, - ); - button_set_action(vol_slider, action_handler, sel!(onVolumeChanged:)); - add_subview(container, vol_slider); - - container - } // unsafe -} - -// ============================================================================ -// Transcription tab -// ============================================================================ diff --git a/app/ui/settings/creator_tab.rs b/app/ui/settings/creator_tab.rs deleted file mode 100644 index 342699c4..00000000 --- a/app/ui/settings/creator_tab.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! AppKit builder for the Creator settings launchpad tab. - -use super::*; - -pub(super) unsafe fn build_creator_tab( - action_handler: Id, - frame: core_graphics::geometry::CGRect, - _config: &Config, - state: &mut SettingsWindowState, -) -> Id { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_view = objc_class("NSView"); - let container: Id = msg_send![ns_view, alloc]; - let container: Id = msg_send![container, initWithFrame: frame]; - - let pad = ui_tokens::EDGE_PADDING; - let content_w = frame.size.width - pad * 2.0; - let gap = ui_tokens::DENSITY_COMFORTABLE; - let mut y = frame.size.height - (24.0 + gap); - let primary = crate::ui_helpers::color_label(); - let secondary = crate::ui_helpers::color_secondary_label(); - - let title = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 24.0)), - text: "Creator".to_string(), - font_size: ui_tokens::TITLE_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, title); - y -= 24.0 + gap; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= gap; - - let subtitle = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "First-run truth, permissions, and launchpads into the working surfaces." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, subtitle); - y -= 16.0 + ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 218.0); - let checklist_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Permission Checklist".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, checklist_header); - y -= 18.0 + gap; - - let mut permission_labels: [Option; 5] = [None; 5]; - let mut permission_action_buttons: [Option; 5] = [None; 5]; - for (tag, kind) in PERMISSION_ORDER.iter().copied().enumerate() { - let status = permission_status(kind); - let granted = status == PermissionStatus::Granted; - let label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w - 134.0, 20.0)), - text: format!( - "{} {}", - if granted { "OK" } else { "TODO" }, - permission_row_label(kind) - ), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: permission_color(granted), - ..Default::default() - }); - add_subview(container, label); - permission_labels[kind.index()] = Some(label as usize); - - let action_title = permission_action_title(kind, status, false).unwrap_or("Granted"); - let action_button = create_button( - CGRect::new( - &CGPoint::new(pad + content_w - 120.0, y - 2.0), - &CGSize::new(120.0, 24.0), - ), - action_title, - button_style::GLASS, - ); - let _: () = msg_send![action_button, setTag: tag as isize]; - let _: () = msg_send![action_button, setHidden: granted]; - button_set_action(action_button, action_handler, sel!(onPermissionAction:)); - add_subview(container, action_button); - permission_action_buttons[kind.index()] = Some(action_button as usize); - - y -= 24.0 + gap; - } - state.permission_labels = permission_labels; - state.permission_action_buttons = permission_action_buttons; - - let refresh_btn = create_button( - CGRect::new(&CGPoint::new(pad, y - 2.0), &CGSize::new(138.0, 24.0)), - "Refresh permissions", - button_style::GLASS, - ); - button_set_action(refresh_btn, action_handler, sel!(onRefreshPermissions:)); - add_subview(container, refresh_btn); - - let open_settings_btn = create_button( - CGRect::new( - &CGPoint::new(pad + 148.0, y - 2.0), - &CGSize::new(158.0, 24.0), - ), - "Open System Settings", - button_style::GLASS, - ); - button_set_action( - open_settings_btn, - action_handler, - sel!(onOpenSystemSettings:), - ); - add_subview(container, open_settings_btn); - y -= 24.0 + ui_tokens::SECTION_GAP; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 178.0); - let quick_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Quick Start".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, quick_header); - y -= 18.0 + gap; - - let quick_specs = [ - ( - "Test microphone", - "Starts a short local recording check.", - "Test mic", - sel!(onTestMic:), - STEP_TEST_MIC, - ), - ( - "Open agent overlay", - "Shows the chat overlay and selects Agent.", - "Open overlay", - sel!(onShowOverlay:), - STEP_SHOW_OVERLAY, - ), - ( - "Tune shortcuts", - "Jump to Keys when the permission checklist is green.", - "Open Keys", - sel!(onTabKeys:), - STEP_PRESS_HOTKEY, - ), - ]; - - for (title, description, button_title, action, step_idx) in quick_specs { - let title_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w - 154.0, 18.0)), - text: title.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: primary, - ..Default::default() - }); - add_subview(container, title_label); - - let action_button = create_button( - CGRect::new( - &CGPoint::new(pad + content_w - 140.0, y - 2.0), - &CGSize::new(140.0, 24.0), - ), - button_title, - button_style::GLASS, - ); - button_set_action(action_button, action_handler, action); - add_subview(container, action_button); - - y -= 18.0; - let desc_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w - 154.0, 16.0)), - text: description.to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, desc_label); - - let status_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad + content_w - 140.0, y), - &CGSize::new(140.0, 16.0), - ), - text: "ready".to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, status_label); - state.step_labels[step_idx] = Some(status_label as usize); - - y -= 16.0 + gap; - } - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 94.0); - let launch_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Launchpads".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, launch_header); - y -= 18.0 + gap; - - let button_w = ((content_w - 16.0) / 3.0).max(120.0); - let launchpads = [ - ("Keys", sel!(onTabKeys:)), - ("Audio", sel!(onTabAudio:)), - ("Voice Lab", sel!(onTabVoiceLab:)), - ("Engine", sel!(onTabEngine:)), - ("User", sel!(onTabUser:)), - ]; - for (idx, (title, action)) in launchpads.iter().enumerate() { - let col = idx % 3; - let row = idx / 3; - let x = pad + (button_w + 8.0) * col as f64; - let button_y = y - (32.0 * row as f64); - let button = create_button( - CGRect::new( - &CGPoint::new(x, button_y - 2.0), - &CGSize::new(button_w, 24.0), - ), - title, - button_style::GLASS, - ); - button_set_action(button, action_handler, *action); - add_subview(container, button); - } - - container - } -} diff --git a/app/ui/settings/dashboards.rs b/app/ui/settings/dashboards.rs deleted file mode 100644 index fa5033c0..00000000 --- a/app/ui/settings/dashboards.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Quality and diagnostics dashboard refresh. - -use super::*; - -pub(super) fn refresh_quality_dashboard() { - Queue::main().exec_async(move || unsafe { - let (available_label, pending_label, last_check_label, report_label, open_report_button) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.quality_available_label, - state.quality_pending_label, - state.quality_last_check_label, - state.qube_report_label, - state.quality_open_report_button, - ) - }; - - let snapshot = crate::qube_lifecycle::dashboard_snapshot(); - let daemon_state = &snapshot.daemon_state; - - if let Some(ptr) = available_label { - let label = ptr as Id; - set_text_field_string(label, snapshot.availability_label()); - let _: () = msg_send![ - label, - setTextColor: if snapshot.available { - ui_colors::status_granted() - } else { - ui_colors::status_warning() - } - ]; - } - - if let Some(ptr) = pending_label { - let label = ptr as Id; - set_text_field_string(label, &daemon_state.pending_mismatches.to_string()); - let _: () = msg_send![ - label, - setTextColor: if daemon_state.pending_mismatches > 0 { - ui_colors::status_warning() - } else { - crate::ui_helpers::color_secondary_label() - } - ]; - } - - if let Some(ptr) = last_check_label { - set_text_field_string( - ptr as Id, - &quality_last_check_text(&daemon_state.last_check), - ); - } - - if let Some(ptr) = report_label { - set_text_field_string(ptr as Id, &qube_report_text(daemon_state)); - } - - if let Some(ptr) = open_report_button { - let _: () = msg_send![ptr as Id, setEnabled: qube_report_exists(daemon_state)]; - } - }); -} - -pub(super) fn refresh_diagnostics_dashboard() { - Queue::main().exec_async(move || unsafe { - let (permission_labels, conflict_label, conflict_button, status_label) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.diagnostics_permission_labels, - state.diagnostics_conflict_label, - state.diagnostics_conflict_details_button, - state.diagnostics_status_label, - ) - }; - - for kind in PERMISSION_ORDER { - let idx = kind.index(); - let status = permission_status(kind); - if let Some(ptr) = permission_labels[idx] { - let label = ptr as Id; - set_text_field_string(label, permission_status_text(status)); - let _: () = msg_send![label, setTextColor: permission_status_color(status)]; - } - } - - let config = Config::load(); - apply_hotkey_conflict_indicator(conflict_label, conflict_button, &config); - - if let Some(ptr) = status_label { - set_text_field_string( - ptr as Id, - "Use Copy diagnostics to capture a full environment + permission report.", - ); - } - }); -} - -// ============================================================================ -// Modes & Shortcuts tab -// ============================================================================ - -pub(super) fn quality_last_check_text(last_check: &str) -> String { - let trimmed = last_check.trim(); - if trimmed.is_empty() { - "Never".to_string() - } else { - trimmed.to_string() - } -} - -pub(super) fn qube_report_exists(state: &crate::qube_daemon::QubeDaemonState) -> bool { - state - .latest_report - .as_ref() - .map(|dir| PathBuf::from(dir).join("index.html").exists()) - .unwrap_or(false) -} - -pub(super) fn qube_report_text(state: &crate::qube_daemon::QubeDaemonState) -> String { - match state.latest_report.as_ref() { - Some(dir) => { - let html_path = PathBuf::from(dir).join("index.html"); - if html_path.exists() { - html_path.display().to_string() - } else { - format!("{dir} (missing index.html)") - } - } - None => "(none)".to_string(), - } -} diff --git a/app/ui/settings/engine_tab.rs b/app/ui/settings/engine_tab.rs deleted file mode 100644 index acf14952..00000000 --- a/app/ui/settings/engine_tab.rs +++ /dev/null @@ -1,530 +0,0 @@ -//! AppKit builder for the read-only Engine settings tab. - -use super::*; - -pub(super) unsafe fn build_engine_tab( - action_handler: Id, - frame: core_graphics::geometry::CGRect, - config: &Config, - state: &mut SettingsWindowState, -) -> Id { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_view = objc_class("NSView"); - let container: Id = msg_send![ns_view, alloc]; - let container: Id = msg_send![container, initWithFrame: frame]; - - let pad = ui_tokens::EDGE_PADDING; - let content_w = frame.size.width - pad * 2.0; - let gap = ui_tokens::DENSITY_COMFORTABLE; - let title_text = "Engine"; - let title_h = - measured_label_height(title_text, content_w, ui_tokens::TITLE_FONT_SIZE, true) - .max(24.0); - let mut y = frame.size.height - (title_h + gap); - let primary = crate::ui_helpers::color_label(); - let secondary = crate::ui_helpers::color_secondary_label(); - - let title = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, title_h)), - text: title_text.to_string(), - font_size: ui_tokens::TITLE_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - configure_wrapping_label(title); - add_subview(container, title); - y -= title_h + gap; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= gap; - - let subtitle_text = - "Read-only runtime truth: STT, preview, keys, permissions, and daemon state."; - let subtitle_h = - measured_label_height(subtitle_text, content_w, ui_tokens::MICRO_FONT_SIZE, false); - let subtitle = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, subtitle_h)), - text: subtitle_text.to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - configure_wrapping_label(subtitle); - add_subview(container, subtitle); - y -= subtitle_h + ui_tokens::SECTION_GAP; - - let runtime_card_top = - add_engine_section_header(container, &mut y, pad, content_w, "Runtime Truth"); - - let preview_model = current_preview_timing_model(); - let runtime_rows = [ - ( - "Active STT:", - if config.use_local_stt { - "Local final verdict" - } else { - "Cloud final verdict" - } - .to_string(), - secondary, - ), - ( - "Whisper language:", - config.whisper_language.as_str().to_string(), - secondary, - ), - ( - "Overlay preview:", - if preview_model.overlay_enabled { - "ON".to_string() - } else { - "OFF".to_string() - }, - if preview_model.overlay_enabled { - ui_colors::status_granted() - } else { - ui_colors::status_warning() - }, - ), - ( - "Preview cadence:", - preview_timing_summary_text(preview_model), - secondary, - ), - ( - "Formatting key:", - key_status_text(formatting_key_is_set()).to_string(), - key_status_color(formatting_key_is_set()), - ), - ( - "Assistive key:", - key_status_text(keychain_key_is_set("LLM_ASSISTIVE_API_KEY")).to_string(), - key_status_color(keychain_key_is_set("LLM_ASSISTIVE_API_KEY")), - ), - ]; - for (label, value, color) in runtime_rows { - add_engine_metric_row(container, &mut y, pad, content_w, label, &value, color); - } - add_settings_group_card_dynamic( - container, - pad - 10.0, - runtime_card_top, - content_w + 20.0, - y, - ); - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - let matrix_card_top = - add_engine_section_header(container, &mut y, pad, content_w, "Permission Matrix"); - - let mut diagnostics_permission_labels: [Option; 5] = [None; 5]; - for kind in PERMISSION_ORDER { - let status = permission_status(kind); - let name_text = permission_row_label(kind); - let value_text = permission_status_text(status); - let name_width = (content_w * 0.46).clamp(120.0, 188.0); - let value_gap = 4.0; - let value_x = pad + name_width + value_gap; - let value_width = (content_w - name_width - value_gap).max(120.0); - let row_h = - measured_label_height(name_text, name_width, ui_tokens::SMALL_FONT_SIZE, false) - .max(measured_label_height( - value_text, - value_width, - ui_tokens::SMALL_FONT_SIZE, - false, - )) - .max(20.0); - let name_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(name_width, row_h)), - text: name_text.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - configure_wrapping_label(name_label); - add_subview(container, name_label); - - let value_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(value_x, y), &CGSize::new(value_width, row_h)), - text: value_text.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: permission_status_color(status), - ..Default::default() - }); - configure_wrapping_label(value_label); - add_subview(container, value_label); - diagnostics_permission_labels[kind.index()] = Some(value_label as usize); - y -= row_h + gap; - } - state.diagnostics_permission_labels = diagnostics_permission_labels; - add_settings_group_card_dynamic( - container, - pad - 10.0, - matrix_card_top, - content_w + 20.0, - y, - ); - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - let conflicts_card_top = - add_engine_section_header(container, &mut y, pad, content_w, "Hotkey Conflicts"); - - let (conflict_text, has_conflict) = hotkey_conflict_status(config); - let conflict_text_width = (content_w - 130.0).max(80.0); - let conflict_h = measured_label_height( - &conflict_text, - conflict_text_width, - ui_tokens::MICRO_FONT_SIZE, - false, - ) - .max(28.0); - let conflict_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(conflict_text_width, conflict_h), - ), - text: conflict_text, - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: if has_conflict { - ui_colors::bubble_error_text() - } else { - secondary - }, - ..Default::default() - }); - configure_wrapping_label(conflict_label); - add_subview(container, conflict_label); - state.diagnostics_conflict_label = Some(conflict_label as usize); - - let conflict_details_button = create_button( - CGRect::new( - &CGPoint::new( - pad + content_w - 120.0, - y + ((conflict_h - 24.0) / 2.0).max(0.0), - ), - &CGSize::new(120.0, 24.0), - ), - "View conflicts", - button_style::GLASS, - ); - button_set_action( - conflict_details_button, - action_handler, - sel!(onShowHotkeyConflicts:), - ); - let _: () = msg_send![conflict_details_button, setEnabled: has_conflict]; - add_subview(container, conflict_details_button); - state.diagnostics_conflict_details_button = Some(conflict_details_button as usize); - y -= conflict_h + gap; - - let status_text = "Use Copy diagnostics to capture a full environment + permission report."; - let status_text_width = (content_w - 148.0).max(80.0); - let status_h = measured_label_height( - status_text, - status_text_width, - ui_tokens::MICRO_FONT_SIZE, - false, - ) - .max(24.0); - let status_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(status_text_width, status_h), - ), - text: status_text.to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - configure_wrapping_label(status_label); - add_subview(container, status_label); - state.diagnostics_status_label = Some(status_label as usize); - - let copy_diag_btn = create_button( - CGRect::new( - &CGPoint::new( - pad + content_w - 138.0, - y + ((status_h - 24.0) / 2.0).max(0.0), - ), - &CGSize::new(138.0, 24.0), - ), - "Copy diagnostics", - button_style::GLASS, - ); - button_set_action(copy_diag_btn, action_handler, sel!(onCopyDiagnostics:)); - add_subview(container, copy_diag_btn); - let conflicts_content_bottom_y = y - status_h; - add_settings_group_card_dynamic( - container, - pad - 10.0, - conflicts_card_top, - content_w + 20.0, - conflicts_content_bottom_y, - ); - y = conflicts_content_bottom_y - ui_tokens::SECTION_GAP; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - let dashboard_card_top = - add_engine_section_header(container, &mut y, pad, content_w, "Quality Daemon"); - - let snapshot = crate::qube_lifecycle::dashboard_snapshot(); - let daemon_state = &snapshot.daemon_state; - state.quality_available_label = Some(add_engine_metric_row( - container, - &mut y, - pad, - content_w, - "Availability:", - snapshot.availability_label(), - if snapshot.available { - ui_colors::status_granted() - } else { - ui_colors::status_warning() - }, - )); - state.quality_pending_label = Some(add_engine_metric_row( - container, - &mut y, - pad, - content_w, - "Pending:", - &daemon_state.pending_mismatches.to_string(), - if daemon_state.pending_mismatches > 0 { - ui_colors::status_warning() - } else { - secondary - }, - )); - state.quality_last_check_label = Some(add_engine_metric_row( - container, - &mut y, - pad, - content_w, - "Last check:", - &quality_last_check_text(&daemon_state.last_check), - secondary, - )); - state.qube_report_label = Some(add_engine_metric_row( - container, - &mut y, - pad, - content_w, - "Latest report:", - &qube_report_text(daemon_state), - secondary, - )); - - let refresh_btn = create_button( - CGRect::new(&CGPoint::new(pad, y - 2.0), &CGSize::new(116.0, 24.0)), - "Refresh status", - button_style::GLASS, - ); - button_set_action(refresh_btn, action_handler, sel!(onQualityRefresh:)); - add_subview(container, refresh_btn); - - let open_report_btn = create_button( - CGRect::new( - &CGPoint::new(pad + 126.0, y - 2.0), - &CGSize::new(128.0, 24.0), - ), - "Open report", - button_style::GLASS, - ); - button_set_action(open_report_btn, action_handler, sel!(onOpenQualityReport:)); - let _: () = msg_send![open_report_btn, setEnabled: qube_report_exists(daemon_state)]; - add_subview(container, open_report_btn); - state.quality_open_report_button = Some(open_report_btn as usize); - // Both buttons share origin `y - 2.0`; close the daemon card below them. - add_settings_group_card_dynamic( - container, - pad - 10.0, - dashboard_card_top, - content_w + 20.0, - y - 2.0, - ); - - y -= 24.0 + ui_tokens::SECTION_GAP; - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - build_mcp_section(container, &mut y, pad, content_w); - - container - } -} - -/// Render the read-only "MCP Servers" runtime-truth section. -/// -/// MCP servers are discovered when the Agent runtime initializes -/// (`agent::tools::mcp::register`). Discovery used to fail silently into -/// `tracing::warn!`, so the Settings UI showed *nothing* — indistinguishable -/// from "MCP doesn't exist" even when `~/.codescribe/mcp.json` was present and a -/// server simply failed to spawn. This section reads the cheap config probe plus -/// the cached runtime discovery report so the UI tells the truth: config path, -/// whether the file exists/parses, and each server's real state + failure -/// reason. -fn build_mcp_section(container: Id, y: &mut f64, pad: f64, content_w: f64) { - use crate::agent::tools::mcp::McpRowTone; - let secondary = crate::ui_helpers::color_secondary_label(); - let status = crate::agent::tools::mcp::probe_mcp_status(); - - let card_top = add_engine_section_header(container, y, pad, content_w, "MCP Servers"); - - add_engine_metric_row( - container, - y, - pad, - content_w, - "Config:", - &status.config_path_display, - secondary, - ); - add_engine_metric_row( - container, - y, - pad, - content_w, - "Custom:", - "Add MCP servers in ~/.codescribe/mcp.json", - secondary, - ); - - for row in status.summary_rows() { - let color = match row.tone { - McpRowTone::Good => ui_colors::status_granted(), - McpRowTone::Warn => ui_colors::status_warning(), - McpRowTone::Bad => ui_colors::bubble_error_text(), - McpRowTone::Neutral => secondary, - }; - add_engine_metric_row(container, y, pad, content_w, &row.label, &row.value, color); - } - - // SAFETY: Settings AppKit main-thread build path. - unsafe { - add_settings_group_card_dynamic(container, pad - 10.0, card_top, content_w + 20.0, *y); - } -} - -fn add_engine_section_header(container: Id, y: &mut f64, pad: f64, width: f64, title: &str) -> f64 { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - let header_h = measured_label_height(title, width, ui_tokens::SMALL_FONT_SIZE, true).max(18.0); - let card_top = *y + header_h + 10.0; - let header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, *y), &CGSize::new(width, header_h)), - text: title.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: crate::ui_helpers::color_label(), - ..Default::default() - }); - configure_wrapping_label(header); - // SAFETY: caller builds this header on the Settings AppKit main-thread path. - unsafe { - add_subview(container, header); - } - *y -= header_h + ui_tokens::DENSITY_COMFORTABLE; - card_top -} - -fn add_engine_metric_row( - container: Id, - y: &mut f64, - pad: f64, - width: f64, - label: &str, - value: &str, - value_color: Id, -) -> usize { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - let label_width = 132.0; - let value_x = pad + 136.0; - let value_width = (width - 136.0).max(120.0); - let row_height = measured_label_height(label, label_width, ui_tokens::SMALL_FONT_SIZE, false) - .max(measured_label_height( - value, - value_width, - ui_tokens::SMALL_FONT_SIZE, - false, - )) - .max(18.0); - let label_view = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, *y), - &CGSize::new(label_width, row_height), - ), - text: label.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: crate::ui_helpers::color_secondary_label(), - ..Default::default() - }); - configure_wrapping_label(label_view); - // SAFETY: caller builds this row on the Settings AppKit main-thread path. - unsafe { - add_subview(container, label_view); - } - - let value_view = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(value_x, *y), - &CGSize::new(value_width, row_height), - ), - text: value.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: value_color, - ..Default::default() - }); - configure_wrapping_label(value_view); - // SAFETY: caller builds this row on the Settings AppKit main-thread path. - unsafe { - add_subview(container, value_view); - } - *y -= row_height + ui_tokens::DENSITY_COMFORTABLE; - value_view as usize -} - -fn measured_label_height(text: &str, width: f64, font_size: f64, bold: bool) -> f64 { - use core_graphics::geometry::{CGRect, CGSize}; - - unsafe { - let ns_font = Class::get("NSFont").unwrap(); - let ns_dict = Class::get("NSDictionary").unwrap(); - let font: Id = if bold { - msg_send![ns_font, boldSystemFontOfSize: font_size] - } else { - msg_send![ns_font, systemFontOfSize: font_size] - }; - let font_key = ns_string("NSFont"); - let attrs: Id = msg_send![ns_dict, dictionaryWithObject: font forKey: font_key]; - let opts: u64 = 1 | 2; // NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - let text_str = ns_string(text); - let rect: CGRect = msg_send![ - text_str, - boundingRectWithSize: CGSize::new(width.max(1.0), 10_000.0) - options: opts - attributes: attrs - ]; - rect.size.height.ceil().max((font_size * 1.35).ceil()) - } -} - -fn configure_wrapping_label(label: Id) { - unsafe { - let _: () = msg_send![label, setUsesSingleLineMode: false]; - let _: () = msg_send![label, setLineBreakMode: 0_isize]; - let cell: Id = msg_send![label, cell]; - if !cell.is_null() { - let _: () = msg_send![cell, setWraps: true]; - let _: () = msg_send![cell, setLineBreakMode: 0_isize]; - let _: () = msg_send![cell, setScrollable: false]; - } - } -} diff --git a/app/ui/settings/handlers.rs b/app/ui/settings/handlers.rs deleted file mode 100644 index 447f1520..00000000 --- a/app/ui/settings/handlers.rs +++ /dev/null @@ -1,478 +0,0 @@ -use objc::declare::ClassDecl; -use objc::runtime::{Class, Object, Sel}; -use objc::{msg_send, sel, sel_impl}; -use std::sync::Once; - -use crate::ui_helpers::ns_string; - -use super::{ - TAB_AUDIO, TAB_CREATOR, TAB_ENGINE, TAB_KEYS, TAB_USER, TAB_VOICE_LAB, handle_hotkey_done, - handle_settings_window_closed, handle_show_overlay, handle_test_mic, - on_assistive_endpoint_changed, on_assistive_key_changed, on_assistive_model_changed, - on_beep_toggled, on_clear_assistive_key, on_clear_llm_key, on_copy_diagnostics, - on_delay_changed, on_diagnostics_refresh, on_double_tap_interval_changed, - on_enter_send_toggled, on_formatting_level_changed, on_formatting_toggled, on_language_changed, - on_llm_endpoint_changed, on_llm_key_changed, on_llm_model_changed, on_mode_binding_change, - on_open_qube_report, on_open_system_settings, on_permission_action, - on_preview_advanced_toggled, on_preview_buffer_delay_changed, - on_preview_emit_words_max_changed, on_preview_interim_cadence_changed, - on_preview_preset_changed, on_preview_typing_cps_changed, on_prompt_load, on_prompt_reset, - on_prompt_save, on_prompt_type_changed, on_quality_refresh, on_qube_daemon_toggled, - on_refresh_permissions, on_save_api_settings, on_show_dock_icon_toggled, - on_show_hotkey_conflicts, on_stt_endpoint_changed, on_stt_key_changed, on_stt_provider_changed, - on_transcript_tagging_toggled, on_transcription_overlay_toggled, on_ultra_quality_toggled, - on_volume_changed, switch_tab, -}; - -pub use crate::ui_helpers::Id; - -static ACTION_HANDLER_INIT: Once = Once::new(); -static mut ACTION_HANDLER_CLASS: *const Class = std::ptr::null(); -static WINDOW_DELEGATE_INIT: Once = Once::new(); -static mut WINDOW_DELEGATE_CLASS: *const Class = std::ptr::null(); -static TOOLBAR_DELEGATE_INIT: Once = Once::new(); -static mut TOOLBAR_DELEGATE_CLASS: *const Class = std::ptr::null(); - -const NSTOOLBAR_FLEXIBLE_SPACE_ITEM_IDENTIFIER: &str = "NSToolbarFlexibleSpaceItem"; - -pub fn action_handler_class() -> *const Class { - // SAFETY: `ACTION_HANDLER_INIT` serializes the one-time registration, and the - // Objective-C runtime keeps the registered class pointer alive for the process lifetime. - unsafe { - ACTION_HANDLER_INIT.call_once(|| { - let superclass = Class::get("NSObject").expect("NSObject not found"); - let mut decl = ClassDecl::new("SettingsWindowActionHandler", superclass) - .expect("Failed to declare handler class"); - - // Creator tab actions - decl.add_method( - sel!(onTestMic:), - on_test_mic as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onShowOverlay:), - on_show_overlay as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onHotkeyDone:), - on_hotkey_done_action as extern "C" fn(&Object, Sel, Id), - ); - - // Tab switching - decl.add_method( - sel!(onTabCreator:), - on_tab_creator as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabKeys:), - on_tab_keys as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabAudio:), - on_tab_audio as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabVoiceLab:), - on_tab_voice_lab as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabEngine:), - on_tab_engine as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabUser:), - on_tab_user as extern "C" fn(&Object, Sel, Id), - ); - // Keys tab actions - decl.add_method( - sel!(onModeBindingChange:), - on_mode_binding_change as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onShowHotkeyConflicts:), - on_show_hotkey_conflicts as extern "C" fn(&Object, Sel, Id), - ); - - // Audio tab actions - decl.add_method( - sel!(onLanguageChanged:), - on_language_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onFormattingToggled:), - on_formatting_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTranscriptTaggingToggled:), - on_transcript_tagging_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onFormattingLevelChanged:), - on_formatting_level_changed as extern "C" fn(&Object, Sel, Id), - ); - // Transcription tab: backend configuration - decl.add_method( - sel!(onLlmEndpointChanged:), - on_llm_endpoint_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onLlmModelChanged:), - on_llm_model_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onLlmKeyChanged:), - on_llm_key_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onClearLlmKey:), - on_clear_llm_key as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onSaveApiSettings:), - on_save_api_settings as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPromptTypeChanged:), - on_prompt_type_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPromptLoad:), - on_prompt_load as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPromptSave:), - on_prompt_save as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPromptReset:), - on_prompt_reset as extern "C" fn(&Object, Sel, Id), - ); - - // Keys tab: delay slider - decl.add_method( - sel!(onDelayChanged:), - on_delay_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onDoubleTapIntervalChanged:), - on_double_tap_interval_changed as extern "C" fn(&Object, Sel, Id), - ); - - // Audio tab: beep + volume - decl.add_method( - sel!(onBeepToggled:), - on_beep_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onEnterSendToggled:), - on_enter_send_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onShowDockIconToggled:), - on_show_dock_icon_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTranscriptionOverlayToggled:), - on_transcription_overlay_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPreviewBufferDelayChanged:), - on_preview_buffer_delay_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPreviewPresetChanged:), - on_preview_preset_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPreviewAdvancedToggled:), - on_preview_advanced_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPreviewTypingCpsChanged:), - on_preview_typing_cps_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPreviewEmitWordsMaxChanged:), - on_preview_emit_words_max_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPreviewInterimCadenceChanged:), - on_preview_interim_cadence_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onSttProviderChanged:), - on_stt_provider_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onSttEndpointChanged:), - on_stt_endpoint_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onSttKeyChanged:), - on_stt_key_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onVolumeChanged:), - on_volume_changed as extern "C" fn(&Object, Sel, Id), - ); - - // Assistive AI fields - decl.add_method( - sel!(onAssistiveEndpointChanged:), - on_assistive_endpoint_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAssistiveModelChanged:), - on_assistive_model_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAssistiveKeyChanged:), - on_assistive_key_changed as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onClearAssistiveKey:), - on_clear_assistive_key as extern "C" fn(&Object, Sel, Id), - ); - - // Quality daemon toggle - decl.add_method( - sel!(onQubeDaemonToggled:), - on_qube_daemon_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onUltraQualityToggled:), - on_ultra_quality_toggled as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onQualityRefresh:), - on_quality_refresh as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onOpenQualityReport:), - on_open_qube_report as extern "C" fn(&Object, Sel, Id), - ); - - // Permission refresh - decl.add_method( - sel!(onRefreshPermissions:), - on_refresh_permissions as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPermissionAction:), - on_permission_action as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onOpenSystemSettings:), - on_open_system_settings as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onDiagnosticsRefresh:), - on_diagnostics_refresh as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCopyDiagnostics:), - on_copy_diagnostics as extern "C" fn(&Object, Sel, Id), - ); - - ACTION_HANDLER_CLASS = decl.register(); - }); - - ACTION_HANDLER_CLASS - } -} - -pub fn window_delegate_class() -> *const Class { - // SAFETY: `WINDOW_DELEGATE_INIT` guarantees exactly-once registration before we - // publish the cached class pointer, which remains valid for the process lifetime. - unsafe { - WINDOW_DELEGATE_INIT.call_once(|| { - let superclass = Class::get("NSObject").expect("NSObject not found"); - let mut decl = ClassDecl::new("SettingsWindowDelegate", superclass) - .expect("Failed to declare window delegate class"); - decl.add_method( - sel!(windowWillClose:), - on_window_will_close as extern "C" fn(&Object, Sel, Id), - ); - WINDOW_DELEGATE_CLASS = decl.register(); - }); - - WINDOW_DELEGATE_CLASS - } -} - -pub fn toolbar_delegate_class() -> *const Class { - // SAFETY: `TOOLBAR_DELEGATE_INIT` serializes registration and the Objective-C - // runtime owns the class object after `register`, so later reads are stable. - unsafe { - TOOLBAR_DELEGATE_INIT.call_once(|| { - let superclass = Class::get("NSObject").expect("NSObject not found"); - let mut decl = ClassDecl::new("SettingsToolbarDelegate", superclass) - .expect("Failed to declare toolbar delegate class"); - decl.add_method( - sel!(toolbarAllowedItemIdentifiers:), - toolbar_allowed_item_identifiers as extern "C" fn(&Object, Sel, Id) -> Id, - ); - decl.add_method( - sel!(toolbarDefaultItemIdentifiers:), - toolbar_default_item_identifiers as extern "C" fn(&Object, Sel, Id) -> Id, - ); - decl.add_method( - sel!(toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar:), - toolbar_item_for_identifier as extern "C" fn(&Object, Sel, Id, Id, bool) -> Id, - ); - TOOLBAR_DELEGATE_CLASS = decl.register(); - }); - - TOOLBAR_DELEGATE_CLASS - } -} - -fn toolbar_identifier_array() -> Id { - // SAFETY: `NSMutableArray::array` returns an autoreleased mutable array owned by - // the current Cocoa autorelease pool, and we only append a known AppKit identifier. - unsafe { - let ns_mutable_array = Class::get("NSMutableArray").unwrap(); - let ids: Id = msg_send![ns_mutable_array, array]; - // AppKit exposes flexible-space as a global identifier constant, not a class selector. - let flexible_space: Id = ns_string(NSTOOLBAR_FLEXIBLE_SPACE_ITEM_IDENTIFIER); - let _: () = msg_send![ids, addObject: flexible_space]; - ids - } -} - -extern "C" fn on_test_mic(_this: &Object, _sel: Sel, _sender: Id) { - handle_test_mic(); -} - -extern "C" fn on_show_overlay(_this: &Object, _sel: Sel, _sender: Id) { - handle_show_overlay(); -} - -extern "C" fn on_hotkey_done_action(_this: &Object, _sel: Sel, _sender: Id) { - handle_hotkey_done(); -} - -extern "C" fn on_tab_creator(_this: &Object, _sel: Sel, _sender: Id) { - switch_tab(TAB_CREATOR); -} - -extern "C" fn on_tab_keys(_this: &Object, _sel: Sel, _sender: Id) { - switch_tab(TAB_KEYS); -} - -extern "C" fn on_tab_audio(_this: &Object, _sel: Sel, _sender: Id) { - switch_tab(TAB_AUDIO); -} - -extern "C" fn on_tab_voice_lab(_this: &Object, _sel: Sel, _sender: Id) { - switch_tab(TAB_VOICE_LAB); -} - -extern "C" fn on_tab_engine(_this: &Object, _sel: Sel, _sender: Id) { - switch_tab(TAB_ENGINE); -} - -extern "C" fn on_tab_user(_this: &Object, _sel: Sel, _sender: Id) { - switch_tab(TAB_USER); -} - -extern "C" fn on_window_will_close(_this: &Object, _sel: Sel, _notification: Id) { - handle_settings_window_closed(); -} - -extern "C" fn toolbar_allowed_item_identifiers(_this: &Object, _sel: Sel, _toolbar: Id) -> Id { - toolbar_identifier_array() -} - -extern "C" fn toolbar_default_item_identifiers(_this: &Object, _sel: Sel, _toolbar: Id) -> Id { - toolbar_identifier_array() -} - -extern "C" fn toolbar_item_for_identifier( - _this: &Object, - _sel: Sel, - _toolbar: Id, - item_identifier: Id, - _will_be_inserted: bool, -) -> Id { - // SAFETY: `NSToolbarItem` instances are created through the documented alloc/init - // pair, and `item_identifier` comes from AppKit's toolbar delegate callback. - unsafe { - let ns_toolbar_item = Class::get("NSToolbarItem").unwrap(); - let item: Id = msg_send![ns_toolbar_item, alloc]; - msg_send![item, initWithItemIdentifier: item_identifier] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn assert_selector_registered(class: *const Class, selector: Sel, label: &str) { - // SAFETY: we only query selector presence on classes we registered in this module, - // so the Objective-C runtime receives a valid class object and selector. - let responds: bool = unsafe { msg_send![class, instancesRespondToSelector: selector] }; - assert!( - responds, - "SettingsWindowActionHandler missing selector `{label}`" - ); - } - - #[test] - fn action_handler_registers_core_settings_selectors() { - let class = action_handler_class(); - assert!( - !class.is_null(), - "SettingsWindowActionHandler class should be registered" - ); - - assert_selector_registered(class, sel!(onTabCreator:), "onTabCreator:"); - assert_selector_registered(class, sel!(onTabKeys:), "onTabKeys:"); - assert_selector_registered(class, sel!(onTabAudio:), "onTabAudio:"); - assert_selector_registered(class, sel!(onTabVoiceLab:), "onTabVoiceLab:"); - assert_selector_registered(class, sel!(onTabEngine:), "onTabEngine:"); - assert_selector_registered(class, sel!(onTabUser:), "onTabUser:"); - assert_selector_registered(class, sel!(onSaveApiSettings:), "onSaveApiSettings:"); - assert_selector_registered(class, sel!(onPromptSave:), "onPromptSave:"); - assert_selector_registered(class, sel!(onQualityRefresh:), "onQualityRefresh:"); - assert_selector_registered(class, sel!(onPermissionAction:), "onPermissionAction:"); - assert_selector_registered( - class, - sel!(onTranscriptionOverlayToggled:), - "onTranscriptionOverlayToggled:", - ); - assert_selector_registered( - class, - sel!(onPreviewBufferDelayChanged:), - "onPreviewBufferDelayChanged:", - ); - assert_selector_registered( - class, - sel!(onPreviewPresetChanged:), - "onPreviewPresetChanged:", - ); - assert_selector_registered( - class, - sel!(onPreviewAdvancedToggled:), - "onPreviewAdvancedToggled:", - ); - assert_selector_registered( - class, - sel!(onPreviewTypingCpsChanged:), - "onPreviewTypingCpsChanged:", - ); - assert_selector_registered( - class, - sel!(onPreviewEmitWordsMaxChanged:), - "onPreviewEmitWordsMaxChanged:", - ); - assert_selector_registered( - class, - sel!(onPreviewInterimCadenceChanged:), - "onPreviewInterimCadenceChanged:", - ); - assert_selector_registered(class, sel!(onSttProviderChanged:), "onSttProviderChanged:"); - assert_selector_registered(class, sel!(onSttEndpointChanged:), "onSttEndpointChanged:"); - assert_selector_registered(class, sel!(onSttKeyChanged:), "onSttKeyChanged:"); - } -} diff --git a/app/ui/settings/hotkey_conflicts.rs b/app/ui/settings/hotkey_conflicts.rs deleted file mode 100644 index cf406454..00000000 --- a/app/ui/settings/hotkey_conflicts.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Hotkey conflict detection: status indicator and details sheet. - -use super::*; - -pub(super) fn hotkey_conflicts(_config: &Config) -> Vec { - let settings = UserSettings::load(); - shortcut_registry::detect_hotkey_conflicts(&settings) -} - -pub(super) fn hotkey_conflict_status_from( - conflicts: &[shortcut_registry::HotkeyConflict], -) -> (String, bool) { - if conflicts.is_empty() { - return ("Mode shortcuts: clear.".to_string(), false); - } - - let first = &conflicts[0]; - let extra = conflicts.len().saturating_sub(1); - let suffix = if extra > 0 { - format!(" (+{} more)", extra) - } else { - String::new() - }; - - ( - format!( - "Review shortcut: {} -> {}{}", - first.gesture.label(), - first.message, - suffix - ), - true, - ) -} - -pub(super) fn hotkey_conflict_status(config: &Config) -> (String, bool) { - let conflicts = hotkey_conflicts(config); - hotkey_conflict_status_from(&conflicts) -} - -pub(super) fn hotkey_conflict_details_text( - conflicts: &[shortcut_registry::HotkeyConflict], -) -> String { - if conflicts.is_empty() { - return "No conflicts detected in current mode shortcuts.".to_string(); - } - - let mut lines = vec![ - "CodeScribe detected shortcuts that may overlap current mode bindings:".to_string(), - String::new(), - ]; - for (index, conflict) in conflicts.iter().enumerate() { - lines.push(format!( - "{}. {} -> {}", - index + 1, - conflict.gesture.label(), - conflict.message - )); - } - lines.push(String::new()); - lines.push("Recommendation: change that mode binding only if the gesture does not behave correctly at runtime.".to_string()); - lines.join("\n") -} - -pub(super) fn set_hotkey_conflict_details_button_enabled(button_ptr: Option, enabled: bool) { - let Some(button_ptr) = button_ptr else { - return; - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let button = button_ptr as Id; - let _: () = msg_send![button, setEnabled: enabled]; - } -} - -pub(super) fn refresh_hotkey_conflict_indicator() { - let config = Config::load(); - let (label_ptr, button_ptr) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.keys_conflict_label, - state.keys_conflict_details_button, - ) - }; - apply_hotkey_conflict_indicator(label_ptr, button_ptr, &config); -} - -pub(super) fn apply_hotkey_conflict_indicator( - label_ptr: Option, - button_ptr: Option, - config: &Config, -) { - let conflicts = hotkey_conflicts(config); - let (text, has_conflict) = hotkey_conflict_status_from(&conflicts); - set_hotkey_conflict_details_button_enabled(button_ptr, has_conflict); - - let Some(label_ptr) = label_ptr else { - return; - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let label = label_ptr as Id; - set_text_field_string(label, &text); - let color = if has_conflict { - ui_colors::bubble_error_text() - } else { - crate::ui_helpers::color_secondary_label() - }; - let _: () = msg_send![label, setTextColor: color]; - } -} - -pub(super) fn show_hotkey_conflicts_sheet() { - let config = Config::load(); - let conflicts = hotkey_conflicts(&config); - let title = if conflicts.is_empty() { - "No Shortcut Conflicts" - } else { - "Shortcut Conflicts Detected" - }; - let details = hotkey_conflict_details_text(&conflicts); - let window_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.window - }; - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_alert = objc_class("NSAlert"); - let alert: Id = msg_send![ns_alert, new]; - let _: () = msg_send![alert, setMessageText: ns_string(title)]; - let _: () = msg_send![alert, setInformativeText: ns_string(&details)]; - let _: () = msg_send![alert, setAlertStyle: 1_isize]; // NSAlertStyleInformational - let _: () = msg_send![alert, addButtonWithTitle: ns_string("OK")]; - - if let Some(window_ptr) = window_ptr { - let window = window_ptr as Id; - if !window.is_null() { - let nil: Id = std::ptr::null_mut(); - let _: () = - msg_send![alert, beginSheetModalForWindow: window completionHandler: nil]; - return; - } - } - - let _: isize = msg_send![alert, runModal]; - } -} diff --git a/app/ui/settings/keychain_status.rs b/app/ui/settings/keychain_status.rs deleted file mode 100644 index 2b627b97..00000000 --- a/app/ui/settings/keychain_status.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Keychain-backed API key status indicators. - -use super::*; - -pub(super) fn keychain_key_is_set(account: &str) -> bool { - std::env::var(account) - .ok() - .map(|v| !v.trim().is_empty()) - .unwrap_or(false) -} - -pub(super) fn key_status_text(is_set: bool) -> &'static str { - if is_set { - "Stored in Keychain" - } else { - "Not set" - } -} - -pub(super) fn key_status_color(is_set: bool) -> Id { - if is_set { - ui_colors::status_granted() - } else { - ui_colors::secondary_label() - } -} - -pub(super) fn key_status_symbol_name(is_set: bool) -> &'static str { - if is_set { - "checkmark.seal.fill" - } else { - "circle" - } -} - -pub(super) fn formatting_key_is_set() -> bool { - keychain_key_is_set("LLM_FORMATTING_API_KEY") -} - -pub(super) unsafe fn update_key_status_indicator(indicator: Id, is_set: bool) { - let _ = - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { crate::ui_helpers::set_button_symbol(indicator, key_status_symbol_name(is_set)) }; - let supports_tint: bool = msg_send![indicator, respondsToSelector: sel!(setContentTintColor:)]; - if supports_tint { - let _: () = msg_send![indicator, setContentTintColor: key_status_color(is_set)]; - } -} - -pub(super) unsafe fn create_key_status_indicator(frame: CGRect, is_set: bool) -> Id { - let ns_button = objc_class("NSButton"); - let indicator: Id = msg_send![ns_button, alloc]; - let indicator: Id = msg_send![indicator, initWithFrame: frame]; - let _: () = msg_send![indicator, setBordered: false]; - let _: () = msg_send![indicator, setEnabled: false]; - let _: () = msg_send![indicator, setTitle: ns_string("")]; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - update_key_status_indicator(indicator, is_set); - } - indicator -} - -pub(super) fn update_keychain_status_labels() { - let (llm_icon, llm_label, assist_icon, assist_label) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.llm_key_status_icon, - state.llm_key_status_label, - state.assistive_key_status_icon, - state.assistive_key_status_label, - ) - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - if let Some(ptr) = llm_icon { - let is_set = formatting_key_is_set(); - update_key_status_indicator(ptr as Id, is_set); - } - if let Some(ptr) = llm_label { - let is_set = formatting_key_is_set(); - let label = ptr as Id; - set_text_field_string(label, key_status_text(is_set)); - let _: () = msg_send![label, setTextColor: key_status_color(is_set)]; - } - if let Some(ptr) = assist_icon { - let is_set = keychain_key_is_set("LLM_ASSISTIVE_API_KEY"); - update_key_status_indicator(ptr as Id, is_set); - } - if let Some(ptr) = assist_label { - let is_set = keychain_key_is_set("LLM_ASSISTIVE_API_KEY"); - let label = ptr as Id; - set_text_field_string(label, key_status_text(is_set)); - let _: () = msg_send![label, setTextColor: key_status_color(is_set)]; - } - } -} - -pub(super) fn clear_keychain_entry(account: &str, field_ptr: Option) { - if let Err(e) = keychain::delete_key(account) { - warn!("Failed to delete {account} from Keychain: {e}"); - } else { - info!("Deleted {account} from Keychain"); - } - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { std::env::remove_var(account) }; - if let Some(ptr) = field_ptr { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { set_text_field_string(ptr as Id, "") }; - } - update_keychain_status_labels(); -} diff --git a/app/ui/settings/mod.rs b/app/ui/settings/mod.rs deleted file mode 100644 index 846a0698..00000000 --- a/app/ui/settings/mod.rs +++ /dev/null @@ -1,1403 +0,0 @@ -//! Native AppKit Settings window. -//! -//! # Safety -//! -//! Every `unsafe` block / function in this module shares one invariant matrix: -//! -//! 1. **Main-thread affinity.** AppKit objects (`NSWindow`, `NSView`, `NSButton`, -//! etc.) MUST only be addressed from the main thread. All entry points here -//! are reached either directly from the main runloop or via -//! `Queue::main().exec_async(...)`, which trampolines onto the main thread -//! before the closure body executes. -//! 2. **Object validity.** `Id = *mut Object` parameters and the values returned -//! by `[cls new]` / `[cls alloc] init...]` are non-null retained pointers -//! obtained on the main thread and not yet released. Subviews/controls owned -//! by their parent window/view live as long as the parent. -//! 3. **Selector / message arity.** `msg_send!` invocations bind to documented -//! AppKit / Foundation selectors with matching argument types. The -//! `extern_class!`-style declarations in `objc2_app_kit` provide the source -//! of truth for selector signatures. -//! 4. **Environment mutation.** `std::env::remove_var` / `set_var` calls in this -//! module run synchronously on the main thread before any worker spawns; no -//! parallel reader is in flight (Rust 2024 soundness contract). -//! -//! Per-block `// SAFETY:` annotations call out additional invariants where the -//! pattern deviates (e.g. raw FFI, retain-count balancing, cross-thread hops). - -use std::fs; -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::thread; -use std::time::Duration; - -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use dispatch::Queue; -use lazy_static::lazy_static; -use objc::runtime::{Class, Object}; -use objc::{msg_send, sel, sel_impl}; -use objc2_app_kit::{NSVisualEffectMaterial, NSWindowButton, NSWindowToolbarStyle}; -use tracing::{info, warn}; - -use crate::config::{Config, ShortcutBinding, UserSettings, WorkMode, keychain}; -use crate::ipc::{IpcCommand, IpcResponse}; -use crate::os::permissions::PermissionStatus; -use crate::os::{hotkeys, shortcut_registry}; -use crate::ui::onboarding::{ - PERMISSION_ORDER, PermissionKind, open_permission_settings, permission_status, - reconcile_permission_runtime_after_grant, request_permission, -}; -use crate::ui::settings::handlers::{ - action_handler_class, toolbar_delegate_class, window_delegate_class, -}; -use crate::ui_helpers::{ - LabelConfig, add_subview, apply_shared_shell_panel_policy, button_set_action, button_style, - create_button, create_glass_effect_view_with, create_label, create_scrollable_text_view, - create_secure_text_input, create_segmented_control, create_slider, create_text_input, - create_toggle, get_text_view_string, layout_region_frame_for_view, ns_string, - present_shared_shell_panel, set_glass_effect_content_view, set_text_field_string, - set_text_view_string, settings_shell_panel_policy, ui_colors, ui_tokens, window_close, - window_content_view, -}; - -mod handlers; - -mod actions; -mod ai_prompts_tab; -mod audio_input_tab; -mod creator_tab; -mod dashboards; -mod engine_tab; -mod hotkey_conflicts; -mod keychain_status; -mod mode_bindings; -mod modes_tab; -mod permissions; -mod preview; -mod prompts; -mod quality_tab; -mod rows; - -use actions::*; -use ai_prompts_tab::*; -use audio_input_tab::*; -use creator_tab::*; -use dashboards::*; -use engine_tab::*; -use hotkey_conflicts::*; -use keychain_status::*; -use mode_bindings::*; -use modes_tab::*; -use permissions::*; -use preview::*; -use prompts::*; -use quality_tab::*; -use rows::*; - -// Type alias for Objective-C object pointers -use crate::ui_helpers::Id; - -const SIDEBAR_WIDTH: f64 = 216.0; -const SETTINGS_WINDOW_WIDTH: f64 = 840.0; -const SETTINGS_WINDOW_HEIGHT: f64 = 700.0; -const SETTINGS_CONTENT_INSET_X: f64 = 20.0; -const SETTINGS_CONTENT_INSET_Y: f64 = 20.0; -const TAB_BUTTON_HEIGHT: f64 = 34.0; -const TAB_BUTTON_GAP: f64 = 6.0; -const SIDEBAR_GROUP_GAP: f64 = 12.0; -const TAB_ACTIVE_BG_ALPHA: f64 = 0.10; -const TAB_ACTIVE_BORDER_ALPHA: f64 = 0.22; -const SIDEBAR_INSET: f64 = 12.0; -const SETTINGS_TITLEBAR_SAFE_INSET: f64 = 56.0; -const SETTINGS_TAB_START_OFFSET: f64 = 20.0; -const TAB_CREATOR: usize = 0; -const TAB_KEYS: usize = 1; -const TAB_AUDIO: usize = 2; -const TAB_VOICE_LAB: usize = 3; -const TAB_ENGINE: usize = 4; -const TAB_USER: usize = 5; -const TAB_COUNT: usize = 6; - -const TOGGLE_ROW_HEIGHT: f64 = 22.0; -const TOGGLE_SWITCH_WIDTH: f64 = 38.0; -const TOGGLE_SWITCH_HEIGHT: f64 = 22.0; -const TOGGLE_ROW_LABEL_INDENT: f64 = 0.0; -const TOGGLE_ROW_DESC_OFFSET: f64 = 18.0; -const TOGGLE_ROW_DESC_HEIGHT: f64 = 16.0; -const SETTINGS_INPUT_HEIGHT: f64 = 22.0; -const KEY_STATUS_ICON_SIZE: f64 = 14.0; -const PREVIEW_NO_OVERLAY_MIN_INTERIM_SEC: f32 = 8.0; -const PREVIEW_SAMPLE_UTTERANCE_SEC: f32 = 12.0; -const PREVIEW_SAMPLE_TEXT: &str = - "Partiale mają być appendowane, poprawiamy tylko aktywny ogon, a nie kasujemy całego tekstu."; -const PROMPT_EDITOR_DESIRED_HEIGHT: f64 = 220.0; -const PROMPT_EDITOR_STATUS_HEIGHT: f64 = 16.0; -const PROMPT_EDITOR_BOTTOM_PADDING: f64 = 24.0; - -const STEP_TEST_MIC: usize = 0; -const STEP_SHOW_OVERLAY: usize = 1; -const STEP_PRESS_HOTKEY: usize = 2; -const MODE_DICTATION_TAG: isize = 0; -const MODE_FORMATTING_TAG: isize = 1; -const MODE_ASSISTIVE_TAG: isize = 2; -const MODE_DISABLE_TAG_OFFSET: isize = 10; -const MODE_DICTATION_DOUBLE_CTRL_TAG: isize = 100; - -#[inline] -fn objc_class(name: &'static str) -> &'static Class { - Class::get(name).unwrap_or_else(|| panic!("Objective-C class not found: {name}")) -} - -/// Compute a safe top inset from Tahoe/AppKit layout guides to keep sidebar controls -/// below titlebar chrome (traffic lights + unified toolbar), with a stable fallback. -fn settings_titlebar_safe_inset(view: Id, fallback: f64) -> f64 { - if view.is_null() { - return fallback; - } - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let bounds: CGRect = msg_send![view, bounds]; - if let Some(layout_frame) = layout_region_frame_for_view(view) { - let top_inset = - (bounds.size.height - (layout_frame.origin.y + layout_frame.size.height)).max(0.0); - if top_inset.is_finite() { - return (top_inset + ui_tokens::DENSITY_MEDIUM).max(fallback); - } - } - } - - fallback -} - -fn parse_env_bool(v: &str) -> bool { - matches!( - v.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) -} - -#[derive(Default)] -struct SettingsWindowState { - window: Option, - window_delegate: Option, - action_handler: Option, - root_view: Option, - step_labels: [Option; 3], - tab_buttons: [Option; TAB_COUNT], - content_views: [Option; TAB_COUNT], - active_tab: usize, - keys_mode_binding_labels: [Option; 3], - keys_recorder_hint_label: Option, - keys_conflict_label: Option, - keys_conflict_details_button: Option, - hold_delay_value_label: Option, - double_tap_value_label: Option, - preview_buffer_delay_value_label: Option, - preview_buffer_delay_slider: Option, - preview_typing_cps_value_label: Option, - preview_typing_cps_slider: Option, - preview_emit_words_max_value_label: Option, - preview_emit_words_max_slider: Option, - preview_interim_sec_value_label: Option, - preview_interim_sec_slider: Option, - preview_timing_summary_label: Option, - preview_timing_text_view: Option, - preview_preset_segment: Option, - preview_env_override_label: Option, - preview_advanced_button: Option, - preview_advanced_rows: Vec, - preview_advanced_expanded: bool, - preview_timing_forced_custom: bool, - config_cache: Option, - // Onboarding additions - permission_labels: [Option; 5], - permission_action_buttons: [Option; 5], - permission_requested: [bool; 5], - permission_polling: bool, - qube_daemon_checkbox: Option, - ultra_quality_checkbox: Option, - quality_available_label: Option, - quality_pending_label: Option, - quality_last_check_label: Option, - qube_report_label: Option, - quality_open_report_button: Option, - llm_endpoint_field: Option, - llm_model_field: Option, - llm_key_field: Option, - llm_key_status_icon: Option, - llm_key_status_label: Option, - assistive_endpoint_field: Option, - assistive_model_field: Option, - assistive_key_field: Option, - assistive_key_status_icon: Option, - assistive_key_status_label: Option, - prompt_type_popup: Option, - prompt_editor_text_view: Option, - prompt_status_label: Option, - prompt_path_label: Option, - diagnostics_permission_labels: [Option; 5], - diagnostics_conflict_label: Option, - diagnostics_conflict_details_button: Option, - diagnostics_status_label: Option, -} - -lazy_static! { - static ref SETTINGS_WINDOW_STATE: Mutex = - Mutex::new(SettingsWindowState::default()); -} - -fn clear_settings_ui_state(state: &mut SettingsWindowState) { - state.step_labels = [None, None, None]; - state.tab_buttons = [None; TAB_COUNT]; - state.content_views = [None; TAB_COUNT]; - state.active_tab = TAB_CREATOR; - state.keys_mode_binding_labels = [None; 3]; - state.keys_recorder_hint_label = None; - state.keys_conflict_label = None; - state.keys_conflict_details_button = None; - state.hold_delay_value_label = None; - state.double_tap_value_label = None; - state.preview_buffer_delay_value_label = None; - state.preview_buffer_delay_slider = None; - state.preview_typing_cps_value_label = None; - state.preview_typing_cps_slider = None; - state.preview_emit_words_max_value_label = None; - state.preview_emit_words_max_slider = None; - state.preview_interim_sec_value_label = None; - state.preview_interim_sec_slider = None; - state.preview_timing_summary_label = None; - state.preview_timing_text_view = None; - state.preview_preset_segment = None; - state.preview_env_override_label = None; - state.preview_advanced_button = None; - state.preview_advanced_rows.clear(); - state.preview_advanced_expanded = false; - state.preview_timing_forced_custom = false; - state.permission_labels = [None, None, None, None, None]; - state.permission_action_buttons = [None, None, None, None, None]; - state.permission_requested = [false; 5]; - state.permission_polling = false; - state.qube_daemon_checkbox = None; - state.ultra_quality_checkbox = None; - state.quality_available_label = None; - state.quality_pending_label = None; - state.quality_last_check_label = None; - state.qube_report_label = None; - state.quality_open_report_button = None; - state.llm_endpoint_field = None; - state.llm_model_field = None; - state.llm_key_field = None; - state.llm_key_status_icon = None; - state.llm_key_status_label = None; - state.assistive_endpoint_field = None; - state.assistive_model_field = None; - state.assistive_key_field = None; - state.assistive_key_status_icon = None; - state.assistive_key_status_label = None; - state.prompt_type_popup = None; - state.prompt_editor_text_view = None; - state.prompt_status_label = None; - state.prompt_path_label = None; - state.diagnostics_permission_labels = [None; 5]; - state.diagnostics_conflict_label = None; - state.diagnostics_conflict_details_button = None; - state.diagnostics_status_label = None; -} - -static SHOW_SETTINGS_WINDOW_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -unsafe fn present_settings_window(window: Id) { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { present_shared_shell_panel(window) }; -} - -/// Show the persistent Settings window. -pub fn show_settings_window() { - // Fast path: if window already exists, just show it on main thread. - { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - if let Some(ptr) = state.window { - drop(state); - Queue::main().exec_async(move || unsafe { - let window = ptr as Id; - present_settings_window(window); - refresh_permission_indicators(); - start_permission_polling(); - }); - return; - } - } - - // Slow path: need to create window — guard against concurrent thread spawns. - if SHOW_SETTINGS_WINDOW_IN_FLIGHT.swap(true, Ordering::SeqCst) { - return; - } - std::thread::spawn(|| { - let config = Config::load(); - Queue::main().exec_async(move || { - SHOW_SETTINGS_WINDOW_IN_FLIGHT.store(false, Ordering::SeqCst); - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.config_cache = Some(config); - drop(state); - show_settings_window_impl(); - }); - }); -} - -fn show_settings_window_impl() { - // Keep Settings as a standalone window. - // It should not depend on the voice chat overlay being available. - // (This also avoids deadlocks when the overlay is mid-layout.) - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let reuse_window = { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - if let Some(window_ptr) = state.window { - let ns_window = objc_class("NSWindow"); - let window = window_ptr as Id; - let is_window: bool = msg_send![window, isKindOfClass: ns_window]; - if is_window { - Some(window) - } else { - state.window = None; - None - } - } else { - None - } - }; // Release lock before AppKit call. - if let Some(window) = reuse_window { - present_settings_window(window); - refresh_permission_indicators(); - start_permission_polling(); - return; - } - - let ns_screen = objc_class("NSScreen"); - let screen: Id = msg_send![ns_screen, mainScreen]; - if screen.is_null() { - warn!("No NSScreen available for settings window"); - return; - } - let visible: CGRect = msg_send![screen, visibleFrame]; - let window_width = SETTINGS_WINDOW_WIDTH; - let window_height = SETTINGS_WINDOW_HEIGHT; - let x = visible.origin.x + (visible.size.width - window_width) * 0.5; - let y = visible.origin.y + (visible.size.height - window_height) * 0.5; - let frame = CGRect::new( - &CGPoint::new(x, y), - &CGSize::new(window_width, window_height), - ); - - let fixed_size = CGSize::new(window_width, window_height); - let shell_policy = settings_shell_panel_policy(fixed_size); - let ns_window = objc_class("NSWindow"); - let window: Id = msg_send![ns_window, alloc]; - let window: Id = msg_send![ - window, - initWithContentRect: frame - styleMask: shell_policy.style_mask - backing: shell_policy.backing_store - defer: false - ]; - - // Keep Settings on a conventional AppKit preferences shell. The content - // panes use semantic NSVisualEffect materials; the window itself stays native. - let _: () = msg_send![window, setTitle: ns_string("Settings")]; - apply_shared_shell_panel_policy(window, &shell_policy); - let toolbar_delegate_class = toolbar_delegate_class(); - let toolbar_delegate: Id = msg_send![toolbar_delegate_class, new]; - let ns_toolbar = objc_class("NSToolbar"); - let toolbar: Id = msg_send![ns_toolbar, alloc]; - let toolbar: Id = msg_send![toolbar, initWithIdentifier: ns_string("settings-toolbar")]; - let _: () = msg_send![toolbar, setDelegate: toolbar_delegate]; - let _: () = msg_send![toolbar, setDisplayMode: 2_isize]; // NSToolbarDisplayModeIconOnly - let _: () = msg_send![toolbar, setAllowsUserCustomization: false]; - let _: () = msg_send![toolbar, setAutosavesConfiguration: false]; - let _: () = msg_send![window, setToolbar: toolbar]; - let supports_toolbar_style: bool = - msg_send![window, respondsToSelector: sel!(setToolbarStyle:)]; - if supports_toolbar_style { - let _: () = msg_send![window, setToolbarStyle: NSWindowToolbarStyle::Preference]; - } - let supports_toolbar_button: bool = - msg_send![window, respondsToSelector: sel!(setShowsToolbarButton:)]; - if supports_toolbar_button { - let _: () = msg_send![window, setShowsToolbarButton: false]; - } - // Hard lock the size (no resize handles, no zoom). - let zoom_btn: Id = msg_send![window, standardWindowButton: NSWindowButton::ZoomButton]; - if !zoom_btn.is_null() { - let _: () = msg_send![zoom_btn, setEnabled: false]; - } - let delegate_class = window_delegate_class(); - let window_delegate: Id = msg_send![delegate_class, new]; - let _: () = msg_send![window, setDelegate: window_delegate]; - let content_view = window_content_view(window); - let bounds: CGRect = msg_send![content_view, bounds]; - let _ = attach_settings_view(content_view, bounds); - - { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.window = Some(window as usize); - state.window_delegate = Some(window_delegate as usize); - } // Release lock before AppKit call to avoid nested-runloop deadlock. - - present_settings_window(window); - refresh_permission_indicators(); - start_permission_polling(); - } -} - -/// Attach the Settings view inside an existing parent view. -/// -/// # Safety -/// `parent` must be a valid `NSView` instance owned by AppKit. -unsafe fn attach_settings_view(parent: Id, frame: core_graphics::geometry::CGRect) -> Option { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let (config, existing_root) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.config_cache.clone().unwrap_or_else(Config::load), - state.root_view, - ) - }; - if let Some(root_ptr) = existing_root { - let root = root_ptr as Id; - let _: () = msg_send![root, setFrame: frame]; - let _: () = msg_send![ - root, - setAutoresizingMask: 2_isize | 16_isize // NSViewWidthSizable | NSViewHeightSizable - ]; - let superview: Id = msg_send![root, superview]; - if superview.is_null() { - add_subview(parent, root); - } - refresh_permission_indicators(); - start_permission_polling(); - return Some(root); - } - - // Create a container view (transparent) to hold the split visual effects. - let ns_view = objc_class("NSView"); - let root: Id = msg_send![ns_view, alloc]; - let root: Id = msg_send![root, initWithFrame: frame]; - let _: () = msg_send![ - root, - setAutoresizingMask: 2_isize | 16_isize // NSViewWidthSizable | NSViewHeightSizable - ]; - add_subview(parent, root); - - let action_handler_class = action_handler_class(); - let action_handler: Id = msg_send![action_handler_class, new]; - let built_state = build_settings_ui( - root, - frame.size.width, - frame.size.height, - action_handler, - &config, - ); - - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.root_view = Some(root as usize); - state.action_handler = Some(action_handler as usize); - state.step_labels = built_state.step_labels; - state.tab_buttons = built_state.tab_buttons; - state.content_views = built_state.content_views; - state.active_tab = built_state.active_tab; - state.keys_mode_binding_labels = built_state.keys_mode_binding_labels; - state.keys_recorder_hint_label = built_state.keys_recorder_hint_label; - state.keys_conflict_label = built_state.keys_conflict_label; - state.keys_conflict_details_button = built_state.keys_conflict_details_button; - state.hold_delay_value_label = built_state.hold_delay_value_label; - state.double_tap_value_label = built_state.double_tap_value_label; - state.preview_buffer_delay_value_label = built_state.preview_buffer_delay_value_label; - state.preview_buffer_delay_slider = built_state.preview_buffer_delay_slider; - state.preview_typing_cps_value_label = built_state.preview_typing_cps_value_label; - state.preview_typing_cps_slider = built_state.preview_typing_cps_slider; - state.preview_emit_words_max_value_label = built_state.preview_emit_words_max_value_label; - state.preview_emit_words_max_slider = built_state.preview_emit_words_max_slider; - state.preview_interim_sec_value_label = built_state.preview_interim_sec_value_label; - state.preview_interim_sec_slider = built_state.preview_interim_sec_slider; - state.preview_timing_summary_label = built_state.preview_timing_summary_label; - state.preview_timing_text_view = built_state.preview_timing_text_view; - state.preview_preset_segment = built_state.preview_preset_segment; - state.preview_env_override_label = built_state.preview_env_override_label; - state.preview_advanced_button = built_state.preview_advanced_button; - state.preview_advanced_rows = built_state.preview_advanced_rows; - state.preview_advanced_expanded = built_state.preview_advanced_expanded; - state.config_cache = built_state.config_cache; - state.permission_labels = built_state.permission_labels; - state.permission_action_buttons = built_state.permission_action_buttons; - state.permission_requested = built_state.permission_requested; - state.permission_polling = built_state.permission_polling; - state.qube_daemon_checkbox = built_state.qube_daemon_checkbox; - state.ultra_quality_checkbox = built_state.ultra_quality_checkbox; - state.quality_available_label = built_state.quality_available_label; - state.quality_pending_label = built_state.quality_pending_label; - state.quality_last_check_label = built_state.quality_last_check_label; - state.qube_report_label = built_state.qube_report_label; - state.quality_open_report_button = built_state.quality_open_report_button; - state.llm_endpoint_field = built_state.llm_endpoint_field; - state.llm_model_field = built_state.llm_model_field; - state.llm_key_field = built_state.llm_key_field; - state.llm_key_status_icon = built_state.llm_key_status_icon; - state.llm_key_status_label = built_state.llm_key_status_label; - state.assistive_endpoint_field = built_state.assistive_endpoint_field; - state.assistive_model_field = built_state.assistive_model_field; - state.assistive_key_field = built_state.assistive_key_field; - state.assistive_key_status_icon = built_state.assistive_key_status_icon; - state.assistive_key_status_label = built_state.assistive_key_status_label; - state.prompt_type_popup = built_state.prompt_type_popup; - state.prompt_editor_text_view = built_state.prompt_editor_text_view; - state.prompt_status_label = built_state.prompt_status_label; - state.prompt_path_label = built_state.prompt_path_label; - state.diagnostics_permission_labels = built_state.diagnostics_permission_labels; - state.diagnostics_conflict_label = built_state.diagnostics_conflict_label; - state.diagnostics_conflict_details_button = built_state.diagnostics_conflict_details_button; - state.diagnostics_status_label = built_state.diagnostics_status_label; - - drop(state); // Release lock before permission calls to avoid deadlock. - - refresh_hotkey_conflict_indicator(); - refresh_quality_dashboard(); - refresh_diagnostics_dashboard(); - refresh_prompt_editor_labels(); - refresh_transcription_preview_panel(); - refresh_permission_indicators(); - start_permission_polling(); - Some(root) - } -} - -// ============================================================================ -// Permission checks / onboarding readiness -// ============================================================================ - -unsafe fn build_settings_ui( - root_view: Id, - settings_width: f64, - settings_height: f64, - action_handler: Id, - config: &Config, -) -> SettingsWindowState { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - let ns_view = objc_class("NSView"); - let mut state = SettingsWindowState::default(); - - let settings_width = settings_width.max(SIDEBAR_WIDTH + 240.0); - let settings_height = settings_height.max(280.0); - let body_h = settings_height; - - let root_content: Id = msg_send![ns_view, alloc]; - let root_content: Id = msg_send![ - root_content, - initWithFrame: CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(settings_width, settings_height), - ) - ]; - let _: () = msg_send![ - root_content, - setAutoresizingMask: 2_isize | 16_isize // Width | Height - ]; - add_subview(root_view, root_content); - - // Left: Sidebar glass pane - let sidebar_frame = - CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(SIDEBAR_WIDTH, body_h)); - let sidebar_glass = create_glass_effect_view_with( - sidebar_frame, - NSVisualEffectMaterial::Sidebar, - objc2_app_kit::NSVisualEffectBlendingMode::WithinWindow, - objc2_app_kit::NSVisualEffectState::FollowsWindowActiveState, - ); - let _: () = msg_send![ - sidebar_glass, - setAutoresizingMask: 16_isize | 4_isize // Height | MaxXMargin - ]; - let sidebar_glass_layer: Id = msg_send![sidebar_glass, layer]; - if !sidebar_glass_layer.is_null() { - let _: () = msg_send![sidebar_glass_layer, setMasksToBounds: true]; - } - add_subview(root_content, sidebar_glass); - - let sidebar_container: Id = msg_send![ns_view, alloc]; - let sidebar_container: Id = msg_send![ - sidebar_container, - initWithFrame: CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(SIDEBAR_WIDTH, body_h), - ) - ]; - let _: () = msg_send![ - sidebar_container, - setAutoresizingMask: 2_isize | 16_isize // Width | Height - ]; - let _: bool = set_glass_effect_content_view(sidebar_glass, sidebar_container); - - // Right: Content glass pane - let content_bg_frame = CGRect::new( - &CGPoint::new(SIDEBAR_WIDTH, 0.0), - &CGSize::new(settings_width - SIDEBAR_WIDTH, body_h), - ); - let content_glass = create_glass_effect_view_with( - content_bg_frame, - NSVisualEffectMaterial::ContentBackground, - objc2_app_kit::NSVisualEffectBlendingMode::WithinWindow, - objc2_app_kit::NSVisualEffectState::FollowsWindowActiveState, - ); - let _: () = msg_send![ - content_glass, - setAutoresizingMask: 2_isize | 16_isize // Width | Height - ]; - let content_glass_layer: Id = msg_send![content_glass, layer]; - if !content_glass_layer.is_null() { - let _: () = msg_send![content_glass_layer, setMasksToBounds: true]; - } - add_subview(root_content, content_glass); - - let content_container: Id = msg_send![ns_view, alloc]; - let content_container: Id = msg_send![ - content_container, - initWithFrame: CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(content_bg_frame.size.width, body_h), - ) - ]; - let _: () = msg_send![ - content_container, - setAutoresizingMask: 2_isize | 16_isize // Width | Height - ]; - let _: bool = set_glass_effect_content_view(content_glass, content_container); - - let split_divider = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(SIDEBAR_WIDTH - 0.5, 0.0), - &CGSize::new(1.0, body_h), - ), - text: String::new(), - background_color: Some(ui_colors::header_border()), - ..Default::default() - }); - let _: () = msg_send![ - split_divider, - setAutoresizingMask: 16_isize | 4_isize // Height | MaxXMargin - ]; - add_subview(root_content, split_divider); - - let content_area_w = content_bg_frame.size.width; - let content_area_h = body_h; - - // Offset sidebar content below the titlebar+toolbar zone (~52px in unified style). - // With FullSizeContentView, content extends under the titlebar, so we must - // manually avoid the traffic lights area. - let titlebar_inset = - settings_titlebar_safe_inset(root_content, SETTINGS_TITLEBAR_SAFE_INSET); - - // Sidebar tab buttons (inside sidebar container, no redundant title label) - let tab_start_y = body_h - titlebar_inset - SETTINGS_TAB_START_OFFSET; - let tab_names = ["Creator", "Keys", "Audio", "Voice Lab", "Engine", "User"]; - let tab_sels = [ - sel!(onTabCreator:), - sel!(onTabKeys:), - sel!(onTabAudio:), - sel!(onTabVoiceLab:), - sel!(onTabEngine:), - sel!(onTabUser:), - ]; - let mut tab_buttons: [Option; TAB_COUNT] = [None; TAB_COUNT]; - - let mut cursor_y = tab_start_y; - for (i, (name, sel)) in tab_names.iter().zip(tab_sels.iter()).enumerate() { - if i == 1 || i == 4 { - cursor_y -= SIDEBAR_GROUP_GAP / 2.0; - let sep_line = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(SIDEBAR_INSET + 4.0, cursor_y), - &CGSize::new(SIDEBAR_WIDTH - SIDEBAR_INSET * 2.0 - 8.0, 1.0), - ), - text: String::new(), - background_color: Some(ui_colors::header_border()), - ..Default::default() - }); - add_subview(sidebar_container, sep_line); - cursor_y -= SIDEBAR_GROUP_GAP / 2.0; - } - - cursor_y -= TAB_BUTTON_HEIGHT; - let btn_frame = CGRect::new( - &CGPoint::new(SIDEBAR_INSET, cursor_y), - &CGSize::new(SIDEBAR_WIDTH - SIDEBAR_INSET * 2.0, TAB_BUTTON_HEIGHT), - ); - - let tab_btn = create_sidebar_tab_button(btn_frame, name, i == TAB_CREATOR); - button_set_action(tab_btn, action_handler, *sel); - add_subview(sidebar_container, tab_btn); - tab_buttons[i] = Some(tab_btn as usize); - cursor_y -= TAB_BUTTON_GAP; - } - - // ==================================================================== - // Content area views (one per tab, inside content container) - // ==================================================================== - // Relative to content container: origin is (0,0) - let tab_content_frame = CGRect::new( - &CGPoint::new(SETTINGS_CONTENT_INSET_X, SETTINGS_CONTENT_INSET_Y), - &CGSize::new( - (content_area_w - SETTINGS_CONTENT_INSET_X * 2.0).max(240.0), - (content_area_h - SETTINGS_CONTENT_INSET_Y - (titlebar_inset + 8.0)).max(220.0), - ), - ); - - let tab_document_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(tab_content_frame.size.width, tab_content_frame.size.height), - ); - - // --- Creator tab (index 0) --- - let creator_view = - build_creator_tab(action_handler, tab_document_frame, config, &mut state); - let creator_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, creator_view); - add_subview(content_container, creator_scroll); - - // --- Keys tab (index 1) --- - let keys_view = - build_modes_shortcuts_tab(action_handler, tab_document_frame, config, &mut state); - let keys_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, keys_view); - let _: () = msg_send![keys_scroll, setHidden: true]; - add_subview(content_container, keys_scroll); - - // --- Audio tab (index 2) --- - let audio_view = build_audio_input_tab(action_handler, tab_document_frame, config); - let audio_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, audio_view); - let _: () = msg_send![audio_scroll, setHidden: true]; - add_subview(content_container, audio_scroll); - - // --- Voice Lab tab (index 3) --- - let voice_lab_view = - build_quality_tab(action_handler, tab_document_frame, config, &mut state); - let voice_lab_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, voice_lab_view); - let _: () = msg_send![voice_lab_scroll, setHidden: true]; - add_subview(content_container, voice_lab_scroll); - - // --- Engine tab (index 4) --- - let engine_view = build_engine_tab(action_handler, tab_document_frame, config, &mut state); - let engine_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, engine_view); - let _: () = msg_send![engine_scroll, setHidden: true]; - add_subview(content_container, engine_scroll); - - // --- User tab (index 5) --- - let user_view = - build_ai_prompts_tab(action_handler, tab_document_frame, config, &mut state); - let user_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, user_view); - let _: () = msg_send![user_scroll, setHidden: true]; - add_subview(content_container, user_scroll); - - // ==================================================================== - // Store state - // ==================================================================== - state.tab_buttons = tab_buttons; - state.content_views = [ - Some(creator_scroll as usize), - Some(keys_scroll as usize), - Some(audio_scroll as usize), - Some(voice_lab_scroll as usize), - Some(engine_scroll as usize), - Some(user_scroll as usize), - ]; - state.active_tab = TAB_CREATOR; - state.config_cache = Some(config.clone()); - - state - } -} - -/// Create a sidebar tab button (flat, full-width, with highlight for active state). -unsafe fn create_sidebar_tab_button( - frame: core_graphics::geometry::CGRect, - title: &str, - active: bool, -) -> Id { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_button = objc_class("NSButton"); - let ns_font = objc_class("NSFont"); - - let btn: Id = msg_send![ns_button, alloc]; - let btn: Id = msg_send![btn, initWithFrame: frame]; - - let title_str = crate::ui_helpers::ns_string(title); - let _: () = msg_send![btn, setTitle: title_str]; - let _: () = msg_send![btn, setBordered: false]; - let _: () = msg_send![ - btn, - setFocusRingType: crate::ui_helpers::NS_FOCUS_RING_TYPE_NONE - ]; - // Left alignment for sidebar items - let _: () = msg_send![btn, setAlignment: 0_isize]; // NSLeftTextAlignment - - // Add SF Symbol icon based on title - let symbol_name = match title { - "Creator" => "wand.and.stars", - "Keys" => "keyboard", - "Audio" => "speaker.wave.2", - "Voice Lab" => "waveform", - "Engine" => "stethoscope", - "User" => "person.crop.circle", - _ => "circle", - }; - crate::ui_helpers::set_button_symbol(btn, symbol_name); - // NSImageLeft = 2 - let _: () = msg_send![btn, setImagePosition: 2_isize]; - - let font: Id = msg_send![ns_font, systemFontOfSize: ui_tokens::BODY_FONT_SIZE]; - let _: () = msg_send![btn, setFont: font]; - - let _: () = msg_send![btn, setWantsLayer: true]; - let layer: Id = msg_send![btn, layer]; - if !layer.is_null() { - let bg = if active { - ui_colors::accent_tint(TAB_ACTIVE_BG_ALPHA) - } else { - crate::ui_helpers::color_clear() - }; - let cg_color: Id = msg_send![bg, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - let _: () = msg_send![layer, setCornerRadius: ui_tokens::SURFACE_RADIUS]; - if active { - let border = ui_colors::accent_tint(TAB_ACTIVE_BORDER_ALPHA); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![layer, setBorderColor: cg_border]; - let _: () = msg_send![layer, setBorderWidth: 1.0f64]; - } else { - let _: () = msg_send![layer, setBorderWidth: 0.0f64]; - } - } - - let tint = if active { - ui_colors::accent() - } else { - ui_colors::secondary_label() - }; - let _: () = msg_send![btn, setContentTintColor: tint]; - - btn - } -} - -/// Switch to a given tab index. Hides all content views, shows the selected one, -/// and updates sidebar button highlights. -pub(super) fn switch_tab(index: usize) { - Queue::main().exec_async(move || unsafe { - let (content_views, tab_buttons) = { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - if index >= TAB_COUNT || state.active_tab == index { - return; - } - state.active_tab = index; - (state.content_views, state.tab_buttons) - }; - - // Hide all content views, show selected - for (i, cv) in content_views.iter().enumerate() { - if let Some(ptr) = cv { - let view = *ptr as Id; - let _: () = msg_send![view, setHidden: (i != index)]; - } - } - - // Update tab button highlights - for (i, tb) in tab_buttons.iter().enumerate() { - if let Some(ptr) = tb { - let btn = *ptr as Id; - let active = i == index; - - let _: () = msg_send![btn, setWantsLayer: true]; - let layer: Id = msg_send![btn, layer]; - if !layer.is_null() { - let bg: Id = if active { - ui_colors::accent_tint(TAB_ACTIVE_BG_ALPHA) - } else { - crate::ui_helpers::color_clear() - }; - let cg_color: Id = msg_send![bg, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - let _: () = msg_send![layer, setCornerRadius: ui_tokens::SURFACE_RADIUS]; - if active { - let border: Id = ui_colors::accent_tint(TAB_ACTIVE_BORDER_ALPHA); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![layer, setBorderColor: cg_border]; - let _: () = msg_send![layer, setBorderWidth: 1.0f64]; - } else { - let _: () = msg_send![layer, setBorderWidth: 0.0f64]; - } - } - - let tint = if active { - ui_colors::accent() - } else { - ui_colors::secondary_label() - }; - let _: () = msg_send![btn, setContentTintColor: tint]; - } - } - - if index == TAB_CREATOR { - refresh_permission_indicators(); - } else if index == TAB_VOICE_LAB { - refresh_transcription_preview_panel(); - } else if index == TAB_ENGINE { - refresh_quality_dashboard(); - refresh_diagnostics_dashboard(); - } else if index == TAB_USER { - refresh_prompt_editor_labels(); - } - }); -} - -pub(super) fn handle_test_mic() { - update_step_status(STEP_TEST_MIC, "recording\u{2026}"); - - if let Err(e) = send_ipc(IpcCommand::StartRecording { assistive: false }) { - warn!("Settings test mic failed to start: {}", e); - update_step_status(STEP_TEST_MIC, "failed"); - return; - } - - thread::spawn(|| { - thread::sleep(Duration::from_secs(3)); - let _ = send_ipc(IpcCommand::StopRecording); - update_step_status(STEP_TEST_MIC, "done"); - }); -} - -pub(super) fn handle_show_overlay() { - crate::ui::voice_chat::show_voice_chat_overlay(); - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::update_voice_chat_status("Listening..."); - update_step_status(STEP_SHOW_OVERLAY, "done"); -} - -pub(super) fn handle_hotkey_done() { - update_step_status(STEP_PRESS_HOTKEY, "done"); -} - -pub(super) fn handle_settings_window_closed() { - let (delegate_ptr, handler_ptr, window_ptr) = { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - let delegate_ptr = state.window_delegate.take(); - let handler_ptr = state.action_handler.take(); - let window_ptr = state.window.take(); - state.root_view = None; - clear_settings_ui_state(&mut state); - state.config_cache = None; - (delegate_ptr, handler_ptr, window_ptr) - }; - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - if let Some(ptr) = delegate_ptr { - let _: () = msg_send![ptr as Id, release]; - } - if let Some(ptr) = handler_ptr { - let _: () = msg_send![ptr as Id, release]; - } - if let Some(ptr) = window_ptr { - let _: () = msg_send![ptr as Id, release]; - } - } -} - -pub fn hide_settings_surface() { - Queue::main().exec_async(|| unsafe { - let (window_ptr, root_ptr) = { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.permission_polling = false; - // Do NOT take ownership of window/delegate/action_handler here. - // The `windowWillClose:` notification fires `handle_settings_window_closed`, - // which drains and releases all three. Releasing twice would crash. - // For the embedded (root-only, no window) path, the parent owns lifecycle. - (state.window, state.root_view) - }; - - if let Some(window_ptr) = window_ptr { - window_close(window_ptr as Id); - return; - } - - if let Some(root_ptr) = root_ptr { - let _: () = msg_send![root_ptr as Id, setHidden: true]; - } - }); -} - -/// Alias: Settings window close. -pub fn hide_settings_window() { - hide_settings_surface(); -} - -/// Reset embedded Settings view state when the overlay is destroyed. -pub fn reset_embedded_settings_state() { - let (delegate_ptr, handler_ptr) = { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - if state.window.is_some() { - return; - } - let delegate_ptr = state.window_delegate.take(); - let handler_ptr = state.action_handler.take(); - state.root_view = None; - state.config_cache = None; - clear_settings_ui_state(&mut state); - (delegate_ptr, handler_ptr) - }; - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - if let Some(ptr) = delegate_ptr { - let _: () = msg_send![ptr as Id, release]; - } - if let Some(ptr) = handler_ptr { - let _: () = msg_send![ptr as Id, release]; - } - } -} - -fn update_step_status(index: usize, text: &str) { - let text = text.to_string(); - Queue::main().exec_async(move || unsafe { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - if let Some(label) = state.step_labels.get(index).and_then(|v| *v) { - set_text_field_string(label as Id, &text); - } - }); -} - -fn send_ipc(cmd: IpcCommand) -> Result { - let socket_path = crate::ipc::socket_path(); - let mut stream = - UnixStream::connect(socket_path).map_err(|e| format!("IPC connect failed: {e}"))?; - let payload = serde_json::to_string(&cmd).map_err(|e| e.to_string())?; - stream - .write_all(payload.as_bytes()) - .map_err(|e| e.to_string())?; - stream.write_all(b"\n").map_err(|e| e.to_string())?; - - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line).map_err(|e| e.to_string())?; - - serde_json::from_str::(&line).map_err(|e| e.to_string()) -} - -fn sync_runtime_config_via_ipc() { - if let Err(e) = send_ipc(IpcCommand::ReloadRuntimeConfig) { - warn!("Settings: runtime config sync via IPC failed: {e}"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serial_test::serial; - - #[test] - fn toggle_row_spacing_is_consistent() { - let gap = ui_tokens::DENSITY_MEDIUM; - assert_eq!(toggle_row_step(false, gap), TOGGLE_ROW_HEIGHT + gap); - assert_eq!( - toggle_row_step(true, gap), - TOGGLE_ROW_DESC_OFFSET + TOGGLE_ROW_DESC_HEIGHT + gap - ); - } - - #[test] - fn mode_binding_tag_helpers_decode_expected_actions() { - assert_eq!(mode_from_tag(MODE_DICTATION_TAG), Some(WorkMode::Dictation)); - assert_eq!( - mode_from_disable_tag(MODE_FORMATTING_TAG + MODE_DISABLE_TAG_OFFSET), - Some(WorkMode::Formatting) - ); - assert!(mode_from_double_ctrl_tag(MODE_DICTATION_DOUBLE_CTRL_TAG)); - assert!(!mode_from_double_ctrl_tag(MODE_ASSISTIVE_TAG)); - } - - #[test] - fn mode_binding_selection_rejects_invalid_mode_binding_pairs() { - let settings = UserSettings::default(); - let err = - mode_binding_selection_error(WorkMode::Formatting, ShortcutBinding::HoldFn, &settings); - assert!(err.is_some()); - } - - #[test] - fn assistive_mode_accepts_hold_bindings() { - for binding in [ - ShortcutBinding::HoldFn, - ShortcutBinding::HoldCtrl, - ShortcutBinding::HoldCtrlAlt, - ShortcutBinding::HoldCtrlShift, - ShortcutBinding::HoldCtrlCmd, - ] { - assert!( - mode_accepts_binding(WorkMode::Assistive, binding), - "assistive should accept {:?}", - binding - ); - } - } - - #[test] - fn binding_from_recorded_event_maps_assistive_hold_ctrl_cmd() { - // NSEventModifierFlagControl | NSEventModifierFlagCommand - let flags = (1_u64 << 18) | (1_u64 << 20); - let binding = binding_from_recorded_event( - WorkMode::Assistive, - 12, // NSEventTypeFlagsChanged - 59, // Left Control - flags, - ); - assert_eq!(binding, Some(ShortcutBinding::HoldCtrlCmd)); - } - - #[test] - fn mode_binding_selection_blocks_option_modes_when_dictation_is_double_ctrl() { - let settings = UserSettings { - mode_bindings: Some(vec![ - crate::config::ModeBinding { - mode: WorkMode::Dictation, - binding: ShortcutBinding::DoubleCtrl, - }, - crate::config::ModeBinding { - mode: WorkMode::Formatting, - binding: ShortcutBinding::Disabled, - }, - crate::config::ModeBinding { - mode: WorkMode::Assistive, - binding: ShortcutBinding::Disabled, - }, - ]), - ..Default::default() - }; - assert_eq!( - settings.mode_binding_for(WorkMode::Dictation), - ShortcutBinding::DoubleCtrl - ); - let err = mode_binding_selection_error( - WorkMode::Assistive, - ShortcutBinding::DoubleRightOption, - &settings, - ); - assert!(err.is_some()); - } - - #[test] - fn hotkey_conflict_details_text_renders_all_conflicts() { - let details = hotkey_conflict_details_text(&[ - shortcut_registry::HotkeyConflict { - gesture: shortcut_registry::HotkeyGesture::HoldFn, - message: "Conflicts with Show Emoji & Symbols (macOS #160).".to_string(), - }, - shortcut_registry::HotkeyConflict { - gesture: shortcut_registry::HotkeyGesture::ToggleDoubleCtrl, - message: "Collides with Hold Ctrl.".to_string(), - }, - ]); - assert!(details.contains("1. Hold Fn/Globe -> Conflicts with Show Emoji & Symbols")); - assert!(details.contains("2. Double-tap Ctrl -> Collides with Hold Ctrl.")); - } - - #[test] - fn hotkey_conflict_details_text_handles_empty_list() { - assert_eq!( - hotkey_conflict_details_text(&[]), - "No conflicts detected in current mode shortcuts." - ); - } - - #[test] - fn preview_effective_interim_sec_clamps_without_overlay() { - assert_eq!(preview_effective_interim_sec(true, 1.2), 1.2); - assert_eq!( - preview_effective_interim_sec(false, 1.2), - PREVIEW_NO_OVERLAY_MIN_INTERIM_SEC - ); - } - - fn model_for_preset(values: PreviewTimingValues) -> PreviewTimingModel { - PreviewTimingModel { - overlay_enabled: true, - buffer_delay_ms: values.buffer_delay_ms, - typing_cps: values.typing_cps, - emit_words_max: values.emit_words_max, - requested_interim_sec: values.interim_sec, - effective_interim_sec: values.interim_sec, - } - } - - #[test] - fn preview_preset_values_anchor_smooth_operator_default() { - let smooth = preset_values(PreviewTimingPreset::Smooth).expect("smooth preset has values"); - assert_eq!(smooth.buffer_delay_ms, 1038); - assert!((smooth.typing_cps - 10.6).abs() < f32::EPSILON); - assert_eq!(smooth.emit_words_max, 5); - assert!((smooth.interim_sec - 8.0).abs() < f32::EPSILON); - } - - #[test] - fn preview_detect_preset_recognizes_presets_and_custom() { - for preset in [ - PreviewTimingPreset::Smooth, - PreviewTimingPreset::Snappy, - PreviewTimingPreset::Relaxed, - ] { - let values = preset_values(preset).expect("timing preset has values"); - assert_eq!(detect_preset(model_for_preset(values)), preset); - } - - let smooth = preset_values(PreviewTimingPreset::Smooth).expect("smooth preset has values"); - let off_model = PreviewTimingModel { - overlay_enabled: false, - ..model_for_preset(smooth) - }; - assert_eq!(detect_preset(off_model), PreviewTimingPreset::Off); - - let custom_model = PreviewTimingModel { - buffer_delay_ms: smooth.buffer_delay_ms + 25, - ..model_for_preset(smooth) - }; - assert_eq!(detect_preset(custom_model), PreviewTimingPreset::Custom); - } - - #[test] - fn preview_detect_preset_allows_small_tolerance() { - let smooth = preset_values(PreviewTimingPreset::Smooth).expect("smooth preset has values"); - let near_smooth = PreviewTimingModel { - buffer_delay_ms: smooth.buffer_delay_ms + 5, - typing_cps: smooth.typing_cps + 0.1, - requested_interim_sec: smooth.interim_sec - 0.1, - effective_interim_sec: smooth.interim_sec - 0.1, - ..model_for_preset(smooth) - }; - assert_eq!(detect_preset(near_smooth), PreviewTimingPreset::Smooth); - } - - #[test] - fn preview_emit_chunks_respect_emit_words_cap() { - let chunks = preview_emit_chunks("Partiale mają być appendowane teraz", 2); - assert_eq!( - chunks, - vec![ - "Partiale mają ".to_string(), - "być appendowane ".to_string(), - "teraz".to_string() - ] - ); - } - - #[test] - fn preview_timing_steps_grow_visible_text_monotonically() { - let model = PreviewTimingModel { - overlay_enabled: true, - buffer_delay_ms: 280, - typing_cps: 90.0, - emit_words_max: 2, - requested_interim_sec: 1.2, - effective_interim_sec: 1.2, - }; - let steps = preview_timing_steps(model); - assert!(!steps.is_empty(), "preview should produce visible steps"); - for pair in steps.windows(2) { - assert!( - pair[1].visible_text.starts_with(&pair[0].visible_text), - "preview should append forward, not reset" - ); - assert!( - pair[1].visible_at_ms >= pair[0].visible_at_ms, - "preview timestamps should be monotonic" - ); - } - let report = preview_timing_report_text(model); - assert!(report.contains("Chunker partial targets")); - assert!(report.contains("Overlay-visible text")); - } - - #[test] - fn prompt_editor_layout_does_not_overlap_fields_above() { - let gap = 10.0; - let controls_bottom_y = 220.0; - let layout = compute_prompt_editor_layout(controls_bottom_y, gap); - let editor_top = layout.editor_y + layout.editor_height; - - assert!( - editor_top <= controls_bottom_y + f64::EPSILON, - "editor overlaps controls above" - ); - assert!( - layout.status_y + gap <= layout.editor_y + f64::EPSILON, - "status must remain below editor" - ); - } - - #[test] - #[serial] - #[cfg(target_os = "macos")] - fn attach_settings_view_builds_root_view() { - if std::env::var("CODESCRIBE_UI_TESTS").is_err() { - return; - } - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_view = objc_class("NSView"); - let parent: Id = msg_send![ns_view, alloc]; - let parent: Id = msg_send![ - parent, - initWithFrame: core_graphics::geometry::CGRect::new( - &core_graphics::geometry::CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new(480.0, 320.0), - ) - ]; - - let frame = core_graphics::geometry::CGRect::new( - &core_graphics::geometry::CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new(480.0, 320.0), - ); - let view = attach_settings_view(parent, frame); - assert!(view.is_some()); - - reset_embedded_settings_state(); - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - assert!(state.root_view.is_none()); - } - } -} diff --git a/app/ui/settings/mode_bindings.rs b/app/ui/settings/mode_bindings.rs deleted file mode 100644 index eab4ec9b..00000000 --- a/app/ui/settings/mode_bindings.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! Mode shortcut binding recorder: capture, validation, and label refresh. - -use super::*; - -#[derive(Default)] -pub(super) struct ModeBindingRecorderState { - monitor_installed: bool, - target_mode: Option, -} - -lazy_static! { - static ref MODE_BINDING_RECORDER_STATE: Mutex = - Mutex::new(ModeBindingRecorderState::default()); -} - -pub(super) fn mode_from_tag(tag: isize) -> Option { - match tag { - MODE_DICTATION_TAG => Some(WorkMode::Dictation), - MODE_FORMATTING_TAG => Some(WorkMode::Formatting), - MODE_ASSISTIVE_TAG => Some(WorkMode::Assistive), - _ => None, - } -} - -pub(super) fn mode_from_disable_tag(tag: isize) -> Option { - mode_from_tag(tag - MODE_DISABLE_TAG_OFFSET) -} - -pub(super) fn mode_from_double_ctrl_tag(tag: isize) -> bool { - tag == MODE_DICTATION_DOUBLE_CTRL_TAG -} - -pub(super) fn mode_label_slot(mode: WorkMode) -> usize { - match mode { - WorkMode::Dictation => 0, - WorkMode::Formatting => 1, - WorkMode::Assistive => 2, - } -} - -pub(super) fn set_mode_recorder_hint(text: &str, is_error: bool) { - let hint_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.keys_recorder_hint_label - }; - let Some(hint_ptr) = hint_ptr else { - return; - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let hint_label = hint_ptr as Id; - set_text_field_string(hint_label, text); - let color = if is_error { - ui_colors::bubble_error_text() - } else { - crate::ui_helpers::color_secondary_label() - }; - let _: () = msg_send![hint_label, setTextColor: color]; - } -} - -pub(super) fn refresh_mode_binding_labels() { - let settings = UserSettings::load(); - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - for mode in [ - WorkMode::Dictation, - WorkMode::Formatting, - WorkMode::Assistive, - ] { - if let Some(label_ptr) = state.keys_mode_binding_labels[mode_label_slot(mode)] { - let text = settings.mode_binding_for(mode).label().to_string(); - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - set_text_field_string(label_ptr as Id, &text); - } - } - } -} - -pub(super) fn binding_from_recorded_event( - mode: WorkMode, - event_type: u64, - keycode: u16, - flags: u64, -) -> Option { - // NSEventModifierFlagShift/Control/Option/Command - const SHIFT: u64 = 1 << 17; - const CONTROL: u64 = 1 << 18; - const OPTION: u64 = 1 << 19; - const COMMAND: u64 = 1 << 20; - const EVENT_TYPE_FLAGS_CHANGED: u64 = 12; - - match mode { - WorkMode::Dictation => match keycode { - 63 => Some(ShortcutBinding::HoldFn), - 59 | 62 => { - if (flags & OPTION) != 0 { - Some(ShortcutBinding::HoldCtrlAlt) - } else if (flags & SHIFT) != 0 { - Some(ShortcutBinding::HoldCtrlShift) - } else if (flags & COMMAND) != 0 { - Some(ShortcutBinding::HoldCtrlCmd) - } else if event_type == EVENT_TYPE_FLAGS_CHANGED && (flags & CONTROL) != 0 { - Some(ShortcutBinding::HoldCtrl) - } else { - None - } - } - _ => None, - }, - WorkMode::Formatting => match keycode { - 58 => Some(ShortcutBinding::DoubleLeftOption), - _ => None, - }, - WorkMode::Assistive => match keycode { - 63 => Some(ShortcutBinding::HoldFn), - 59 | 62 => { - if (flags & OPTION) != 0 { - Some(ShortcutBinding::HoldCtrlAlt) - } else if (flags & SHIFT) != 0 { - Some(ShortcutBinding::HoldCtrlShift) - } else if (flags & COMMAND) != 0 { - Some(ShortcutBinding::HoldCtrlCmd) - } else if event_type == EVENT_TYPE_FLAGS_CHANGED && (flags & CONTROL) != 0 { - Some(ShortcutBinding::HoldCtrl) - } else { - None - } - } - 61 => Some(ShortcutBinding::DoubleRightOption), - _ => None, - }, - } -} - -pub(super) fn mode_accepts_binding(mode: WorkMode, binding: ShortcutBinding) -> bool { - match mode { - WorkMode::Dictation => matches!( - binding, - ShortcutBinding::Disabled - | ShortcutBinding::HoldFn - | ShortcutBinding::HoldCtrl - | ShortcutBinding::HoldCtrlAlt - | ShortcutBinding::HoldCtrlShift - | ShortcutBinding::HoldCtrlCmd - | ShortcutBinding::DoubleCtrl - ), - WorkMode::Formatting => { - matches!( - binding, - ShortcutBinding::Disabled | ShortcutBinding::DoubleLeftOption - ) - } - WorkMode::Assistive => { - matches!( - binding, - ShortcutBinding::Disabled - | ShortcutBinding::HoldFn - | ShortcutBinding::HoldCtrl - | ShortcutBinding::HoldCtrlAlt - | ShortcutBinding::HoldCtrlShift - | ShortcutBinding::HoldCtrlCmd - | ShortcutBinding::DoubleRightOption - ) - } - } -} - -pub(super) fn mode_binding_selection_error( - mode: WorkMode, - binding: ShortcutBinding, - settings: &UserSettings, -) -> Option { - if !mode_accepts_binding(mode, binding) { - return Some(format!( - "{} mode supports only {} bindings.", - mode.label(), - match mode { - WorkMode::Dictation => "hold modifiers or Double Ctrl", - WorkMode::Formatting => "Double Left Option", - WorkMode::Assistive => "hold modifiers or Double Right Option", - } - )); - } - - if mode != WorkMode::Dictation - && binding != ShortcutBinding::Disabled - && settings.mode_binding_for(WorkMode::Dictation) == ShortcutBinding::DoubleCtrl - && matches!( - binding, - ShortcutBinding::DoubleLeftOption | ShortcutBinding::DoubleRightOption - ) - { - return Some( - "Dictation is currently on Double Ctrl. Disable it first to use Option bindings." - .to_string(), - ); - } - - None -} - -pub(super) fn apply_mode_binding(mode: WorkMode, binding: ShortcutBinding) { - let mut settings = UserSettings::load(); - if let Some(message) = mode_binding_selection_error(mode, binding, &settings) { - set_mode_recorder_hint(&message, true); - return; - } - - settings.set_mode_binding(mode, binding); - - if mode == WorkMode::Dictation && binding == ShortcutBinding::DoubleCtrl { - settings.set_mode_binding(WorkMode::Formatting, ShortcutBinding::Disabled); - settings.set_mode_binding(WorkMode::Assistive, ShortcutBinding::Disabled); - } - - let config = Config::load(); - hotkeys::apply_hotkey_runtime_config(hotkeys::HotkeyRuntimeConfig::from(&config)); - sync_runtime_config_via_ipc(); - - refresh_mode_binding_labels(); - refresh_hotkey_conflict_indicator(); - set_mode_recorder_hint( - &format!("{} mode -> {}", mode.label(), binding.label()), - false, - ); -} - -pub(super) fn apply_recorded_mode_binding(mode: WorkMode, binding: ShortcutBinding) { - apply_mode_binding(mode, binding); -} - -pub(super) fn recorder_capture_mode() -> Option { - let recorder = MODE_BINDING_RECORDER_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - recorder.target_mode -} - -pub(super) fn recorder_clear_target_mode() { - let mut recorder = MODE_BINDING_RECORDER_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - recorder.target_mode = None; -} - -pub(super) fn handle_mode_binding_recorder_event(event: Id) -> Id { - let Some(mode) = recorder_capture_mode() else { - return event; - }; - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let event_type: u64 = msg_send![event, type]; - let keycode: u16 = msg_send![event, keyCode]; - let flags: u64 = msg_send![event, modifierFlags]; - - // Escape cancels recording. - if event_type == 10 && keycode == 53 { - recorder_clear_target_mode(); - set_mode_recorder_hint("Mode binding capture cancelled.", false); - return std::ptr::null_mut(); - } - - if let Some(binding) = binding_from_recorded_event(mode, event_type, keycode, flags) { - recorder_clear_target_mode(); - apply_recorded_mode_binding(mode, binding); - return std::ptr::null_mut(); - } - } - - set_mode_recorder_hint( - "Unsupported shortcut for this mode. Press Esc to cancel capture.", - true, - ); - std::ptr::null_mut() -} - -pub(super) fn ensure_mode_binding_recorder_monitor() -> bool { - let should_install = { - let recorder = MODE_BINDING_RECORDER_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - !recorder.monitor_installed - }; - if !should_install { - return true; - } - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_event = objc_class("NSEvent"); - let mask: u64 = (1_u64 << 10) | (1_u64 << 12); // keyDown + flagsChanged - let handler = block::ConcreteBlock::new(|event: Id| -> Id { - handle_mode_binding_recorder_event(event) - }) - .copy(); - let monitor: Id = - msg_send![ns_event, addLocalMonitorForEventsMatchingMask: mask handler: &*handler]; - if monitor.is_null() { - warn!("Mode binding recorder: failed to install local event monitor"); - return false; - } - std::mem::forget(handler); - } - - let mut recorder = MODE_BINDING_RECORDER_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - recorder.monitor_installed = true; - true -} - -pub(super) fn start_mode_binding_recorder(mode: WorkMode) { - if !ensure_mode_binding_recorder_monitor() { - set_mode_recorder_hint("Mode binding recorder failed to initialize.", true); - return; - } - { - let mut recorder = MODE_BINDING_RECORDER_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - recorder.target_mode = Some(mode); - } - set_mode_recorder_hint( - &format!( - "Recording {} binding... Press Fn/Ctrl/Option (Esc to cancel).", - mode.label() - ), - false, - ); -} diff --git a/app/ui/settings/modes_tab.rs b/app/ui/settings/modes_tab.rs deleted file mode 100644 index dd48b00f..00000000 --- a/app/ui/settings/modes_tab.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! AppKit builder for the Modes & Shortcuts settings tab. - -use super::*; - -pub(super) unsafe fn build_modes_shortcuts_tab( - action_handler: Id, - frame: core_graphics::geometry::CGRect, - config: &Config, - state: &mut SettingsWindowState, -) -> Id { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_view = objc_class("NSView"); - - let container: Id = msg_send![ns_view, alloc]; - let container: Id = msg_send![container, initWithFrame: frame]; - - let pad = ui_tokens::EDGE_PADDING; - let content_w = frame.size.width - pad * 2.0; - let gap = ui_tokens::DENSITY_COMFORTABLE; - let mut y = frame.size.height - (24.0 + gap); - let primary = crate::ui_helpers::color_label(); - let secondary = crate::ui_helpers::color_secondary_label(); - let mono_font_input = crate::ui_helpers::monospace_font(ui_tokens::BODY_FONT_SIZE); - - let title = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 24.0)), - text: "Keys".to_string(), - font_size: ui_tokens::TITLE_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, title); - y -= 24.0 + gap; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= gap; - - let subtitle = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Mode hotkeys, trigger timing, and AI provider keys live together here." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, subtitle); - y -= 16.0 + gap; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 358.0); - let bindings_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Mode Bindings".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, bindings_header); - y -= 18.0 + gap; - - let usage_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 28.0)), - text: "Hold records while pressed. Double-tap records hands-free; repeat the gesture to stop.".to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, usage_hint); - y -= 28.0 + gap; - - let mode_specs = [ - (WorkMode::Dictation, MODE_DICTATION_TAG), - (WorkMode::Formatting, MODE_FORMATTING_TAG), - (WorkMode::Assistive, MODE_ASSISTIVE_TAG), - ]; - let settings_snapshot = UserSettings::load(); - let mut mode_binding_labels: [Option; 3] = [None; 3]; - for (mode, tag) in mode_specs { - let disable_button_w = 72.0; - let disable_x = pad + content_w - disable_button_w; - let binding_right_x = disable_x - 8.0; - - let row_title = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(126.0, 20.0)), - text: format!("{}:", mode.label()), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, row_title); - - let binding_x = pad + 128.0; - let binding_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(binding_x, y), - &CGSize::new((binding_right_x - binding_x).max(140.0), 20.0), - ), - text: settings_snapshot.mode_binding_for(mode).label().to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, binding_label); - mode_binding_labels[mode_label_slot(mode)] = Some(binding_label as usize); - - let disable_button = create_button( - CGRect::new( - &CGPoint::new(disable_x, y - 2.0), - &CGSize::new(disable_button_w, 24.0), - ), - "Disable", - button_style::GLASS, - ); - let _: () = msg_send![disable_button, setTag: tag + MODE_DISABLE_TAG_OFFSET]; - button_set_action(disable_button, action_handler, sel!(onModeBindingChange:)); - add_subview(container, disable_button); - - y -= 24.0; - - let mode_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 14.0)), - text: format!( - "{} {} {}", - mode.description(), - if mode.defaults_to_auto_paste() { - "Auto-paste: ON." - } else { - "Auto-paste: OFF." - }, - if mode.forces_ai() { - "AI required." - } else { - "AI optional." - } - ), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, mode_hint); - y -= 14.0 + gap; - - if mode == WorkMode::Assistive { - let selection_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 14.0)), - text: "Tip: Select text in the frontmost app before triggering Assistive to operate on the selection.".to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, selection_hint); - y -= 14.0 + gap; - } - } - - let recorder_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Mode shortcuts are fixed presets for now. Use Disable to turn a mode off." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, recorder_hint); - y -= 16.0 + gap; - - if let Some(fn_note) = shortcut_registry::fn_tap_intercept_note(&settings_snapshot) { - let fn_note_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 28.0)), - text: fn_note.to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, fn_note_label); - y -= 28.0 + gap; - } - - let (conflict_text, has_conflict) = hotkey_conflict_status(config); - let conflict_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w - 130.0, 28.0)), - text: conflict_text, - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: if has_conflict { - ui_colors::bubble_error_text() - } else { - secondary - }, - ..Default::default() - }); - add_subview(container, conflict_label); - - let conflict_details_button = create_button( - CGRect::new( - &CGPoint::new(pad + content_w - 120.0, y + 2.0), - &CGSize::new(120.0, 24.0), - ), - "View conflicts", - button_style::GLASS, - ); - button_set_action( - conflict_details_button, - action_handler, - sel!(onShowHotkeyConflicts:), - ); - let _: () = msg_send![conflict_details_button, setEnabled: has_conflict]; - add_subview(container, conflict_details_button); - y -= 28.0 + gap; - - let config_divider = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 1.0)), - text: String::new(), - background_color: Some(ui_colors::surface_border()), - ..Default::default() - }); - let _: () = msg_send![config_divider, setAlphaValue: 0.9f64]; - add_subview(container, config_divider); - y -= gap; - - let api_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Tighten the shortcut feel here. These controls affect trigger responsiveness, not transcript quality." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, api_hint); - y -= 16.0 + gap; - - let timing_divider = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 1.0)), - text: String::new(), - background_color: Some(ui_colors::surface_border()), - ..Default::default() - }); - let _: () = msg_send![timing_divider, setAlphaValue: 0.9f64]; - add_subview(container, timing_divider); - y -= ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 100.0); - let timing_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Trigger Timing".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, timing_header); - y -= 18.0 + gap; - - let delay_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(120.0, 20.0)), - text: "Hold delay (ms):".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, delay_label); - - let delay_ms = config.hold_start_delay_ms as f64; - let value_w = 60.0; - let value_gap = 8.0; - let slider_w = (content_w - 124.0 - value_gap - value_w).max(120.0); - let delay_slider = create_slider( - CGRect::new(&CGPoint::new(pad + 124.0, y), &CGSize::new(slider_w, 20.0)), - 200.0, - 1500.0, - delay_ms, - ); - button_set_action(delay_slider, action_handler, sel!(onDelayChanged:)); - add_subview(container, delay_slider); - - let delay_value = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad + 124.0 + slider_w + value_gap, y - 1.0), - &CGSize::new(value_w, 20.0), - ), - text: format!("{} ms", delay_ms.round() as u64), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, delay_value); - state.hold_delay_value_label = Some(delay_value as usize); - y -= 20.0 + gap; - - let double_tap_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(160.0, 20.0)), - text: "Double-tap interval (ms):".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, double_tap_label); - - let double_tap_ms = config.double_tap_interval_ms as f64; - let double_tap_slider_w = (content_w - 164.0 - value_gap - value_w).max(120.0); - let double_tap_slider = create_slider( - CGRect::new( - &CGPoint::new(pad + 164.0, y), - &CGSize::new(double_tap_slider_w, 20.0), - ), - 100.0, - 450.0, - double_tap_ms, - ); - button_set_action( - double_tap_slider, - action_handler, - sel!(onDoubleTapIntervalChanged:), - ); - add_subview(container, double_tap_slider); - - let double_tap_value = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad + 164.0 + double_tap_slider_w + value_gap, y - 1.0), - &CGSize::new(value_w, 20.0), - ), - text: format!("{} ms", double_tap_ms.round() as u64), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, double_tap_value); - state.double_tap_value_label = Some(double_tap_value as usize); - y -= 20.0 + ui_tokens::SECTION_GAP; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 286.0); - let provider_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "AI Providers".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, provider_header); - y -= 18.0 + gap; - - let provider_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "OpenAI Responses is the default. Formatting and Assistive can still use separate keys, models, or endpoints.".to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, provider_hint); - y -= 16.0 + gap; - - let llm_endpoint_val = std::env::var("LLM_FORMATTING_ENDPOINT") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(codescribe_core::config::default_llm_endpoint); - let llm_endpoint_field = create_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Formatting endpoint (OpenAI Responses)", - &llm_endpoint_val, - ); - style_paper_input(llm_endpoint_field); - let _: () = msg_send![llm_endpoint_field, setFont: mono_font_input]; - button_set_action( - llm_endpoint_field, - action_handler, - sel!(onLlmEndpointChanged:), - ); - add_subview(container, llm_endpoint_field); - state.llm_endpoint_field = Some(llm_endpoint_field as usize); - y -= SETTINGS_INPUT_HEIGHT + gap; - - let llm_model_val = std::env::var("LLM_FORMATTING_MODEL") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(codescribe_core::config::default_formatting_model); - let llm_model_field = create_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Formatting model (gpt-4.1)", - &llm_model_val, - ); - style_paper_input(llm_model_field); - let _: () = msg_send![llm_model_field, setFont: mono_font_input]; - button_set_action(llm_model_field, action_handler, sel!(onLlmModelChanged:)); - add_subview(container, llm_model_field); - state.llm_model_field = Some(llm_model_field as usize); - y -= SETTINGS_INPUT_HEIGHT + gap; - - let llm_key_field = create_secure_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Formatting OpenAI API key (stored in Keychain)", - ); - style_paper_input(llm_key_field); - let _: () = msg_send![llm_key_field, setFont: mono_font_input]; - button_set_action(llm_key_field, action_handler, sel!(onLlmKeyChanged:)); - add_subview(container, llm_key_field); - state.llm_key_field = Some(llm_key_field as usize); - y -= SETTINGS_INPUT_HEIGHT + gap; - - let llm_key_status = formatting_key_is_set(); - let llm_status_icon = create_key_status_indicator( - CGRect::new( - &CGPoint::new(pad, y + 1.0), - &CGSize::new(KEY_STATUS_ICON_SIZE, KEY_STATUS_ICON_SIZE), - ), - llm_key_status, - ); - add_subview(container, llm_status_icon); - state.llm_key_status_icon = Some(llm_status_icon as usize); - - let llm_status_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad + KEY_STATUS_ICON_SIZE + 6.0, y), - &CGSize::new(content_w - KEY_STATUS_ICON_SIZE - 6.0, 16.0), - ), - text: key_status_text(llm_key_status).to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: key_status_color(llm_key_status), - ..Default::default() - }); - add_subview(container, llm_status_label); - state.llm_key_status_label = Some(llm_status_label as usize); - y -= 16.0 + gap; - - let assist_endpoint_val = std::env::var("LLM_ASSISTIVE_ENDPOINT") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(codescribe_core::config::default_llm_endpoint); - let assist_endpoint_field = create_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Assistive endpoint (OpenAI Responses)", - &assist_endpoint_val, - ); - style_paper_input(assist_endpoint_field); - let _: () = msg_send![assist_endpoint_field, setFont: mono_font_input]; - button_set_action( - assist_endpoint_field, - action_handler, - sel!(onAssistiveEndpointChanged:), - ); - add_subview(container, assist_endpoint_field); - state.assistive_endpoint_field = Some(assist_endpoint_field as usize); - y -= SETTINGS_INPUT_HEIGHT + gap; - - let assist_model_val = std::env::var("LLM_ASSISTIVE_MODEL") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(codescribe_core::config::default_assistive_model); - let assist_model_field = create_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Assistive model (gpt-5.5)", - &assist_model_val, - ); - style_paper_input(assist_model_field); - let _: () = msg_send![assist_model_field, setFont: mono_font_input]; - button_set_action( - assist_model_field, - action_handler, - sel!(onAssistiveModelChanged:), - ); - add_subview(container, assist_model_field); - state.assistive_model_field = Some(assist_model_field as usize); - y -= SETTINGS_INPUT_HEIGHT + gap; - - let assist_key_field = create_secure_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Assistive OpenAI API key (stored in Keychain)", - ); - style_paper_input(assist_key_field); - let _: () = msg_send![assist_key_field, setFont: mono_font_input]; - button_set_action( - assist_key_field, - action_handler, - sel!(onAssistiveKeyChanged:), - ); - add_subview(container, assist_key_field); - state.assistive_key_field = Some(assist_key_field as usize); - y -= SETTINGS_INPUT_HEIGHT + gap; - - let assist_key_status = keychain_key_is_set("LLM_ASSISTIVE_API_KEY"); - let assist_status_icon = create_key_status_indicator( - CGRect::new( - &CGPoint::new(pad, y + 1.0), - &CGSize::new(KEY_STATUS_ICON_SIZE, KEY_STATUS_ICON_SIZE), - ), - assist_key_status, - ); - add_subview(container, assist_status_icon); - state.assistive_key_status_icon = Some(assist_status_icon as usize); - - let assist_status_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad + KEY_STATUS_ICON_SIZE + 6.0, y), - &CGSize::new(content_w - KEY_STATUS_ICON_SIZE - 6.0, 16.0), - ), - text: key_status_text(assist_key_status).to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: key_status_color(assist_key_status), - ..Default::default() - }); - add_subview(container, assist_status_label); - state.assistive_key_status_label = Some(assist_status_label as usize); - y -= 16.0 + gap; - - let save_btn = create_button( - CGRect::new( - &CGPoint::new(pad + content_w - 90.0, y - 2.0), - &CGSize::new(90.0, 24.0), - ), - "Save AI", - button_style::GLASS, - ); - button_set_action(save_btn, action_handler, sel!(onSaveApiSettings:)); - add_subview(container, save_btn); - - state.keys_mode_binding_labels = mode_binding_labels; - state.keys_recorder_hint_label = Some(recorder_hint as usize); - state.keys_conflict_label = Some(conflict_label as usize); - state.keys_conflict_details_button = Some(conflict_details_button as usize); - - container - } // unsafe -} - -// ============================================================================ -// AI & Prompts tab -// ============================================================================ diff --git a/app/ui/settings/permissions.rs b/app/ui/settings/permissions.rs deleted file mode 100644 index eee4689b..00000000 --- a/app/ui/settings/permissions.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Permission rows, grant actions, and permission polling for the settings window. - -use super::*; - -pub(super) fn permission_color(granted: bool) -> Id { - if granted { - ui_colors::status_granted() - } else { - ui_colors::status_denied() - } -} - -pub(super) fn permission_row_label(kind: PermissionKind) -> &'static str { - kind.title() -} - -pub(super) fn permission_action_title( - kind: PermissionKind, - status: PermissionStatus, - requested: bool, -) -> Option<&'static str> { - if status == PermissionStatus::Granted { - None - } else if kind == PermissionKind::FullDiskAccess || requested { - Some("Open Settings") - } else { - Some("Grant") - } -} - -pub(super) fn permission_kind_from_tag(tag: isize) -> Option { - if tag < 0 { - return None; - } - PERMISSION_ORDER.get(tag as usize).copied() -} - -pub(super) fn open_system_settings_security() { - let _ = std::process::Command::new("open") - .arg("x-apple.systempreferences:com.apple.preference.security") - .spawn(); -} - -pub(super) fn handle_permission_action(kind: PermissionKind) { - let idx = kind.index(); - let already_requested = { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - let was_requested = state.permission_requested[idx]; - state.permission_requested[idx] = true; - was_requested - }; - - if kind == PermissionKind::FullDiskAccess || already_requested { - open_permission_settings(kind); - refresh_permission_indicators(); - return; - } - - if kind == PermissionKind::Microphone { - thread::spawn(move || { - let _ = request_permission(kind); - reconcile_permission_runtime_after_grant(kind); - refresh_permission_indicators(); - }); - refresh_permission_indicators(); - return; - } - - let granted = request_permission(kind); - if !granted - && matches!( - kind, - PermissionKind::Accessibility | PermissionKind::InputMonitoring - ) - { - open_permission_settings(kind); - } - - if granted { - reconcile_permission_runtime_after_grant(kind); - } - - refresh_permission_indicators(); -} - -pub(super) fn start_permission_polling() { - let should_start = { - let mut state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - if state.permission_polling { - false - } else { - state.permission_polling = true; - true - } - }; - - if !should_start { - return; - } - - thread::spawn(|| { - loop { - thread::sleep(Duration::from_secs(2)); - let keep_running = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.permission_polling - }; - if !keep_running { - break; - } - refresh_permission_indicators(); - } - }); -} - -pub(super) fn refresh_permission_indicators() { - Queue::main().exec_async(move || unsafe { - let (labels, action_buttons, requested) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.permission_labels, - state.permission_action_buttons, - state.permission_requested, - ) - }; - - for kind in PERMISSION_ORDER { - let idx = kind.index(); - let status = permission_status(kind); - let granted = status == PermissionStatus::Granted; - let marker = if granted { "\u{2713}" } else { "\u{2715}" }; - let text = format!("{marker} {}", permission_row_label(kind)); - - if let Some(label_ptr) = labels[idx] { - let label = label_ptr as Id; - set_text_field_string(label, &text); - let color = permission_color(granted); - let _: () = msg_send![label, setTextColor: color]; - } - - if let Some(button_ptr) = action_buttons[idx] { - let action_button = button_ptr as Id; - if let Some(title) = permission_action_title(kind, status, requested[idx]) { - let _: () = msg_send![action_button, setHidden: false]; - let _: () = msg_send![action_button, setTitle: ns_string(title)]; - } else { - let _: () = msg_send![action_button, setHidden: true]; - } - } - } - - refresh_diagnostics_dashboard(); - }); -} - -pub(super) fn permission_status_text(status: PermissionStatus) -> &'static str { - match status { - PermissionStatus::Granted => "Granted", - PermissionStatus::Denied => "Denied", - PermissionStatus::NotDetermined => "Not determined", - } -} - -pub(super) fn permission_status_color(status: PermissionStatus) -> Id { - match status { - PermissionStatus::Granted => ui_colors::status_granted(), - PermissionStatus::Denied => ui_colors::status_denied(), - PermissionStatus::NotDetermined => ui_colors::status_warning(), - } -} diff --git a/app/ui/settings/preview.rs b/app/ui/settings/preview.rs deleted file mode 100644 index 0cd33f5b..00000000 --- a/app/ui/settings/preview.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! Transcription preview timing model and preview panel refresh. - -use super::*; - -pub(super) const PREVIEW_TIMING_ENV_KEYS: [&str; 4] = [ - "CODESCRIBE_BUFFER_DELAY_MS", - "CODESCRIBE_TYPING_CPS", - "CODESCRIBE_EMIT_WORDS_MAX", - "CODESCRIBE_BUFFERED_INTERIM_SEC", -]; - -pub(super) const PREVIEW_PRESET_LABELS: [&str; 5] = - ["Smooth", "Snappy", "Relaxed", "Off", "Custom"]; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum PreviewTimingPreset { - Smooth, - Snappy, - Relaxed, - Off, - Custom, -} - -impl PreviewTimingPreset { - pub(super) fn segment_index(self) -> isize { - match self { - Self::Smooth => 0, - Self::Snappy => 1, - Self::Relaxed => 2, - Self::Off => 3, - Self::Custom => 4, - } - } - - pub(super) fn from_segment_index(index: isize) -> Self { - match index { - 0 => Self::Smooth, - 1 => Self::Snappy, - 2 => Self::Relaxed, - 3 => Self::Off, - _ => Self::Custom, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(super) struct PreviewTimingValues { - pub(super) buffer_delay_ms: u64, - pub(super) typing_cps: f32, - pub(super) emit_words_max: usize, - pub(super) interim_sec: f32, -} - -// Source: operator screenshot, 2026-06-11. This is the recommended default -// because it produced the smoothest live preview in real dictation use. -const SMOOTH_PREVIEW_TIMING: PreviewTimingValues = PreviewTimingValues { - buffer_delay_ms: 1038, - typing_cps: 10.6, - emit_words_max: 5, - interim_sec: 8.0, -}; - -const SNAPPY_PREVIEW_TIMING: PreviewTimingValues = PreviewTimingValues { - buffer_delay_ms: 350, - typing_cps: 28.0, - emit_words_max: 3, - interim_sec: 4.0, -}; - -const RELAXED_PREVIEW_TIMING: PreviewTimingValues = PreviewTimingValues { - buffer_delay_ms: 1500, - typing_cps: 8.0, - emit_words_max: 8, - interim_sec: 8.0, -}; - -pub(super) fn preset_values(preset: PreviewTimingPreset) -> Option { - match preset { - PreviewTimingPreset::Smooth => Some(SMOOTH_PREVIEW_TIMING), - PreviewTimingPreset::Snappy => Some(SNAPPY_PREVIEW_TIMING), - PreviewTimingPreset::Relaxed => Some(RELAXED_PREVIEW_TIMING), - PreviewTimingPreset::Off | PreviewTimingPreset::Custom => None, - } -} - -pub(super) fn detect_preset(model: PreviewTimingModel) -> PreviewTimingPreset { - if !model.overlay_enabled { - return PreviewTimingPreset::Off; - } - - for preset in [ - PreviewTimingPreset::Smooth, - PreviewTimingPreset::Snappy, - PreviewTimingPreset::Relaxed, - ] { - if let Some(values) = preset_values(preset) - && preview_timing_values_match(model, values) - { - return preset; - } - } - - PreviewTimingPreset::Custom -} - -fn preview_timing_values_match(model: PreviewTimingModel, values: PreviewTimingValues) -> bool { - let buffer_close = model.buffer_delay_ms.abs_diff(values.buffer_delay_ms) <= 10; - let cps_close = (model.typing_cps - values.typing_cps).abs() <= 0.15; - let words_match = model.emit_words_max == values.emit_words_max; - let interim_close = (model.requested_interim_sec - values.interim_sec).abs() <= 0.15; - - buffer_close && cps_close && words_match && interim_close -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(super) struct PreviewTimingModel { - pub(super) overlay_enabled: bool, - pub(super) buffer_delay_ms: u64, - pub(super) typing_cps: f32, - pub(super) emit_words_max: usize, - pub(super) requested_interim_sec: f32, - pub(super) effective_interim_sec: f32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct PreviewTimingStep { - publish_ms: u64, - pub(super) visible_at_ms: u64, - pub(super) target_text: String, - pub(super) visible_text: String, -} - -pub(super) fn preview_effective_interim_sec( - overlay_enabled: bool, - requested_interim_sec: f32, -) -> f32 { - let requested = requested_interim_sec.clamp(1.0, 30.0); - if overlay_enabled { - requested - } else { - requested.max(PREVIEW_NO_OVERLAY_MIN_INTERIM_SEC) - } -} - -pub(super) fn current_preview_timing_model() -> PreviewTimingModel { - let config = Config::load(); - let settings = UserSettings::load(); - let requested_interim_sec = settings - .buffered_interim_sec - .unwrap_or(SMOOTH_PREVIEW_TIMING.interim_sec); - - PreviewTimingModel { - overlay_enabled: config.transcription_overlay_enabled, - buffer_delay_ms: settings - .buffer_delay_ms - .unwrap_or(SMOOTH_PREVIEW_TIMING.buffer_delay_ms), - typing_cps: settings - .typing_cps - .unwrap_or(SMOOTH_PREVIEW_TIMING.typing_cps) - .max(5.0), - emit_words_max: settings - .emit_words_max - .unwrap_or(SMOOTH_PREVIEW_TIMING.emit_words_max as u64) - .clamp(1, 10) as usize, - requested_interim_sec, - effective_interim_sec: preview_effective_interim_sec( - config.transcription_overlay_enabled, - requested_interim_sec, - ), - } -} - -pub(super) fn preview_timing_has_env_override() -> bool { - let settings = UserSettings::load(); - let process_override = PREVIEW_TIMING_ENV_KEYS.iter().any(|key| { - std::env::var(key) - .map(|value| preview_env_value_differs_from_settings(key, &value, &settings)) - .unwrap_or(false) - }); - if process_override { - return true; - } - - let env_path = Config::env_path(); - Config::parse_env_file(&env_path) - .map(|vars| { - PREVIEW_TIMING_ENV_KEYS - .iter() - .any(|key| vars.contains_key(*key)) - }) - .unwrap_or(false) -} - -fn preview_env_value_differs_from_settings( - key: &str, - value: &str, - settings: &UserSettings, -) -> bool { - match key { - "CODESCRIBE_BUFFER_DELAY_MS" => settings - .buffer_delay_ms - .map(|v| value != v.to_string()) - .unwrap_or(true), - "CODESCRIBE_TYPING_CPS" => settings - .typing_cps - .map(|v| value != format!("{v:.1}")) - .unwrap_or(true), - "CODESCRIBE_EMIT_WORDS_MAX" => settings - .emit_words_max - .map(|v| value != v.to_string()) - .unwrap_or(true), - "CODESCRIBE_BUFFERED_INTERIM_SEC" => settings - .buffered_interim_sec - .map(|v| value != format!("{v:.1}")) - .unwrap_or(true), - _ => false, - } -} - -pub(super) fn preview_tokenize_for_emit(text: &str) -> Vec { - let mut tokens = Vec::new(); - let chars: Vec = text.chars().collect(); - let mut cursor = 0usize; - - while cursor < chars.len() { - let mut token = String::new(); - if chars[cursor].is_whitespace() { - while cursor < chars.len() && chars[cursor].is_whitespace() { - token.push(chars[cursor]); - cursor += 1; - } - } else { - while cursor < chars.len() && !chars[cursor].is_whitespace() { - token.push(chars[cursor]); - cursor += 1; - } - while cursor < chars.len() && chars[cursor].is_whitespace() { - token.push(chars[cursor]); - cursor += 1; - } - } - tokens.push(token); - } - if tokens.is_empty() && !text.is_empty() { - tokens.push(text.to_string()); - } - tokens -} - -pub(super) fn preview_emit_chunks(text: &str, emit_words_max: usize) -> Vec { - let tokens = preview_tokenize_for_emit(text); - let mut chunks = Vec::new(); - let mut current_index = 0usize; - - while current_index < tokens.len() { - let mut chunk = String::new(); - let mut words = 0usize; - while current_index < tokens.len() { - let token = &tokens[current_index]; - chunk.push_str(token); - if token.chars().any(|c| !c.is_whitespace()) { - words += 1; - } - current_index += 1; - - if words >= emit_words_max { - if current_index < tokens.len() { - let next = &tokens[current_index]; - if next.chars().all(|c| c.is_whitespace()) { - chunk.push_str(next); - current_index += 1; - } - } - break; - } - } - - if !chunk.is_empty() { - chunks.push(chunk); - } - } - - chunks -} - -pub(super) fn preview_partial_targets(sample: &str, interim_sec: f32) -> Vec<(u64, String)> { - let words: Vec<&str> = sample.split_whitespace().collect(); - if words.is_empty() { - return Vec::new(); - } - - let total_duration = PREVIEW_SAMPLE_UTTERANCE_SEC.max(interim_sec); - let total_words = words.len(); - let mut targets = Vec::new(); - let mut reveal_cursor = 0usize; - let mut t = interim_sec; - - while t < total_duration { - let progress = (t / total_duration).clamp(0.0, 1.0); - let reveal_words = ((total_words as f32 * progress).ceil() as usize) - .clamp(1, total_words) - .max((reveal_cursor + 1).min(total_words)); - if reveal_words > reveal_cursor { - reveal_cursor = reveal_words; - targets.push(( - (t * 1000.0).round() as u64, - words[..reveal_cursor].join(" "), - )); - } - t += interim_sec; - } - - if reveal_cursor < total_words { - targets.push(( - (total_duration * 1000.0).round() as u64, - words[..total_words].join(" "), - )); - } - - targets -} - -pub(super) fn preview_timing_steps(model: PreviewTimingModel) -> Vec { - let partial_targets = preview_partial_targets(PREVIEW_SAMPLE_TEXT, model.effective_interim_sec); - let tick_ms = ((1000.0 / model.typing_cps as f64).round() as u64).max(1); - let mut visible_text = String::new(); - let mut previous_target = String::new(); - let mut last_emit_done_ms = 0u64; - let mut steps = Vec::new(); - - for (index, (publish_ms, target_text)) in partial_targets.into_iter().enumerate() { - let suffix = if target_text.starts_with(&previous_target) { - target_text[previous_target.len()..].to_string() - } else { - target_text.clone() - }; - - if suffix.is_empty() { - previous_target = target_text; - continue; - } - - let start_ms = if index == 0 { - publish_ms - } else { - publish_ms - .saturating_add(model.buffer_delay_ms) - .max(last_emit_done_ms) - }; - - let mut current_ms = start_ms; - for chunk in preview_emit_chunks(&suffix, model.emit_words_max) { - visible_text.push_str(&chunk); - steps.push(PreviewTimingStep { - publish_ms, - visible_at_ms: current_ms, - target_text: target_text.clone(), - visible_text: visible_text.clone(), - }); - current_ms = current_ms.saturating_add(tick_ms); - } - - last_emit_done_ms = current_ms; - previous_target = target_text; - } - - steps -} - -pub(super) fn preview_timing_summary_text(model: PreviewTimingModel) -> String { - let tick_ms = ((1000.0 / model.typing_cps as f64).round() as u64).max(1); - if model.overlay_enabled { - format!( - "Overlay ON • partial target every {:.1}s • first output immediate • later growth +{}ms • {} words/tick • {}ms between ticks", - model.effective_interim_sec, model.buffer_delay_ms, model.emit_words_max, tick_ms - ) - } else { - format!( - "Overlay OFF • runtime hides floating preview • cadence clamped to {:.1}s (requested {:.1}s) • if shown: +{}ms buffer • {} words/tick • {}ms ticks", - model.effective_interim_sec, - model.requested_interim_sec, - model.buffer_delay_ms, - model.emit_words_max, - tick_ms - ) - } -} - -pub(super) fn preview_timing_report_text(model: PreviewTimingModel) -> String { - let partial_targets = preview_partial_targets(PREVIEW_SAMPLE_TEXT, model.effective_interim_sec); - let steps = preview_timing_steps(model); - let mut lines = Vec::new(); - lines.push(format!("Sample: {PREVIEW_SAMPLE_TEXT}")); - lines.push(String::new()); - lines.push("Chunker partial targets".to_string()); - for (publish_ms, target_text) in partial_targets { - lines.push(format!( - "[{:.1}s] {}", - publish_ms as f32 / 1000.0, - target_text - )); - } - lines.push(String::new()); - lines.push(if model.overlay_enabled { - "Overlay-visible text".to_string() - } else { - "Overlay-visible text (would look like this if overlay was enabled)".to_string() - }); - for step in steps { - lines.push(format!( - "[publish {:.1}s -> visible {:.2}s] {}", - step.publish_ms as f32 / 1000.0, - step.visible_at_ms as f32 / 1000.0, - step.visible_text - )); - } - lines.join("\n") -} - -pub(super) fn refresh_transcription_preview_panel() { - let model = current_preview_timing_model(); - let env_override = preview_timing_has_env_override(); - let preview_text = preview_timing_report_text(model); - let summary_text = preview_timing_summary_text(model); - let ( - buffer_delay_label, - buffer_delay_slider, - typing_cps_label, - typing_cps_slider, - emit_words_label, - emit_words_slider, - interim_label, - interim_slider, - summary_label, - preview_text_view, - preset_segment, - env_override_label, - forced_custom, - ) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.preview_buffer_delay_value_label, - state.preview_buffer_delay_slider, - state.preview_typing_cps_value_label, - state.preview_typing_cps_slider, - state.preview_emit_words_max_value_label, - state.preview_emit_words_max_slider, - state.preview_interim_sec_value_label, - state.preview_interim_sec_slider, - state.preview_timing_summary_label, - state.preview_timing_text_view, - state.preview_preset_segment, - state.preview_env_override_label, - state.preview_timing_forced_custom, - ) - }; - let preset = if forced_custom { - PreviewTimingPreset::Custom - } else { - detect_preset(model) - }; - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - if let Some(ptr) = buffer_delay_label { - set_text_field_string(ptr as Id, &format!("{} ms", model.buffer_delay_ms)); - } - if let Some(ptr) = buffer_delay_slider { - let _: () = msg_send![ptr as Id, setDoubleValue: model.buffer_delay_ms as f64]; - } - if let Some(ptr) = typing_cps_label { - set_text_field_string(ptr as Id, &format!("{:.1} cps", model.typing_cps)); - } - if let Some(ptr) = typing_cps_slider { - let _: () = msg_send![ptr as Id, setDoubleValue: model.typing_cps as f64]; - } - if let Some(ptr) = emit_words_label { - set_text_field_string(ptr as Id, &format!("{} words", model.emit_words_max)); - } - if let Some(ptr) = emit_words_slider { - let _: () = msg_send![ptr as Id, setDoubleValue: model.emit_words_max as f64]; - } - if let Some(ptr) = interim_label { - let label = if (model.effective_interim_sec - model.requested_interim_sec).abs() - > f32::EPSILON - { - format!( - "{:.1} s -> {:.1} s effective", - model.requested_interim_sec, model.effective_interim_sec - ) - } else { - format!("{:.1} s", model.effective_interim_sec) - }; - set_text_field_string(ptr as Id, &label); - } - if let Some(ptr) = interim_slider { - let _: () = msg_send![ptr as Id, setDoubleValue: model.requested_interim_sec as f64]; - } - if let Some(ptr) = summary_label { - set_text_field_string(ptr as Id, &summary_text); - } - if let Some(ptr) = preview_text_view { - set_text_view_string(ptr as Id, &preview_text); - } - if let Some(ptr) = preset_segment { - let _: () = msg_send![ptr as Id, setSelectedSegment: preset.segment_index()]; - } - if let Some(ptr) = env_override_label { - set_text_field_string( - ptr as Id, - if env_override { - "overridden by .env" - } else { - "" - }, - ); - let _: () = msg_send![ptr as Id, setHidden: !env_override]; - } - } - refresh_preview_advanced_visibility(); -} - -pub(super) fn refresh_preview_advanced_visibility() { - let (advanced_rows, advanced_button, expanded) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - ( - state.preview_advanced_rows.clone(), - state.preview_advanced_button, - state.preview_advanced_expanded, - ) - }; - - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - for ptr in advanced_rows { - let _: () = msg_send![ptr as Id, setHidden: !expanded]; - } - if let Some(ptr) = advanced_button { - let title = if expanded { - "Hide advanced" - } else { - "Show advanced" - }; - let _: () = msg_send![ptr as Id, setTitle: ns_string(title)]; - } - } -} diff --git a/app/ui/settings/prompts.rs b/app/ui/settings/prompts.rs deleted file mode 100644 index 55ef1582..00000000 --- a/app/ui/settings/prompts.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! AI prompt editor: load/save/reset prompt content and editor layout. - -use super::*; - -pub(super) fn prompt_type_from_index(index: isize) -> &'static str { - if index == 1 { - "assistive" - } else { - "formatting" - } -} - -pub(super) fn prompt_display_name(prompt_type: &str) -> &'static str { - if prompt_type == "assistive" { - "Assistive" - } else { - "Formatting" - } -} - -pub(super) fn selected_prompt_type() -> &'static str { - let popup_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.prompt_type_popup - }; - let Some(popup_ptr) = popup_ptr else { - return "formatting"; - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let popup = popup_ptr as Id; - let idx: isize = msg_send![popup, indexOfSelectedItem]; - prompt_type_from_index(idx) - } -} - -pub(super) fn prompt_path_text(prompt_type: &str) -> String { - if prompt_type == "assistive" { - crate::get_assistive_prompt_path().display().to_string() - } else { - crate::get_formatting_prompt_path().display().to_string() - } -} - -pub(super) fn load_prompt_content(prompt_type: &str) -> Result { - match send_ipc(IpcCommand::GetPrompt { - prompt_type: prompt_type.to_string(), - }) { - Ok(IpcResponse::Prompt(content)) => Ok(content), - Ok(IpcResponse::Error(err)) => Err(err), - Ok(other) => Err(format!("Unexpected IPC response: {other:?}")), - Err(err) => { - warn!("Settings: prompt IPC unavailable, using config fallback: {err}"); - Ok(if prompt_type == "assistive" { - crate::config::get_assistive_prompt() - } else { - crate::config::get_formatting_prompt() - }) - } - } -} - -pub(super) fn save_prompt_content(prompt_type: &str, content: &str) -> Result<(), String> { - match send_ipc(IpcCommand::SavePrompt { - prompt_type: prompt_type.to_string(), - content: content.to_string(), - }) { - Ok(IpcResponse::Ok) => Ok(()), - Ok(IpcResponse::Error(err)) => Err(err), - Ok(other) => Err(format!("Unexpected IPC response: {other:?}")), - Err(err) => { - warn!("Settings: prompt IPC unavailable, using config fallback: {err}"); - let path = if prompt_type == "assistive" { - crate::config::get_assistive_prompt_path() - } else { - crate::config::get_formatting_prompt_path() - }; - if let Some(parent) = path.parent() - && let Err(e) = fs::create_dir_all(parent) - { - return Err(e.to_string()); - } - fs::write(path, content).map_err(|e| e.to_string()) - } - } -} - -pub(super) fn reset_prompt_content(prompt_type: &str) -> Result<(), String> { - match send_ipc(IpcCommand::ResetPrompt { - prompt_type: prompt_type.to_string(), - }) { - Ok(IpcResponse::Ok) => Ok(()), - Ok(IpcResponse::Error(err)) => Err(err), - Ok(other) => Err(format!("Unexpected IPC response: {other:?}")), - Err(err) => { - warn!("Settings: prompt IPC unavailable, using config fallback: {err}"); - let path = if prompt_type == "assistive" { - crate::config::get_assistive_prompt_path() - } else { - crate::config::get_formatting_prompt_path() - }; - let default = if prompt_type == "assistive" { - crate::config::DEFAULT_ASSISTIVE_PROMPT - } else { - crate::config::DEFAULT_FORMATTING_PROMPT - }; - if let Some(parent) = path.parent() - && let Err(e) = fs::create_dir_all(parent) - { - return Err(e.to_string()); - } - fs::write(path, default).map_err(|e| e.to_string()) - } - } -} - -pub(super) fn set_prompt_editor_content(text: &str) { - let text_view_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.prompt_editor_text_view - }; - let Some(text_view_ptr) = text_view_ptr else { - return; - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - set_text_view_string(text_view_ptr as Id, text); - } -} - -pub(super) fn read_prompt_editor_content() -> String { - let text_view_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.prompt_editor_text_view - }; - let Some(text_view_ptr) = text_view_ptr else { - return String::new(); - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { get_text_view_string(text_view_ptr as Id) } -} - -pub(super) fn set_prompt_editor_status(text: &str, is_error: bool) { - let status_ptr = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - state.prompt_status_label - }; - let Some(status_ptr) = status_ptr else { - return; - }; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let label = status_ptr as Id; - set_text_field_string(label, text); - let color = if is_error { - ui_colors::bubble_error_text() - } else { - crate::ui_helpers::color_secondary_label() - }; - let _: () = msg_send![label, setTextColor: color]; - } -} - -pub(super) fn refresh_prompt_editor_labels() { - Queue::main().exec_async(move || unsafe { - let (path_ptr, status_ptr) = { - let state = SETTINGS_WINDOW_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()); - (state.prompt_path_label, state.prompt_status_label) - }; - let prompt_type = selected_prompt_type(); - if let Some(ptr) = path_ptr { - let path_text = format!("Path: {}", prompt_path_text(prompt_type)); - set_text_field_string(ptr as Id, &path_text); - } - if let Some(ptr) = status_ptr { - let hint = if prompt_type == "assistive" { - "Editing assistive prompt." - } else { - "Editing formatting prompt." - }; - set_text_field_string(ptr as Id, hint); - let _: () = - msg_send![ptr as Id, setTextColor: crate::ui_helpers::color_secondary_label()]; - } - }); -} - -#[derive(Clone, Copy, Debug)] -pub(super) struct PromptEditorLayout { - pub(super) editor_height: f64, - pub(super) editor_y: f64, - pub(super) status_y: f64, -} - -pub(super) fn compute_prompt_editor_layout(y: f64, gap: f64) -> PromptEditorLayout { - // Keep status text and bottom breathing room below the editor so the editor - // never climbs into API/model/key controls on smaller vertical space. - let reserved_below_editor = PROMPT_EDITOR_STATUS_HEIGHT + gap + PROMPT_EDITOR_BOTTOM_PADDING; - let available_editor_height = (y - reserved_below_editor).max(0.0); - let editor_height = available_editor_height.min(PROMPT_EDITOR_DESIRED_HEIGHT); - let editor_y = (y - editor_height).max(0.0); - let status_y = (editor_y - gap).max(0.0); - - PromptEditorLayout { - editor_height, - editor_y, - status_y, - } -} diff --git a/app/ui/settings/quality_tab.rs b/app/ui/settings/quality_tab.rs deleted file mode 100644 index fd653f37..00000000 --- a/app/ui/settings/quality_tab.rs +++ /dev/null @@ -1,377 +0,0 @@ -//! AppKit builder for the Voice Lab settings tab. - -use super::*; - -pub(super) unsafe fn build_quality_tab( - action_handler: Id, - frame: core_graphics::geometry::CGRect, - _config: &Config, - state: &mut SettingsWindowState, -) -> Id { - use core_graphics::geometry::{CGPoint, CGRect, CGSize}; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - let ns_view = objc_class("NSView"); - let container: Id = msg_send![ns_view, alloc]; - let container: Id = msg_send![container, initWithFrame: frame]; - - let pad = ui_tokens::EDGE_PADDING; - let content_w = frame.size.width - pad * 2.0; - let gap = ui_tokens::DENSITY_COMFORTABLE; - let mut y = frame.size.height - (24.0 + gap); - let mono_font_input = crate::ui_helpers::monospace_font(ui_tokens::BODY_FONT_SIZE); - let primary = crate::ui_helpers::color_label(); - let secondary = crate::ui_helpers::color_secondary_label(); - let preview_model = current_preview_timing_model(); - - let title = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 24.0)), - text: "Voice Lab".to_string(), - font_size: ui_tokens::TITLE_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, title); - y -= 24.0 + gap; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= gap; - - let subtitle = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Live preview cadence and final STT routing. Only knobs that materially improve UX live here." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, subtitle); - y -= 16.0 + gap; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 142.0); - let engine_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Final Transcript Path".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, engine_header); - y -= 18.0 + gap; - - let provider_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(92.0, 18.0)), - text: "Commit with:".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, provider_label); - - let ns_popup = objc_class("NSPopUpButton"); - let provider_popup: Id = msg_send![ns_popup, alloc]; - let provider_popup: Id = msg_send![provider_popup, initWithFrame: - CGRect::new(&CGPoint::new(pad + 96.0, y - 2.0), &CGSize::new(220.0, 24.0)) - pullsDown: false - ]; - let _: () = msg_send![provider_popup, addItemWithTitle: ns_string("Local final verdict")]; - let _: () = msg_send![provider_popup, addItemWithTitle: ns_string("Cloud final verdict")]; - let provider_index: isize = if _config.use_local_stt { 0 } else { 1 }; - let _: () = msg_send![provider_popup, selectItemAtIndex: provider_index]; - button_set_action(provider_popup, action_handler, sel!(onSttProviderChanged:)); - add_subview(container, provider_popup); - y -= 24.0 + gap; - - let backend_note = if _config.use_local_stt { - "Current mode: preview stays local, then the local verdict becomes the committed transcript. File-based final pass can strengthen that verdict or surface weak-truth warnings before paste/save." - } else { - "Current mode: preview stays local, then cloud STT becomes the committed verdict. If cloud does not return a reliable result, the app marks any surviving fallback as degraded and blocks silent auto-paste." - }; - let provider_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: format!( - "{backend_note} Endpoints ending with :stream use NDJSON and fit long buffered runs better." - ), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, provider_hint); - y -= 16.0 + gap; - - let stt_endpoint_val = std::env::var("STT_ENDPOINT").unwrap_or_default(); - let stt_endpoint_field = create_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Cloud final-verdict endpoint (multipart or ...:stream for NDJSON)", - &stt_endpoint_val, - ); - style_paper_input(stt_endpoint_field); - let _: () = msg_send![stt_endpoint_field, setFont: mono_font_input]; - button_set_action( - stt_endpoint_field, - action_handler, - sel!(onSttEndpointChanged:), - ); - add_subview(container, stt_endpoint_field); - y -= SETTINGS_INPUT_HEIGHT + gap; - - let stt_key_field = create_secure_text_input( - CGRect::new( - &CGPoint::new(pad, y), - &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), - ), - "Cloud final-verdict API key (stored in Keychain; erase field to remove)", - ); - style_paper_input(stt_key_field); - let _: () = msg_send![stt_key_field, setFont: mono_font_input]; - button_set_action(stt_key_field, action_handler, sel!(onSttKeyChanged:)); - add_subview(container, stt_key_field); - y -= SETTINGS_INPUT_HEIGHT + gap; - - y = add_tafla_header_separator(container, pad, y, content_w); - y -= ui_tokens::SECTION_GAP; - - add_settings_group_card(container, pad - 10.0, y + 28.0, content_w + 20.0, 376.0); - let preview_header = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), - text: "Preview Timing".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: primary, - ..Default::default() - }); - add_subview(container, preview_header); - y -= 18.0 + gap; - - let preview_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Live preview cadence. Buffer delay applies after the first visible partial." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, preview_hint); - y -= 16.0 + gap; - - let preview_preset = detect_preset(preview_model); - state.preview_advanced_expanded = matches!(preview_preset, PreviewTimingPreset::Custom); - - // Smooth anchor for C5b audit: 1038ms / 10.6 cps / 5 words / 8.0s. - let preset_control_w = (content_w - 138.0).clamp(320.0, 420.0); - let preset_segment = create_segmented_control( - CGRect::new( - &CGPoint::new(pad, y - 2.0), - &CGSize::new(preset_control_w, 24.0), - ), - &PREVIEW_PRESET_LABELS, - ); - let _: () = msg_send![ - preset_segment, - setSelectedSegment: preview_preset.segment_index() - ]; - button_set_action( - preset_segment, - action_handler, - sel!(onPreviewPresetChanged:), - ); - add_subview(container, preset_segment); - state.preview_preset_segment = Some(preset_segment as usize); - - let env_override = preview_timing_has_env_override(); - let override_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad + preset_control_w + 10.0, y), - &CGSize::new((content_w - preset_control_w - 10.0).max(96.0), 18.0), - ), - text: if env_override { - "overridden by .env".to_string() - } else { - String::new() - }, - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: ui_colors::status_warning(), - ..Default::default() - }); - let _: () = msg_send![override_label, setHidden: !env_override]; - add_subview(container, override_label); - state.preview_env_override_label = Some(override_label as usize); - y -= 24.0 + gap; - - let advanced_button = create_button( - CGRect::new(&CGPoint::new(pad, y - 2.0), &CGSize::new(132.0, 24.0)), - if state.preview_advanced_expanded { - "Hide advanced" - } else { - "Show advanced" - }, - button_style::GLASS, - ); - button_set_action( - advanced_button, - action_handler, - sel!(onPreviewAdvancedToggled:), - ); - add_subview(container, advanced_button); - state.preview_advanced_button = Some(advanced_button as usize); - y -= 24.0 + gap; - - let advanced_hidden = !state.preview_advanced_expanded; - - let buffer_row = add_slider_setting_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - SliderSettingRowSpec { - title: "Buffer delay:", - value_text: &format!("{} ms", preview_model.buffer_delay_ms), - min: 0.0, - max: 1500.0, - current: preview_model.buffer_delay_ms as f64, - action: sel!(onPreviewBufferDelayChanged:), - gap, - }, - ); - for ptr in buffer_row.all_views() { - let _: () = msg_send![ptr as Id, setHidden: advanced_hidden]; - state.preview_advanced_rows.push(ptr); - } - state.preview_buffer_delay_value_label = Some(buffer_row.value_label); - state.preview_buffer_delay_slider = Some(buffer_row.slider); - - let typing_row = add_slider_setting_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - SliderSettingRowSpec { - title: "Typing speed:", - value_text: &format!("{:.1} cps", preview_model.typing_cps), - min: 5.0, - max: 180.0, - current: preview_model.typing_cps as f64, - action: sel!(onPreviewTypingCpsChanged:), - gap, - }, - ); - for ptr in typing_row.all_views() { - let _: () = msg_send![ptr as Id, setHidden: advanced_hidden]; - state.preview_advanced_rows.push(ptr); - } - state.preview_typing_cps_value_label = Some(typing_row.value_label); - state.preview_typing_cps_slider = Some(typing_row.slider); - - let emit_row = add_slider_setting_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - SliderSettingRowSpec { - title: "Words per tick:", - value_text: &format!("{} words", preview_model.emit_words_max), - min: 1.0, - max: 10.0, - current: preview_model.emit_words_max as f64, - action: sel!(onPreviewEmitWordsMaxChanged:), - gap, - }, - ); - for ptr in emit_row.all_views() { - let _: () = msg_send![ptr as Id, setHidden: advanced_hidden]; - state.preview_advanced_rows.push(ptr); - } - state.preview_emit_words_max_value_label = Some(emit_row.value_label); - state.preview_emit_words_max_slider = Some(emit_row.slider); - - let interim_row = add_slider_setting_row( - container, - action_handler, - pad, - &mut y, - content_w, - secondary, - SliderSettingRowSpec { - title: "Interim cadence:", - value_text: &if (preview_model.effective_interim_sec - - preview_model.requested_interim_sec) - .abs() - > f32::EPSILON - { - format!( - "{:.1} s -> {:.1} s effective", - preview_model.requested_interim_sec, preview_model.effective_interim_sec - ) - } else { - format!("{:.1} s", preview_model.effective_interim_sec) - }, - min: 1.0, - max: 12.0, - current: preview_model.requested_interim_sec as f64, - action: sel!(onPreviewInterimCadenceChanged:), - gap, - }, - ); - for ptr in interim_row.all_views() { - let _: () = msg_send![ptr as Id, setHidden: advanced_hidden]; - state.preview_advanced_rows.push(ptr); - } - state.preview_interim_sec_value_label = Some(interim_row.value_label); - state.preview_interim_sec_slider = Some(interim_row.slider); - - let preview_summary = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: preview_timing_summary_text(preview_model), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, preview_summary); - state.preview_timing_summary_label = Some(preview_summary as usize); - y -= 16.0 + gap; - - let preview_box_height = 168.0; - let (preview_scroll, preview_text_view) = create_scrollable_text_view( - CGRect::new( - &CGPoint::new(pad, y - preview_box_height), - &CGSize::new(content_w, preview_box_height), - ), - false, - ); - let _: () = msg_send![preview_text_view, setFont: mono_font_input]; - set_text_view_string( - preview_text_view, - &preview_timing_report_text(preview_model), - ); - add_subview(container, preview_scroll); - state.preview_timing_text_view = Some(preview_text_view as usize); - y -= preview_box_height + gap; - - let routing_hint = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), - text: "Whisper language lives in Audio. AI formatting and slow-moving user toggles live in User." - .to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - add_subview(container, routing_hint); - - container - } -} - -// ============================================================================ -// Diagnostics tab -// ============================================================================ diff --git a/app/ui/settings/rows.rs b/app/ui/settings/rows.rs deleted file mode 100644 index 4a920668..00000000 --- a/app/ui/settings/rows.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! Settings row and tab-layout builders: toggle rows, slider rows, scroll wrapping. - -use super::*; - -#[derive(Clone, Copy)] -pub(super) struct ToggleRowSpec<'a> { - pub(super) title: &'a str, - pub(super) checked: bool, - pub(super) action: objc::runtime::Sel, - pub(super) description: Option<&'a str>, - pub(super) tag: Option, - pub(super) gap: f64, -} - -#[derive(Clone, Copy)] -pub(super) struct SliderSettingRowSpec<'a> { - pub(super) title: &'a str, - pub(super) value_text: &'a str, - pub(super) min: f64, - pub(super) max: f64, - pub(super) current: f64, - pub(super) action: objc::runtime::Sel, - pub(super) gap: f64, -} - -#[derive(Clone, Copy)] -pub(super) struct SliderSettingRowHandles { - pub(super) label: usize, - pub(super) value_label: usize, - pub(super) slider: usize, -} - -impl SliderSettingRowHandles { - pub(super) fn all_views(self) -> [usize; 3] { - [self.label, self.value_label, self.slider] - } -} - -pub(super) fn toggle_row_step(has_description: bool, gap: f64) -> f64 { - if has_description { - TOGGLE_ROW_DESC_OFFSET + TOGGLE_ROW_DESC_HEIGHT + gap - } else { - TOGGLE_ROW_HEIGHT + gap - } -} - -pub(super) unsafe fn style_paper_input(field: Id) { - let _: () = msg_send![field, setDrawsBackground: true]; - let input_bg = unsafe { settings_input_paper_bg() }; - let _: () = msg_send![field, setBackgroundColor: input_bg]; -} - -pub(super) unsafe fn settings_input_paper_bg() -> Id { - let base = ui_colors::surface_paper_warm(); - msg_send![base, colorWithAlphaComponent: 0.84f64] -} - -pub(super) unsafe fn add_tafla_header_separator(container: Id, x: f64, y: f64, width: f64) -> f64 { - let separator = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(x, y), &CGSize::new(width, 1.0)), - text: String::new(), - background_color: Some(ui_colors::header_border()), - ..Default::default() - }); - let _: () = msg_send![separator, setAlphaValue: 0.9f64]; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - add_subview(container, separator); - } - y - 1.0 -} - -pub(super) unsafe fn add_settings_group_card( - container: Id, - x: f64, - top_y: f64, - width: f64, - height: f64, -) -> Id { - unsafe { build_settings_group_card(container, x, top_y, width, height.max(44.0), false) } -} - -/// Padding (px) the dynamic card keeps below a section's last content row. -pub(super) const SETTINGS_CARD_BOTTOM_PAD: f64 = 12.0; - -/// Draw a settings group card sized to the section's *actual* content advance -/// instead of a hand-tuned fixed height, inserted BEHIND the rows that were -/// already added. -/// -/// AppKit has no constraint solver here: section rows are laid out by a manual -/// top-down `y` cursor. Hardcoding the card height (the old `172.0`/`170.0`/...) -/// drifts the instant a row step or token gap changes, so the last rows spill -/// past the card and collide with the next section. Capturing `top_y` before -/// the rows and `content_bottom_y` after them makes the card track reality and -/// stay collision-free at any font scale / value length. -/// -/// `content_bottom_y` is the cursor position just below the last row; the card -/// extends `SETTINGS_CARD_BOTTOM_PAD` further down for breathing room. Inserted -/// at the back of `container` (NSWindowBelow) so the rows stay legible on top. -pub(super) unsafe fn add_settings_group_card_dynamic( - container: Id, - x: f64, - top_y: f64, - width: f64, - content_bottom_y: f64, -) -> Id { - let height = (top_y - (content_bottom_y - SETTINGS_CARD_BOTTOM_PAD)).max(44.0); - unsafe { build_settings_group_card(container, x, top_y, width, height, true) } -} - -unsafe fn build_settings_group_card( - container: Id, - x: f64, - top_y: f64, - width: f64, - height: f64, - behind: bool, -) -> Id { - let ns_view = objc_class("NSView"); - let card: Id = msg_send![ns_view, alloc]; - let card: Id = msg_send![ - card, - initWithFrame: CGRect::new( - &CGPoint::new(x, top_y - height), - &CGSize::new(width.max(120.0), height.max(44.0)), - ) - ]; - let _: () = msg_send![card, setWantsLayer: true]; - let layer: Id = msg_send![card, layer]; - if !layer.is_null() { - let bg = ui_colors::card_bg(); - let cg_color: Id = msg_send![bg, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - // SAFETY: `layer` belongs to the AppKit card view allocated above on the main thread. - unsafe { - crate::ui_helpers::apply_tafla_surface(layer, true); - } - let _: () = msg_send![layer, setMasksToBounds: true]; - let _: () = msg_send![layer, setShadowRadius: 0.0f64]; - } - if behind { - // NSWindowBelow == -1: drop the card to the back so the section's rows, - // which were added first, render on top of it. - let below: isize = -1; - let nil_view: Id = std::ptr::null_mut(); - let _: () = msg_send![container, addSubview: card positioned: below relativeTo: nil_view]; - } else { - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - add_subview(container, card); - } - } - card -} - -pub(super) unsafe fn add_slider_setting_row( - container: Id, - action_handler: Id, - x: f64, - y: &mut f64, - width: f64, - secondary: Id, - spec: SliderSettingRowSpec<'_>, -) -> SliderSettingRowHandles { - let label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(x, *y), &CGSize::new(136.0, 18.0)), - text: spec.title.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - add_subview(container, label); - } - - let value_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(x + width - 110.0, *y), - &CGSize::new(110.0, 18.0), - ), - text: spec.value_text.to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - add_subview(container, value_label); - } - - let slider = create_slider( - CGRect::new( - &CGPoint::new(x + 140.0, *y - 1.0), - &CGSize::new((width - 254.0).max(160.0), 20.0), - ), - spec.min, - spec.max, - spec.current, - ); - let _: () = msg_send![slider, setContinuous: true]; - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - button_set_action(slider, action_handler, spec.action); - add_subview(container, slider); - } - - *y -= 24.0 + spec.gap; - SliderSettingRowHandles { - label: label as usize, - value_label: value_label as usize, - slider: slider as usize, - } -} - -pub(super) unsafe fn autosize_tab_document_view(document_view: Id, minimum_height: f64) -> f64 { - let subviews: Id = msg_send![document_view, subviews]; - if subviews.is_null() { - let mut doc_frame: CGRect = msg_send![document_view, frame]; - doc_frame.origin = CGPoint::new(0.0, 0.0); - doc_frame.size.height = minimum_height.max(doc_frame.size.height); - let _: () = msg_send![document_view, setFrame: doc_frame]; - return doc_frame.size.height; - } - - let count: usize = msg_send![subviews, count]; - if count == 0 { - let mut doc_frame: CGRect = msg_send![document_view, frame]; - doc_frame.origin = CGPoint::new(0.0, 0.0); - doc_frame.size.height = minimum_height.max(doc_frame.size.height); - let _: () = msg_send![document_view, setFrame: doc_frame]; - return doc_frame.size.height; - } - - let mut min_y = f64::INFINITY; - let mut max_y = 0.0_f64; - for idx in 0..count { - let subview: Id = msg_send![subviews, objectAtIndex: idx]; - if subview.is_null() { - continue; - } - let frame: CGRect = msg_send![subview, frame]; - min_y = min_y.min(frame.origin.y); - max_y = max_y.max(frame.origin.y + frame.size.height); - } - - let shift_y = if min_y.is_finite() && min_y < SETTINGS_CONTENT_INSET_Y { - SETTINGS_CONTENT_INSET_Y - min_y - } else { - 0.0 - }; - - if shift_y > 0.0 { - for idx in 0..count { - let subview: Id = msg_send![subviews, objectAtIndex: idx]; - if subview.is_null() { - continue; - } - let mut frame: CGRect = msg_send![subview, frame]; - frame.origin.y += shift_y; - let _: () = msg_send![subview, setFrame: frame]; - } - max_y += shift_y; - } - - let mut doc_frame: CGRect = msg_send![document_view, frame]; - doc_frame.origin = CGPoint::new(0.0, 0.0); - doc_frame.size.height = minimum_height.max(max_y.ceil()); - let _: () = msg_send![document_view, setFrame: doc_frame]; - doc_frame.size.height -} - -pub(super) unsafe fn wrap_tab_content_in_scroll_view(frame: CGRect, document_view: Id) -> Id { - let ns_scroll_view = objc_class("NSScrollView"); - let scroll: Id = msg_send![ns_scroll_view, alloc]; - let scroll: Id = msg_send![scroll, initWithFrame: frame]; - let _: () = msg_send![scroll, setHasVerticalScroller: true]; - let _: () = msg_send![scroll, setHasHorizontalScroller: false]; - let _: () = msg_send![scroll, setAutohidesScrollers: true]; - let _: () = msg_send![scroll, setBorderType: 0_isize]; // NSNoBorder - let _: () = msg_send![scroll, setDrawsBackground: false]; - let _: () = msg_send![ - scroll, - setAutoresizingMask: 2_isize | 16_isize // width + height - ]; - - let doc_h = unsafe { autosize_tab_document_view(document_view, frame.size.height) }; - let _: () = msg_send![scroll, setDocumentView: document_view]; - let _: () = msg_send![scroll, setHasVerticalScroller: doc_h > frame.size.height + 1.0]; - - let clip_view: Id = msg_send![scroll, contentView]; - if !clip_view.is_null() { - let top_point = CGPoint::new(0.0, (doc_h - frame.size.height).max(0.0)); - let _: () = msg_send![clip_view, scrollToPoint: top_point]; - let _: () = msg_send![scroll, reflectScrolledClipView: clip_view]; - } - - scroll -} - -pub(super) unsafe fn add_toggle_row( - container: Id, - action_handler: Id, - x: f64, - y: &mut f64, - width: f64, - secondary: Id, - spec: ToggleRowSpec<'_>, -) -> Id { - let text_width = (width - TOGGLE_SWITCH_WIDTH - 10.0).max(80.0); - let title_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(x, *y + 1.0), - &CGSize::new(text_width, TOGGLE_ROW_HEIGHT), - ), - text: spec.title.to_string(), - font_size: ui_tokens::BODY_FONT_SIZE, - text_color: crate::ui_helpers::color_label(), - ..Default::default() - }); - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - add_subview(container, title_label); - } - - let toggle = create_toggle( - CGRect::new( - &CGPoint::new(x + width - TOGGLE_SWITCH_WIDTH, *y), - &CGSize::new(TOGGLE_SWITCH_WIDTH, TOGGLE_SWITCH_HEIGHT), - ), - spec.checked, - ); - if let Some(tag) = spec.tag { - let _: () = msg_send![toggle, setTag: tag]; - } - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - button_set_action(toggle, action_handler, spec.action); - add_subview(container, toggle); - } - - if let Some(desc) = spec.description { - let desc_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(x + TOGGLE_ROW_LABEL_INDENT, *y - TOGGLE_ROW_DESC_OFFSET), - &CGSize::new( - (width - TOGGLE_ROW_LABEL_INDENT - TOGGLE_SWITCH_WIDTH - 10.0).max(60.0), - TOGGLE_ROW_DESC_HEIGHT, - ), - ), - text: desc.to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - text_color: secondary, - ..Default::default() - }); - // SAFETY: see module-level # Safety doc — main-thread AppKit / msg_send! access on retained `Id` pointers. - unsafe { - add_subview(container, desc_label); - } - } - - *y -= toggle_row_step(spec.description.is_some(), spec.gap); - toggle -} diff --git a/app/ui/shared/helpers/chat_views.rs b/app/ui/shared/helpers/chat_views.rs deleted file mode 100644 index da050d3d..00000000 --- a/app/ui/shared/helpers/chat_views.rs +++ /dev/null @@ -1,1879 +0,0 @@ -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use objc::declare::ClassDecl; -use objc::runtime::{Class, Object, Sel}; -use objc::{msg_send, sel, sel_impl}; -use std::collections::HashMap; -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; -use std::sync::Once; - -use super::{ - Id, add_subview, apply_tafla_surface, ns_string, set_button_symbol, ui_colors, ui_tokens, -}; - -const NSTRACKING_MOUSE_ENTERED_AND_EXITED: u64 = 1 << 0; -const NSTRACKING_ACTIVE_ALWAYS: u64 = 1 << 7; -const NSTRACKING_IN_VISIBLE_RECT: u64 = 1 << 9; -const BUBBLE_COPY_ACTION_WIDTH: f64 = 40.0; -const BUBBLE_RENDER_ACTION_WIDTH: f64 = 28.0; -const BUBBLE_ACTION_GAP: f64 = 10.0; - -/// Role for chat bubble styling -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BubbleRole { - User, - Assistant, - System, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RenderMode { - Plain, - Markdown, -} - -pub fn streaming_render_mode(_is_streaming: bool, _role: BubbleRole) -> RenderMode { - RenderMode::Plain -} - -pub fn next_render_mode(current: RenderMode) -> RenderMode { - match current { - RenderMode::Plain => RenderMode::Markdown, - RenderMode::Markdown => RenderMode::Plain, - } -} - -// ============================================================================ -// Bubble height measurement cache -// ============================================================================ -// -// AppKit text measurement (`cellSizeForBounds:` -> `__NSStringDrawingEngine`) -// is the hot path that pegs the main thread on big transcripts: every -// `update_chat_view_with_state` rebuild re-measures the *full* wrapped attributed -// string of *every* bubble. The text + layout width + font + render-mode of an -// already-rendered bubble do not change across rebuilds, so the measured height -// is deterministically reusable. We cache it keyed on those inputs and only pay -// the AppKit measurement for new/changed bubbles. -// -// For pathologically long text we skip AppKit entirely and estimate the height -// from a wrap model — `__NSStringDrawingEngine` on a multi-megabyte string is -// the part that actually hangs, and an estimate is visually indistinguishable at -// that scale (the bubble is scrolled, not read in full). - -/// Above this character count we estimate bubble height instead of asking -/// AppKit to lay out the entire string. Keeps the main thread responsive for -/// huge pasted/selected blobs. -pub const BUBBLE_MEASURE_CAP_CHARS: usize = 20_000; - -/// Cache key for a bubble height measurement. -/// -/// Floats are hashed via their bit patterns; layout width and font size are the -/// only float inputs and both come from deterministic layout math, so identical -/// logical inputs produce identical bits. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct BubbleMeasureKey { - text_hash: u64, - width_bits: u64, - font_bits: u64, - render_mode: u8, -} - -impl BubbleMeasureKey { - /// `bound_width` is the layout input that determines wrapping — pass the - /// bubble's `max_width` so a cache hit can skip *both* AppKit measurements - /// (width decision + final height) for an unchanged bubble. - pub fn new(text: &str, bound_width: f64, font_size: f64, render_mode: RenderMode) -> Self { - let mut hasher = DefaultHasher::new(); - text.hash(&mut hasher); - let text_hash = hasher.finish(); - let render_mode = match render_mode { - RenderMode::Plain => 0, - RenderMode::Markdown => 1, - }; - Self { - text_hash, - width_bits: bound_width.to_bits(), - font_bits: font_size.to_bits(), - render_mode, - } - } -} - -/// Cached, deterministic layout outputs for a bubble's text. -/// -/// Both values are pure functions of the cache key inputs, so reusing them -/// across rebuilds is safe and avoids the AppKit text-engine cost. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct BubbleMeasure { - /// Final bubble width (content-aware, capped to max). - pub bubble_width: f64, - /// Measured wrapped text height. - pub text_height: f64, -} - -/// Per-overlay cache of measured bubble text heights. -/// -/// Lives in `VoiceChatOverlayState` (UI state). On a chat-view rebuild the cache -/// is consulted before any AppKit measurement; unchanged bubbles hit the cache -/// and skip `cellSizeForBounds:` entirely. -#[derive(Debug, Default)] -pub struct BubbleMeasureCache { - entries: HashMap, -} - -impl BubbleMeasureCache { - pub fn new() -> Self { - Self::default() - } - - /// Return a cached measurement for this key, if present. - pub fn get(&self, key: &BubbleMeasureKey) -> Option { - self.entries.get(key).copied() - } - - /// Store a measurement for this key. - pub fn insert(&mut self, key: BubbleMeasureKey, measure: BubbleMeasure) { - self.entries.insert(key, measure); - } - - /// Drop everything (e.g. on font/zoom change where every entry is stale). - pub fn clear(&mut self) { - self.entries.clear(); - } - - /// Number of cached entries (test/diagnostic helper). - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -/// Estimate wrapped text height without invoking AppKit. -/// -/// Used for text above `BUBBLE_MEASURE_CAP_CHARS`, where running the real -/// `__NSStringDrawingEngine` over the whole string is what hangs the main -/// thread. Models word-wrapping by columns-per-line plus hard newlines. The -/// result only needs to be a generous over-estimate so the bubble is never -/// clipped; precise wrapped height is irrelevant for a multi-screen blob that -/// the user scrolls rather than reads at a glance. -pub fn estimate_text_height(text: &str, layout_width: f64, font_size: f64) -> f64 { - let line_height = (font_size * 1.4).max(1.0); - // Monospace advance width is ~0.6em for the bundled JetBrains Mono / system - // monospaced font; bias slightly narrow so we over-count wrapped lines. - let char_width = (font_size * 0.6).max(1.0); - let cols_per_line = (layout_width / char_width).floor().max(1.0) as usize; - - let mut total_lines: usize = 0; - for raw_line in text.split('\n') { - let chars = raw_line.chars().count().max(1); - let wrapped = chars.div_ceil(cols_per_line).max(1); - total_lines += wrapped; - } - let total_lines = total_lines.max(1); - (total_lines as f64) * line_height -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct BubbleActionFrame { - x: f64, - width: f64, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct BubbleActionFrames { - copy: BubbleActionFrame, - render: Option, -} - -fn bubble_action_frames( - bubble_width: f64, - padding_x: f64, - include_render_toggle: bool, -) -> BubbleActionFrames { - // Put Markdown and Copy on opposite bubble edges. Keeping the toggle away - // from Copy is more robust than a small same-edge gap during streaming - // resizes and makes the hitboxes visually unmistakable. - let left = padding_x.max(4.0); - let right = (bubble_width - padding_x).max(left); - - if include_render_toggle { - let copy_width = BUBBLE_COPY_ACTION_WIDTH.min((right - left).max(1.0)); - let copy_x = (right - copy_width).max(left); - let render_width = - BUBBLE_RENDER_ACTION_WIDTH.min((copy_x - BUBBLE_ACTION_GAP - left).max(1.0)); - let render = BubbleActionFrame { - x: left, - width: render_width, - }; - let copy = BubbleActionFrame { - x: copy_x, - width: copy_width, - }; - BubbleActionFrames { - copy, - render: Some(render), - } - } else { - BubbleActionFrames { - copy: BubbleActionFrame { - x: (right - BUBBLE_COPY_ACTION_WIDTH).max(left), - width: BUBBLE_COPY_ACTION_WIDTH.min((right - left).max(1.0)), - }, - render: None, - } - } -} - -fn is_markdown_table_separator_line(line: &str) -> bool { - let trimmed = line.trim().trim_matches('|').trim(); - if trimmed.is_empty() || !trimmed.contains('-') { - return false; - } - trimmed.split('|').all(|cell| { - let cell = cell.trim(); - !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')) - }) -} - -pub(crate) fn looks_like_markdown_table(text: &str) -> bool { - let lines: Vec<&str> = text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect(); - lines.windows(2).any(|pair| { - let header = pair[0]; - let sep = pair[1]; - header.contains('|') && is_markdown_table_separator_line(sep) - }) -} - -pub(crate) fn should_apply_native_markdown(text: &str) -> bool { - // Product decision: chat bubbles are a faithful raw-Markdown carrier, not a - // partial AppKit Markdown renderer. The native importer collapses block - // structure in NSTextField, so preserve bytes/newlines and let monospace do - // the work until a real block renderer exists. - let _ = looks_like_markdown_table(text); - false -} - -fn should_render_native_markdown(render_mode: RenderMode, text: &str) -> bool { - match render_mode { - RenderMode::Markdown => true, - RenderMode::Plain => should_apply_native_markdown(text), - } -} - -fn markdown_options_with_base_font(_text: &str, font: Id) -> Option { - unsafe { - let options_cls = Class::get("NSAttributedStringMarkdownParsingOptions")?; - let options: Id = msg_send![options_cls, alloc]; - let options: Id = msg_send![options, init]; - if options.is_null() { - return None; - } - let responds_base: bool = msg_send![options, respondsToSelector: sel!(setBaseFont:)]; - if responds_base && !font.is_null() { - let _: () = msg_send![options, setBaseFont: font]; - } - // Keep inline-preserving mode for chat bubbles. Table Markdown is intentionally - // bypassed before this point because AppKit collapses tables in NSTextField. - let responds_syntax: bool = - msg_send![options, respondsToSelector: sel!(setInterpretedSyntax:)]; - if responds_syntax { - // 0 = .full, 1 = .inlineOnly, 2 = .inlineOnlyPreservingWhitespace - let syntax: isize = 2; - let _: () = msg_send![options, setInterpretedSyntax: syntax]; - } - Some(options) - } -} - -/// NSRange for Objective-C attributed string APIs. -#[repr(C)] -#[derive(Copy, Clone)] -struct NSRange { - location: usize, - length: usize, -} - -// NSFontTraitMask bits (subset). -const NS_ITALIC_FONT_MASK: u64 = 1 << 0; -const NS_BOLD_FONT_MASK: u64 = 1 << 1; - -/// Normalize per-range font attributes to stay within the provided base font family. -/// -/// AppKit's Markdown parser may introduce different font families for inline `code` spans or -/// emphasis runs. We want consistent typography inside bubbles, while preserving bold/italic -/// traits and point sizes. -/// -/// Returns an attributed string instance (possibly mutable) that is safe to set on -/// `NSTextField.setAttributedStringValue:`. -unsafe fn normalize_attributed_string_fonts(attr: Id, base_font: Id) -> Id { - if attr.is_null() || base_font.is_null() { - return attr; - } - - let mutable: Id = msg_send![attr, mutableCopy]; - if mutable.is_null() { - return attr; - } - // Release original — we now own the mutable copy exclusively. - let _: () = msg_send![attr, release]; - - let len: usize = msg_send![mutable, length]; - if len == 0 { - return mutable; - } - - let Some(ns_font_manager) = Class::get("NSFontManager") else { - return mutable; - }; - let fm: Id = msg_send![ns_font_manager, sharedFontManager]; - if fm.is_null() { - return mutable; - } - - let font_key = ns_string("NSFont"); - let base_family: Id = msg_send![base_font, familyName]; - let mut idx: usize = 0; - while idx < len { - let mut effective = NSRange { - location: 0, - length: 0, - }; - let cur_font: Id = msg_send![ - mutable, - attribute: font_key - atIndex: idx - effectiveRange: &mut effective - ]; - if effective.length == 0 { - idx += 1; - continue; - } - - if cur_font.is_null() { - // Some markdown runs may not carry an explicit NSFont attribute. - // Ensure those ranges still inherit the bubble's monospace base font - // instead of NSTextField defaulting them to system UI font. - let _: () = - msg_send![mutable, addAttribute: font_key value: base_font range: effective]; - idx = effective.location + effective.length; - continue; - } - - if !cur_font.is_null() { - let traits: u64 = msg_send![fm, traitsOfFont: cur_font]; - let desired_traits = traits & (NS_ITALIC_FONT_MASK | NS_BOLD_FONT_MASK); - - let cur_size: f64 = msg_send![cur_font, pointSize]; - let base_size: f64 = msg_send![base_font, pointSize]; - - let mut new_font: Id = base_font; - if (cur_size - base_size).abs() > 0.05 { - let sized: Id = msg_send![fm, convertFont: base_font toSize: cur_size]; - if !sized.is_null() { - new_font = sized; - } - } - if desired_traits != 0 { - let converted: Id = - msg_send![fm, convertFont: new_font toHaveTrait: desired_traits]; - if !converted.is_null() { - // NSFontManager may fallback to a proportional system family when the - // requested trait isn't available in the base family. Keep monospace family - // stable for chat bubbles, even if that means dropping a trait. - let converted_family: Id = msg_send![converted, familyName]; - let same_family: bool = if base_family.is_null() || converted_family.is_null() { - false - } else { - msg_send![base_family, isEqualToString: converted_family] - }; - if same_family { - new_font = converted; - } - } - } - - let _: () = msg_send![mutable, addAttribute: font_key value: new_font range: effective]; - } - - idx = effective.location + effective.length; - } - - mutable -} - -unsafe fn markdown_attributed_string(text: &str, font: Id) -> Option { - let ns_attr = Class::get("NSAttributedString")?; - let text_ns = ns_string(text); - let options = - markdown_options_with_base_font(text, font).unwrap_or(std::ptr::null_mut::()); - - // initWithMarkdown: expects NSData, not NSString - let utf8_encoding: usize = 4; // NSUTF8StringEncoding - let text_data: Id = msg_send![text_ns, dataUsingEncoding: utf8_encoding]; - if text_data.is_null() { - return None; - } - - let supports_with_error: bool = msg_send![ns_attr, instancesRespondToSelector: sel!(initWithMarkdown:options:baseURL:error:)]; - if supports_with_error { - let obj: Id = msg_send![ns_attr, alloc]; - let obj: Id = msg_send![ - obj, - initWithMarkdown: text_data - options: options - baseURL: std::ptr::null::() - error: std::ptr::null_mut::<*mut Object>() - ]; - if !obj.is_null() { - return Some(unsafe { normalize_attributed_string_fonts(obj, font) }); - } - } - - let supports_simple: bool = - msg_send![ns_attr, instancesRespondToSelector: sel!(initWithMarkdown:options:baseURL:)]; - if supports_simple { - let obj: Id = msg_send![ns_attr, alloc]; - let obj: Id = msg_send![ - obj, - initWithMarkdown: text_data - options: options - baseURL: std::ptr::null::() - ]; - if !obj.is_null() { - return Some(unsafe { normalize_attributed_string_fonts(obj, font) }); - } - } - - None -} - -pub(crate) unsafe fn apply_markdown_to_text_field(text_label: Id, text: &str, font: Id) -> bool { - let responds_attr: bool = - msg_send![text_label, respondsToSelector: sel!(setAttributedStringValue:)]; - if !responds_attr { - return false; - } - let font = if font.is_null() { - let ns_font = Class::get("NSFont").unwrap(); - msg_send![ns_font, systemFontOfSize: 13.0f64] - } else { - font - }; - // Keep NSTextField fallback font aligned with markdown base font. - let _: () = msg_send![text_label, setFont: font]; - if let Some(attr) = unsafe { markdown_attributed_string(text, font) } { - let _: () = msg_send![text_label, setAttributedStringValue: attr]; - // Re-assert base font after attributed update so any future fallback ranges - // (e.g. during incremental updates) remain monospace. - let _: () = msg_send![text_label, setFont: font]; - // Balance the +1 from mutableCopy inside normalize_attributed_string_fonts. - // setAttributedStringValue: retains its own copy. - let _: () = msg_send![attr, release]; - return true; - } - false -} - -/// Configuration for creating a chat bubble -pub struct BubbleConfig { - pub text: String, - pub role: BubbleRole, - pub max_width: f64, - pub font_size: f64, - pub is_streaming: bool, - pub is_error: bool, - pub render_mode: Option, - pub metadata: Option, - /// Optional message index for Copy button (None = no button) - pub message_index: Option, - /// Optional action target for Copy button - pub copy_action_target: Option, - /// Optional per-overlay measurement cache. When present, an unchanged bubble - /// (same text/width/font/render-mode) reuses its previously measured size and - /// skips the AppKit text-engine measurement entirely. - /// - /// Raw pointer (not a reference) so `BubbleConfig` stays `Copy`-free but - /// movable across the FFI-heavy builder; the caller owns the cache for the - /// duration of the call. `None` falls back to always measuring. - pub measure_cache: Option<*mut BubbleMeasureCache>, -} - -/// Create a chat bubble view (NSView container with styled text) -/// -/// Returns (container_view, text_label) tuple for later updates -pub fn create_bubble_view(config: BubbleConfig) -> (Id, Id) { - unsafe { - let ns_view = bubble_container_view_class(); - let ns_text_field = bubble_text_field_class(); - let ns_font = Class::get("NSFont").unwrap(); - let ns_dict = Class::get("NSDictionary").unwrap(); - - let font_size = config.font_size; - let padding_x = 12.0; - let padding_top = 10.0; - let copy_button_height = if config.message_index.is_some() { - 16.0 - } else { - 0.0 - }; - // Reserve space for the Copy button so it never overlaps text. - let padding_bottom = if copy_button_height > 0.0 { - copy_button_height + 8.0 - } else { - 10.0 - }; - let line_height = font_size * 1.4; - let meta_height = if config.metadata.is_some() { - (ui_tokens::SMALL_FONT_SIZE + 4.0).max(12.0) - } else { - 0.0 - }; - let meta_spacing = if config.metadata.is_some() { 4.0 } else { 0.0 }; - - // Font (prefer JetBrains Mono if installed) - let jb_name = ns_string("JetBrainsMono-Regular"); - let jb_font: Id = msg_send![ns_font, fontWithName: jb_name size: font_size]; - let font: Id = if jb_font.is_null() { - msg_send![ns_font, monospacedSystemFontOfSize: font_size weight: 0.0f64] - } else { - jb_font - }; - - // Set text (with streaming indicator if needed) - let display_text = if config.is_streaming && config.text.is_empty() { - "• • •".to_string() // Pulsing dots placeholder - } else if config.is_streaming { - format!("{} …", config.text) - } else { - config.text.clone() - }; - - // Measure text height/width using NSString boundingRectWithSize (handles newlines/wrapping). - // - // NOTE: `NSFontAttributeName` (key) has the string value "NSFont". AppKit expects that - // key, not the literal "NSFontAttributeName" string. - let text_str = ns_string(&display_text); - let font_key = ns_string("NSFont"); - let attrs: Id = msg_send![ns_dict, dictionaryWithObject: font forKey: font_key]; - let opts: u64 = 1 | 2; // NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - - // Keep a small side margin inside the container so full-width bubbles don't overflow. - let bubble_max_width = (config.max_width - 16.0).max(80.0); - let text_max_width = (bubble_max_width - padding_x * 2.0).max(40.0); - - let render_mode = config - .render_mode - .unwrap_or_else(|| streaming_render_mode(config.is_streaming, config.role)); - - // Measurement cache lookup. Key on the layout inputs that fully determine - // the result: text, the bubble's max width (drives wrapping), font size, - // and render mode. A hit lets us skip BOTH AppKit measurements - // (`boundingRectWithSize:` width probe + `cellSizeForBounds:` height) — - // the `__NSStringDrawingEngine` calls that peg the main thread on rebuilds. - let measure_key = - BubbleMeasureKey::new(&display_text, config.max_width, font_size, render_mode); - let cache_ref: Option<&mut BubbleMeasureCache> = - config.measure_cache.and_then(|ptr| ptr.as_mut()); - let cached_measure = cache_ref.as_ref().and_then(|cache| cache.get(&measure_key)); - - // For pathologically long text, estimate instead of laying out the whole - // string through AppKit — that layout is the actual hang. Always full-width. - let over_cap = display_text.chars().count() > BUBBLE_MEASURE_CAP_CHARS; - - // Bubble width: content-aware but capped. - // If it wraps (or is long), keep the bubble full width for readability. - // - // We treat streaming messages as "wrap-prone" earlier to avoid the initial narrow bubble - // that later expands mid-stream. - let long_threshold = if config.is_streaming { 30 } else { 80 }; - let is_long = display_text.chars().count() > long_threshold; - - let (bubble_width, cached_text_height): (f64, Option) = - if let Some(measure) = cached_measure { - // Reuse the previously measured width + height verbatim. - (measure.bubble_width, Some(measure.text_height)) - } else if over_cap { - // Skip AppKit entirely for huge text: full-width bubble + estimate. - (bubble_max_width, None) - } else { - let rect_max: CGRect = msg_send![ - text_str, - boundingRectWithSize: CGSize::new(text_max_width, 10_000.0) - options: opts - attributes: attrs - ]; - let wraps_at_max = rect_max.size.height > line_height * 1.6 - || display_text.contains('\n') - || is_long - // When streaming starts with the "• • •" placeholder, force full-width - // bubbles to avoid the initial tiny/narrow bubble that later expands mid-stream. - || (config.is_streaming && config.text.is_empty()); - let width = if wraps_at_max { - bubble_max_width - } else { - let content_width = rect_max.size.width.min(text_max_width).max(1.0); - (content_width + padding_x * 2.0).min(bubble_max_width) - }; - (width, None) - }; - - // Label width for wrapping and later reflow. - let text_layout_width = (bubble_width - padding_x * 2.0).max(40.0); - - // Build the label first and ask AppKit (cell) for the exact wrapped height. - // This avoids "second line appears only after click" issues where our NSString - // measurement disagrees with NSTextField's rendering. - let text_label: Id = msg_send![ns_text_field, alloc]; - let text_label: Id = msg_send![ - text_label, - initWithFrame: CGRect::new( - &CGPoint::new(padding_x, padding_top), - &CGSize::new(text_layout_width.max(1.0), line_height), - ) - ]; - - let _: () = msg_send![text_label, setBezeled: false]; - let _: () = msg_send![text_label, setEditable: false]; - let _: () = msg_send![text_label, setSelectable: true]; - let _: () = msg_send![text_label, setDrawsBackground: false]; - let _: () = msg_send![text_label, setUsesSingleLineMode: false]; - let _: () = msg_send![text_label, setRefusesFirstResponder: false]; - let responds_attr: bool = - msg_send![text_label, respondsToSelector: sel!(setAllowsEditingTextAttributes:)]; - if responds_attr { - let _: () = msg_send![text_label, setAllowsEditingTextAttributes: false]; - } - - // Enable wrapping for multi-line messages. - let cell: Id = msg_send![text_label, cell]; - if !cell.is_null() { - let _: () = msg_send![cell, setWraps: true]; - let _: () = msg_send![cell, setLineBreakMode: 0_isize]; // NSLineBreakByWordWrapping - let _: () = msg_send![cell, setScrollable: false]; - } - - // Text color (role-aware) - let text_color: Id = if config.is_error { - ui_colors::bubble_error_text() - } else { - match config.role { - BubbleRole::User => ui_colors::bubble_text(), - BubbleRole::Assistant => { - if config.is_streaming { - ui_colors::bubble_streaming_text() - } else { - ui_colors::bubble_text() - } - } - BubbleRole::System => ui_colors::bubble_text(), - } - }; - let _: () = msg_send![text_label, setTextColor: text_color]; - - let _: () = msg_send![text_label, setFont: font]; - if !(should_render_native_markdown(render_mode, &display_text) - && apply_markdown_to_text_field(text_label, &display_text, font)) - { - let _: () = msg_send![text_label, setStringValue: text_str]; - } - let _: () = msg_send![text_label, setLineBreakMode: 0_isize]; // NSLineBreakByWordWrapping - - // Resolve the wrapped text height: cache hit > estimate (over cap) > AppKit. - let text_height = if let Some(height) = cached_text_height { - height - } else if over_cap { - estimate_text_height(&display_text, text_layout_width, font_size) - .ceil() - .max(line_height) - } else { - // Ask the cell for the wrapped size within the fixed width. - let measure_bounds = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(text_layout_width.max(1.0), 10_000.0), - ); - let cell: Id = msg_send![text_label, cell]; - let measured: CGSize = if cell.is_null() { - // Fallback to NSString measurement (best effort). - let text_rect: CGRect = msg_send![ - text_str, - boundingRectWithSize: CGSize::new(text_layout_width, 10_000.0) - options: opts - attributes: attrs - ]; - text_rect.size - } else { - msg_send![cell, cellSizeForBounds: measure_bounds] - }; - measured.height.ceil().max(line_height) - }; - - // Store the freshly computed measurement so the next rebuild of this - // unchanged bubble hits the cache and skips AppKit measurement. - if cached_text_height.is_none() - && let Some(cache) = cache_ref - { - cache.insert( - measure_key, - BubbleMeasure { - bubble_width, - text_height, - }, - ); - } - - let bubble_height = text_height + padding_top + padding_bottom; - let container_height = bubble_height + meta_height + meta_spacing; - - // Container view (for alignment) - let container: Id = msg_send![ns_view, alloc]; - let container_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(config.max_width, container_height), - ); - let container: Id = msg_send![container, initWithFrame: container_frame]; - - // Bubble background view - let bubble: Id = msg_send![ns_view, alloc]; - let bubble_x = match config.role { - BubbleRole::User => (config.max_width - bubble_width - 8.0).max(8.0), // Right-aligned - BubbleRole::Assistant | BubbleRole::System => 8.0, // Left-aligned - }; - let bubble_y = meta_height + meta_spacing; - let bubble_frame = CGRect::new( - &CGPoint::new(bubble_x, bubble_y), - &CGSize::new(bubble_width, bubble_height), - ); - let bubble: Id = msg_send![bubble, initWithFrame: bubble_frame]; - - // Set bubble background color based on role - let bg_color: Id = if config.is_error { - ui_colors::bubble_error_bg() - } else { - match config.role { - BubbleRole::User => ui_colors::bubble_user_bg(), - BubbleRole::Assistant => ui_colors::bubble_assistant_bg(), - BubbleRole::System => ui_colors::bubble_system_bg(), - } - }; - - // Set background via layer (for rounded corners) - let _: () = msg_send![bubble, setWantsLayer: true]; - let layer: Id = msg_send![bubble, layer]; - if !layer.is_null() { - // CGColor from NSColor - let cg_color: Id = msg_send![bg_color, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - apply_tafla_surface(layer, false); - let _: () = msg_send![layer, setMasksToBounds: false]; - // Border styling - let (border_color, bw) = if config.is_error { - ( - ui_colors::bubble_error_text(), - ui_tokens::SURFACE_BORDER_WIDTH, - ) - } else { - match config.role { - BubbleRole::User => ( - ui_colors::bubble_user_border(), - ui_tokens::SURFACE_BORDER_WIDTH, - ), - BubbleRole::Assistant | BubbleRole::System => { - (ui_colors::bubble_border(), ui_tokens::SURFACE_BORDER_WIDTH) - } - } - }; - if bw > 0.0 { - let cg_border: Id = msg_send![border_color, CGColor]; - let _: () = msg_send![layer, setBorderColor: cg_border]; - let _: () = msg_send![layer, setBorderWidth: bw]; - } - } - - // Update label frame to the final measured height. - let text_frame = CGRect::new( - &CGPoint::new(padding_x, padding_top), - &CGSize::new(text_layout_width.max(1.0), text_height), - ); - let _: () = msg_send![text_label, setFrame: text_frame]; - add_subview(bubble, text_label); - - // Metadata (role/time/mode) above the bubble. - if let Some(meta) = config.metadata.as_ref() { - let meta_label: Id = msg_send![ns_text_field, alloc]; - let meta_frame = CGRect::new( - &CGPoint::new(bubble_x, 0.0), - &CGSize::new(bubble_width.max(1.0), meta_height.max(1.0)), - ); - let meta_label: Id = msg_send![meta_label, initWithFrame: meta_frame]; - let _: () = msg_send![meta_label, setBezeled: false]; - let _: () = msg_send![meta_label, setEditable: false]; - let _: () = msg_send![meta_label, setSelectable: false]; - let _: () = msg_send![meta_label, setDrawsBackground: false]; - - let meta_font: Id = msg_send![ns_font, systemFontOfSize: ui_tokens::SMALL_FONT_SIZE]; - let _: () = msg_send![meta_label, setFont: meta_font]; - let meta_color: Id = ui_colors::bubble_meta_text(); - let _: () = msg_send![meta_label, setTextColor: meta_color]; - - let alignment: isize = if config.role == BubbleRole::User { - 2 - } else { - 0 - }; - let _: () = msg_send![meta_label, setAlignment: alignment]; - let _: () = msg_send![meta_label, setStringValue: ns_string(meta)]; - let _: () = msg_send![meta_label, setIdentifier: ns_string("codescribe_bubble_meta")]; - - let _: () = msg_send![container, addSubview: meta_label]; - } - - // Assemble hierarchy - // (text_label already added to bubble above — directly or via scroll wrapper) - // Add hover action buttons if message_index is provided. - if let (Some(msg_index), Some(target)) = (config.message_index, config.copy_action_target) { - let Some(ns_button) = Class::get("NSButton") else { - let _: () = msg_send![container, addSubview: bubble]; - return (container, text_label); - }; - - let action_frames = bubble_action_frames( - bubble_width, - padding_x, - matches!( - config.role, - BubbleRole::User | BubbleRole::Assistant | BubbleRole::System - ), - ); - let button_height = copy_button_height; - // Flipped coords: anchor near the bottom edge. - let button_y = (bubble_height - button_height - 4.0).max(4.0); - - let button_frame = CGRect::new( - &CGPoint::new(action_frames.copy.x, button_y), - &CGSize::new(action_frames.copy.width, button_height), - ); - - let copy_button: Id = msg_send![ns_button, alloc]; - let copy_button: Id = msg_send![copy_button, initWithFrame: button_frame]; - - // Style: small borderless button - let _: () = msg_send![copy_button, setBezelStyle: 0_isize]; // NSBezelStyleRounded - let _: () = msg_send![copy_button, setBordered: false]; - - // "Copy" title is the fallback; the native system copy glyph - // (`doc.on.doc`) replaces it whenever SF Symbols are available so the - // action reads unambiguously as Copy, distinct from the Markdown - // toggle next to it. - let title = ns_string("Copy"); - let _: () = msg_send![copy_button, setTitle: title]; - - let small_font: Id = if jb_font.is_null() { - msg_send![ns_font, monospacedSystemFontOfSize: 10.0f64 weight: 0.0f64] - } else { - msg_send![ns_font, fontWithName: jb_name size: 10.0f64] - }; - let _: () = msg_send![copy_button, setFont: small_font]; - let _ = set_button_symbol(copy_button, "doc.on.doc"); - let _: () = msg_send![copy_button, setToolTip: ns_string("Copy")]; - let _: () = msg_send![copy_button, setAccessibilityLabel: ns_string("Copy")]; - - // Match bubble text tint - let button_color: Id = ui_colors::bubble_text(); - let _: () = msg_send![copy_button, setContentTintColor: button_color]; - - // Store message index in tag for retrieval on click - let _: () = msg_send![copy_button, setTag: msg_index as isize]; - let _: () = msg_send![ - copy_button, - setIdentifier: ns_string("codescribe_copy_button") - ]; - - // Dedicated frame + handler — an NSButton hit-tests to its own - // bounds, and `bubble_action_frames` guarantees those bounds never - // overlap the Markdown toggle, so Copy can never toggle Markdown. - let _: () = msg_send![copy_button, setTarget: target]; - let _: () = msg_send![copy_button, setAction: sel!(onCopyMessage:)]; - - let _: () = msg_send![copy_button, setHidden: true]; - let _: () = msg_send![bubble, addSubview: copy_button]; - - if let Some(render_action_frame) = action_frames.render { - let render_frame = CGRect::new( - &CGPoint::new(render_action_frame.x, button_y), - &CGSize::new(render_action_frame.width, button_height), - ); - let render_button: Id = msg_send![ns_button, alloc]; - let render_button: Id = msg_send![render_button, initWithFrame: render_frame]; - let _: () = msg_send![render_button, setBezelStyle: 0_isize]; - let _: () = msg_send![render_button, setBordered: false]; - - // Explicit `MD` text label — replaces the ambiguous "Aa" - // (`textformat`) / "{}" (`curlybraces`) SF Symbols the operator - // flagged. Title-only (no image) so it never reads as a generic - // glyph; the tooltip + accessibility label carry toggle direction. - let _: () = msg_send![render_button, setTitle: ns_string("MD")]; - let _: () = msg_send![render_button, setImagePosition: 0_isize]; // NSNoImage - let tooltip = match render_mode { - RenderMode::Plain => "Render Markdown", - RenderMode::Markdown => "Show raw Markdown", - }; - let _: () = msg_send![render_button, setToolTip: ns_string(tooltip)]; - let _: () = msg_send![render_button, setAccessibilityLabel: ns_string(tooltip)]; - let _: () = msg_send![render_button, setFont: small_font]; - let _: () = msg_send![render_button, setContentTintColor: button_color]; - let _: () = msg_send![render_button, setTag: msg_index as isize]; - let _: () = msg_send![ - render_button, - setIdentifier: ns_string("codescribe_render_button") - ]; - // Dedicated frame + handler — Markdown toggle never copies. - let _: () = msg_send![render_button, setTarget: target]; - let _: () = msg_send![render_button, setAction: sel!(onToggleBubbleRender:)]; - let _: () = msg_send![render_button, setHidden: true]; - let _: () = msg_send![bubble, addSubview: render_button]; - } - } - - let _: () = msg_send![container, addSubview: bubble]; - - if config.message_index.is_some() { - let ns_tracking_area = Class::get("NSTrackingArea").unwrap(); - let tracking_opts = NSTRACKING_MOUSE_ENTERED_AND_EXITED - | NSTRACKING_ACTIVE_ALWAYS - | NSTRACKING_IN_VISIBLE_RECT; - let tracking_area: Id = msg_send![ns_tracking_area, alloc]; - let tracking_area: Id = msg_send![ - tracking_area, - initWithRect: CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(bubble_width.max(1.0), bubble_height.max(1.0)), - ) - options: tracking_opts - owner: bubble - userInfo: std::ptr::null::() - ]; - let _: () = msg_send![bubble, addTrackingArea: tracking_area]; - } - - (container, text_label) - } -} - -/// Update bubble text (for streaming updates) -/// # Safety -/// `text_label` must be a valid `NSTextField` instance. -pub unsafe fn update_bubble_text( - text_label: Id, - text: &str, - role: BubbleRole, - is_streaming: bool, - is_error: bool, -) { - let render_mode = streaming_render_mode(is_streaming, role); - unsafe { - update_bubble_text_with_render_mode( - text_label, - text, - role, - is_streaming, - is_error, - render_mode, - ); - } -} - -/// Update bubble text with an explicit render mode. -/// # Safety -/// `text_label` must be a valid `NSTextField` instance. -pub unsafe fn update_bubble_text_with_render_mode( - text_label: Id, - text: &str, - role: BubbleRole, - is_streaming: bool, - is_error: bool, - render_mode: RenderMode, -) { - unsafe { - let display_text = if is_streaming && text.is_empty() { - "• • •".to_string() - } else if is_streaming { - format!("{} …", text) - } else { - text.to_string() - }; - - // Always create a fresh monospace font instead of reading from the label. - // After markdown parsing, text_label.font may return a system font from - // the first attributed range, causing cascading degradation on subsequent updates. - let label_font: Id = msg_send![text_label, font]; - let font_size: f64 = if label_font.is_null() { - 13.0 - } else { - msg_send![label_font, pointSize] - }; - let ns_font_cls = Class::get("NSFont").unwrap(); - let jb_name = ns_string("JetBrainsMono-Regular"); - let jb_font: Id = msg_send![ns_font_cls, fontWithName: jb_name size: font_size]; - let font: Id = if jb_font.is_null() { - msg_send![ns_font_cls, monospacedSystemFontOfSize: font_size weight: 0.0f64] - } else { - jb_font - }; - let _: () = msg_send![text_label, setFont: font]; - if !(should_render_native_markdown(render_mode, &display_text) - && apply_markdown_to_text_field(text_label, &display_text, font)) - { - let text_str = ns_string(&display_text); - let _: () = msg_send![text_label, setStringValue: text_str]; - } - - let text_color: Id = if is_error { - ui_colors::bubble_error_text() - } else { - match role { - BubbleRole::User => ui_colors::bubble_text(), - BubbleRole::Assistant => { - if is_streaming { - ui_colors::bubble_streaming_text() - } else { - ui_colors::bubble_text() - } - } - BubbleRole::System => ui_colors::bubble_text(), - } - }; - let _: () = msg_send![text_label, setTextColor: text_color]; - } -} - -/// Update a stack view item (bubble container) height constraint if present. -/// -/// `stack_view_add` installs a fixed-height constraint on each arranged subview. -/// During streaming, the bubble text grows and we need to update that constraint -/// so the view doesn't clip. -/// -/// # Safety -/// `view` must be a valid `NSView` instance. -pub unsafe fn update_stack_item_height(view: Id, new_height: f64) { - unsafe { - let constraints: Id = msg_send![view, constraints]; - if constraints.is_null() { - return; - } - let count: usize = msg_send![constraints, count]; - for i in 0..count { - let c: Id = msg_send![constraints, objectAtIndex: i]; - if c.is_null() { - continue; - } - - // Prefer our tagged constraint. - let ident: Id = msg_send![c, identifier]; - if !ident.is_null() { - let c_str: *const i8 = msg_send![ident, UTF8String]; - if !c_str.is_null() { - let s = std::ffi::CStr::from_ptr(c_str).to_string_lossy(); - if s == "codescribe_height" { - let _: () = msg_send![c, setConstant: new_height]; - return; - } - } - } - - // Fallback: find a height constraint on this view. - let first: Id = msg_send![c, firstItem]; - if first != view { - continue; - } - let second: Id = msg_send![c, secondItem]; - if !second.is_null() { - continue; - } - let first_attr: isize = msg_send![c, firstAttribute]; - // NSLayoutAttributeHeight == 8 - if first_attr == 8 { - let _: () = msg_send![c, setConstant: new_height]; - return; - } - } - } -} - -/// Resize an existing bubble container + its internal views for the given text. -/// -/// Used for streaming updates to prevent clipping without rebuilding the whole view tree. -/// -/// # Safety -/// `container` must be the container returned by `create_bubble_view`. -/// `text_label` must be the label returned by `create_bubble_view`. -pub unsafe fn resize_bubble_container_for_text(container: Id, text_label: Id, display_text: &str) { - unsafe { - let ns_font = Class::get("NSFont").unwrap(); - - let font: Id = msg_send![text_label, font]; - let font = if font.is_null() { - msg_send![ns_font, systemFontOfSize: 13.0f64] - } else { - font - }; - - let container_frame: CGRect = msg_send![container, frame]; - let max_width = container_frame.size.width.max(80.0); - let bubble_max_width = (max_width - 16.0).max(80.0); - - // If the message is getting long, switch to full-width to avoid one-word-per-line bubbles. - // - // During streaming we append " …" so we can detect it and widen earlier to prevent - // the initial narrow bubble phase. - let streaming_like = display_text.ends_with('…'); - let long_threshold = if streaming_like { 30 } else { 80 }; - let is_long = display_text.chars().count() > long_threshold; - let force_full_width = display_text.contains('\n') || is_long; - - let label_frame: CGRect = msg_send![text_label, frame]; - let width = if force_full_width { - let padding_x = 12.0; - (bubble_max_width - padding_x * 2.0).max(40.0) - } else { - label_frame.size.width.max(1.0) - }; - - // Approximate line-height floor to avoid tiny/bad measurements. - let point_size: f64 = msg_send![font, pointSize]; - let line_height = (point_size * 1.35).max(14.0); - - // Match `create_bubble_view` layout constants. - let padding_top = 10.0; - let copy_button_height = 16.0; - let padding_bottom = copy_button_height + 8.0; - - // Ask the label's cell for the wrapped height in the current width. - let measure_bounds = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(width.max(1.0), 10_000.0), - ); - let cell: Id = msg_send![text_label, cell]; - let measured: CGSize = if cell.is_null() { - // Fallback to a conservative single line height. - CGSize::new(width.max(1.0), line_height) - } else { - msg_send![cell, cellSizeForBounds: measure_bounds] - }; - let text_height = measured.height.ceil().max(line_height); - let bubble_height = text_height + padding_top + padding_bottom; - let mut meta_height = 0.0; - let mut meta_spacing = 0.0; - let mut meta_label: Option = None; - - let subviews: Id = msg_send![container, subviews]; - if !subviews.is_null() { - let sub_count: usize = msg_send![subviews, count]; - for i in 0..sub_count { - let v: Id = msg_send![subviews, objectAtIndex: i]; - if v.is_null() { - continue; - } - let ident: Id = msg_send![v, identifier]; - if ident.is_null() { - continue; - } - let c_str: *const i8 = msg_send![ident, UTF8String]; - if c_str.is_null() { - continue; - } - let s = std::ffi::CStr::from_ptr(c_str).to_string_lossy(); - if s == "codescribe_bubble_meta" { - let frame: CGRect = msg_send![v, frame]; - meta_height = frame.size.height.max(ui_tokens::SMALL_FONT_SIZE); - meta_spacing = 4.0; - meta_label = Some(v); - break; - } - } - } - - // Resize bubble background view (label's superview). - let bubble: Id = msg_send![text_label, superview]; - if !bubble.is_null() { - let bubble_frame: CGRect = msg_send![bubble, frame]; - let mut bubble_width = bubble_frame.size.width; - let mut bubble_x = bubble_frame.origin.x; - - if force_full_width { - bubble_width = bubble_max_width; - // Preserve alignment based on prior x (user bubbles are right-aligned). - let was_right_aligned = bubble_x > 20.0; - bubble_x = if was_right_aligned { - (max_width - bubble_width - 8.0).max(8.0) - } else { - 8.0 - }; - } - - // Resize label to match bubble width (keep in sync with create_bubble_view). - let padding_x = 12.0; - let new_label_w = (bubble_width - padding_x * 2.0).max(1.0); - let new_label_frame = CGRect::new( - &CGPoint::new(padding_x, padding_top), - &CGSize::new(new_label_w, text_height), - ); - let _: () = msg_send![text_label, setFrame: new_label_frame]; - - if let Some(meta_ptr) = meta_label { - let meta_frame = CGRect::new( - &CGPoint::new(bubble_x, 0.0), - &CGSize::new(bubble_width.max(1.0), meta_height.max(1.0)), - ); - let _: () = msg_send![meta_ptr, setFrame: meta_frame]; - } - - // Reposition action buttons to stay anchored near the bottom edge (flipped coords) - // and recompute X from bubble width. Streaming can widen bubbles after creation; - // preserving the old X lets MD/Copy hitboxes drift into each other. - let ns_button = Class::get("NSButton").unwrap(); - let subviews: Id = msg_send![bubble, subviews]; - if !subviews.is_null() { - let sub_count: usize = msg_send![subviews, count]; - let mut copy_button: Option = None; - let mut render_button: Option = None; - for i in 0..sub_count { - let v: Id = msg_send![subviews, objectAtIndex: i]; - if v.is_null() { - continue; - } - let is_button: bool = msg_send![v, isKindOfClass: ns_button]; - if !is_button { - continue; - } - let ident: Id = msg_send![v, identifier]; - if ident.is_null() { - continue; - } - let c_str: *const i8 = msg_send![ident, UTF8String]; - if c_str.is_null() { - continue; - } - let s = std::ffi::CStr::from_ptr(c_str).to_string_lossy(); - match s.as_ref() { - "codescribe_copy_button" => copy_button = Some(v), - "codescribe_render_button" => render_button = Some(v), - _ => {} - } - } - - let action_frames = - bubble_action_frames(bubble_width, padding_x, render_button.is_some()); - if let Some(button) = render_button - && let Some(render_frame) = action_frames.render - { - let btn_frame: CGRect = msg_send![button, frame]; - let btn_h = btn_frame.size.height; - let new_y = (bubble_height - btn_h - 4.0).max(4.0); - let new_frame = CGRect::new( - &CGPoint::new(render_frame.x, new_y), - &CGSize::new(render_frame.width, btn_h), - ); - let _: () = msg_send![button, setFrame: new_frame]; - } - if let Some(button) = copy_button { - let btn_frame: CGRect = msg_send![button, frame]; - let btn_h = btn_frame.size.height; - let new_y = (bubble_height - btn_h - 4.0).max(4.0); - let new_frame = CGRect::new( - &CGPoint::new(action_frames.copy.x, new_y), - &CGSize::new(action_frames.copy.width, btn_h), - ); - let _: () = msg_send![button, setFrame: new_frame]; - } - } - - let bubble_y = if meta_height > 0.0 { - meta_height + meta_spacing - } else { - bubble_frame.origin.y - }; - let new_bubble_frame = CGRect::new( - &CGPoint::new(bubble_x, bubble_y), - &CGSize::new(bubble_width, bubble_height), - ); - let _: () = msg_send![bubble, setFrame: new_bubble_frame]; - let _: () = msg_send![bubble, setNeedsDisplay: true]; - let _: () = msg_send![text_label, setNeedsDisplay: true]; - } - - // Resize container (stack arranged subview). - let container_height = bubble_height + meta_height + meta_spacing; - let _: () = msg_send![ - container, - setFrameSize: CGSize::new(container_frame.size.width, container_height) - ]; - update_stack_item_height(container, container_height); - - let _: () = msg_send![container, setNeedsLayout: true]; - let _: () = msg_send![container, layoutSubtreeIfNeeded]; - let _: () = msg_send![container, setNeedsDisplay: true]; - - // NSStackView (superview) does the actual arrangement; ensure it reflows immediately - // so updated height constraints take effect without requiring a click/focus change. - let stack: Id = msg_send![container, superview]; - if !stack.is_null() { - let _: () = msg_send![stack, setNeedsLayout: true]; - let _: () = msg_send![stack, layoutSubtreeIfNeeded]; - } - } -} - -// ============================================================================ -// File Operations Helpers -// ============================================================================ - -pub fn create_vertical_stack_view(frame: CGRect) -> Id { - unsafe { - let ns_stack_view = Class::get("NSStackView").unwrap(); - - let stack: Id = msg_send![ns_stack_view, alloc]; - let stack: Id = msg_send![stack, initWithFrame: frame]; - - // Vertical orientation (1 = NSUserInterfaceLayoutOrientationVertical) - let _: () = msg_send![stack, setOrientation: 1_isize]; - // Top alignment - let _: () = msg_send![stack, setAlignment: 1_isize]; // NSLayoutAttributeLeft - // Spacing between views - let _: () = msg_send![stack, setSpacing: 8.0f64]; - - stack - } -} - -/// Create a flipped vertical NSStackView (y-axis grows downward). -/// -/// This is useful for chat-like UIs where we want "top-down" coordinates and stable bubble -/// sizing during streaming. -pub fn create_flipped_vertical_stack_view(frame: CGRect) -> Id { - unsafe { - let ns_stack_view = flipped_stack_view_class(); - - let stack: Id = msg_send![ns_stack_view, alloc]; - let stack: Id = msg_send![stack, initWithFrame: frame]; - - // Vertical orientation (1 = NSUserInterfaceLayoutOrientationVertical) - let _: () = msg_send![stack, setOrientation: 1_isize]; - // Top alignment - let _: () = msg_send![stack, setAlignment: 1_isize]; // NSLayoutAttributeLeft - // Spacing between views - let _: () = msg_send![stack, setSpacing: 8.0f64]; - - stack - } -} - -fn flipped_stack_view_class() -> &'static Class { - static mut CLS: *const Class = std::ptr::null(); - static ONCE: Once = Once::new(); - ONCE.call_once(|| unsafe { - let superclass = Class::get("NSStackView").expect("NSStackView class missing"); - let mut decl = ClassDecl::new("CodeScribeFlippedStackView", superclass) - .expect("CodeScribeFlippedStackView already defined"); - decl.add_method( - sel!(isFlipped), - is_flipped as extern "C" fn(&Object, Sel) -> bool, - ); - let cls = decl.register(); - CLS = cls as *const Class; - }); - unsafe { &*CLS } -} - -extern "C" fn is_flipped(_this: &Object, _cmd: Sel) -> bool { - true -} - -fn bubble_container_view_class() -> &'static Class { - static mut CLS: *const Class = std::ptr::null(); - static ONCE: Once = Once::new(); - ONCE.call_once(|| unsafe { - let superclass = Class::get("NSView").expect("NSView class missing"); - let mut decl = ClassDecl::new("CodeScribeBubbleContainerView", superclass) - .expect("CodeScribeBubbleContainerView already defined"); - decl.add_method( - sel!(isFlipped), - is_flipped as extern "C" fn(&Object, Sel) -> bool, - ); - decl.add_method( - sel!(scrollWheel:), - bubble_container_scroll_wheel as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(mouseEntered:), - bubble_container_mouse_entered as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(mouseExited:), - bubble_container_mouse_exited as extern "C" fn(&Object, Sel, Id), - ); - let cls = decl.register(); - CLS = cls as *const Class; - }); - unsafe { &*CLS } -} - -extern "C" fn bubble_container_scroll_wheel(this: &Object, _cmd: Sel, event: Id) { - unsafe { - let view: Id = (this as *const Object) as Id; - if view.is_null() || event.is_null() { - return; - } - - // When the pointer is over a bubble background, AppKit may not route wheel events to the - // surrounding scroll view. Forward explicitly so long messages stay scrollable. - let scroll: Id = msg_send![view, enclosingScrollView]; - if !scroll.is_null() { - let _: () = msg_send![scroll, scrollWheel: event]; - return; - } - - let next: Id = msg_send![view, nextResponder]; - if !next.is_null() { - let _: () = msg_send![next, scrollWheel: event]; - } - } -} - -extern "C" fn bubble_container_mouse_entered(this: &Object, _cmd: Sel, _event: Id) { - unsafe { - let view: Id = (this as *const Object) as Id; - toggle_bubble_copy_buttons(view, true); - } -} - -extern "C" fn bubble_container_mouse_exited(this: &Object, _cmd: Sel, _event: Id) { - unsafe { - let view: Id = (this as *const Object) as Id; - toggle_bubble_copy_buttons(view, false); - } -} - -unsafe fn toggle_bubble_copy_buttons(view: Id, visible: bool) { - let ns_button = Class::get("NSButton").unwrap(); - let subviews: Id = msg_send![view, subviews]; - if subviews.is_null() { - return; - } - let count: usize = msg_send![subviews, count]; - for i in 0..count { - let v: Id = msg_send![subviews, objectAtIndex: i]; - if v.is_null() { - continue; - } - let is_button: bool = msg_send![v, isKindOfClass: ns_button]; - if is_button { - let ident: Id = msg_send![v, identifier]; - if !ident.is_null() { - let c_str: *const i8 = msg_send![ident, UTF8String]; - if !c_str.is_null() { - let s = unsafe { std::ffi::CStr::from_ptr(c_str) }.to_string_lossy(); - if s == "codescribe_copy_button" || s == "codescribe_render_button" { - let _: () = msg_send![v, setHidden: !visible]; - } - } - } - continue; - } - unsafe { toggle_bubble_copy_buttons(v, visible) }; - } -} - -fn bubble_text_field_class() -> &'static Class { - static mut CLS: *const Class = std::ptr::null(); - static ONCE: Once = Once::new(); - ONCE.call_once(|| unsafe { - let superclass = Class::get("NSTextField").expect("NSTextField class missing"); - let mut decl = ClassDecl::new("CodeScribeBubbleTextField", superclass) - .expect("CodeScribeBubbleTextField already defined"); - decl.add_method( - sel!(scrollWheel:), - bubble_text_scroll_wheel as extern "C" fn(&Object, Sel, Id), - ); - let cls = decl.register(); - CLS = cls as *const Class; - }); - unsafe { &*CLS } -} - -extern "C" fn bubble_text_scroll_wheel(this: &Object, _cmd: Sel, event: Id) { - unsafe { - let view: Id = (this as *const Object) as Id; - if view.is_null() || event.is_null() { - return; - } - - // Selectable text fields sometimes "eat" scroll wheel events without scrolling anything. - // Forward the wheel to the enclosing scroll view so Agent/Drawer can always scroll. - let scroll: Id = msg_send![view, enclosingScrollView]; - if !scroll.is_null() { - let _: () = msg_send![scroll, scrollWheel: event]; - return; - } - - let next: Id = msg_send![view, nextResponder]; - if !next.is_null() { - let _: () = msg_send![next, scrollWheel: event]; - } - } -} - -/// Add a view to NSStackView -/// # Safety -/// `stack` must be a valid `NSStackView` and `view` a valid `NSView`. -pub unsafe fn stack_view_add(stack: Id, view: Id) { - unsafe { - // NSStackView uses Auto Layout for arranged subviews. Our views are created with manual - // frames, so we need to: - // - opt out of autoresizing-mask constraints - // - provide at least a height constraint, otherwise subviews can collapse/overlap - let _: () = msg_send![view, setTranslatesAutoresizingMaskIntoConstraints: false]; - - let _: () = msg_send![stack, addArrangedSubview: view]; - - // Ensure a deterministic width. Without leading/trailing constraints, NSStackView can - // produce ambiguous layouts (overlaps / broken scrolling) when used as a scroll document. - let view_leading: Id = msg_send![view, leadingAnchor]; - let view_trailing: Id = msg_send![view, trailingAnchor]; - let stack_leading: Id = msg_send![stack, leadingAnchor]; - let stack_trailing: Id = msg_send![stack, trailingAnchor]; - if !view_leading.is_null() - && !view_trailing.is_null() - && !stack_leading.is_null() - && !stack_trailing.is_null() - { - let leading: Id = msg_send![view_leading, constraintEqualToAnchor: stack_leading]; - let trailing: Id = msg_send![view_trailing, constraintEqualToAnchor: stack_trailing]; - if !leading.is_null() { - let _: () = msg_send![leading, setActive: true]; - } - if !trailing.is_null() { - let _: () = msg_send![trailing, setActive: true]; - } - } - - // Pin height to the initial frame height (good enough for our chat bubbles/cards). - let frame: CGRect = msg_send![view, frame]; - let height_anchor: Id = msg_send![view, heightAnchor]; - let height_constraint: Id = - msg_send![height_anchor, constraintEqualToConstant: frame.size.height]; - // Tag for later updates (streaming bubbles grow). - let _: () = msg_send![height_constraint, setIdentifier: ns_string("codescribe_height")]; - let _: () = msg_send![height_constraint, setActive: true]; - } -} - -/// Remove all views from NSStackView -/// # Safety -/// `stack` must be a valid `NSStackView` instance. -pub unsafe fn stack_view_clear(stack: Id) { - unsafe { - let arranged: Id = msg_send![stack, arrangedSubviews]; - let count: usize = msg_send![arranged, count]; - - for i in (0..count).rev() { - let view: Id = msg_send![arranged, objectAtIndex: i]; - // For NSStackView, removing an arranged subview requires two steps: - // 1) removeArrangedSubview (removes constraints/arrangement bookkeeping) - // 2) removeFromSuperview (removes it from the view hierarchy) - let _: () = msg_send![stack, removeArrangedSubview: view]; - let _: () = msg_send![view, removeFromSuperview]; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn frame_right(frame: BubbleActionFrame) -> f64 { - frame.x + frame.width - } - - #[test] - fn bubble_action_frames_keep_render_and_copy_separate() { - let frames = bubble_action_frames(92.0, 12.0, true); - let render = frames - .render - .expect("renderable bubbles should get render toggle"); - - assert_eq!(render.x, 12.0); - assert_eq!(frames.copy.x, 40.0); - assert!( - frame_right(render) <= frames.copy.x, - "render and copy actions must not overlap" - ); - assert!( - frames.copy.x - frame_right(render) >= BUBBLE_ACTION_GAP, - "render and copy actions must keep a strict gap" - ); - assert!(render.x >= 4.0); - assert!(frame_right(frames.copy) <= 92.0); - } - - #[test] - fn bubble_action_frames_without_render_use_right_edge_for_copy() { - let frames = bubble_action_frames(180.0, 12.0, false); - - assert_eq!(frames.render, None); - assert_eq!(frames.copy.width, BUBBLE_COPY_ACTION_WIDTH); - assert_eq!(frames.copy.x, 128.0); - } - - // ------------------------------------------------------------------ - // Measurement cache + cap - // ------------------------------------------------------------------ - - /// Simulates the create_bubble_view measurement path against a cache while - /// counting how many times an actual (expensive) measurement would run. - /// Returns (resolved_height, measured_this_call). - fn measure_with_cache( - cache: &mut BubbleMeasureCache, - measure_calls: &mut usize, - text: &str, - max_width: f64, - font_size: f64, - render_mode: RenderMode, - ) -> f64 { - let key = BubbleMeasureKey::new(text, max_width, font_size, render_mode); - if let Some(measure) = cache.get(&key) { - return measure.text_height; // cache hit — no measurement - } - // Cache miss — perform the "expensive" measurement (counted). - *measure_calls += 1; - let height = estimate_text_height(text, max_width, font_size) - .ceil() - .max(font_size * 1.4); - cache.insert( - key, - BubbleMeasure { - bubble_width: max_width, - text_height: height, - }, - ); - height - } - - #[test] - fn measure_cache_hits_on_identical_inputs_and_skips_remeasure() { - let mut cache = BubbleMeasureCache::new(); - let mut measure_calls = 0usize; - - let text = "hello world, this is a chat bubble that will be measured"; - let h1 = measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Plain, - ); - assert_eq!(measure_calls, 1, "first measurement must run"); - assert_eq!(cache.len(), 1); - - // Same text/width/font/render-mode -> cache hit, no new measurement. - let h2 = measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Plain, - ); - assert_eq!( - measure_calls, 1, - "identical inputs must reuse the cached measurement" - ); - assert_eq!(h1, h2, "cache must return the same height"); - assert_eq!(cache.len(), 1, "no new entry on a hit"); - } - - #[test] - fn measure_cache_misses_when_any_key_input_changes() { - let mut cache = BubbleMeasureCache::new(); - let mut measure_calls = 0usize; - let text = "bubble text"; - - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Plain, - ); - // Different width -> miss. - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 500.0, - 13.0, - RenderMode::Plain, - ); - // Different font size -> miss. - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 15.0, - RenderMode::Plain, - ); - // Different render mode -> miss. - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Markdown, - ); - // Different text -> miss. - measure_with_cache( - &mut cache, - &mut measure_calls, - "other", - 400.0, - 13.0, - RenderMode::Plain, - ); - - assert_eq!( - measure_calls, 5, - "each distinct key must measure exactly once" - ); - assert_eq!(cache.len(), 5); - - // Re-issuing all five keys must be pure cache hits. - let before = measure_calls; - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Plain, - ); - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 500.0, - 13.0, - RenderMode::Plain, - ); - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 15.0, - RenderMode::Plain, - ); - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Markdown, - ); - measure_with_cache( - &mut cache, - &mut measure_calls, - "other", - 400.0, - 13.0, - RenderMode::Plain, - ); - assert_eq!(measure_calls, before, "all repeats must hit the cache"); - } - - #[test] - fn measure_cache_clear_forces_remeasure() { - let mut cache = BubbleMeasureCache::new(); - let mut measure_calls = 0usize; - let text = "clearable"; - - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Plain, - ); - assert_eq!(measure_calls, 1); - cache.clear(); - assert!(cache.is_empty()); - measure_with_cache( - &mut cache, - &mut measure_calls, - text, - 400.0, - 13.0, - RenderMode::Plain, - ); - assert_eq!(measure_calls, 2, "cleared cache must re-measure"); - } - - #[test] - fn cap_threshold_selects_estimation_for_huge_text() { - // Below cap: not over cap. Above cap: over cap (would skip AppKit). - let small = "x".repeat(BUBBLE_MEASURE_CAP_CHARS); - let huge = "x".repeat(BUBBLE_MEASURE_CAP_CHARS + 1); - assert!(small.chars().count() <= BUBBLE_MEASURE_CAP_CHARS); - assert!(huge.chars().count() > BUBBLE_MEASURE_CAP_CHARS); - } - - #[test] - fn estimate_text_height_grows_with_length_and_respects_newlines() { - let width = 400.0; - let font = 13.0; - - let one_line = estimate_text_height("short", width, font); - let line_height = (font * 1.4).max(1.0); - assert!( - one_line >= line_height, - "single short line must be at least one line tall" - ); - - // A very long single line wraps to multiple lines -> taller. - let long = "a".repeat(10_000); - let wrapped = estimate_text_height(&long, width, font); - assert!( - wrapped > one_line * 5.0, - "wrapped long text must be many lines tall ({wrapped} vs {one_line})" - ); - - // Hard newlines add lines even for short content. - let multiline = "a\nb\nc\nd\ne"; - let multi_h = estimate_text_height(multiline, width, font); - assert!( - multi_h >= line_height * 5.0, - "five newline-separated lines must be >= 5 line-heights ({multi_h})" - ); - } - - #[test] - fn estimate_text_height_caps_runtime_for_megabyte_blob() { - // The whole point of the cap: estimation is O(n) cheap, never invokes - // AppKit. This just proves it returns a finite, sane height for a blob - // far larger than the cap without hanging. - let blob = "lorem ipsum dolor sit amet ".repeat(200_000); // ~5.4 MB - let h = estimate_text_height(&blob, 400.0, 13.0); - assert!(h.is_finite() && h > 0.0); - } -} diff --git a/app/ui/shared/helpers/controls.rs b/app/ui/shared/helpers/controls.rs deleted file mode 100644 index 860cdb45..00000000 --- a/app/ui/shared/helpers/controls.rs +++ /dev/null @@ -1,524 +0,0 @@ -use crate::os::clipboard; -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use objc::runtime::Class; -use objc::{msg_send, sel, sel_impl}; -use std::ffi::CString; - -use super::{Id, apply_tafla_surface, color_label, color_secondary_label, ui_colors, ui_tokens}; - -pub fn ns_string(s: &str) -> Id { - unsafe { - let ns_string = Class::get("NSString").unwrap(); - let c_str = CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()); - msg_send![ns_string, stringWithUTF8String: c_str.as_ptr()] - } -} - -/// Copy text to the system clipboard (best-effort). -pub fn copy_to_clipboard(text: &str) { - let _ = clipboard::copy(text); -} - -// ============================================================================ -// Text Field Helpers -// ============================================================================ - -/// Get string value from an NSTextField/NSSearchField -/// # Safety -/// `field` must be a valid `NSTextField`/`NSSearchField` instance. -pub unsafe fn get_text_field_string(field: Id) -> String { - unsafe { - let value: Id = msg_send![field, stringValue]; - let c_str: *const i8 = msg_send![value, UTF8String]; - if c_str.is_null() { - return String::new(); - } - std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .to_string() - } -} - -/// Set string value for an NSTextField/NSSearchField -/// # Safety -/// `field` must be a valid `NSTextField`/`NSSearchField` instance. -pub unsafe fn set_text_field_string(field: Id, text: &str) { - unsafe { - let value = ns_string(text); - let _: () = msg_send![field, setStringValue: value]; - } -} - -/// Set tooltip for a control/view. -/// # Safety -/// `view` must be a valid Objective-C object that supports `setToolTip:`. -pub unsafe fn set_tooltip(view: Id, text: &str) { - unsafe { - let tip = ns_string(text); - let _: () = msg_send![view, setToolTip: tip]; - } -} - -// ============================================================================ -// Label / TextField Helpers -// ============================================================================ - -/// Configuration for creating a label -pub struct LabelConfig { - pub frame: CGRect, - pub text: String, - pub font_size: f64, - pub bold: bool, - pub text_color: Id, - pub background_color: Option, - pub selectable: bool, - pub editable: bool, -} - -impl Default for LabelConfig { - fn default() -> Self { - Self { - frame: CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(100.0, 24.0)), - text: String::new(), - font_size: 13.0, - bold: false, - text_color: color_label(), - background_color: None, - selectable: false, - editable: false, - } - } -} - -/// Create a label (non-editable text field) -pub fn create_label(config: LabelConfig) -> Id { - unsafe { - let ns_text_field = Class::get("NSTextField").unwrap(); - let ns_font = Class::get("NSFont").unwrap(); - - let field: Id = msg_send![ns_text_field, alloc]; - let field: Id = msg_send![field, initWithFrame: config.frame]; - - let _: () = msg_send![field, setBezeled: false]; - let _: () = msg_send![field, setEditable: config.editable]; - let _: () = msg_send![field, setSelectable: config.selectable]; - - if let Some(bg) = config.background_color { - let _: () = msg_send![field, setDrawsBackground: true]; - let _: () = msg_send![field, setBackgroundColor: bg]; - } else { - let _: () = msg_send![field, setDrawsBackground: false]; - } - - let _: () = msg_send![field, setTextColor: config.text_color]; - - let font: Id = if config.bold { - msg_send![ns_font, boldSystemFontOfSize: config.font_size] - } else { - msg_send![ns_font, systemFontOfSize: config.font_size] - }; - let _: () = msg_send![field, setFont: font]; - - let text = ns_string(&config.text); - let _: () = msg_send![field, setStringValue: text]; - - field - } -} - -/// Create a search field -pub fn create_search_field(frame: CGRect, placeholder: &str) -> Id { - unsafe { - let ns_search_field = Class::get("NSSearchField").unwrap(); - let field: Id = msg_send![ns_search_field, alloc]; - let field: Id = msg_send![field, initWithFrame: frame]; - let placeholder = ns_string(placeholder); - let _: () = msg_send![field, setPlaceholderString: placeholder]; - field - } -} - -/// Quick label creation with minimal config -pub fn label(frame: CGRect, text: &str) -> Id { - create_label(LabelConfig { - frame, - text: text.to_string(), - ..Default::default() - }) -} - -/// Quick label with custom font size -pub fn label_sized(frame: CGRect, text: &str, font_size: f64, bold: bool) -> Id { - create_label(LabelConfig { - frame, - text: text.to_string(), - font_size, - bold, - ..Default::default() - }) -} - -// ============================================================================ -// Segmented Control Helpers -// ============================================================================ - -/// Create a segmented control with labels -pub fn create_segmented_control(frame: CGRect, labels: &[&str]) -> Id { - unsafe { - let ns_segmented = Class::get("NSSegmentedControl").unwrap(); - let control: Id = msg_send![ns_segmented, alloc]; - let control: Id = msg_send![control, initWithFrame: frame]; - let _: () = msg_send![control, setSegmentCount: labels.len() as isize]; - for (idx, label) in labels.iter().enumerate() { - let title = ns_string(label); - let _: () = msg_send![control, setLabel: title forSegment: idx as isize]; - } - let _: () = msg_send![control, setSelectedSegment: 0_isize]; - control - } -} - -// ============================================================================ -// Button Helpers -// ============================================================================ - -/// Button style constants -pub mod button_style { - pub const ROUNDED: isize = 1; - pub const SMALL_SQUARE: isize = 10; - pub const INLINE: isize = 15; - pub const GLASS: isize = 16; -} - -/// Create a button with title and action -pub fn create_button(frame: CGRect, title: &str, style: isize) -> Id { - unsafe { - let ns_button = Class::get("NSButton").unwrap(); - - let btn: Id = msg_send![ns_button, alloc]; - let btn: Id = msg_send![btn, initWithFrame: frame]; - - let title_str = ns_string(title); - let _: () = msg_send![btn, setTitle: title_str]; - let _: () = msg_send![btn, setBezelStyle: style]; - - btn - } -} - -/// Set a button's SF Symbol image (returns true if applied). -/// # Safety -/// `button` must be a valid NSButton instance. -pub unsafe fn set_button_symbol(button: Id, symbol_name: &str) -> bool { - unsafe { - let Some(ns_image) = Class::get("NSImage") else { - return false; - }; - let responds: bool = msg_send![ - ns_image, - respondsToSelector: sel!(imageWithSystemSymbolName:accessibilityDescription:) - ]; - if !responds { - return false; - } - let name = ns_string(symbol_name); - let desc = ns_string(""); - let image: Id = msg_send![ - ns_image, - imageWithSystemSymbolName: name - accessibilityDescription: desc - ]; - if image.is_null() { - return false; - } - let _: () = msg_send![button, setImage: image]; - // NSImageOnly == 1 - let _: () = msg_send![button, setImagePosition: 1_isize]; - true - } -} - -/// Set an SF Symbol image on a segmented control segment. -/// # Safety -/// `control` must be a valid NSSegmentedControl instance. -pub unsafe fn set_segment_symbol(control: Id, segment: isize, symbol_name: &str) -> bool { - let Some(ns_image) = Class::get("NSImage") else { - return false; - }; - let responds: bool = msg_send![ - ns_image, - respondsToSelector: sel!(imageWithSystemSymbolName:accessibilityDescription:) - ]; - if !responds { - return false; - } - let name = ns_string(symbol_name); - let desc = ns_string(""); - let image: Id = msg_send![ - ns_image, - imageWithSystemSymbolName: name - accessibilityDescription: desc - ]; - if image.is_null() { - return false; - } - let _: () = msg_send![control, setImage: image forSegment: segment]; - true -} - -/// Style a toolbar icon button (borderless, inline bezel, tinted symbol). -/// # Safety -/// `button` must be a valid NSButton instance. -pub unsafe fn style_toolbar_icon_button(button: Id) { - let _: () = msg_send![button, setBezelStyle: button_style::INLINE]; - let responds_bordered: bool = msg_send![button, respondsToSelector: sel!(setBordered:)]; - if responds_bordered { - let _: () = msg_send![button, setBordered: false]; - } - // NOTE: Do NOT set transparent=true — it hides the button image entirely. - // setBordered=false + setImagePosition=NSImageOnly is sufficient for borderless icons. - let responds_shows_border: bool = - msg_send![button, respondsToSelector: sel!(setShowsBorderOnlyWhileMouseInside:)]; - if responds_shows_border { - let _: () = msg_send![button, setShowsBorderOnlyWhileMouseInside: false]; - } - let responds_image_position: bool = - msg_send![button, respondsToSelector: sel!(setImagePosition:)]; - if responds_image_position { - // NSImageOnly == 1 - let _: () = msg_send![button, setImagePosition: 1_isize]; - } - let responds_tint: bool = msg_send![button, respondsToSelector: sel!(setContentTintColor:)]; - if responds_tint { - let tint = color_label(); - let _: () = msg_send![button, setContentTintColor: tint]; - } - let responds_control_size: bool = msg_send![button, respondsToSelector: sel!(setControlSize:)]; - if responds_control_size { - let _: () = msg_send![button, setControlSize: 1_isize]; // NSSmallControlSize - } -} - -/// Update a toolbar icon button to reflect active/inactive tab state. -/// # Safety -/// `button` must be a valid NSButton instance. -pub unsafe fn set_tab_button_active(button: Id, active: bool) { - let ns_color = Class::get("NSColor").unwrap(); - let responds_tint: bool = msg_send![button, respondsToSelector: sel!(setContentTintColor:)]; - if responds_tint { - let tint: Id = if active { - msg_send![ns_color, controlAccentColor] - } else { - msg_send![ns_color, secondaryLabelColor] - }; - let _: () = msg_send![button, setContentTintColor: tint]; - } - let responds_state: bool = msg_send![button, respondsToSelector: sel!(setState:)]; - if responds_state { - let _: () = msg_send![button, setState: 0_isize]; - } -} - -/// Set button target and action -/// # Safety -/// `button` and `target` must be valid Objective-C objects. -pub unsafe fn button_set_action(button: Id, target: Id, action: objc::runtime::Sel) { - unsafe { - let _: () = msg_send![button, setTarget: target]; - let _: () = msg_send![button, setAction: action]; - } -} - -/// Quick rounded button -pub fn button(frame: CGRect, title: &str) -> Id { - create_button(frame, title, button_style::ROUNDED) -} - -// ============================================================================ -// Toggle Helpers -// ============================================================================ - -/// Create a native NSSwitch toggle. -pub fn create_toggle(frame: CGRect, checked: bool) -> Id { - unsafe { - let ns_switch = Class::get("NSSwitch").unwrap(); - let toggle: Id = msg_send![ns_switch, alloc]; - let toggle: Id = msg_send![toggle, initWithFrame: frame]; - let state: isize = if checked { 1 } else { 0 }; - let _: () = msg_send![toggle, setState: state]; - toggle - } -} - -// ============================================================================ -// Card Helpers -// ============================================================================ - -/// Create a drawer card view with a title, subtitle, and preview text -pub fn create_card_view(frame: CGRect, title: &str, subtitle: &str, preview: &str) -> Id { - unsafe { - let ns_view = Class::get("NSView").unwrap(); - let ns_text_field = Class::get("NSTextField").unwrap(); - let ns_font = Class::get("NSFont").unwrap(); - - let card: Id = msg_send![ns_view, alloc]; - let card: Id = msg_send![card, initWithFrame: frame]; - let _: () = msg_send![card, setWantsLayer: true]; - let layer: Id = msg_send![card, layer]; - if !layer.is_null() { - let bg_color: Id = ui_colors::card_bg(); - let cg_color: Id = msg_send![bg_color, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg_color]; - apply_tafla_surface(layer, true); - // Drawer cards stay intentionally flat: one border, no halo. - let _: () = msg_send![layer, setMasksToBounds: true]; - let _: () = msg_send![layer, setShadowRadius: 0.0f64]; - let _: () = msg_send![layer, setShadowOffset: CGSize::new(0.0, 0.0)]; - } - - let title_frame = CGRect::new( - &CGPoint::new(12.0, frame.size.height - 24.0), - &CGSize::new(frame.size.width - 24.0, 18.0), - ); - let title_field: Id = msg_send![ns_text_field, alloc]; - let title_field: Id = msg_send![title_field, initWithFrame: title_frame]; - let _: () = msg_send![title_field, setBezeled: false]; - let _: () = msg_send![title_field, setDrawsBackground: false]; - let _: () = msg_send![title_field, setEditable: false]; - let _: () = msg_send![title_field, setSelectable: false]; - let title_text = ns_string(title); - let _: () = msg_send![title_field, setStringValue: title_text]; - let title_font: Id = msg_send![ns_font, boldSystemFontOfSize: ui_tokens::BODY_FONT_SIZE]; - let _: () = msg_send![title_field, setFont: title_font]; - let title_color: Id = color_label(); - let _: () = msg_send![title_field, setTextColor: title_color]; - let _: () = msg_send![card, addSubview: title_field]; - - let subtitle_frame = CGRect::new( - &CGPoint::new(12.0, frame.size.height - 42.0), - &CGSize::new(frame.size.width - 24.0, 16.0), - ); - let subtitle_field: Id = msg_send![ns_text_field, alloc]; - let subtitle_field: Id = msg_send![subtitle_field, initWithFrame: subtitle_frame]; - let _: () = msg_send![subtitle_field, setBezeled: false]; - let _: () = msg_send![subtitle_field, setDrawsBackground: false]; - let _: () = msg_send![subtitle_field, setEditable: false]; - let _: () = msg_send![subtitle_field, setSelectable: false]; - let subtitle_text = ns_string(subtitle); - let _: () = msg_send![subtitle_field, setStringValue: subtitle_text]; - let subtitle_font: Id = msg_send![ns_font, systemFontOfSize: ui_tokens::SMALL_FONT_SIZE]; - let _: () = msg_send![subtitle_field, setFont: subtitle_font]; - let subtitle_color: Id = color_secondary_label(); - let _: () = msg_send![subtitle_field, setTextColor: subtitle_color]; - let _: () = msg_send![card, addSubview: subtitle_field]; - - // Leave room for the actions row ("Copy / Edit / Delete / ♥") at the bottom. - let preview_bottom = 36.0; - let preview_top = 56.0; - let preview_frame = CGRect::new( - &CGPoint::new(12.0, preview_bottom), - &CGSize::new( - frame.size.width - 24.0, - (frame.size.height - preview_top - preview_bottom).max(18.0), - ), - ); - let preview_field: Id = msg_send![ns_text_field, alloc]; - let preview_field: Id = msg_send![preview_field, initWithFrame: preview_frame]; - let _: () = msg_send![preview_field, setBezeled: false]; - let _: () = msg_send![preview_field, setDrawsBackground: false]; - let _: () = msg_send![preview_field, setEditable: false]; - let _: () = msg_send![preview_field, setSelectable: false]; - let _: () = msg_send![preview_field, setLineBreakMode: 0]; - let preview_text = ns_string(preview); - let _: () = msg_send![preview_field, setStringValue: preview_text]; - let preview_font: Id = msg_send![ns_font, systemFontOfSize: ui_tokens::BODY_FONT_SIZE]; - let _: () = msg_send![preview_field, setFont: preview_font]; - let preview_color: Id = color_secondary_label(); - let _: () = msg_send![preview_field, setTextColor: preview_color]; - let _: () = msg_send![card, addSubview: preview_field]; - - card - } -} - -// ============================================================================ -// Scroll View + Text View Helpers -// ============================================================================ - -/// Create a scroll view with embedded text view for multi-line text display -pub fn create_scrollable_text_view(frame: CGRect, editable: bool) -> (Id, Id) { - let ns_text_view = Class::get("NSTextView").expect("NSTextView class missing"); - create_scrollable_text_view_with_class(frame, editable, ns_text_view) -} - -/// Create a scroll view with embedded text view using a caller-provided NSTextView subclass. -pub fn create_scrollable_text_view_with_class( - frame: CGRect, - editable: bool, - text_view_class: &'static Class, -) -> (Id, Id) { - unsafe { - let ns_scroll_view = Class::get("NSScrollView").unwrap(); - let ns_color = Class::get("NSColor").unwrap(); - - // Create scroll view - let scroll: Id = msg_send![ns_scroll_view, alloc]; - let scroll: Id = msg_send![scroll, initWithFrame: frame]; - // Keep scrolling enabled; hide scrollbars via overlay + autohide. - let _: () = msg_send![scroll, setHasVerticalScroller: true]; - let _: () = msg_send![scroll, setHasHorizontalScroller: false]; - let _: () = msg_send![scroll, setDrawsBackground: false]; - let _: () = msg_send![scroll, setBorderType: 0_isize]; // NSNoBorder - let _: () = msg_send![scroll, setAutohidesScrollers: true]; - // NSScrollerStyleOverlay == 1 - let _: () = msg_send![scroll, setScrollerStyle: 1_isize]; - - // Create text view with same size - let text_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(frame.size.width, frame.size.height), - ); - let text_view: Id = msg_send![text_view_class, alloc]; - let text_view: Id = msg_send![text_view, initWithFrame: text_frame]; - - let _: () = msg_send![text_view, setEditable: editable]; - let _: () = msg_send![text_view, setSelectable: true]; - if editable { - let responds_placeholder: bool = - msg_send![text_view, respondsToSelector: sel!(setPlaceholderString:)]; - if responds_placeholder { - let placeholder = ns_string("Type a message"); - let _: () = msg_send![text_view, setPlaceholderString: placeholder]; - } - } - - // Transparent background - let clear: Id = msg_send![ns_color, clearColor]; - let _: () = msg_send![text_view, setBackgroundColor: clear]; - - // Dynamic text color (light/dark). - let text_color: Id = msg_send![ns_color, textColor]; - let _: () = msg_send![text_view, setTextColor: text_color]; - let responds_caret: bool = - msg_send![text_view, respondsToSelector: sel!(setInsertionPointColor:)]; - if responds_caret { - let caret: Id = msg_send![ns_color, controlAccentColor]; - let _: () = msg_send![text_view, setInsertionPointColor: caret]; - } - - // Auto-resize with scroll view - let _: () = msg_send![text_view, setMinSize: CGSize::new(0.0, frame.size.height)]; - let _: () = msg_send![text_view, setMaxSize: CGSize::new(f64::MAX, f64::MAX)]; - let _: () = msg_send![text_view, setVerticallyResizable: true]; - let _: () = msg_send![text_view, setHorizontallyResizable: false]; - - // Text container settings - let container: Id = msg_send![text_view, textContainer]; - let _: () = msg_send![container, setWidthTracksTextView: true]; - - // Set as document view - let _: () = msg_send![scroll, setDocumentView: text_view]; - - (scroll, text_view) - } -} diff --git a/app/ui/shared/helpers/file_actions.rs b/app/ui/shared/helpers/file_actions.rs deleted file mode 100644 index 78a75079..00000000 --- a/app/ui/shared/helpers/file_actions.rs +++ /dev/null @@ -1,239 +0,0 @@ -use objc::runtime::Class; -use objc::{msg_send, sel, sel_impl}; - -use super::{Id, ns_string}; - -pub fn pick_files_open_panel(title: &str) -> Vec { - #[cfg(target_os = "macos")] - unsafe { - let Some(ns_open_panel) = Class::get("NSOpenPanel") else { - return Vec::new(); - }; - let panel: Id = msg_send![ns_open_panel, openPanel]; - if panel.is_null() { - return Vec::new(); - } - - let _: () = msg_send![panel, setTitle: ns_string(title)]; - let _: () = msg_send![panel, setCanChooseFiles: true]; - let _: () = msg_send![panel, setCanChooseDirectories: false]; - let _: () = msg_send![panel, setAllowsMultipleSelection: true]; - - // Prefer predictable prompt text (keeps UX clear). - let _: () = msg_send![panel, setPrompt: ns_string("Attach")]; - - // runModal returns NSModalResponse (NSModalResponseOK == 1). - let resp: isize = msg_send![panel, runModal]; - if resp != 1 { - return Vec::new(); - } - - let urls: Id = msg_send![panel, URLs]; - if urls.is_null() { - return Vec::new(); - } - - let count: usize = msg_send![urls, count]; - let mut out = Vec::with_capacity(count); - for i in 0..count { - let url: Id = msg_send![urls, objectAtIndex: i]; - if url.is_null() { - continue; - } - let ns_path: Id = msg_send![url, path]; - if ns_path.is_null() { - continue; - } - let c_str: *const i8 = msg_send![ns_path, UTF8String]; - if c_str.is_null() { - continue; - } - let s = std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .to_string(); - if s.is_empty() { - continue; - } - out.push(std::path::PathBuf::from(s)); - } - out - } - - #[cfg(not(target_os = "macos"))] - { - let _ = title; - Vec::new() - } -} - -/// Open a file in the default editor (TextEdit, etc.) -pub fn open_file_in_editor(path: &std::path::Path) -> bool { - // Most reliable approach in the app-bundle environment: call `/usr/bin/open`. - // NSWorkspace sometimes reports success but doesn't surface the editor window. `open -e` - // (TextEdit) is predictable and works without PATH. - #[cfg(target_os = "macos")] - { - use std::time::Duration; - use tracing::{info, warn}; - - let path = path.to_path_buf(); - if !path.exists() { - warn!( - "open_file_in_editor: path does not exist: {}", - path.display() - ); - return false; - } - - let open_via_nsworkspace_textedit = || -> bool { - unsafe { - let ns_workspace = match Class::get("NSWorkspace") { - Some(c) => c, - None => return false, - }; - let workspace: Id = msg_send![ns_workspace, sharedWorkspace]; - if workspace.is_null() { - return false; - } - - let path_str = path.to_string_lossy(); - let ns_path = ns_string(&path_str); - let app = ns_string("TextEdit"); - - // Prefer explicit app open (avoids "Open…" panel / wrong default handler). - let ok: bool = msg_send![workspace, openFile: ns_path withApplication: app]; - info!("NSWorkspace openFile:withApplication(TextEdit) ok={}", ok); - ok - } - }; - - let run_open = |args: &[&str]| -> bool { - let out = std::process::Command::new("/usr/bin/open") - .args(args) - .arg(&path) - .output(); - match out { - Ok(out) => { - let code = out.status.code().unwrap_or(-1); - let stderr = String::from_utf8_lossy(&out.stderr); - if !stderr.trim().is_empty() { - info!( - "open {:?} exit={} stderr={}", - args, - code, - stderr.trim().replace('\n', "\\n") - ); - } else { - info!("open {:?} exit={}", args, code); - } - out.status.success() - } - Err(e) => { - warn!("open {:?} failed to spawn: {}", args, e); - false - } - } - }; - - let activate_textedit_best_effort = || { - // Try to bring TextEdit to the foreground without requiring Automation permissions - // (osascript can trigger a prompt / fail silently). - unsafe { - let ns_running_app = match Class::get("NSRunningApplication") { - Some(c) => c, - None => return, - }; - let bundle_id = ns_string("com.apple.TextEdit"); - let apps: Id = - msg_send![ns_running_app, runningApplicationsWithBundleIdentifier: bundle_id]; - if apps.is_null() { - return; - } - - let count: usize = msg_send![apps, count]; - if count == 0 { - return; - } - - // NSApplicationActivateAllWindows (1) | NSApplicationActivateIgnoringOtherApps (2) - let opts: u64 = 3; - for i in 0..count { - let app: Id = msg_send![apps, objectAtIndex: i]; - if app.is_null() { - continue; - } - let ok: bool = msg_send![app, activateWithOptions: opts]; - info!("TextEdit activateWithOptions result={}", ok); - } - } - }; - - // Force TextEdit and try to surface it; otherwise it can open "somewhere" (another Space) - // and look like a no-op from the user's POV. - // Prefer `open -a TextEdit ` (explicit app + file). Fallback to `-e` if needed. - if open_via_nsworkspace_textedit() || run_open(&["-a", "TextEdit"]) || run_open(&["-e"]) { - // Give launch a moment so NSRunningApplication can see the process. - std::thread::sleep(Duration::from_millis(75)); - activate_textedit_best_effort(); - return true; - } - if run_open(&["-t"]) || run_open(&[]) { - return true; - } - } - - unsafe { - let ns_workspace = Class::get("NSWorkspace").unwrap(); - let workspace: Id = msg_send![ns_workspace, sharedWorkspace]; - - let path_str = path.to_string_lossy(); - let ns_path = ns_string(&path_str); - - let ok: bool = msg_send![workspace, openFile: ns_path]; - if ok { - return true; - } - - // Fallback: open via file:// URL (some apps prefer this path). - let ns_url = Class::get("NSURL").unwrap(); - let url: Id = msg_send![ns_url, fileURLWithPath: ns_path]; - if url.is_null() { - // last resort below (shell open) - } else { - let ok2: bool = msg_send![workspace, openURL: url]; - if ok2 { - return true; - } - } - } - - let _ = path; - false -} - -/// List draft files from a directory, sorted by modification time (newest first) -pub fn list_draft_files(dir: &std::path::Path) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { - return Vec::new(); - }; - - let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries - .flatten() - .filter(|e| { - e.path().is_file() - && e.path() - .extension() - .is_some_and(|ext| ext == "txt" || ext == "md") - }) - .filter_map(|e| { - let path = e.path(); - let modified = e.metadata().ok()?.modified().ok()?; - Some((path, modified)) - }) - .collect(); - - // Sort by modification time, newest first - files.sort_by_key(|b| std::cmp::Reverse(b.1)); - - files.into_iter().map(|(path, _)| path).collect() -} diff --git a/app/ui/shared/helpers/inputs.rs b/app/ui/shared/helpers/inputs.rs deleted file mode 100644 index 14f9b43b..00000000 --- a/app/ui/shared/helpers/inputs.rs +++ /dev/null @@ -1,76 +0,0 @@ -use core_graphics::geometry::CGRect; -use objc::runtime::Class; -use objc::{msg_send, sel, sel_impl}; - -use super::{Id, ns_string}; - -/// Create an editable text input field with a border and placeholder. -pub fn create_text_input(frame: CGRect, placeholder: &str, initial_value: &str) -> Id { - unsafe { - let ns_text_field = Class::get("NSTextField").unwrap(); - let ns_font = Class::get("NSFont").unwrap(); - - let field: Id = msg_send![ns_text_field, alloc]; - let field: Id = msg_send![field, initWithFrame: frame]; - - let _: () = msg_send![field, setBezeled: true]; - let _: () = msg_send![field, setEditable: true]; - let _: () = msg_send![field, setSelectable: true]; - let _: () = msg_send![field, setDrawsBackground: true]; - - let font: Id = msg_send![ns_font, systemFontOfSize: 13.0f64]; - let _: () = msg_send![field, setFont: font]; - - let ph = ns_string(placeholder); - let _: () = msg_send![field, setPlaceholderString: ph]; - - if !initial_value.is_empty() { - let val = ns_string(initial_value); - let _: () = msg_send![field, setStringValue: val]; - } - - field - } -} - -/// Create a secure (password) text input field. -pub fn create_secure_text_input(frame: CGRect, placeholder: &str) -> Id { - unsafe { - let ns_secure = Class::get("NSSecureTextField").unwrap(); - let ns_font = Class::get("NSFont").unwrap(); - - let field: Id = msg_send![ns_secure, alloc]; - let field: Id = msg_send![field, initWithFrame: frame]; - - let _: () = msg_send![field, setBezeled: true]; - let _: () = msg_send![field, setEditable: true]; - let _: () = msg_send![field, setSelectable: true]; - let _: () = msg_send![field, setDrawsBackground: true]; - - let font: Id = msg_send![ns_font, systemFontOfSize: 13.0f64]; - let _: () = msg_send![field, setFont: font]; - - let ph = ns_string(placeholder); - let _: () = msg_send![field, setPlaceholderString: ph]; - - field - } -} - -/// Create an NSSlider (continuous, horizontal). -pub fn create_slider(frame: CGRect, min: f64, max: f64, value: f64) -> Id { - unsafe { - let ns_slider = Class::get("NSSlider").unwrap(); - - let slider: Id = msg_send![ns_slider, alloc]; - let slider: Id = msg_send![slider, initWithFrame: frame]; - - let _: () = msg_send![slider, setMinValue: min]; - let _: () = msg_send![slider, setMaxValue: max]; - let _: () = msg_send![slider, setDoubleValue: value]; - // Fire action only on mouse-up to avoid spamming settings.json writes. - let _: () = msg_send![slider, setContinuous: false]; - - slider - } -} diff --git a/app/ui/shared/helpers/layout.rs b/app/ui/shared/helpers/layout.rs deleted file mode 100644 index 55dcb600..00000000 --- a/app/ui/shared/helpers/layout.rs +++ /dev/null @@ -1,151 +0,0 @@ -use super::ui_tokens; - -#[derive(Debug, Clone, Copy)] -pub struct ChatHeaderLayout { - pub tab_cluster_x: f64, - pub tab_button_width: f64, - pub tab_button_gap: f64, - pub status_pill_x: f64, - pub status_pill_width: f64, - pub show_status_pill: bool, -} - -#[derive(Debug, Clone, Copy)] -pub struct ChatInputRowLayout { - pub attach_x: f64, - pub attach_y: f64, - pub send_x: f64, - pub send_y: f64, - pub button_width: f64, - pub button_height: f64, - pub text_x: f64, - pub text_y: f64, - pub text_width: f64, - pub text_height: f64, -} - -pub fn chat_header_layout( - title_x: f64, - title_width: f64, - right_cluster_start_x: f64, -) -> ChatHeaderLayout { - use ui_tokens::{ - CHAT_HEADER_BUTTON_SIZE, CHAT_HEADER_GROUP_GAP, CHAT_TAB_BUTTON_COLLAPSED_WIDTH, - CHAT_TAB_BUTTON_GAP, CHAT_TAB_BUTTON_MIN_GAP, CHAT_TAB_BUTTON_MIN_WIDTH, - STATUS_PILL_MIN_WIDTH, STATUS_PILL_WIDTH, - }; - - let left_anchor = (title_x + title_width + CHAT_HEADER_GROUP_GAP).max(0.0); - let right_anchor = (right_cluster_start_x - CHAT_HEADER_GROUP_GAP).max(left_anchor); - let available = (right_anchor - left_anchor).max(0.0); - - let tab_target_width = (CHAT_HEADER_BUTTON_SIZE - 2.0).max(CHAT_TAB_BUTTON_MIN_WIDTH); - let tab_target_gap = CHAT_TAB_BUTTON_GAP.max(CHAT_TAB_BUTTON_MIN_GAP); - let min_tab_total = CHAT_TAB_BUTTON_MIN_WIDTH * 3.0 + CHAT_TAB_BUTTON_MIN_GAP * 2.0; - - let mut show_status = - available >= (min_tab_total + CHAT_HEADER_GROUP_GAP + STATUS_PILL_MIN_WIDTH); - let mut status_width = if show_status { - (available - CHAT_HEADER_GROUP_GAP - min_tab_total) - .clamp(STATUS_PILL_MIN_WIDTH, STATUS_PILL_WIDTH) - } else { - 0.0 - }; - - let mut tab_space = if show_status { - (available - CHAT_HEADER_GROUP_GAP - status_width).max(0.0) - } else { - available - }; - - let mut tab_gap = tab_target_gap; - let mut tab_width = ((tab_space - tab_gap * 2.0) / 3.0).min(tab_target_width); - - if tab_width < CHAT_TAB_BUTTON_MIN_WIDTH { - tab_width = CHAT_TAB_BUTTON_MIN_WIDTH; - tab_gap = - ((tab_space - tab_width * 3.0) / 2.0).clamp(CHAT_TAB_BUTTON_MIN_GAP, tab_target_gap); - } - - let min_gap_fit_width = CHAT_TAB_BUTTON_MIN_GAP * 2.0 + CHAT_TAB_BUTTON_COLLAPSED_WIDTH * 3.0; - if tab_space < min_gap_fit_width { - show_status = false; - status_width = 0.0; - tab_space = available; - tab_gap = CHAT_TAB_BUTTON_MIN_GAP; - tab_width = ((tab_space - tab_gap * 2.0) / 3.0).min(tab_target_width); - } - - tab_space = tab_space.max(0.0); - let max_gap = (tab_space / 2.0).max(0.0); - tab_gap = tab_gap.min(max_gap); - let max_width_for_space = ((tab_space - tab_gap * 2.0) / 3.0).max(0.0); - tab_width = tab_width.min(max_width_for_space); - - let mut tab_total = (tab_width * 3.0 + tab_gap * 2.0).max(0.0); - if show_status { - let min_status_x = left_anchor + tab_total + CHAT_HEADER_GROUP_GAP; - let max_status_width = (right_anchor - min_status_x).max(0.0); - if max_status_width < STATUS_PILL_MIN_WIDTH { - show_status = false; - status_width = 0.0; - tab_space = available.max(0.0); - tab_gap = tab_target_gap.min((tab_space / 2.0).max(0.0)); - tab_width = ((tab_space - tab_gap * 2.0) / 3.0) - .max(0.0) - .min(tab_target_width); - tab_total = (tab_width * 3.0 + tab_gap * 2.0).max(0.0); - } else { - status_width = status_width.min(max_status_width); - } - } - let status_x = if show_status { - (right_anchor - status_width).max(left_anchor + tab_total + CHAT_HEADER_GROUP_GAP) - } else { - right_anchor - }; - - ChatHeaderLayout { - tab_cluster_x: left_anchor, - tab_button_width: tab_width.max(0.0), - tab_button_gap: tab_gap.max(0.0), - status_pill_x: status_x, - status_pill_width: status_width, - show_status_pill: show_status, - } -} - -pub fn chat_input_row_layout(bar_width: f64, bar_height: f64) -> ChatInputRowLayout { - use ui_tokens::{ - CHAT_INPUT_BUTTON_HEIGHT, CHAT_INPUT_BUTTON_WIDTH, CHAT_INPUT_CONTROL_GAP, - CHAT_INPUT_SIDE_INSET, CHAT_INPUT_TEXT_INSET_Y, - }; - - let button_width = CHAT_INPUT_BUTTON_WIDTH; - let button_height = CHAT_INPUT_BUTTON_HEIGHT; - let side_inset = CHAT_INPUT_SIDE_INSET.max(0.0); - let control_gap = CHAT_INPUT_CONTROL_GAP.max(0.0); - - let attach_x = side_inset; - let send_x = (bar_width - side_inset - button_width).max(attach_x + button_width + control_gap); - let text_x = attach_x + button_width + control_gap; - let text_right = (send_x - control_gap).max(text_x); - let text_width = (text_right - text_x).max(0.0); - - let button_y = ((bar_height - button_height) * 0.5).max(6.0); - let text_y = CHAT_INPUT_TEXT_INSET_Y.max(0.0); - let text_height = (bar_height - text_y * 2.0).max(24.0); - - ChatInputRowLayout { - attach_x, - attach_y: button_y, - send_x, - send_y: button_y, - button_width, - button_height, - text_x, - text_y, - text_width, - text_height, - } -} diff --git a/app/ui/shared/helpers/mod.rs b/app/ui/shared/helpers/mod.rs deleted file mode 100644 index b70eee3d..00000000 --- a/app/ui/shared/helpers/mod.rs +++ /dev/null @@ -1,1254 +0,0 @@ -//! Native AppKit UI helpers for CodeScribe -//! -//! Reduces msg_send! boilerplate by providing high-level functions for common UI patterns. -//! These helpers wrap Objective-C calls in safe, reusable Rust functions. -//! -//! # Safety -//! All functions in this module operate on raw Objective-C pointers (`Id = *mut Object`). -//! Callers must ensure pointers are valid. This is standard for Rust-ObjC FFI. - -use core_graphics::geometry::{CGRect, CGSize}; -use objc::runtime::Sel; -use objc::runtime::{Class, Object, class_getInstanceMethod, object_getClass}; -use objc::{msg_send, sel, sel_impl}; -#[cfg(feature = "liquid_glass")] -use objc2::MainThreadMarker; -#[cfg(feature = "liquid_glass")] -use objc2::rc::Retained; -#[cfg(feature = "liquid_glass")] -use objc2_app_kit::{NSAppKitVersionNumber, NSGlassEffectView, NSGlassEffectViewStyle}; -use objc2_app_kit::{NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState}; -#[cfg(feature = "liquid_glass")] -use objc2_core_foundation::{ - CGPoint as Objc2CGPoint, CGRect as Objc2CGRect, CGSize as Objc2CGSize, -}; -use std::ffi::CString; -use std::sync::{Once, OnceLock}; - -/// Type alias for Objective-C object pointers -pub type Id = *mut Object; - -/// Window level constants -pub const NS_FLOATING_WINDOW_LEVEL: i64 = 3; -pub const NS_STATUS_WINDOW_LEVEL: i64 = 25; -pub const NS_NORMAL_WINDOW_LEVEL: i64 = 0; - -/// Focus ring constants -pub const NS_FOCUS_RING_TYPE_DEFAULT: i64 = 0; -pub const NS_FOCUS_RING_TYPE_NONE: i64 = 1; -pub const NS_FOCUS_RING_TYPE_EXTERIOR: i64 = 2; - -mod chat_views; -mod controls; -mod file_actions; -mod inputs; -mod layout; -mod shell; -pub use chat_views::*; -pub use controls::*; -pub use file_actions::*; -pub use inputs::*; -pub use layout::*; -pub use shell::*; - -// ============================================================================ -// UI Tokens (shared sizes/spacing; aligned to Settings) -// ============================================================================ - -pub mod ui_tokens { - pub const TITLE_FONT_SIZE: f64 = 15.0; - pub const BODY_FONT_SIZE: f64 = 13.0; - pub const SMALL_FONT_SIZE: f64 = 11.0; - pub const MICRO_FONT_SIZE: f64 = 10.0; - - pub const HEADER_HEIGHT: f64 = 44.0; - pub const FOOTER_HEIGHT: f64 = 40.0; - /// Slim brand-only footer for the voice-chat window: just tall enough for - /// the watermark label, so chat content scrolls beneath the input bar to - /// (almost) the window edge instead of clipping high above it. - pub const CHAT_FOOTER_HEIGHT: f64 = 20.0; - pub const EDGE_PADDING: f64 = 16.0; - pub const EDGE_PADDING_TIGHT: f64 = 12.0; - - pub const TITLE_LABEL_WIDTH: f64 = 96.0; - pub const CHAT_TITLE_LABEL_WIDTH: f64 = 104.0; - pub const TRAFFIC_LIGHTS_SPACER_WIDTH: f64 = 80.0; - pub const HEADER_BUTTON_SIZE: f64 = 28.0; - pub const HEADER_BUTTON_GAP: f64 = 8.0; - pub const CHAT_HEADER_BUTTON_SIZE: f64 = 26.0; - pub const CHAT_HEADER_BUTTON_GAP: f64 = 6.0; - pub const CHAT_HEADER_GROUP_GAP: f64 = 8.0; - pub const CHAT_TAB_BUTTON_MIN_WIDTH: f64 = 22.0; - pub const CHAT_TAB_BUTTON_MIN_GAP: f64 = 3.0; - pub const CHAT_TAB_BUTTON_GAP: f64 = 4.0; - pub const CHAT_TAB_BUTTON_COLLAPSED_WIDTH: f64 = 18.0; - pub const HELP_PANEL_WIDTH: f64 = 150.0; - pub const FOOTER_INSET: f64 = 4.0; - pub const AGENT_INPUT_HEIGHT: f64 = 44.0; - pub const CONTENT_GAP: f64 = 4.0; - pub const SIDEBAR_MIN_WIDTH: f64 = 200.0; - pub const SIDEBAR_MAX_WIDTH: f64 = 320.0; - pub const CHAT_INPUT_BUTTON_WIDTH: f64 = 36.0; - pub const CHAT_INPUT_BUTTON_HEIGHT: f64 = 32.0; - pub const CHAT_INPUT_SIDE_INSET: f64 = 8.0; - pub const CHAT_INPUT_CONTROL_GAP: f64 = 8.0; - pub const CHAT_INPUT_TEXT_INSET_Y: f64 = 7.0; - - // Legacy CORNER_RADIUS_LG/MD/SM removed — use SURFACE_RADIUS everywhere. - - pub const STATUS_PILL_HEIGHT: f64 = 18.0; - pub const STATUS_PILL_WIDTH: f64 = 96.0; - pub const STATUS_PILL_MIN_WIDTH: f64 = 68.0; - pub const STATUS_PILL_DOT_INSET_X: f64 = 6.0; - pub const STATUS_PILL_LABEL_INSET_X: f64 = 14.0; - pub const STATUS_PILL_LABEL_INSET_RIGHT: f64 = 4.0; - pub const STATUS_DOT_SIZE: f64 = 5.0; - pub const BUBBLE_MAX_WIDTH: f64 = 560.0; - - pub const PLACEHOLDER_LINE_WIDTH: f64 = 120.0; - pub const PLACEHOLDER_LINE_HEIGHT: f64 = 2.0; - - pub const EMPTY_STATE_HEIGHT: f64 = 160.0; - pub const EMPTY_STATE_BUTTON_HEIGHT: f64 = 28.0; - pub const EMPTY_STATE_BUTTON_WIDTH: f64 = 140.0; - pub const EMPTY_STATE_BUTTON_GAP: f64 = 12.0; - - pub const DRAWER_ROW_WIDTH: f64 = 410.0; - pub const DRAWER_ROW_HEIGHT: f64 = 58.0; - pub const DRAWER_ROW_RADIUS: f64 = 8.0; - pub const DRAWER_ROW_PAD_X: f64 = 10.0; - pub const DRAWER_BADGE_WIDTH: f64 = 62.0; - pub const DRAWER_BADGE_HEIGHT: f64 = 16.0; - pub const DRAWER_ACTION_BUTTON_SIZE: f64 = 19.0; - pub const DRAWER_ACTION_BUTTON_GAP: f64 = 2.0; - pub const DRAWER_ACTION_RIGHT_INSET: f64 = 18.0; - pub const DRAWER_SECTION_HEADER_HEIGHT: f64 = 22.0; - - // ─── Tafla: unified surface design system ────────────────────── - // Glass = frame (cool, system materials). Paper = content (warm, readable). - // One radius, one border, no stacking — flat pane, not bubble soup. - - /// Canonical corner radius — use this everywhere instead of LG/MD/SM mix. - pub const SURFACE_RADIUS: f64 = 12.0; - pub const SURFACE_BORDER_WIDTH: f64 = 1.0; - pub const SURFACE_BORDER_ALPHA: f64 = 0.14; - - /// Glass background: alpha for vibrancy-backed views. - pub const GLASS_BG_ALPHA: f64 = 0.24; - /// Glass fallback: alpha when NSVisualEffectView is not available. - pub const GLASS_FALLBACK_ALPHA: f64 = 0.34; - - /// Paper tiers are appearance-aware: derived from controlBackgroundColor. - pub const PAPER_WARM_ALPHA: f64 = 0.74; - pub const PAPER_WARM_FALLBACK_ALPHA: f64 = 0.84; - /// Paper cool tier (system/meta areas). - pub const PAPER_COOL_ALPHA: f64 = 0.70; - pub const PAPER_COOL_FALLBACK_ALPHA: f64 = 0.80; - pub const PAPER_BORDER_ALPHA: f64 = 0.14; - - /// Compact header for Tafla windows. - pub const HEADER_HEIGHT_COMPACT: f64 = 46.0; - pub const HEADER_BORDER_ALPHA: f64 = 0.10; - pub const SETTINGS_WINDOW_OPACITY: f64 = 1.00; - - /// Tafla density tiers (vertical gap between controls per tab density). - pub const DENSITY_COMFORTABLE: f64 = 12.0; - pub const DENSITY_MEDIUM: f64 = 8.0; - pub const DENSITY_COMPACT: f64 = 6.0; - - /// Extra vertical gap inserted above section headers within settings tabs. - pub const SECTION_GAP: f64 = 20.0; - - /// Dictation overlay tuning: lighter sheet + compact action row. - pub const OVERLAY_GLASS_BG_ALPHA: f64 = 0.18; - pub const OVERLAY_GLASS_FALLBACK_ALPHA: f64 = 0.28; - pub const OVERLAY_BORDER_ALPHA: f64 = 0.10; - pub const OVERLAY_TEXT_PANEL_ALPHA: f64 = 0.74; - pub const OVERLAY_ACTION_BG_ALPHA: f64 = 0.70; - pub const OVERLAY_ACTION_BORDER_ALPHA: f64 = 0.12; - pub const OVERLAY_ACTION_BUTTON_WIDTH: f64 = 84.0; - pub const OVERLAY_ACTION_BUTTON_HEIGHT: f64 = 24.0; -} - -// ============================================================================ -// Color Helpers -// ============================================================================ - -pub mod ui_colors { - use super::Id; - use objc::runtime::Class; - use objc::{msg_send, sel, sel_impl}; - - fn with_alpha(color: Id, alpha: f64) -> Id { - unsafe { msg_send![color, colorWithAlphaComponent: alpha] } - } - - fn adaptive_alpha(glass_alpha: f64, fallback_alpha: f64) -> f64 { - if super::glass_effect_supported() { - glass_alpha - } else { - fallback_alpha - } - } - - pub fn sidebar_bg() -> Id { - control_bg_tint(adaptive_alpha(0.22, 0.32)) - } - - pub fn panel_bg() -> Id { - control_bg_tint(adaptive_alpha(0.28, 0.38)) - } - - pub fn settings_glass_bg() -> Id { - control_bg_tint(adaptive_alpha(0.26, 0.36)) - } - - pub fn input_bar_bg() -> Id { - control_bg_tint(adaptive_alpha(0.22, 0.32)) - } - - pub fn input_bar_border() -> Id { - header_border() - } - - pub fn overlay_text() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let base: Id = msg_send![ns_color, labelColor]; - with_alpha(base, 0.92) - } - } - - pub fn overlay_hint_text() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let base: Id = msg_send![ns_color, secondaryLabelColor]; - with_alpha(base, 0.7) - } - } - - pub fn overlay_sheet_bg() -> Id { - use super::ui_tokens::{OVERLAY_GLASS_BG_ALPHA, OVERLAY_GLASS_FALLBACK_ALPHA}; - control_bg_tint(adaptive_alpha( - OVERLAY_GLASS_BG_ALPHA, - OVERLAY_GLASS_FALLBACK_ALPHA, - )) - } - - pub fn overlay_sheet_border() -> Id { - use super::ui_tokens::OVERLAY_BORDER_ALPHA; - with_alpha(separator(), OVERLAY_BORDER_ALPHA) - } - - pub fn overlay_text_panel_bg() -> Id { - use super::ui_tokens::OVERLAY_TEXT_PANEL_ALPHA; - with_alpha(surface_paper_warm(), OVERLAY_TEXT_PANEL_ALPHA) - } - - pub fn overlay_action_bg() -> Id { - use super::ui_tokens::OVERLAY_ACTION_BG_ALPHA; - with_alpha(surface_paper_warm(), OVERLAY_ACTION_BG_ALPHA) - } - - pub fn overlay_action_border() -> Id { - use super::ui_tokens::OVERLAY_ACTION_BORDER_ALPHA; - with_alpha(separator(), OVERLAY_ACTION_BORDER_ALPHA) - } - - pub fn separator() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, separatorColor] - } - } - - pub fn secondary_label() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, secondaryLabelColor] - } - } - - pub fn control_bg_tint(alpha: f64) -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let base: Id = msg_send![ns_color, controlBackgroundColor]; - with_alpha(base, alpha) - } - } - - pub fn accent() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, controlAccentColor] - } - } - - pub fn accent_tint(alpha: f64) -> Id { - with_alpha(accent(), alpha) - } - - pub fn status_granted() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, systemGreenColor] - } - } - - pub fn status_denied() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, systemRedColor] - } - } - - pub fn status_warning() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, systemOrangeColor] - } - } - - pub fn card_bg() -> Id { - control_bg_tint(adaptive_alpha(0.24, 0.34)) - } - - pub fn empty_state_bg() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let base: Id = msg_send![ns_color, controlBackgroundColor]; - with_alpha(base, adaptive_alpha(0.26, 0.36)) - } - } - - pub fn search_highlight_bg() -> Id { - accent_tint(0.20) - } - - pub fn bubble_user_bg() -> Id { - accent_tint(0.10) - } - - pub fn bubble_user_border() -> Id { - accent_tint(0.22) - } - - pub fn bubble_assistant_bg() -> Id { - surface_paper_warm() - } - - pub fn bubble_system_bg() -> Id { - surface_paper_cool() - } - - pub fn bubble_border() -> Id { - use super::ui_tokens::PAPER_BORDER_ALPHA; - with_alpha(separator(), PAPER_BORDER_ALPHA) - } - - pub fn bubble_text() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, labelColor] - } - } - - pub fn bubble_meta_text() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let base: Id = msg_send![ns_color, secondaryLabelColor]; - with_alpha(base, 0.82) - } - } - - pub fn bubble_streaming_text() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let base: Id = msg_send![ns_color, secondaryLabelColor]; - with_alpha(base, 0.82) - } - } - - pub fn bubble_error_bg() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let base: Id = msg_send![ns_color, systemRedColor]; - with_alpha(base, 0.12) - } - } - - pub fn bubble_error_text() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, systemRedColor] - } - } - - // ─── Tafla: unified surface colors ───────────────────────────── - - /// Glass surface background (panels, sidebar, overlays with vibrancy). - pub fn surface_glass() -> Id { - use super::ui_tokens::{GLASS_BG_ALPHA, GLASS_FALLBACK_ALPHA}; - control_bg_tint(adaptive_alpha(GLASS_BG_ALPHA, GLASS_FALLBACK_ALPHA)) - } - - /// Paper warm surface (content: bubbles, transcription text, input fields). - pub fn surface_paper_warm() -> Id { - use super::ui_tokens::{PAPER_WARM_ALPHA, PAPER_WARM_FALLBACK_ALPHA}; - control_bg_tint(adaptive_alpha(PAPER_WARM_ALPHA, PAPER_WARM_FALLBACK_ALPHA)) - } - - /// Paper cool surface (system/meta content). - pub fn surface_paper_cool() -> Id { - use super::ui_tokens::{PAPER_COOL_ALPHA, PAPER_COOL_FALLBACK_ALPHA}; - control_bg_tint(adaptive_alpha(PAPER_COOL_ALPHA, PAPER_COOL_FALLBACK_ALPHA)) - } - - /// Canonical surface border — one style for all Tafla windows. - pub fn surface_border() -> Id { - use super::ui_tokens::SURFACE_BORDER_ALPHA; - with_alpha(separator(), SURFACE_BORDER_ALPHA) - } - - /// Header bottom separator — subtler than surface border. - pub fn header_border() -> Id { - use super::ui_tokens::HEADER_BORDER_ALPHA; - with_alpha(separator(), HEADER_BORDER_ALPHA) - } -} - -/// Apply Tafla surface treatment to a CALayer: corner radius + optional border. -/// Shadows off by default — Tafla is flat pane, not bubble soup. -/// -/// # Safety -/// `layer` must be a valid pointer to a CALayer (or NSView.layer). -pub unsafe fn apply_tafla_surface(layer: Id, with_border: bool) { - let _: () = msg_send![layer, setCornerRadius: ui_tokens::SURFACE_RADIUS]; - if with_border { - let border = ui_colors::surface_border(); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![layer, setBorderColor: cg_border]; - let _: () = msg_send![layer, setBorderWidth: ui_tokens::SURFACE_BORDER_WIDTH]; - } - let _: () = msg_send![layer, setShadowOpacity: 0.0f32]; -} - -#[repr(C)] -#[derive(Clone, Copy, Default)] -pub struct NSEdgeInsets { - pub top: f64, - pub left: f64, - pub bottom: f64, - pub right: f64, -} - -#[cfg(feature = "liquid_glass")] -#[repr(C)] -#[derive(Clone, Copy, Default)] -struct NSOperatingSystemVersion { - major_version: isize, - minor_version: isize, - patch_version: isize, -} - -/// Create an NSColor from RGBA values (0.0-1.0) -pub fn color_rgba(r: f64, g: f64, b: f64, a: f64) -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, colorWithRed: r green: g blue: b alpha: a] - } -} - -/// Create clear (transparent) color -pub fn color_clear() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, clearColor] - } -} - -/// Create label color (dynamic in light/dark). -pub fn color_label() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, labelColor] - } -} - -/// Create secondary label color (dynamic in light/dark). -pub fn color_secondary_label() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, secondaryLabelColor] - } -} - -/// Layout insets for a view using Tahoe's layoutRegionGuides API. -/// -/// # Safety -/// `view` must be a valid `NSView` instance. -pub unsafe fn layout_insets_for_view(view: Id) -> NSEdgeInsets { - let bounds: CGRect = unsafe { msg_send![view, bounds] }; - - if let Some(frame) = unsafe { layout_region_frame_for_view(view) } { - return insets_from_frame(bounds, frame); - } - - unsafe { msg_send![view, safeAreaInsets] } -} - -/// Layout region frame for a view (Tahoe layoutRegionGuides → contentLayoutGuide). -/// -/// # Safety -/// `view` must be a valid `NSView` instance. -pub unsafe fn layout_region_frame_for_view(view: Id) -> Option { - let guide = unsafe { layout_region_guide_for_view(view) }?; - unsafe { layout_guide_frame(guide) } -} - -/// Layout region guide using Tahoe's layoutRegionGuides API. -/// -/// # Safety -/// `view` must be a valid `NSView` instance. -pub unsafe fn layout_region_guide_for_view(view: Id) -> Option { - if view.is_null() { - return None; - } - let cls = unsafe { object_getClass(view as *const Object) }; - if cls.is_null() { - return None; - } - // Tahoe can expose NSGlassEffectView without this selector; avoid unrecognized selector crash. - if unsafe { class_getInstanceMethod(cls, sel!(layoutRegionGuides)) }.is_null() { - return None; - } - - let guides: Id = unsafe { msg_send![view, layoutRegionGuides] }; - if !guides.is_null() { - let guide: Id = unsafe { msg_send![guides, contentLayoutGuide] }; - if !guide.is_null() { - return Some(guide); - } - let guide: Id = unsafe { msg_send![guides, safeAreaLayoutGuide] }; - if !guide.is_null() { - return Some(guide); - } - } - None -} - -unsafe fn layout_guide_frame(guide: Id) -> Option { - if guide.is_null() { - return None; - } - let frame: CGRect = unsafe { msg_send![guide, layoutFrame] }; - Some(frame) -} - -fn insets_from_frame(bounds: CGRect, frame: CGRect) -> NSEdgeInsets { - let bounds_max_x = bounds.origin.x + bounds.size.width; - let bounds_max_y = bounds.origin.y + bounds.size.height; - let frame_max_x = frame.origin.x + frame.size.width; - let frame_max_y = frame.origin.y + frame.size.height; - - let left = (frame.origin.x - bounds.origin.x).max(0.0); - let bottom = (frame.origin.y - bounds.origin.y).max(0.0); - let right = (bounds_max_x - frame_max_x).max(0.0); - let top = (bounds_max_y - frame_max_y).max(0.0); - - NSEdgeInsets { - top, - left, - bottom, - right, - } -} - -#[cfg(feature = "liquid_glass")] -const NS_APPKIT_VERSION_26_0: f64 = 2685.0; - -#[cfg(feature = "liquid_glass")] -fn glass_effect_style_for_material(material: NSVisualEffectMaterial) -> NSGlassEffectViewStyle { - match material { - // Titlebar stays Clear (light, floating chrome). - // Sidebar uses Regular for readability — matches System Settings behaviour. - NSVisualEffectMaterial::Titlebar => NSGlassEffectViewStyle::Clear, - _ => NSGlassEffectViewStyle::Regular, - } -} - -fn glass_effect_view_class_available() -> bool { - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| Class::get("NSGlassEffectView").is_some()) -} - -#[cfg(feature = "liquid_glass")] -#[derive(Clone, Copy)] -struct GlassRuntimeProbe { - appkit_version: f64, - appkit_supported: bool, - class_available: bool, - os_version: NSOperatingSystemVersion, - os_supported: bool, -} - -#[cfg(feature = "liquid_glass")] -impl GlassRuntimeProbe { - fn runtime_supported(self) -> bool { - self.appkit_supported || self.os_supported - } -} - -#[cfg(feature = "liquid_glass")] -fn probe_glass_runtime() -> GlassRuntimeProbe { - unsafe { - let appkit_version = NSAppKitVersionNumber; - let appkit_supported = appkit_version >= NS_APPKIT_VERSION_26_0; - let os_version = Class::get("NSProcessInfo") - .map(|cls| { - let process_info: Id = msg_send![cls, processInfo]; - if process_info.is_null() { - NSOperatingSystemVersion::default() - } else { - let version: NSOperatingSystemVersion = - msg_send![process_info, operatingSystemVersion]; - version - } - }) - .unwrap_or_default(); - let os_supported = os_version.major_version >= 26; - - GlassRuntimeProbe { - appkit_version, - appkit_supported, - class_available: glass_effect_view_class_available(), - os_version, - os_supported, - } - } -} - -#[cfg(feature = "liquid_glass")] -fn glass_effect_runtime_supported() -> bool { - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| probe_glass_runtime().runtime_supported()) -} - -#[cfg(not(feature = "liquid_glass"))] -fn glass_effect_runtime_supported() -> bool { - false -} - -#[cfg(feature = "liquid_glass")] -fn create_typed_glass_effect_view(frame: CGRect, material: NSVisualEffectMaterial) -> Option { - let runtime = probe_glass_runtime(); - if !runtime.runtime_supported() || !runtime.class_available { - tracing::info!( - "NSGlassEffectView fallback to NSVisualEffectView: runtime_supported={} appkit_supported={} appkit_version={:.1} threshold={:.1} os_version={}.{}.{} os_supported={} class_available={}", - runtime.runtime_supported(), - runtime.appkit_supported, - runtime.appkit_version, - NS_APPKIT_VERSION_26_0, - runtime.os_version.major_version, - runtime.os_version.minor_version, - runtime.os_version.patch_version, - runtime.os_supported, - runtime.class_available - ); - return None; - } - let mtm = match MainThreadMarker::new() { - Some(marker) => marker, - None => { - let is_main_thread = if let Some(ns_thread) = Class::get("NSThread") { - unsafe { msg_send![ns_thread, isMainThread] } - } else { - false - }; - tracing::warn!( - "NSGlassEffectView fallback to NSVisualEffectView: MainThreadMarker::new() returned None (NSThread.isMainThread={})", - is_main_thread - ); - return None; - } - }; - let frame = Objc2CGRect::new( - Objc2CGPoint::new(frame.origin.x, frame.origin.y), - Objc2CGSize::new(frame.size.width, frame.size.height), - ); - let view = NSGlassEffectView::initWithFrame(mtm.alloc(), frame); - view.setStyle(glass_effect_style_for_material(material)); - // Hand the +1 retain to the autorelease pool so the parent's `addSubview:` - // (which adds its own retain) becomes the sole owner. Without this, the - // initial alloc/init retain leaked one NSGlassEffectView per call. - let view: Id = Retained::autorelease_return(view).cast::(); - unsafe { - let _: () = msg_send![view, setWantsLayer: true]; - let supports_corner_radius: bool = - msg_send![view, respondsToSelector: sel!(setCornerRadius:)]; - if supports_corner_radius { - let _: () = msg_send![view, setCornerRadius: ui_tokens::SURFACE_RADIUS]; - } - } - Some(view) -} - -#[cfg(not(feature = "liquid_glass"))] -fn create_typed_glass_effect_view(_frame: CGRect, _material: NSVisualEffectMaterial) -> Option { - None -} - -/// Check whether Tahoe `NSGlassEffectView` is usable on this runtime. -/// -/// We intentionally use only official style values: -/// - `Regular` (0) -/// - `Clear` (1) -pub fn glass_effect_supported() -> bool { - glass_effect_runtime_supported() && glass_effect_view_class_available() -} - -// ── Safe NSVisualEffectView subclass ───────────────────────────────── -// macOS 26 Tahoe beta: AppKit internally calls `layoutRegionGuides` on -// NSVisualEffectView during layout, but the method is missing → -// -[NSVisualEffectView layoutRegionGuides]: unrecognized selector. -// We register a thin subclass once that adds a stub returning nil so -// ObjC nil-messaging silently eats any further calls. - -static CS_LAYOUT_REGION_GUIDES_INIT: Once = Once::new(); - -fn ensure_layout_region_guides_for_class(class_name: &str) { - let Some(cls) = Class::get(class_name) else { - return; - }; - let has_method = unsafe { !class_getInstanceMethod(cls, sel!(layoutRegionGuides)).is_null() }; - if has_method { - return; - } - - tracing::info!( - "Injecting layoutRegionGuides stub into {} (Tahoe beta workaround)", - class_name - ); - extern "C" fn layout_region_guides(_this: &Object, _cmd: Sel) -> Id { - std::ptr::null_mut() - } - // SAFETY: transmute fn(&Object, Sel) -> Id to Imp (extern "C" fn()). - // ObjC runtime internally casts Imp to the correct signature via selector dispatch. - // Encoding "@@:" means: return `id`, args `(id self, SEL _cmd)`, which - // matches `extern "C" fn(&Object, Sel) -> Id`. - unsafe { - let imp: objc::runtime::Imp = - std::mem::transmute(layout_region_guides as extern "C" fn(&Object, Sel) -> Id); - let encoding = CString::new("@@:").unwrap(); - objc::runtime::class_addMethod( - cls as *const Class as *mut Class, - sel!(layoutRegionGuides), - imp, - encoding.as_ptr(), - ); - } -} - -/// Ensure vibrancy classes expose a `layoutRegionGuides` method. -/// -/// macOS 26 Tahoe beta: AppKit internally calls `layoutRegionGuides` on -/// NSVisualEffectView during layout, but the method is missing on current betas. -/// A subclass-based fix only protects our instances — AppKit also creates its own -/// NSVisualEffectView internally (e.g. titlebar blur on FullSizeContentView windows). -/// -/// This injects the stub method directly into `NSVisualEffectView` itself, -/// protecting ALL instances including AppKit-internal ones. We apply the same -/// fallback for `NSGlassEffectView` when available. -pub fn ensure_layout_region_guides_exists() { - CS_LAYOUT_REGION_GUIDES_INIT.call_once(|| { - ensure_layout_region_guides_for_class("NSVisualEffectView"); - ensure_layout_region_guides_for_class("NSGlassEffectView"); - }); -} - -fn safe_visual_effect_view_class() -> *const Class { - ensure_layout_region_guides_exists(); - Class::get("NSVisualEffectView").unwrap() -} - -/// Create a vibrancy effect view. -/// -/// Uses `safe_visual_effect_view_class()` which adds a `layoutRegionGuides` -/// stub on Tahoe 26 beta to prevent the internal AppKit crash. -pub fn create_glass_effect_view(frame: CGRect, material: NSVisualEffectMaterial) -> Id { - create_glass_effect_view_with( - frame, - material, - NSVisualEffectBlendingMode::WithinWindow, - NSVisualEffectState::Active, - ) -} - -/// Create a vibrancy effect view with explicit blending and state. -pub fn create_glass_effect_view_with( - frame: CGRect, - material: NSVisualEffectMaterial, - blending: NSVisualEffectBlendingMode, - state: NSVisualEffectState, -) -> Id { - unsafe { - if let Some(view) = create_typed_glass_effect_view(frame, material) { - return view; - } - - let cls = safe_visual_effect_view_class(); - let view: Id = msg_send![cls, alloc]; - let view: Id = msg_send![view, initWithFrame: frame]; - set_visual_effect_material(view, material); - set_visual_effect_blending(view, blending); - set_visual_effect_state(view, state); - let _: () = msg_send![view, setWantsLayer: true]; - view - } -} - -/// Create an `NSGlassEffectContainerView` when available, otherwise fallback to `NSView`. -pub fn create_glass_effect_container_view(frame: CGRect, spacing: f64) -> Id { - unsafe { - let view: Id = if let Some(container_class) = Class::get("NSGlassEffectContainerView") { - let view: Id = msg_send![container_class, alloc]; - let view: Id = msg_send![view, initWithFrame: frame]; - let supports_spacing: bool = msg_send![view, respondsToSelector: sel!(setSpacing:)]; - if supports_spacing { - let _: () = msg_send![view, setSpacing: spacing.max(0.0)]; - } - view - } else { - let ns_view = Class::get("NSView").unwrap(); - let view: Id = msg_send![ns_view, alloc]; - msg_send![view, initWithFrame: frame] - }; - let _: () = msg_send![view, setWantsLayer: true]; - view - } -} - -unsafe fn set_content_view_or_subview(host: Id, content_view: Id) -> bool { - if host.is_null() || content_view.is_null() { - return false; - } - let supports_content_view: bool = - unsafe { msg_send![host, respondsToSelector: sel!(setContentView:)] }; - if supports_content_view { - let _: () = unsafe { msg_send![host, setContentView: content_view] }; - true - } else { - let _: () = unsafe { msg_send![host, addSubview: content_view] }; - false - } -} - -/// Attach content to a glass effect view using WWDC25 `contentView` semantics when available. -/// -/// Returns `true` when `setContentView:` was used; `false` when it fell back to `addSubview:`. -/// # Safety -/// `glass_view` and `content_view` must be valid Objective-C view objects. -pub unsafe fn set_glass_effect_content_view(glass_view: Id, content_view: Id) -> bool { - unsafe { set_content_view_or_subview(glass_view, content_view) } -} - -/// Attach content to a glass container view using `contentView` when available. -/// -/// Returns `true` when `setContentView:` was used; `false` when it fell back to `addSubview:`. -/// # Safety -/// `container_view` and `content_view` must be valid Objective-C view objects. -pub unsafe fn set_glass_container_content_view(container_view: Id, content_view: Id) -> bool { - unsafe { set_content_view_or_subview(container_view, content_view) } -} - -/// # Safety -/// `view` must be a valid `NSVisualEffectView`/`NSGlassEffectView` instance. -pub unsafe fn set_visual_effect_material(view: Id, material: NSVisualEffectMaterial) { - if view.is_null() { - return; - } - let cls = unsafe { object_getClass(view as *const Object) }; - if cls.is_null() { - return; - } - if unsafe { class_getInstanceMethod(cls, sel!(setMaterial:)) }.is_null() { - return; - } - let _: () = msg_send![view, setMaterial: material]; -} - -/// # Safety -/// `view` must be a valid `NSVisualEffectView`/`NSGlassEffectView` instance. -pub unsafe fn set_visual_effect_blending(view: Id, blending: NSVisualEffectBlendingMode) { - if view.is_null() { - return; - } - let cls = unsafe { object_getClass(view as *const Object) }; - if cls.is_null() { - return; - } - if unsafe { class_getInstanceMethod(cls, sel!(setBlendingMode:)) }.is_null() { - return; - } - let _: () = msg_send![view, setBlendingMode: blending]; -} - -/// # Safety -/// `view` must be a valid `NSVisualEffectView`/`NSGlassEffectView` instance. -pub unsafe fn set_visual_effect_state(view: Id, state: NSVisualEffectState) { - if view.is_null() { - return; - } - let cls = unsafe { object_getClass(view as *const Object) }; - if cls.is_null() { - return; - } - if unsafe { class_getInstanceMethod(cls, sel!(setState:)) }.is_null() { - return; - } - let _: () = msg_send![view, setState: state]; -} - -/// Clamp overlay position to visible frame with margin. -pub fn clamp_overlay_position( - visible_frame: CGRect, - window_width: f64, - window_height: f64, - margin: f64, - raw_x: f64, - raw_y: f64, -) -> (f64, f64) { - let min_x = visible_frame.origin.x + margin; - let max_x = visible_frame.origin.x + visible_frame.size.width - window_width - margin; - let min_y = visible_frame.origin.y + margin; - let max_y = visible_frame.origin.y + visible_frame.size.height - window_height - margin; - - let x = raw_x.clamp(min_x, max_x.max(min_x)); - let y = raw_y.clamp(min_y, max_y.max(min_y)); - (x, y) -} - -// ============================================================================ -// Text Field Value Helpers -// ============================================================================ - -/// Set text field string value -/// # Safety -/// `field` must be a valid `NSTextField` (or compatible) instance. -pub unsafe fn set_text(field: Id, text: &str) { - unsafe { - let text_str = ns_string(text); - let _: () = msg_send![field, setStringValue: text_str]; - } -} - -/// Get text field string value -/// # Safety -/// `field` must be a valid `NSTextField` (or compatible) instance. -pub unsafe fn get_text(field: Id) -> String { - unsafe { - let ns_str: Id = msg_send![field, stringValue]; - if ns_str.is_null() { - return String::new(); - } - let c_str: *const i8 = msg_send![ns_str, UTF8String]; - if c_str.is_null() { - return String::new(); - } - std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .into_owned() - } -} - -/// Set text view string (for NSTextView, not NSTextField) -/// # Safety -/// `text_view` must be a valid `NSTextView` instance. -pub unsafe fn set_text_view_string(text_view: Id, text: &str) { - unsafe { - let text_str = ns_string(text); - let _: () = msg_send![text_view, setString: text_str]; - } -} - -/// Get text view string (for NSTextView) -/// # Safety -/// `text_view` must be a valid `NSTextView` instance. -pub unsafe fn get_text_view_string(text_view: Id) -> String { - unsafe { - let ns_str: Id = msg_send![text_view, string]; - if ns_str.is_null() { - return String::new(); - } - let c_str: *const i8 = msg_send![ns_str, UTF8String]; - if c_str.is_null() { - return String::new(); - } - std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .into_owned() - } -} - -// ============================================================================ -// Animation Helpers -// ============================================================================ - -/// Run a simple fade animation -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn animate_fade(window: Id, to_alpha: f64, duration: f64) { - unsafe { - let ns_animation_context = Class::get("NSAnimationContext").unwrap(); - - let _: () = msg_send![ns_animation_context, beginGrouping]; - let ctx: Id = msg_send![ns_animation_context, currentContext]; - let _: () = msg_send![ctx, setDuration: duration]; - - let animator: Id = msg_send![window, animator]; - let _: () = msg_send![animator, setAlphaValue: to_alpha]; - - let _: () = msg_send![ns_animation_context, endGrouping]; - } -} - -/// Animate window width change (horizontal slide for drawer collapse) -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn animate_window_width(window: Id, to_width: f64, duration: f64) { - unsafe { - let ns_animation_context = Class::get("NSAnimationContext").unwrap(); - - // Get current frame - let current_frame: CGRect = msg_send![window, frame]; - - // Calculate new frame with same origin and height but new width - let new_frame = CGRect::new( - ¤t_frame.origin, - &CGSize::new(to_width, current_frame.size.height), - ); - - let _: () = msg_send![ns_animation_context, beginGrouping]; - let ctx: Id = msg_send![ns_animation_context, currentContext]; - let _: () = msg_send![ctx, setDuration: duration]; - - // Animate frame change - let animator: Id = msg_send![window, animator]; - let _: () = msg_send![animator, setFrame: new_frame display: true]; - - let _: () = msg_send![ns_animation_context, endGrouping]; - } -} - -// ============================================================================ -// View Visibility Helpers -// ============================================================================ - -/// Set view hidden state -/// # Safety -/// `view` must be a valid `NSView` (or subclass) instance. -pub unsafe fn set_hidden(view: Id, hidden: bool) { - unsafe { - let _: () = msg_send![view, setHidden: hidden]; - } -} - -/// Set view enabled state (for buttons) -/// # Safety -/// `view` must be a valid `NSView`/`NSControl` instance. -pub unsafe fn set_enabled(view: Id, enabled: bool) { - unsafe { - let _: () = msg_send![view, setEnabled: enabled]; - } -} - -/// Opt into a visible focus ring for keyboard navigation. -/// # Safety -/// `view` must be a valid NSView instance. -pub unsafe fn set_focus_ring(view: Id) { - unsafe { - let _: () = msg_send![view, setFocusRingType: NS_FOCUS_RING_TYPE_EXTERIOR]; - } -} - -/// Return a monospaced system font (best-effort). -/// # Safety -/// Uses AppKit selectors; caller must be on main thread when applied to views. -pub unsafe fn monospace_font(size: f64) -> Id { - unsafe { - let ns_font = Class::get("NSFont").unwrap(); - let supports: bool = - msg_send![ns_font, respondsToSelector: sel!(monospacedSystemFontOfSize:weight:)]; - if supports { - let font: Id = msg_send![ns_font, monospacedSystemFontOfSize: size weight: 0.0]; - if !font.is_null() { - return font; - } - } - - let font: Id = msg_send![ns_font, userFixedPitchFontOfSize: size]; - if !font.is_null() { - font - } else { - msg_send![ns_font, systemFontOfSize: size] - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use core_graphics::geometry::CGPoint; - use serial_test::serial; - - #[test] - fn markdown_table_detection_handles_common_patterns() { - let table = "| Name | Value |\n| ---- | ----- |\n| A | 1 |"; - assert!(looks_like_markdown_table(table)); - - let plain = "line one\nline two\nline three"; - assert!(!looks_like_markdown_table(plain)); - } - - #[test] - fn native_markdown_is_bypassed_for_chat_bubbles() { - let table = "# Report\n\n| Name | Value |\n| ---- | ----- |\n| A | 1 |"; - assert!(!should_apply_native_markdown(table)); - - let inline_markdown = "**bold** `code`"; - assert!(!should_apply_native_markdown(inline_markdown)); - } - - #[test] - fn chat_header_layout_avoids_cluster_collisions() { - let header_w = 450.0; - let right_pad = ui_tokens::EDGE_PADDING_TIGHT; - let cluster_w = - ui_tokens::CHAT_HEADER_BUTTON_SIZE * 5.0 + ui_tokens::CHAT_HEADER_BUTTON_GAP * 4.0; - let right_cluster_start_x = header_w - right_pad - cluster_w; - let title_x = ui_tokens::EDGE_PADDING_TIGHT; - let title_w = ui_tokens::CHAT_TITLE_LABEL_WIDTH; - - let layout = chat_header_layout(title_x, title_w, right_cluster_start_x); - let tabs_right = - layout.tab_cluster_x + layout.tab_button_width * 3.0 + layout.tab_button_gap * 2.0; - assert!(tabs_right <= right_cluster_start_x - ui_tokens::CHAT_HEADER_GROUP_GAP + 0.001); - if layout.show_status_pill { - assert!(layout.status_pill_x >= tabs_right + ui_tokens::CHAT_HEADER_GROUP_GAP - 0.001); - assert!( - layout.status_pill_x + layout.status_pill_width - <= right_cluster_start_x - ui_tokens::CHAT_HEADER_GROUP_GAP + 0.001 - ); - } - } - - #[test] - fn chat_header_layout_hides_status_when_space_is_tight() { - let layout = chat_header_layout(12.0, ui_tokens::CHAT_TITLE_LABEL_WIDTH, 142.0); - assert!(!layout.show_status_pill); - let tabs_right = - layout.tab_cluster_x + layout.tab_button_width * 3.0 + layout.tab_button_gap * 2.0; - assert!(tabs_right <= 142.0 - ui_tokens::CHAT_HEADER_GROUP_GAP + 0.001); - } - - #[test] - fn chat_header_layout_keeps_status_before_right_cluster() { - let right_cluster_start_x = 270.0; - let layout = chat_header_layout( - 86.0, - ui_tokens::CHAT_TITLE_LABEL_WIDTH, - right_cluster_start_x, - ); - if layout.show_status_pill { - let right_anchor = right_cluster_start_x - ui_tokens::CHAT_HEADER_GROUP_GAP; - assert!(layout.status_pill_x + layout.status_pill_width <= right_anchor + 0.001); - } - } - - #[test] - fn chat_input_row_layout_keeps_buttons_on_sides() { - let layout = chat_input_row_layout(420.0, ui_tokens::AGENT_INPUT_HEIGHT); - assert!( - layout.attach_x + layout.button_width + ui_tokens::CHAT_INPUT_CONTROL_GAP - <= layout.text_x - ); - assert!( - layout.text_x + layout.text_width + ui_tokens::CHAT_INPUT_CONTROL_GAP <= layout.send_x - ); - } - - #[test] - fn chat_input_row_layout_avoids_overlap_on_narrow_width() { - let layout = chat_input_row_layout(140.0, ui_tokens::AGENT_INPUT_HEIGHT); - assert!(layout.text_width >= 0.0); - assert!(layout.attach_x + layout.button_width <= layout.send_x); - } - - #[test] - #[serial] - #[cfg(target_os = "macos")] - fn layout_insets_default_are_non_negative() { - if std::env::var("CODESCRIBE_UI_TESTS").is_err() { - return; - } - unsafe { - let ns_view = Class::get("NSView").unwrap(); - let view: Id = msg_send![ns_view, alloc]; - let view: Id = msg_send![ - view, - initWithFrame: CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(120.0, 80.0)) - ]; - let insets = layout_insets_for_view(view); - assert!(insets.left >= 0.0); - assert!(insets.right >= 0.0); - assert!(insets.top >= 0.0); - assert!(insets.bottom >= 0.0); - } - } - - #[test] - #[serial] - #[cfg(target_os = "macos")] - fn markdown_render_applies_or_falls_back() { - if std::env::var("CODESCRIBE_UI_TESTS").is_err() { - return; - } - unsafe { - let ns_text = Class::get("NSTextField").unwrap(); - let ns_font = Class::get("NSFont").unwrap(); - let field: Id = msg_send![ns_text, alloc]; - let field: Id = msg_send![ - field, - initWithFrame: CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(200.0, 60.0)) - ]; - let font: Id = msg_send![ns_font, systemFontOfSize: 13.0f64]; - let applied = apply_markdown_to_text_field(field, "**bold** `code`", font); - let text = get_text(field); - assert!(text.contains("bold")); - assert!(text.contains("code")); - if applied { - let attr: Id = msg_send![field, attributedStringValue]; - let len: usize = msg_send![attr, length]; - assert!(len >= text.len()); - } - } - } - - #[test] - #[serial] - #[cfg(target_os = "macos")] - fn set_button_symbol_uses_sf_symbols() { - if std::env::var("CODESCRIBE_UI_TESTS").is_err() { - return; - } - unsafe { - let ns_button = Class::get("NSButton").unwrap(); - let button: Id = msg_send![ns_button, alloc]; - let button: Id = msg_send![ - button, - initWithFrame: CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(24.0, 24.0)) - ]; - assert!(set_button_symbol(button, "tray.full")); - } - } -} diff --git a/app/ui/shared/helpers/shell.rs b/app/ui/shared/helpers/shell.rs deleted file mode 100644 index 3a1eb118..00000000 --- a/app/ui/shared/helpers/shell.rs +++ /dev/null @@ -1,374 +0,0 @@ -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use objc::runtime::{Class, Object}; -use objc::{msg_send, sel, sel_impl}; -use objc2_app_kit::{NSBackingStoreType, NSWindowCollectionBehavior, NSWindowStyleMask}; - -use super::{ - Id, NS_FLOATING_WINDOW_LEVEL, NS_NORMAL_WINDOW_LEVEL, clamp_overlay_position, color_clear, -}; - -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn window_content_view(window: Id) -> Id { - unsafe { msg_send![window, contentView] } -} - -/// Add subview to a view -/// # Safety -/// `parent` and `child` must be valid Objective-C views. -pub unsafe fn add_subview(parent: Id, child: Id) { - unsafe { - let _: () = msg_send![parent, addSubview: child]; - } -} - -/// Show window (order front) -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn window_show(window: Id) { - unsafe { - let _: () = msg_send![window, orderFrontRegardless]; - } -} - -/// Present a first-class CodeScribe panel like a normal AppKit window. -/// -/// Unlike overlay-only `orderFrontRegardless`, this makes the window key and -/// activates the app so text fields, scroll views, and standard controls behave -/// like Settings/Onboarding. -/// -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn present_shared_shell_panel(window: Id) { - // SAFETY: per the function contract, `window` is a valid `NSWindow` - // instance. `NSApplication.sharedApplication` returns a singleton retained - // by the runtime. Caller MUST be on the main thread; `msg_send!` is only - // valid for AppKit objects from the main thread. - unsafe { - if let Some(ns_app) = Class::get("NSApplication") { - let shared_app: Id = msg_send![ns_app, sharedApplication]; - if !shared_app.is_null() { - let _: () = msg_send![shared_app, activateIgnoringOtherApps: true]; - } - } - let nil: *mut Object = std::ptr::null_mut(); - let _: () = msg_send![window, makeKeyAndOrderFront: nil]; - let _: () = msg_send![window, makeMainWindow]; - let _: () = msg_send![window, orderFrontRegardless]; - } -} - -/// Hide window (order out) -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn window_hide(window: Id) { - unsafe { - let nil: *mut Object = std::ptr::null_mut(); - let _: () = msg_send![window, orderOut: nil]; - } -} - -/// Close window -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn window_close(window: Id) { - unsafe { - let _: () = msg_send![window, close]; - } -} - -/// Release an Objective-C object retained via `alloc`/`new`/`copy`/explicit -/// `retain`. Null-safe: no-op if `object` is null. -/// -/// # Safety -/// `object` must be a valid Objective-C object pointer or null. Caller must -/// hold a +1 retain that this call balances. After this call, `object` -/// becomes a dangling pointer; do not reuse. -pub unsafe fn release_object(object: Id) { - if object.is_null() { - return; - } - unsafe { - let _: () = msg_send![object, release]; - } -} - -/// Close and release a window that should not remain alive after closing. -/// Null-safe: no-op if `window` is null. -/// -/// Used for windows created with shared shell policy -/// (`released_when_closed = false`), where `[window close]` does NOT balance -/// the initial `alloc`/`init` retain — manual `release` is required to -/// prevent leaking the `NSWindow` instance. -/// -/// IMPORTANT: this helper closes the window FIRST, then releases. AppKit -/// dispatches `windowWillClose` delegate callbacks during `close`, so any -/// delegate or action handler retain that participates in those callbacks -/// MUST be released after this call returns, not before — otherwise the -/// callback chain runs on freed pointers. -/// -/// # Safety -/// `window` must be a valid `NSWindow` instance or null. Caller must hold -/// the +1 retain from window construction. After this call, `window` -/// becomes a dangling pointer; do not reuse. -pub unsafe fn window_discard(window: Id) { - if window.is_null() { - return; - } - unsafe { - window_close(window); - release_object(window); - } -} - -/// Set window alpha (for fade animations) -/// # Safety -/// `window` must be a valid `NSWindow` instance. -pub unsafe fn window_set_alpha(window: Id, alpha: f64) { - unsafe { - let _: () = msg_send![window, setAlphaValue: alpha]; - } -} - -/// Shared AppKit shell policy for first-class CodeScribe panels. -/// -/// The intent is to keep chat, Settings, and Onboarding in one explicit -/// window-policy matrix while callers continue to own their content trees. -pub struct SharedShellPanelPolicy { - pub style_mask: NSWindowStyleMask, - pub backing_store: NSBackingStoreType, - pub collection_behavior: NSWindowCollectionBehavior, - pub level: i64, - pub min_content_size: Option, - pub max_content_size: Option, - pub hides_title: bool, - pub transparent_titlebar: bool, - pub movable_by_window_background: bool, - pub opaque: bool, - pub released_when_closed: bool, -} - -/// Visible frame for the main screen, if AppKit can provide one. -pub fn main_screen_visible_frame() -> Option { - // SAFETY: `Class::get("NSScreen")` returns `None` if the runtime class is - // not registered (e.g. headless test). When present, `+[NSScreen mainScreen]` - // is a documented Foundation API returning either nil or a singleton owned - // by AppKit. Must be called from the main thread; this helper is invoked - // exclusively by AppKit-side code paths that already hold the main thread. - unsafe { - let ns_screen = Class::get("NSScreen")?; - let screen: Id = msg_send![ns_screen, mainScreen]; - if screen.is_null() { - None - } else { - Some(msg_send![screen, visibleFrame]) - } - } -} - -/// Shared policy for the Agent chat shell. -/// -/// **Multi-Space visibility, FLOATING window level.** Operator's directive -/// 2026-05-13: chat overlay must be reachable from any Space and not pin -/// itself to the Space where it was last shown. Prior `FullScreenNone` -/// caused that pinning bug. -/// -/// The earlier floating attempt (commit `dc0f9ee`) regressed text input because -/// AppKit would not make the floating overlay keyable. The voice chat window is -/// now allocated through `VoiceChatOverlayWindow`, an `NSWindow` subclass that -/// overrides `canBecomeKeyWindow` and `canBecomeMainWindow`, so the overlay can -/// stay truly always-on-top without dropping Agent input keystrokes. -/// -/// `CanJoinAllSpaces` — window follows Space switches. -/// `FullScreenAuxiliary` — window draws over fullscreen apps instead of -/// being banished to the desktop (this behavior is independent of level). -pub fn agent_chat_shell_panel_policy(visible_frame: CGRect) -> SharedShellPanelPolicy { - SharedShellPanelPolicy { - style_mask: NSWindowStyleMask::Titled - | NSWindowStyleMask::Closable - | NSWindowStyleMask::Miniaturizable - | NSWindowStyleMask::FullSizeContentView - | NSWindowStyleMask::Resizable, - backing_store: NSBackingStoreType::Buffered, - collection_behavior: NSWindowCollectionBehavior::CanJoinAllSpaces - | NSWindowCollectionBehavior::FullScreenAuxiliary, - level: NS_FLOATING_WINDOW_LEVEL, - min_content_size: Some(CGSize::new(380.0, 360.0)), - max_content_size: Some(CGSize::new( - visible_frame.size.width.min(1000.0), - visible_frame.size.height, - )), - hides_title: true, - transparent_titlebar: true, - movable_by_window_background: true, - opaque: false, - released_when_closed: false, - } -} - -/// Shared policy for the native Settings preferences shell. -pub fn settings_shell_panel_policy(fixed_size: CGSize) -> SharedShellPanelPolicy { - SharedShellPanelPolicy { - style_mask: NSWindowStyleMask::Titled - | NSWindowStyleMask::Closable - | NSWindowStyleMask::Miniaturizable - | NSWindowStyleMask::FullSizeContentView, - backing_store: NSBackingStoreType::Buffered, - collection_behavior: NSWindowCollectionBehavior::FullScreenNone, - level: NS_NORMAL_WINDOW_LEVEL, - min_content_size: Some(fixed_size), - max_content_size: Some(fixed_size), - hides_title: false, - transparent_titlebar: true, - movable_by_window_background: false, - opaque: true, - released_when_closed: false, - } -} - -/// Frame an Agent chat shell from the persisted/raw position and clamp to screen. -pub fn agent_chat_shell_frame( - visible_frame: CGRect, - window_width: f64, - window_height: f64, - margin: f64, - raw_x: f64, - raw_y: f64, -) -> CGRect { - let (x, y) = clamp_overlay_position( - visible_frame, - window_width, - window_height, - margin, - raw_x, - raw_y, - ); - CGRect::new( - &CGPoint::new(x, y), - &CGSize::new(window_width, window_height), - ) -} - -/// Apply the shared shell policy to an already-allocated `NSWindow`. -/// -/// # Safety -/// `window` must be a valid initialized `NSWindow` instance. -pub unsafe fn apply_shared_shell_panel_policy(window: Id, policy: &SharedShellPanelPolicy) { - // SAFETY: per the function contract, `window` is a valid initialized - // `NSWindow`. `policy` is a Rust borrow held for the entire call. Each - // `msg_send!` setter mutates AppKit-internal state; this MUST run on the - // main thread (AppKit affinity). - unsafe { - let title_visibility = if policy.hides_title { 1_isize } else { 0_isize }; - let _: () = msg_send![window, setTitleVisibility: title_visibility]; - let _: () = msg_send![window, setTitlebarAppearsTransparent: policy.transparent_titlebar]; - let _: () = msg_send![ - window, - setMovableByWindowBackground: policy.movable_by_window_background - ]; - let _: () = msg_send![window, setOpaque: policy.opaque]; - if !policy.opaque { - let _: () = msg_send![window, setBackgroundColor: color_clear()]; - } - let _: () = msg_send![window, setLevel: policy.level]; - let _: () = msg_send![window, setReleasedWhenClosed: policy.released_when_closed]; - if let Some(min_size) = policy.min_content_size { - let _: () = msg_send![window, setContentMinSize: min_size]; - } - if let Some(max_size) = policy.max_content_size { - let _: () = msg_send![window, setContentMaxSize: max_size]; - } - let _: () = msg_send![window, setCollectionBehavior: policy.collection_behavior]; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn release_object_is_null_safe() { - // Null pointer should be a no-op (no segfault, no msg_send to nil). - // Documented contract: `if object.is_null() { return; }`. - // Test passes by virtue of completing without segfault — no panic - // means the null guard worked. - unsafe { - release_object(std::ptr::null_mut()); - } - } - - #[test] - fn window_discard_is_null_safe() { - // Same null-safety contract — window_discard guards null before - // window_close + release_object. Critical because teardown call - // sites may pass a freshly-taken Option::None as null Id. - // Reaching the end of the function without segfault = pass. - unsafe { - window_discard(std::ptr::null_mut()); - } - } - - #[test] - fn clamp_overlay_position_keeps_window_inside_frame() { - let visible = CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(100.0, 100.0)); - let (x, y) = clamp_overlay_position(visible, 60.0, 60.0, 10.0, 1000.0, -1000.0); - assert_eq!(x, 30.0); - assert_eq!(y, 10.0); - } - - #[test] - fn agent_chat_shell_policy_caps_to_visible_frame() { - let visible = CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(1200.0, 700.0)); - let policy = agent_chat_shell_panel_policy(visible); - - let min_size = policy.min_content_size.expect("min size"); - assert_eq!(min_size.width, 380.0); - assert_eq!(min_size.height, 360.0); - - let max_size = policy.max_content_size.expect("max size"); - assert_eq!(max_size.width, 1000.0); - assert_eq!(max_size.height, 700.0); - assert_eq!(policy.level, NS_FLOATING_WINDOW_LEVEL); - assert!( - policy - .collection_behavior - .contains(NSWindowCollectionBehavior::CanJoinAllSpaces) - ); - assert!( - policy - .collection_behavior - .contains(NSWindowCollectionBehavior::FullScreenAuxiliary) - ); - assert!(policy.hides_title); - assert!(!policy.opaque); - assert!(policy.style_mask.contains(NSWindowStyleMask::Titled)); - assert!(policy.style_mask.contains(NSWindowStyleMask::Closable)); - } - - #[test] - fn settings_shell_policy_is_fixed_native_panel() { - let fixed = CGSize::new(840.0, 700.0); - let policy = settings_shell_panel_policy(fixed); - - assert_eq!(policy.level, NS_NORMAL_WINDOW_LEVEL); - assert_eq!( - policy.collection_behavior, - NSWindowCollectionBehavior::FullScreenNone - ); - let min_size = policy.min_content_size.expect("min size"); - assert_eq!(min_size.width, fixed.width); - assert_eq!(min_size.height, fixed.height); - let max_size = policy.max_content_size.expect("max size"); - assert_eq!(max_size.width, fixed.width); - assert_eq!(max_size.height, fixed.height); - assert!(policy.opaque); - assert!(!policy.hides_title); - assert!(policy.style_mask.contains(NSWindowStyleMask::Titled)); - assert!( - policy - .style_mask - .contains(NSWindowStyleMask::FullSizeContentView) - ); - assert!(!policy.style_mask.contains(NSWindowStyleMask::Resizable)); - } -} diff --git a/app/ui/shared/mod.rs b/app/ui/shared/mod.rs deleted file mode 100644 index e9095042..00000000 --- a/app/ui/shared/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod helpers; -pub mod status; diff --git a/app/ui/shared/status.rs b/app/ui/shared/status.rs deleted file mode 100644 index d99ff314..00000000 --- a/app/ui/shared/status.rs +++ /dev/null @@ -1,164 +0,0 @@ -use objc::{sel, sel_impl}; - -use crate::tray::TrayStatus; - -type Id = *mut objc::runtime::Object; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UiStatus { - Idle, - Listening, - Processing, - Error, -} - -pub struct StatusPalette { - pub bg: (f64, f64, f64, f64), - pub text: (f64, f64, f64, f64), - pub dot: (f64, f64, f64, f64), -} - -impl UiStatus { - pub fn label(self) -> &'static str { - match self { - UiStatus::Idle => "Idle", - UiStatus::Listening => "Listening", - UiStatus::Processing => "Processing", - UiStatus::Error => "Error", - } - } - - pub fn palette(self) -> StatusPalette { - match self { - UiStatus::Idle => StatusPalette { - bg: (0.12, 0.2, 0.14, 0.65), - text: (0.78, 0.95, 0.82, 1.0), - dot: (0.36, 0.92, 0.55, 1.0), - }, - UiStatus::Listening => StatusPalette { - bg: (0.22, 0.12, 0.12, 0.7), - text: (0.98, 0.78, 0.78, 1.0), - dot: (0.98, 0.35, 0.35, 1.0), - }, - UiStatus::Processing => StatusPalette { - bg: (0.24, 0.18, 0.1, 0.75), - text: (0.98, 0.88, 0.7, 1.0), - dot: (0.98, 0.7, 0.25, 1.0), - }, - UiStatus::Error => StatusPalette { - bg: (0.28, 0.1, 0.1, 0.8), - text: (1.0, 0.75, 0.75, 1.0), - dot: (1.0, 0.25, 0.25, 1.0), - }, - } - } - - /// System-dynamic text color for status labels. - /// Uses NSColor named colors that adapt to light/dark appearance. - pub fn text_color(self) -> Id { - unsafe { - let cls = objc::runtime::Class::get("NSColor").unwrap(); - match self { - UiStatus::Idle => objc::msg_send![cls, systemGreenColor], - UiStatus::Listening => objc::msg_send![cls, systemRedColor], - UiStatus::Processing => objc::msg_send![cls, systemOrangeColor], - UiStatus::Error => objc::msg_send![cls, systemRedColor], - } - } - } - - pub fn to_tray(self) -> TrayStatus { - match self { - UiStatus::Idle => TrayStatus::Idle, - UiStatus::Listening => TrayStatus::Listening, - UiStatus::Processing => TrayStatus::Thinking, - UiStatus::Error => TrayStatus::Error, - } - } -} - -pub fn status_from_detail(detail: &str) -> UiStatus { - let text = detail.trim().to_lowercase(); - if text.is_empty() { - return UiStatus::Idle; - } - - if text.contains("error") - || text.contains("failed") - || text.contains("fallback") - || text.contains("hallucination") - || text.contains("no reliable speech") - || text.contains("very low speech") - || text.contains("degraded") - || text.contains("unsafe") - || text.contains("błąd") - || text.contains("unavailable") - { - return UiStatus::Error; - } - - if text.contains("processing") - || text.contains("formatting") - || text.contains("augment") - || text.contains("finalizing") - || text.contains("thinking") - || text.contains("responding") - || text.contains("sending") - || text.contains("wysyłam") - || text.contains("wysylam") - { - return UiStatus::Processing; - } - - if text.contains("listening") - || text.contains("recording") - || text.contains("nagrywam") - || text.contains("speaking") - { - return UiStatus::Listening; - } - - if text.contains("ready") - || text.contains("idle") - || text.contains("ended") - || text.contains("response") - || text.contains("formatted") - || text.contains("no selection") - { - return UiStatus::Idle; - } - - UiStatus::Idle -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn status_from_detail_maps_key_phrases() { - assert_eq!(status_from_detail("Listening..."), UiStatus::Listening); - assert_eq!(status_from_detail("Processing..."), UiStatus::Processing); - assert_eq!(status_from_detail("Augmenting..."), UiStatus::Processing); - assert_eq!(status_from_detail("Formatting Failed"), UiStatus::Error); - assert_eq!(status_from_detail("Streaming fallback"), UiStatus::Error); - assert_eq!( - status_from_detail("Possible hallucination"), - UiStatus::Error - ); - assert_eq!( - status_from_detail("No reliable speech detected"), - UiStatus::Error - ); - assert_eq!(status_from_detail("Conversation ended"), UiStatus::Idle); - assert_eq!(status_from_detail("AI Response:"), UiStatus::Idle); - } - - #[test] - fn status_to_tray_mapping() { - assert_eq!(UiStatus::Idle.to_tray(), TrayStatus::Idle); - assert_eq!(UiStatus::Listening.to_tray(), TrayStatus::Listening); - assert_eq!(UiStatus::Processing.to_tray(), TrayStatus::Thinking); - assert_eq!(UiStatus::Error.to_tray(), TrayStatus::Error); - } -} diff --git a/app/ui/tray/handlers.rs b/app/ui/tray/handlers.rs deleted file mode 100644 index 8198aae8..00000000 --- a/app/ui/tray/handlers.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Menu action handlers for tray menu events -//! Handles menu item clicks and dispatches appropriate events. - -use muda::MenuId; -use std::process::Command; -use tracing::{debug, info}; - -use crate::config::Config; -use crate::os::clipboard; -use crate::os::permissions; -use crate::tray::state::{NOTES_MENU_ITEMS, send_menu_event}; -use crate::tray::types::{MenuIds, TrayMenuEvent}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MenuRoute { - CopyLast, - ShowOverlay, - ContinueOnboarding, - OpenSettings, - OpenHistory, - CopyDiagnostics, - Help, - About, - Quit, - ToggleQuickNotes, - ToggleQuickNotesSaveOnly, - OpenNotesFolder, - OpenTodayNote, - OpenQualityReport, - InstallSileroVad, -} - -fn resolve_menu_route(event_id: &MenuId, menu_ids: &MenuIds) -> Option { - if event_id == &menu_ids.copy_last { - Some(MenuRoute::CopyLast) - } else if event_id == &menu_ids.show_overlay { - Some(MenuRoute::ShowOverlay) - } else if menu_ids - .continue_onboarding - .as_ref() - .is_some_and(|id| event_id == id) - { - Some(MenuRoute::ContinueOnboarding) - } else if event_id == &menu_ids.open_settings { - Some(MenuRoute::OpenSettings) - } else if event_id == &menu_ids.open_history { - Some(MenuRoute::OpenHistory) - } else if event_id == &menu_ids.copy_diagnostics { - Some(MenuRoute::CopyDiagnostics) - } else if event_id == &menu_ids.help { - Some(MenuRoute::Help) - } else if event_id == &menu_ids.about { - Some(MenuRoute::About) - } else if event_id == &menu_ids.quit { - Some(MenuRoute::Quit) - } else if event_id == &menu_ids.notes_toggle_quick_notes { - Some(MenuRoute::ToggleQuickNotes) - } else if event_id == &menu_ids.notes_toggle_save_only { - Some(MenuRoute::ToggleQuickNotesSaveOnly) - } else if event_id == &menu_ids.notes_open_folder { - Some(MenuRoute::OpenNotesFolder) - } else if event_id == &menu_ids.notes_open_today { - Some(MenuRoute::OpenTodayNote) - } else if event_id == &menu_ids.quality_open_report { - Some(MenuRoute::OpenQualityReport) - } else if event_id == &menu_ids.silero_vad_install { - Some(MenuRoute::InstallSileroVad) - } else { - None - } -} - -/// Handle menu item click and send appropriate event. -pub fn handle_menu_event(event_id: &MenuId, menu_ids: &MenuIds) { - match resolve_menu_route(event_id, menu_ids) { - Some(MenuRoute::CopyLast) => handle_copy_last(), - Some(MenuRoute::ShowOverlay) => crate::ui::voice_chat::show_voice_chat_overlay(), - Some(MenuRoute::ContinueOnboarding) => crate::ui::onboarding::show_onboarding_wizard(), - Some(MenuRoute::OpenSettings) => crate::ui::settings::show_settings_window(), - Some(MenuRoute::OpenHistory) => handle_open_history_folder(), - Some(MenuRoute::CopyDiagnostics) => handle_copy_diagnostics(), - Some(MenuRoute::Help) => handle_open_help(), - Some(MenuRoute::About) => handle_show_about(), - Some(MenuRoute::Quit) => send_menu_event(TrayMenuEvent::Quit), - Some(MenuRoute::ToggleQuickNotes) => handle_toggle_quick_notes(), - Some(MenuRoute::ToggleQuickNotesSaveOnly) => handle_toggle_quick_notes_save_only(), - Some(MenuRoute::OpenNotesFolder) => handle_open_notes_folder(), - Some(MenuRoute::OpenTodayNote) => handle_open_today_note(), - Some(MenuRoute::OpenQualityReport) => handle_open_qube_report(), - Some(MenuRoute::InstallSileroVad) => handle_install_silero_vad(), - None => debug!("Unknown menu event id: {:?}", event_id), - } -} - -/// Copy last transcript to clipboard -fn handle_copy_last() { - send_menu_event(TrayMenuEvent::CopyLast); - - // Get last transcript from history - if let Some(last_entry) = crate::state::history::latest_copyable_entry() { - if let Ok(text) = std::fs::read_to_string(&last_entry.path) { - if let Err(e) = clipboard::set_clipboard(&text) { - info!("Failed to copy to clipboard: {}", e); - } else { - info!("Copied last transcript to clipboard ({} chars)", text.len()); - } - } - } else { - info!("No copyable transcript history available"); - } -} - -fn handle_copy_diagnostics() { - send_menu_event(TrayMenuEvent::CopyDiagnostics); - - let report = permissions::diagnostics_report(); - if let Err(e) = clipboard::set_clipboard(&report) { - info!("Failed to copy diagnostics to clipboard: {}", e); - return; - } - - #[cfg(target_os = "macos")] - { - let _ = Command::new("osascript") - .arg("-e") - .arg( - r#"display notification "Copied diagnostics to clipboard" with title "CodeScribe""#, - ) - .spawn(); - } -} - -fn handle_install_silero_vad() { - send_menu_event(TrayMenuEvent::InstallSileroVad); -} - -// ============================================================================ -// Notes Handlers -// ============================================================================ - -fn handle_toggle_quick_notes() { - let config = Config::load(); - let new_state = !config.quick_notes_enabled; - - info!( - "Toggling Quick Notes: {}", - if new_state { "ON" } else { "OFF" } - ); - - let _ = config.save_to_env("QUICK_NOTES_ENABLED", if new_state { "1" } else { "0" }); - send_menu_event(TrayMenuEvent::SetQuickNotesEnabled(new_state)); - - NOTES_MENU_ITEMS.with(|items_cell| { - if let Some(ref items) = *items_cell.borrow() { - items.quick_notes_toggle.set_checked(new_state); - // If disabled, also uncheck "save-only" in the UI (config remains on disk). - if !new_state { - items.quick_notes_save_only.set_checked(false); - } - } - }); - - #[cfg(target_os = "macos")] - { - let _ = Command::new("osascript") - .arg("-e") - .arg(format!( - r#"display notification "Quick Notes: {}" with title "CodeScribe""#, - if new_state { "ON" } else { "OFF" } - )) - .spawn(); - } -} - -fn handle_toggle_quick_notes_save_only() { - let config = Config::load(); - let enabled = config.quick_notes_enabled; - let new_state = !config.quick_notes_save_only; - - if !enabled && new_state { - // UX: turning "save-only" ON implies Quick Notes ON. - let _ = config.save_to_env("QUICK_NOTES_ENABLED", "1"); - send_menu_event(TrayMenuEvent::SetQuickNotesEnabled(true)); - } - - let _ = config.save_to_env("QUICK_NOTES_SAVE_ONLY", if new_state { "1" } else { "0" }); - send_menu_event(TrayMenuEvent::SetQuickNotesSaveOnly(new_state)); - - NOTES_MENU_ITEMS.with(|items_cell| { - if let Some(ref items) = *items_cell.borrow() { - if !enabled && new_state { - items.quick_notes_toggle.set_checked(true); - } - items.quick_notes_save_only.set_checked(new_state); - } - }); -} - -fn handle_open_notes_folder() { - crate::state::notes::open_notes_folder(); -} - -fn handle_open_today_note() { - crate::state::notes::open_today_note(); -} - -/// Open history folder in Finder -fn handle_open_history_folder() { - send_menu_event(TrayMenuEvent::OpenHistoryFolder); - crate::state::history::open_history_folder(); - info!("Opening history folder"); -} - -/// Open help documentation in browser -fn handle_open_help() { - send_menu_event(TrayMenuEvent::OpenHelp); - - #[cfg(target_os = "macos")] - { - // Try local docs first, fall back to GitHub - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let local_docs = format!("{}/.codescribe/docs/README.md", home); - - let url = if std::path::Path::new(&local_docs).exists() { - local_docs - } else { - "https://github.com/VetCoders/CodeScribe#readme".to_string() - }; - - info!("Opening help: {}", url); - let _ = Command::new("open").arg(&url).spawn(); - } -} - -/// Show about dialog with version -fn handle_show_about() { - send_menu_event(TrayMenuEvent::ShowAbout); - - #[cfg(target_os = "macos")] - { - let version = env!("CARGO_PKG_VERSION"); - let build = env!("CODESCRIBE_BUILD_COMMIT"); - let message = format!( - "CodeScribe v{} ({})\n\nSpeech-to-text for macOS\n\n𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by VetCoders (c)2024-2026 LibraxisAI", - version, build - ); - - // Use osascript for native dialog - let script = format!( - r#"display dialog "{}" buttons {{"OK"}} default button "OK" with title "About CodeScribe" with icon note"#, - message - ); - - info!("Showing about dialog"); - let _ = Command::new("osascript").arg("-e").arg(&script).spawn(); - } -} - -// ============================================================================ -// Quality Handlers -// ============================================================================ - -/// Open the latest quality report in browser -fn handle_open_qube_report() { - info!("Opening quality report..."); - - if crate::qube_daemon::open_latest_report() { - info!("Opened quality report"); - } else { - // No report available - show notification - info!("No quality report available"); - let _ = Command::new("osascript") - .arg("-e") - .arg(r#"display notification "No quality report available. Run: qube-daemon --daemon" with title "CodeScribe Quality""#) - .spawn(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use muda::MenuId; - - fn menu_ids_for_test() -> MenuIds { - MenuIds { - copy_last: MenuId::new("copy-last"), - show_overlay: MenuId::new("show-overlay"), - open_settings: MenuId::new("open-settings"), - continue_onboarding: Some(MenuId::new("continue-onboarding")), - open_history: MenuId::new("open-history"), - copy_diagnostics: MenuId::new("copy-diagnostics"), - help: MenuId::new("help"), - about: MenuId::new("about"), - quit: MenuId::new("quit"), - quality_open_report: MenuId::new("quality-open-report"), - silero_vad_install: MenuId::new("silero-vad-install"), - notes_toggle_quick_notes: MenuId::new("notes-toggle-quick-notes"), - notes_toggle_save_only: MenuId::new("notes-toggle-save-only"), - notes_open_folder: MenuId::new("notes-open-folder"), - notes_open_today: MenuId::new("notes-open-today"), - } - } - - #[test] - fn resolve_menu_route_separates_onboarding_from_settings() { - let menu_ids = menu_ids_for_test(); - - assert_eq!( - resolve_menu_route(&menu_ids.open_settings, &menu_ids), - Some(MenuRoute::OpenSettings) - ); - assert_eq!( - resolve_menu_route( - menu_ids - .continue_onboarding - .as_ref() - .expect("test menu ids should include onboarding"), - &menu_ids - ), - Some(MenuRoute::ContinueOnboarding) - ); - } -} diff --git a/app/ui/tray/icons.rs b/app/ui/tray/icons.rs deleted file mode 100644 index 9f8b2f0e..00000000 --- a/app/ui/tray/icons.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Icon management and status glyph rendering for tray icon -//! -//! Handles loading the CodeScribe logo and drawing status indicators. - -use anyhow::Result; -use image::{GenericImageView, imageops::FilterType}; -use tray_icon::Icon; - -use crate::tray::types::TrayStatus; - -/// Parameters for drawing a glyph on the icon -struct GlyphParams { - center_x: i32, - center_y: i32, - radius: i32, - color: (u8, u8, u8), -} - -/// Embedded CodeScribe logo icon (resized for menu bar) -/// Place icon.png in codescribe-rs/assets/ directory -const ICON_BYTES: &[u8] = include_bytes!("../../../assets/icon.png"); - -/// Menu bar icon size (44x44 for Retina, 22x22 logical) -const ICON_SIZE: u32 = 44; - -/// Load the custom CodeScribe icon with explicit status glyph visibility. -pub fn load_custom_icon_with_glyph(status: TrayStatus, show_status_glyph: bool) -> Result { - let img = image::load_from_memory(ICON_BYTES) - .map_err(|e| anyhow::anyhow!("Failed to load icon: {}", e))?; - - // Resize to menu bar size (44x44 for Retina) - let resized = img.resize_exact(ICON_SIZE, ICON_SIZE, FilterType::Lanczos3); - let (width, height) = resized.dimensions(); - let mut rgba = resized.to_rgba8().into_raw(); - - // Icon stays white/neutral - no tinting - // Status is shown only via the glyph color - - // Draw status glyph if enabled (larger colored dot in bottom-right corner) - if show_status_glyph { - draw_status_glyph(&mut rgba, width, height, status); - } - - Icon::from_rgba(rgba, width, height) - .map_err(|e| anyhow::anyhow!("Failed to create icon: {}", e)) -} - -/// Draw the status glyph on the icon -fn draw_status_glyph(rgba: &mut [u8], width: u32, height: u32, status: TrayStatus) { - // Glyph parameters - larger circle (12x12) for better visibility - const GLYPH_RADIUS: i32 = 6; - let glyph_center_x = (width as i32) - GLYPH_RADIUS - 2; // 2px padding from edge - let glyph_center_y = (height as i32) - GLYPH_RADIUS - 2; - - // Status-based glyph colors: - // - Green: Idle/Ready, Success - // - Red: Recording/Listening, Error (X shape) - // - Orange: Processing/Thinking - let color = match status { - TrayStatus::Starting => (80u8, 220, 100), // Blinking green - starting - TrayStatus::Idle => (80u8, 200, 100), // Green - ready - TrayStatus::Listening => (255, 70, 70), // Red - recording - TrayStatus::Thinking => (255, 165, 0), // Orange - processing - TrayStatus::Success => (80, 220, 100), // Bright green - done - TrayStatus::Error => (255, 50, 50), // Bright red - error - TrayStatus::Thermal => (255, 210, 40), // Yellow - thermal pressure - TrayStatus::HotkeyConflict => (180, 95, 255), // Violet - shortcut conflict - }; - - let params = GlyphParams { - center_x: glyph_center_x, - center_y: glyph_center_y, - radius: GLYPH_RADIUS, - color, - }; - - // For Error status, draw an "X" instead of a circle - if status == TrayStatus::Error { - draw_x_glyph(rgba, width, height, ¶ms); - } else { - draw_circle_glyph(rgba, width, height, ¶ms); - } -} - -/// Draw an X-shaped glyph for error status -fn draw_x_glyph(rgba: &mut [u8], width: u32, height: u32, params: &GlyphParams) { - const LINE_WIDTH: i32 = 2; - let (r, g, b) = params.color; - - for y in (params.center_y - params.radius).max(0) - ..(params.center_y + params.radius).min(height as i32) - { - for x in (params.center_x - params.radius).max(0) - ..(params.center_x + params.radius).min(width as i32) - { - let dx = x - params.center_x; - let dy = y - params.center_y; - - // Check if point is on diagonal lines (forming X) - let on_diag1 = (dx - dy).abs() <= LINE_WIDTH; - let on_diag2 = (dx + dy).abs() <= LINE_WIDTH; - - // Only draw within the circle bounds - let in_bounds = dx * dx + dy * dy <= params.radius * params.radius; - - if in_bounds && (on_diag1 || on_diag2) { - let idx = ((y as u32 * width + x as u32) * 4) as usize; - rgba[idx] = r; - rgba[idx + 1] = g; - rgba[idx + 2] = b; - rgba[idx + 3] = 255; - } - } - } -} - -/// Draw a circular glyph for normal status -fn draw_circle_glyph(rgba: &mut [u8], width: u32, height: u32, params: &GlyphParams) { - let (r, g, b) = params.color; - - for y in (params.center_y - params.radius).max(0) - ..(params.center_y + params.radius).min(height as i32) - { - for x in (params.center_x - params.radius).max(0) - ..(params.center_x + params.radius).min(width as i32) - { - let dx = x - params.center_x; - let dy = y - params.center_y; - let distance_squared = dx * dx + dy * dy; - - if distance_squared <= params.radius * params.radius { - let idx = ((y as u32 * width + x as u32) * 4) as usize; - rgba[idx] = r; - rgba[idx + 1] = g; - rgba[idx + 2] = b; - rgba[idx + 3] = 255; // Fully opaque - } - } - } -} - -/// Create a simple colored circle icon as fallback -pub fn create_fallback_icon(status: TrayStatus, show_status_glyph: bool) -> Result { - const SIZE: u32 = 22; - const RADIUS: i32 = 10; - const CENTER: i32 = 11; - - let (r, g, b) = match status { - TrayStatus::Starting => (60u8, 200, 100), // Green - TrayStatus::Idle => (100u8, 100, 100), // Gray - TrayStatus::Listening => (220, 60, 60), // Red - TrayStatus::Thinking => (60, 130, 220), // Blue - TrayStatus::Success => (60, 200, 100), // Green - TrayStatus::Error => (255, 50, 50), // Bright red - TrayStatus::Thermal => (255, 210, 40), // Yellow - TrayStatus::HotkeyConflict => (180, 95, 255), // Violet - }; - - let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize]; - - if !show_status_glyph { - return Icon::from_rgba(rgba, SIZE, SIZE) - .map_err(|e| anyhow::anyhow!("Failed to create fallback icon: {}", e)); - } - - for y in 0..SIZE as i32 { - for x in 0..SIZE as i32 { - let dx = x - CENTER; - let dy = y - CENTER; - if dx * dx + dy * dy <= RADIUS * RADIUS { - let idx = ((y as u32 * SIZE + x as u32) * 4) as usize; - rgba[idx] = r; - rgba[idx + 1] = g; - rgba[idx + 2] = b; - rgba[idx + 3] = 255; - } - } - } - - Icon::from_rgba(rgba, SIZE, SIZE) - .map_err(|e| anyhow::anyhow!("Failed to create fallback icon: {}", e)) -} diff --git a/app/ui/tray/menu.rs b/app/ui/tray/menu.rs deleted file mode 100644 index 42aef35c..00000000 --- a/app/ui/tray/menu.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Main menu building logic for the tray menu -//! -//! Menu structure (flat): -//! - Status line (dynamic) -//! - Show Agent / Open history / Copy last -//! - Notes ▸ / Diagnostics ▸ -//! - Continue Onboarding / Settings / Help / About -//! - Quit -//! -//! Note: Settings opens the persistent Settings window. - -use std::cell::RefCell; - -use anyhow::Result; -use muda::accelerator::{Accelerator, Code, Modifiers}; -use muda::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; -use tracing::debug; - -use codescribe_core::vad; - -use crate::config::Config; -use crate::tray::state::NOTES_MENU_ITEMS; -use crate::tray::types::{MenuIds, NotesMenuItems, TrayStatus}; - -// Thread-local storage for menu items that need dynamic updates -thread_local! { - pub static ROOT_MENU: RefCell> = const { RefCell::new(None) }; - pub static STATUS_MENU_ITEM: RefCell> = const { RefCell::new(None) }; - pub static QUALITY_MENU_ITEM: RefCell> = const { RefCell::new(None) }; - pub static SILERO_VAD_MENU_ITEM: RefCell> = const { RefCell::new(None) }; - pub static CONTINUE_ONBOARDING_MENU_ITEM: RefCell> = const { RefCell::new(None) }; -} - -/// Build the tray menu -/// -/// Note: Settings opens the persistent Settings window. -pub fn build_menu(initial_status: TrayStatus) -> Result<(Menu, MenuIds)> { - let menu = Menu::new(); - ROOT_MENU.with(|cell| { - *cell.borrow_mut() = Some(menu.clone()); - }); - - // 1. Status line (disabled, dynamic text) - let status_item = MenuItem::new(initial_status.menu_label(), false, None); - menu.append(&status_item)?; - - // Store for dynamic updates - STATUS_MENU_ITEM.with(|cell| { - *cell.borrow_mut() = Some(status_item); - }); - - // 2. Show Agent - let show_overlay_item = MenuItem::new("Show Agent", true, None); - let show_overlay_id = show_overlay_item.id().clone(); - menu.append(&show_overlay_item)?; - - // 3. Open history folder - let open_history_item = MenuItem::new("Open history...", true, None); - let open_history_id = open_history_item.id().clone(); - menu.append(&open_history_item)?; - - // 4. Copy last transcript - let copy_last_item = MenuItem::new("Copy last transcript", true, None); - let copy_last_id = copy_last_item.id().clone(); - menu.append(©_last_item)?; - - // 5. Separator - menu.append(&PredefinedMenuItem::separator())?; - - // 6. Notes submenu - let notes_menu = Submenu::new("Notes", true); - let notes_cfg = Config::load(); - - let quick_notes_toggle = CheckMenuItem::new( - "Quick Notes (save)", - true, - notes_cfg.quick_notes_enabled, - None, - ); - let notes_toggle_quick_notes_id = quick_notes_toggle.id().clone(); - notes_menu.append(&quick_notes_toggle)?; - - let quick_notes_save_only = CheckMenuItem::new( - "Save-only (no paste)", - true, - notes_cfg.quick_notes_enabled && notes_cfg.quick_notes_save_only, - None, - ); - let notes_toggle_save_only_id = quick_notes_save_only.id().clone(); - notes_menu.append(&quick_notes_save_only)?; - - NOTES_MENU_ITEMS.with(|cell| { - *cell.borrow_mut() = Some(NotesMenuItems { - quick_notes_toggle, - quick_notes_save_only, - }); - }); - - notes_menu.append(&PredefinedMenuItem::separator())?; - - let notes_open_folder_item = MenuItem::new("Open notes folder", true, None); - let notes_open_folder_id = notes_open_folder_item.id().clone(); - notes_menu.append(¬es_open_folder_item)?; - - let notes_open_today_item = MenuItem::new("Open today's note", true, None); - let notes_open_today_id = notes_open_today_item.id().clone(); - notes_menu.append(¬es_open_today_item)?; - - menu.append(¬es_menu)?; - - // 7. Diagnostics submenu - let diagnostics_menu = Submenu::new("Diagnostics", true); - let copy_diag_item = MenuItem::new("Copy diagnostics", true, None); - let copy_diag_id = copy_diag_item.id().clone(); - diagnostics_menu.append(©_diag_item)?; - - // Quality menu item (shows pending mismatches from daemon) - let snapshot = crate::qube_lifecycle::dashboard_snapshot(); - let state = &snapshot.daemon_state; - let quality_label = if !snapshot.available { - match &snapshot.lifecycle { - crate::qube_lifecycle::QubeLifecycleState::Disabled => "Quality: disabled".to_string(), - _ => "Quality: unavailable".to_string(), - } - } else if state.pending_mismatches > 0 { - format!("Quality: {} pending", state.pending_mismatches) - } else { - "Quality: OK".to_string() - }; - let quality_item = MenuItem::new(&quality_label, true, None); - let quality_open_report_id = quality_item.id().clone(); - diagnostics_menu.append(&quality_item)?; - - // Store for dynamic updates - QUALITY_MENU_ITEM.with(|cell| { - *cell.borrow_mut() = Some(quality_item); - }); - - diagnostics_menu.append(&PredefinedMenuItem::separator())?; - - // Silero VAD model status / install action - let vad_label = silero_vad_label(); - let silero_vad_item = MenuItem::new(&vad_label, true, None); - let silero_vad_install_id = silero_vad_item.id().clone(); - diagnostics_menu.append(&silero_vad_item)?; - SILERO_VAD_MENU_ITEM.with(|cell| { - *cell.borrow_mut() = Some(silero_vad_item); - }); - - menu.append(&diagnostics_menu)?; - - menu.append(&PredefinedMenuItem::separator())?; - - let continue_onboarding_id = if crate::should_show_onboarding() { - let continue_onboarding_item = MenuItem::new("Continue Onboarding...", true, None); - let id = continue_onboarding_item.id().clone(); - menu.append(&continue_onboarding_item)?; - CONTINUE_ONBOARDING_MENU_ITEM.with(|cell| { - *cell.borrow_mut() = Some(continue_onboarding_item); - }); - Some(id) - } else { - CONTINUE_ONBOARDING_MENU_ITEM.with(|cell| { - *cell.borrow_mut() = None; - }); - None - }; - - // 9. Settings - let settings_item = MenuItem::new("Settings", true, None); - let settings_id = settings_item.id().clone(); - menu.append(&settings_item)?; - - // 10. Help - let help_item = MenuItem::new("Help", true, None); - let help_id = help_item.id().clone(); - menu.append(&help_item)?; - - // 11. About - let about_item = MenuItem::new("About", true, None); - let about_id = about_item.id().clone(); - menu.append(&about_item)?; - - // 12. Separator - menu.append(&PredefinedMenuItem::separator())?; - - // 13. Quit (Cmd+Q) - let quit_accel = Accelerator::new(Some(Modifiers::SUPER), Code::KeyQ); - let quit_item = MenuItem::new("Quit", true, Some(quit_accel)); - let quit_id = quit_item.id().clone(); - menu.append(&quit_item)?; - - Ok(( - menu, - MenuIds { - copy_last: copy_last_id, - show_overlay: show_overlay_id, - open_settings: settings_id, - continue_onboarding: continue_onboarding_id, - open_history: open_history_id, - copy_diagnostics: copy_diag_id, - help: help_id, - about: about_id, - quit: quit_id, - // Quality - quality_open_report: quality_open_report_id, - // Models - silero_vad_install: silero_vad_install_id, - // Notes - notes_toggle_quick_notes: notes_toggle_quick_notes_id, - notes_toggle_save_only: notes_toggle_save_only_id, - notes_open_folder: notes_open_folder_id, - notes_open_today: notes_open_today_id, - }, - )) -} - -/// Update the status label in the menu -/// Must be called from the main thread -pub fn update_status_label(label: &str) { - STATUS_MENU_ITEM.with(|cell| { - if let Some(ref item) = *cell.borrow() { - item.set_text(label); - } - }); -} - -/// Update the quality label in the menu -/// Call this periodically to reflect daemon state changes -pub fn update_quality_label() { - let snapshot = crate::qube_lifecycle::dashboard_snapshot(); - let state = &snapshot.daemon_state; - let label = if !snapshot.available { - match &snapshot.lifecycle { - crate::qube_lifecycle::QubeLifecycleState::Disabled => "Quality: disabled".to_string(), - _ => "Quality: unavailable".to_string(), - } - } else if state.pending_mismatches > 0 { - format!("Quality: {} pending", state.pending_mismatches) - } else { - "Quality: OK".to_string() - }; - - QUALITY_MENU_ITEM.with(|cell| { - if let Some(ref item) = *cell.borrow() { - item.set_text(&label); - } - }); -} - -fn silero_vad_label() -> String { - let model_path = vad::default_model_path(); - if model_path.exists() { - "Silero VAD: ready (Install/Repair…)".to_string() - } else { - "Silero VAD: missing (Install…)".to_string() - } -} - -pub fn update_silero_vad_label() { - let label = silero_vad_label(); - SILERO_VAD_MENU_ITEM.with(|cell| { - if let Some(ref item) = *cell.borrow() { - item.set_text(&label); - } - }); -} - -pub fn update_onboarding_item() { - if crate::should_show_onboarding() { - return; - } - - let menu = ROOT_MENU.with(|cell| cell.borrow().clone()); - let onboarding_item = CONTINUE_ONBOARDING_MENU_ITEM.with(|cell| cell.borrow_mut().take()); - if let (Some(menu), Some(item)) = (menu, onboarding_item) - && let Err(err) = menu.remove(&item) - { - debug!("Failed to remove Continue Onboarding menu item: {}", err); - CONTINUE_ONBOARDING_MENU_ITEM.with(|cell| { - *cell.borrow_mut() = Some(item); - }); - } -} - -#[cfg(test)] -mod tests { - fn menu_labels_for_test() -> Vec { - vec![ - "Status: Starting...".to_string(), - "Show Agent".to_string(), - "Open history...".to_string(), - "Copy last transcript".to_string(), - "Continue Onboarding...".to_string(), - "Settings".to_string(), - "Help".to_string(), - "About".to_string(), - "Quit".to_string(), - ] - } - - #[test] - #[cfg(target_os = "macos")] - fn tray_menu_includes_show_agent() { - let labels = menu_labels_for_test(); - let found = labels.iter().any(|label| label == "Show Agent"); - assert!(found, "Show Agent menu item missing"); - } - - #[test] - fn tray_menu_uses_onboarding_vocabulary() { - let labels = menu_labels_for_test(); - assert!(labels.iter().any(|label| label == "Continue Onboarding...")); - assert!(!labels.iter().any(|label| label.contains("Setup"))); - } -} diff --git a/app/ui/tray/mod.rs b/app/ui/tray/mod.rs deleted file mode 100644 index b352f048..00000000 --- a/app/ui/tray/mod.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! System tray icon and menu for CodeScribe -//! -//! Provides visual status feedback and menu controls via macOS menu bar icon. -//! Uses tao event loop for proper macOS integration. -//! -//! ## Module Structure -//! -//! - `types` - Type definitions (TrayStatus, TrayMenuEvent, MenuIds) -//! - `icons` - Icon rendering and status glyph management -//! - `state` - Cross-thread channels for status updates -//! - `menu` - Menu building logic -//! - `handlers` - Menu action handlers -//! -//! ## Menu Structure -//! -//! ```text -//! Status: Starting... / Idle -//! Show Agent -//! Open history... -//! Copy last transcript -//! Notes ▸ -//! Diagnostics ▸ -//! Continue Onboarding... (when onboarding is incomplete) -//! Settings -//! Help -//! About -//! Quit -//! ``` - -mod handlers; -mod icons; -mod menu; -mod state; -mod types; - -use std::sync::OnceLock; -use std::sync::atomic::Ordering; -use std::time::{Duration, Instant}; - -use crate::os::hotkeys; -use anyhow::Result; -use crossbeam_channel::TryRecvError; -use tao::event::Event; -use tao::event_loop::{ControlFlow, EventLoopBuilder}; -use tracing::{debug, info}; -use tray_icon::{TrayIconBuilder, menu::MenuEvent}; - -// Re-export public API -pub use menu::update_quality_label; -pub use menu::update_silero_vad_label; -pub use state::send_menu_event; -pub use state::{menu_event_receiver, update_tray_status}; -pub use types::{MenuIds, TrayMenuEvent, TrayStatus}; - -// ============================================================================ -// Shutdown Management -// ============================================================================ - -/// Global shutdown flag for graceful exit -static SHUTDOWN_REQUESTED: OnceLock = OnceLock::new(); - -/// Request graceful shutdown of the tray application. -/// -/// This can be called from any thread to signal that the app should exit. -/// The event loop will check this flag and perform cleanup before exiting. -pub fn request_shutdown() { - if let Some(flag) = SHUTDOWN_REQUESTED.get() { - flag.store(true, Ordering::SeqCst); - info!("Shutdown requested"); - } -} - -/// Check if shutdown has been requested -pub fn is_shutdown_requested() -> bool { - SHUTDOWN_REQUESTED - .get() - .map(|f| f.load(Ordering::SeqCst)) - .unwrap_or(false) -} - -// ============================================================================ -// Run Functions -// ============================================================================ - -/// Run the tray application (blocking) -/// -/// Uses tao event loop for proper macOS integration. -pub fn run() -> Result<()> { - run_with_hotkeys(None) -} - -pub fn run_with_startup( - hotkey_manager: Option, - on_started: F, -) -> Result<()> -where - F: FnOnce() + 'static, -{ - run_inner(hotkey_manager, on_started) -} - -fn shutdown_hotkeys(hotkey_manager: &mut Option) { - if let Some(hk_manager) = hotkey_manager.as_mut() { - hk_manager.shutdown(); - } - hotkeys::shutdown_global_hotkey_manager(); - *hotkey_manager = None; -} - -/// Run the tray application with optional hotkey manager -/// -/// The hotkey manager must be created on main thread before calling this. -/// -/// ## Shutdown Behavior -/// -/// The event loop will exit when: -/// - User clicks Quit in the tray menu -/// - `request_shutdown()` is called from any thread -/// - Status channel is disconnected -/// -/// On exit, cleanup is performed: -/// - Hotkey runtime is explicitly shut down (event tap disabled, run loop stopped, thread joined) -/// - Tray icon is removed -/// - All channels are closed -pub fn run_with_hotkeys(hotkey_manager: Option) -> Result<()> { - run_inner(hotkey_manager, || {}) -} - -fn run_inner(hotkey_manager: Option, on_started: F) -> Result<()> -where - F: FnOnce() + 'static, -{ - info!("Initializing system tray..."); - - // Inject layoutRegionGuides stub into NSVisualEffectView early, - // before AppKit creates any internal instances (Tahoe beta workaround). - super::shared::helpers::ensure_layout_region_guides_exists(); - - // Initialize shutdown flag - SHUTDOWN_REQUESTED.get_or_init(|| std::sync::atomic::AtomicBool::new(false)); - - // Initialize status channel - let status_rx = state::init_channels()?; - - // Build event loop (must be on main thread for macOS) - let event_loop = EventLoopBuilder::new().build(); - - // Build the menu and get IDs - let initial_status = TrayStatus::Starting; - let (menu, menu_ids) = menu::build_menu(initial_status)?; - - // Create initial icon - let icon = initial_status.to_icon()?; - - // Build the tray icon - let tray_icon = TrayIconBuilder::new() - .with_menu(Box::new(menu)) - .with_tooltip(initial_status.tooltip()) - .with_icon(icon) - .build()?; - - info!("System tray initialized"); - - // Get menu event receiver - let menu_channel = MenuEvent::receiver(); - - if hotkey_manager.is_some() || hotkeys::is_global_hotkey_manager_active() { - info!("Global hotkeys enabled"); - } - - info!("Starting tray event loop..."); - info!("Press Quit in the tray menu to exit"); - - // Poll interval for checking channels - let poll_interval = Duration::from_millis(100); - let startup_blink_interval = Duration::from_millis(500); - let mut current_status = initial_status; - let mut startup_glyph_visible = true; - let mut last_startup_blink = Instant::now(); - let mut last_menu_refresh = Instant::now(); - let mut hotkey_manager = hotkey_manager; - - on_started(); - - // Run the event loop - event_loop.run(move |event, _, control_flow| { - // Use WaitUntil to avoid busy-waiting while still checking channels. - // Startup blinking needs a shorter wake-up while the app is not ready. - let wake_interval = if current_status == TrayStatus::Starting { - poll_interval.min(startup_blink_interval) - } else { - poll_interval - }; - *control_flow = ControlFlow::WaitUntil(Instant::now() + wake_interval); - - // Handle dock icon click (macOS Reopen event) - if let Event::Reopen { .. } = event { - debug!("Dock icon clicked → opening Settings window"); - crate::ui::settings::show_settings_window(); - return; - } - - // Check for programmatic shutdown request - if is_shutdown_requested() { - info!("Shutdown flag detected, performing cleanup..."); - let _ = crate::qube_lifecycle::stop_managed(); - shutdown_hotkeys(&mut hotkey_manager); - *control_flow = ControlFlow::Exit; - return; - } - - // Periodic menu label refresh (must run on main thread) - if last_menu_refresh.elapsed() >= Duration::from_secs(2) { - menu::update_quality_label(); - menu::update_silero_vad_label(); - menu::update_onboarding_item(); - last_menu_refresh = Instant::now(); - } - - if current_status == TrayStatus::Starting - && last_startup_blink.elapsed() >= startup_blink_interval - { - startup_glyph_visible = !startup_glyph_visible; - last_startup_blink = Instant::now(); - if let Ok(new_icon) = current_status.to_icon_with_glyph(startup_glyph_visible) - && let Err(e) = tray_icon.set_icon(Some(new_icon)) - { - debug!("Failed to blink startup tray icon: {}", e); - } - } - - // Check for status updates (non-blocking) - match status_rx.try_recv() { - Ok(new_status) => { - debug!("Received status update: {:?}", new_status); - current_status = new_status; - startup_glyph_visible = true; - last_startup_blink = Instant::now(); - - // Update menu label - state::apply_status_update(new_status); - - // Update tooltip - if let Err(e) = tray_icon.set_tooltip(Some(new_status.tooltip())) { - debug!("Failed to update tray tooltip: {}", e); - } - - // Update icon - if let Ok(new_icon) = new_status.to_icon_with_glyph(true) - && let Err(e) = tray_icon.set_icon(Some(new_icon)) - { - debug!("Failed to update tray icon: {}", e); - } - - info!("Tray status updated to: {:?}", new_status); - } - Err(TryRecvError::Empty) => {} - Err(TryRecvError::Disconnected) => { - info!("Status channel closed, exiting"); - let _ = crate::qube_lifecycle::stop_managed(); - shutdown_hotkeys(&mut hotkey_manager); - *control_flow = ControlFlow::Exit; - } - } - - // Check for menu events (non-blocking) - if let Ok(event) = menu_channel.try_recv() { - debug!("Menu event received: id={:?}", event.id); - // Handle menu item clicks - handlers::handle_menu_event(&event.id, &menu_ids); - - // Handle Quit specially to exit event loop - if event.id == menu_ids.quit { - info!("Quit requested via menu, exiting..."); - let _ = crate::qube_lifecycle::stop_managed(); - shutdown_hotkeys(&mut hotkey_manager); - *control_flow = ControlFlow::Exit; - } - } - }); - - // Note: This code is unreachable because event_loop.run() never returns on macOS. - // Hotkeys are shut down in-loop before requesting exit. -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_icon_creation() { - let icon = TrayStatus::Idle.to_icon(); - assert!(icon.is_ok()); - let startup_icon = TrayStatus::Starting.to_icon_with_glyph(false); - assert!(startup_icon.is_ok()); - } - - #[test] - fn test_status_tooltips() { - assert_eq!(TrayStatus::Starting.tooltip(), "CodeScribe - Starting..."); - assert_eq!(TrayStatus::Idle.tooltip(), "CodeScribe - Ready"); - assert_eq!(TrayStatus::Listening.tooltip(), "CodeScribe - Recording..."); - assert_eq!(TrayStatus::Thinking.tooltip(), "CodeScribe - Processing..."); - assert_eq!(TrayStatus::Success.tooltip(), "CodeScribe - Done!"); - assert_eq!( - TrayStatus::Thermal.tooltip(), - "CodeScribe - Thermal throttling" - ); - assert_eq!( - TrayStatus::HotkeyConflict.tooltip(), - "CodeScribe - Hotkey conflict" - ); - } - - #[test] - fn test_status_menu_labels() { - assert_eq!(TrayStatus::Starting.menu_label(), "Status: Starting..."); - assert_eq!(TrayStatus::Idle.menu_label(), "Status: Idle"); - assert_eq!(TrayStatus::Listening.menu_label(), "Status: Recording..."); - assert_eq!(TrayStatus::Thinking.menu_label(), "Status: Processing..."); - assert_eq!(TrayStatus::Success.menu_label(), "Status: Done!"); - assert_eq!( - TrayStatus::Thermal.menu_label(), - "Status: Thermal throttling" - ); - assert_eq!( - TrayStatus::HotkeyConflict.menu_label(), - "Status: Hotkey conflict" - ); - } -} diff --git a/app/ui/tray/state.rs b/app/ui/tray/state.rs deleted file mode 100644 index 8376f392..00000000 --- a/app/ui/tray/state.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Thread-local state and cross-thread update channels -//! -//! Manages tray status updates from async tasks to the main thread. - -use std::cell::RefCell; -use std::sync::OnceLock; - -use crossbeam_channel::{Receiver, Sender, unbounded}; -use tracing::debug; - -use crate::tray::menu::update_status_label; -use crate::tray::types::{NotesMenuItems, TrayMenuEvent, TrayStatus}; - -// ============================================================================ -// Thread-local Menu Item Storage -// ============================================================================ - -thread_local! { - pub static NOTES_MENU_ITEMS: RefCell> = const { RefCell::new(None) }; -} - -// ============================================================================ -// Global Channels -// ============================================================================ - -/// Channel for status updates (crossbeam for sync safety) -pub static STATUS_CHANNEL: OnceLock> = OnceLock::new(); - -/// Channel for menu events -pub static MENU_EVENT_CHANNEL: OnceLock> = OnceLock::new(); - -// ============================================================================ -// Public API Functions -// ============================================================================ - -/// Update the tray icon to reflect current status -pub fn update_tray_status(status: TrayStatus) -> anyhow::Result<()> { - if let Some(sender) = STATUS_CHANNEL.get() { - sender - .send(status) - .map_err(|e| anyhow::anyhow!("Failed to send tray status: {}", e))?; - debug!("Tray status update sent: {:?}", status); - Ok(()) - } else { - debug!("Tray status channel not initialized yet"); - Ok(()) - } -} - -/// Get a receiver for menu events (call once from main controller) -pub fn menu_event_receiver() -> anyhow::Result> { - let (tx, rx) = unbounded(); - MENU_EVENT_CHANNEL - .set(tx) - .map_err(|_| anyhow::anyhow!("Menu event channel already initialized"))?; - Ok(rx) -} - -/// Send a menu event to the main controller -pub fn send_menu_event(event: TrayMenuEvent) { - if let Some(sender) = MENU_EVENT_CHANNEL.get() - && let Err(e) = sender.send(event) - { - debug!("Failed to send menu event: {}", e); - } -} - -// ============================================================================ -// Main Thread Apply Functions -// ============================================================================ - -/// Apply status update to the menu label (must be called on main thread) -pub fn apply_status_update(status: TrayStatus) { - update_status_label(status.menu_label()); -} - -// ============================================================================ -// Channel Initialization (for run loop) -// ============================================================================ - -/// Initialize status channel, returning receiver for the event loop -pub fn init_channels() -> anyhow::Result> { - // Create channel for status updates - let (status_tx, status_rx): (Sender, Receiver) = unbounded(); - STATUS_CHANNEL - .set(status_tx) - .map_err(|_| anyhow::anyhow!("Status channel already initialized"))?; - - Ok(status_rx) -} diff --git a/app/ui/tray/types.rs b/app/ui/tray/types.rs deleted file mode 100644 index 5952a7b3..00000000 --- a/app/ui/tray/types.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Type definitions for the tray module -//! -//! Contains all enums and structs used by the tray system. - -use anyhow::Result; -use muda::{CheckMenuItem, MenuId}; -use tracing::debug; -use tray_icon::Icon; - -use crate::tray::icons::{create_fallback_icon, load_custom_icon_with_glyph}; - -/// Status of the CodeScribe system, reflected in tray icon -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TrayStatus { - /// App process is visible, but runtime initialization is still in progress. - Starting, - /// Idle, waiting for activation - Idle, - /// Actively listening/recording - Listening, - /// Processing/transcribing - Thinking, - /// Successfully completed - Success, - /// Error state - backend not available - Error, - /// System thermal pressure is high enough to throttle STT. - Thermal, - /// A hotkey gesture was detected but blocked before dispatch. - HotkeyConflict, -} - -impl TrayStatus { - /// Get the human-readable tooltip for this status - pub fn tooltip(&self) -> String { - match self { - TrayStatus::Starting => "CodeScribe - Starting...".to_string(), - TrayStatus::Idle => "CodeScribe - Ready".to_string(), - TrayStatus::Listening => "CodeScribe - Recording...".to_string(), - TrayStatus::Thinking => "CodeScribe - Processing...".to_string(), - TrayStatus::Success => "CodeScribe - Done!".to_string(), - TrayStatus::Error => "CodeScribe - Backend unavailable!".to_string(), - TrayStatus::Thermal => "CodeScribe - Thermal throttling".to_string(), - TrayStatus::HotkeyConflict => "CodeScribe - Hotkey conflict".to_string(), - } - } - - /// Get the status line text for the menu - pub fn menu_label(&self) -> &'static str { - match self { - TrayStatus::Starting => "Status: Starting...", - TrayStatus::Idle => "Status: Idle", - TrayStatus::Listening => "Status: Recording...", - TrayStatus::Thinking => "Status: Processing...", - TrayStatus::Success => "Status: Done!", - TrayStatus::Error => "Status: Error", - TrayStatus::Thermal => "Status: Thermal throttling", - TrayStatus::HotkeyConflict => "Status: Hotkey conflict", - } - } - - /// Create an icon from this status using the custom CodeScribe logo - /// Falls back to simple circle if custom icon fails - pub fn to_icon(self) -> Result { - self.to_icon_with_glyph(true) - } - - /// Create an icon with the status glyph explicitly visible/hidden. - /// - /// Used by the startup blink loop: non-startup statuses keep the glyph on, - /// while `Starting` alternates it so "not ready yet" is visible in the menu bar. - pub fn to_icon_with_glyph(self, show_status_glyph: bool) -> Result { - load_custom_icon_with_glyph(self, show_status_glyph).or_else(|e| { - debug!("Custom icon failed, using fallback: {}", e); - create_fallback_icon(self, show_status_glyph) - }) - } -} - -/// Menu events that can be sent to the main controller. -/// Some variants are prepared for future use but handlers may not be implemented yet. -#[derive(Debug, Clone)] -pub enum TrayMenuEvent { - /// Copy last transcript to clipboard - CopyLast, - /// Open help/documentation in browser - OpenHelp, - /// Show about dialog - ShowAbout, - /// User clicked Quit - clean shutdown - Quit, - - // History (open folder) - OpenHistoryFolder, - - // Diagnostics - CopyDiagnostics, - InstallSileroVad, - - // Notes - SetQuickNotesEnabled(bool), - SetQuickNotesSaveOnly(bool), -} - -// ============================================================================ -// Menu Item Storage Structs -// ============================================================================ - -/// Notes menu items that need runtime updates. -pub struct NotesMenuItems { - pub quick_notes_toggle: CheckMenuItem, - pub quick_notes_save_only: CheckMenuItem, -} - -// ============================================================================ -// Menu IDs Structure -// ============================================================================ - -/// Menu item IDs for tracking all clickable items -/// Note: Settings opens the persistent Settings window; onboarding is separate. -pub struct MenuIds { - // Top-level - pub copy_last: MenuId, - pub show_overlay: MenuId, - pub open_settings: MenuId, - pub continue_onboarding: Option, - pub open_history: MenuId, - pub copy_diagnostics: MenuId, - pub help: MenuId, - pub about: MenuId, - pub quit: MenuId, - - // Quality - pub quality_open_report: MenuId, - - // Models - pub silero_vad_install: MenuId, - - // Notes - pub notes_toggle_quick_notes: MenuId, - pub notes_toggle_save_only: MenuId, - pub notes_open_folder: MenuId, - pub notes_open_today: MenuId, -} diff --git a/app/ui/voice_chat/api/drawer.rs b/app/ui/voice_chat/api/drawer.rs deleted file mode 100644 index 076c7366..00000000 --- a/app/ui/voice_chat/api/drawer.rs +++ /dev/null @@ -1,1666 +0,0 @@ -//! Drawer tab: transcription/thread cards, filtering, rendering and loading. - -use super::*; -use std::sync::atomic::{AtomicU64, Ordering}; - -const DRAWER_PREVIEW_IDENTIFIER: &str = "codescribe_drawer_preview"; -const DRAWER_ACTION_IDENTIFIER: &str = "codescribe_drawer_action"; - -/// Search-as-you-type debounce window. Fast typing only triggers ONE render -/// (the last keystroke after the user pauses), not one render per character. -const DRAWER_SEARCH_DEBOUNCE: Duration = Duration::from_millis(180); - -/// Monotonic generation token for search debounce. Each keystroke bumps this; -/// a queued debounce callback only renders if its captured token is still the -/// latest, so stale callbacks from earlier keystrokes are dropped. Lives at -/// module scope (not in `OVERLAY_STATE`) so the keystroke path never has to -/// `.lock()` the non-reentrant overlay mutex just to schedule a debounce. -static DRAWER_SEARCH_GENERATION: AtomicU64 = AtomicU64::new(0); - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct DrawerRowActionLayout { - pub title_x: f64, - pub title_width: f64, - pub text_column_x: f64, - pub text_column_width: f64, - pub actions_x: f64, - pub actions_width: f64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DrawerSection { - Today, - Yesterday, - ThisWeek, - Older, -} - -/// Refresh drawer entries from disk. -/// -/// Disk I/O (favorites + `ThreadStore`/`ThreadIndex` scan) runs on a background -/// thread; only the resulting owned `Vec` is marshalled back to the main thread -/// for state assignment + render. This keeps the AppKit main thread responsive -/// even when the thread index is large (P1.3 acceptance #4). -pub fn refresh_drawer() { - std::thread::spawn(|| { - let favorites = load_favorites_from_disk(); - let entries = load_drawer_entries(); - Queue::main().exec_async(move || { - apply_refreshed_drawer_entries(favorites, entries); - }); - }); -} - -/// Apply a freshly loaded (off-main) drawer snapshot on the main thread. -fn apply_refreshed_drawer_entries(favorites: HashSet, entries: Vec) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.favorites = favorites; - let query = drawer_query_from_state(&state); - state.drawer_entries = entries; - render_drawer_entries(&mut state, &query); -} - -pub fn drawer_row_action_layout(row_width: f64) -> DrawerRowActionLayout { - let pad = ui_tokens::DRAWER_ROW_PAD_X; - let title_x = pad + ui_tokens::DRAWER_BADGE_WIDTH + 7.0; - let button_size = ui_tokens::DRAWER_ACTION_BUTTON_SIZE; - let button_gap = ui_tokens::DRAWER_ACTION_BUTTON_GAP; - let actions_width = button_size * 4.0 + button_gap * 3.0; - let actions_x = (row_width - ui_tokens::DRAWER_ACTION_RIGHT_INSET - actions_width).max(title_x); - let title_width = (actions_x - title_x - 8.0).max(24.0); - let text_column_x = title_x; - let text_column_width = (row_width - pad - text_column_x).max(24.0); - - DrawerRowActionLayout { - title_x, - title_width, - text_column_x, - text_column_width, - actions_x, - actions_width, - } -} - -/// Filter drawer entries by query (search-as-you-type path). -/// -/// This is the per-keystroke hot path. It does NOT touch disk: the full entry -/// set is loaded once when the drawer opens / refreshes (`refresh_drawer`), and -/// every keystroke filters that in-memory `state.drawer_entries` snapshot via -/// `render_drawer_entries` (which calls `filtered_drawer_entries`). Re-reading -/// `ThreadStore` / `ThreadIndex` on every character was the jank source (P1.3). -/// -/// Renders are debounced: each call bumps `DRAWER_SEARCH_GENERATION` and queues -/// a callback after `DRAWER_SEARCH_DEBOUNCE`; only the callback whose captured -/// generation is still current performs the render, so a burst of fast typing -/// collapses to a single render after the user pauses. -pub fn filter_drawer(query: &str) { - let query_owned = query.to_string(); - let generation = DRAWER_SEARCH_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; - Queue::main().exec_after(DRAWER_SEARCH_DEBOUNCE, move || { - // Stale callback: a newer keystroke superseded this one. Drop it. - if DRAWER_SEARCH_GENERATION.load(Ordering::SeqCst) != generation { - return; - } - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - render_drawer_entries(&mut state, &query_owned); - }); -} - -pub fn handle_card_copy(index: usize) { - // LATCH FIX: snapshot the entry identity under a brief lock, then do the disk - // read + clipboard write OFF the main thread. The previous version held - // OVERLAY_STATE across `ThreadStore::new()` + `load_thread` + `fs::read_to_string` - // on the AppKit main thread — a synchronous disk read that froze the run loop - // (and blocked every queued UI update behind the lock) for the read duration. - // `copy_to_clipboard` is CGEvent/pasteboard-based and thread-safe (clipboard.rs), - // so the whole body is safe off-main. Mirrors `refresh_drawer`'s off-main pattern. - let snapshot = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(entry) = state.drawer_entries.get(index) else { - return; - }; - if is_drawer_unavailable_placeholder(entry) { - return; - } - (entry.source.clone(), entry.path.clone()) - }; - - let (source, path) = snapshot; - std::thread::spawn(move || match source { - DrawerEntrySource::Thread { id } => { - if let Ok(store) = ThreadStore::new() { - if let Ok(thread) = store.load_thread(&id) { - copy_to_clipboard(&thread_markdown_for_copy(&thread)); - } else if let Ok(raw) = std::fs::read_to_string(&path) { - copy_to_clipboard(&raw); - } - } - } - DrawerEntrySource::LegacyFile => { - if let Ok(contents) = std::fs::read_to_string(&path) { - copy_to_clipboard(&contents); - } - } - }); -} - -pub fn handle_card_restore(index: usize) { - // LATCH FIX: snapshot the thread id under a brief lock, load it off the main - // thread, then marshal the owned `Thread` back to main for state assignment. - // The synchronous `ThreadStore::new()` + `load_thread` read previously ran on - // the main thread while holding OVERLAY_STATE — a disk read that froze the UI - // on every restore click. Mirrors `refresh_drawer`'s load-off-main / apply-on-main. - let id = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(entry) = state.drawer_entries.get(index) else { - return; - }; - if is_drawer_unavailable_placeholder(entry) { - return; - } - let DrawerEntrySource::Thread { id } = &entry.source else { - return; - }; - id.clone() - }; - - std::thread::spawn(move || { - let Ok(store) = ThreadStore::new() else { - warn!("Failed to initialize ThreadStore for restore"); - return; - }; - let thread = match store.load_thread(&id) { - Ok(thread) => thread, - Err(error) => { - warn!("Failed to restore thread {id}: {error}"); - return; - } - }; - Queue::main().exec_async(move || apply_restored_thread(thread)); - }); -} - -/// Apply a freshly loaded (off-main) thread to the overlay on the main thread. -fn apply_restored_thread(thread: Thread) { - let title = thread.title.trim().to_string(); - let mut restored_messages = thread_messages_for_restore(&thread); - let backend_thread = thread.clone(); - if restored_messages.is_empty() { - restored_messages.push(ChatMessage { - role: ChatRole::System, - text: "Restored thread has no messages.".to_string(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode_label(transcription_mode_from_thread_mode(&thread.mode)).to_string()), - is_pending_followup: false, - }); - } - tokio::spawn(async move { - if let Err(error) = - crate::controller::restore_agent_runtime_from_thread(backend_thread).await - { - warn!("Failed to restore Agent runtime from drawer thread: {error}"); - } - }); - - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.messages = restored_messages; - state.active_user_stream_index = None; - state.active_assistant_stream_index = None; - state.is_sending = false; - state.manual_draft.clear(); - state.attachments.clear(); - state.attachments_last_sent = None; - clear_agent_thinking_state(&mut state); - update_active_tab_locked(&mut state, Tab::Agent); - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - let title = if title.is_empty() { - "Restored thread".to_string() - } else { - format!("Restored: {title}") - }; - state.status_base_text = title; - state.status_text = compose_runtime_status_text( - &state.status_base_text, - state.is_agent_degraded, - state.runtime_degraded_reason.as_deref(), - ); - state.status_kind = UiStatus::Idle; - apply_status_pill(&state); - let _ = crate::tray::update_tray_status(state.status_kind.to_tray()); -} - -pub fn handle_card_edit(index: usize) { - let (path, window_usize) = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let path = state.drawer_entries.get(index).map(|e| e.path.clone()); - (path, state.window) - }; - - let Some(path) = path else { - return; - }; - if path.as_os_str().is_empty() { - return; - } - - tracing::info!("Drawer Edit clicked: {}", path.display()); - let ok = open_file_in_editor(&path); - if !ok { - #[cfg(target_os = "macos")] - { - let _ = std::process::Command::new("/usr/bin/open") - .arg("-R") - .arg(&path) - .status(); - } - tracing::warn!("Drawer Edit failed to open: {}", path.display()); - return; - } - - // UX: briefly hide the overlay so the editor is visible immediately. - // Then only bring it back if CodeScribe is still the active app. - #[cfg(target_os = "macos")] - if let Some(window_usize) = window_usize { - unsafe { - crate::ui_helpers::window_hide(window_usize as Id); - } - - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(750)); - - Queue::main().exec_async(move || { - let still_same_window = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window == Some(window_usize) - }; - if !still_same_window { - return; - } - - let is_active = unsafe { - let ns_running_app = match Class::get("NSRunningApplication") { - Some(c) => c, - None => return, - }; - let current: Id = msg_send![ns_running_app, currentApplication]; - if current.is_null() { - return; - } - let active: bool = msg_send![current, isActive]; - active - }; - - // Restore floating level and show only if CodeScribe is active. - unsafe { - let window = window_usize as Id; - let _: () = msg_send![ - window, - setLevel: crate::ui_helpers::NS_FLOATING_WINDOW_LEVEL - ]; - } - if is_active { - unsafe { - crate::ui_helpers::window_show(window_usize as Id); - } - } - }); - }); - } -} - -pub fn handle_card_delete(index: usize) { - // LATCH FIX: snapshot the entry identity under a brief lock, then run the - // destructive disk ops (ThreadStore/fs delete + favorites rewrite) AND the - // full drawer reload off the main thread. The previous version deleted AND - // re-scanned the entire ThreadStore (`load_drawer_entries_with_query` -> - // `load_thread_drawer_entries`, a per-entry disk read) on the main thread - // while holding OVERLAY_STATE — a full disk re-scan that froze the UI. - // Reuses `apply_refreshed_drawer_entries` (the same apply path as `refresh_drawer`). - let snapshot = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(entry) = state.drawer_entries.get(index) else { - return; - }; - if is_drawer_unavailable_placeholder(entry) { - return; - } - ( - entry.source.clone(), - entry.path.clone(), - drawer_entry_favorite_key(entry), - ) - }; - - let (source, path, favorite_key) = snapshot; - std::thread::spawn(move || { - match &source { - DrawerEntrySource::Thread { id } => { - if let Ok(store) = ThreadStore::new() { - if let Err(err) = store.delete_thread(id) { - warn!("Failed to delete thread {id}: {err}"); - } - } else if let Err(err) = std::fs::remove_file(&path) { - warn!( - "Failed to delete thread fallback {}: {}", - path.display(), - err - ); - } - } - DrawerEntrySource::LegacyFile => { - if let Err(err) = std::fs::remove_file(&path) { - warn!("Failed to delete {}: {}", path.display(), err); - } - } - } - - let mut favorites = load_favorites_from_disk(); - favorites.remove(&favorite_key); - save_favorites_to_disk(&favorites); - - let favorites = load_favorites_from_disk(); - let entries = load_drawer_entries(); - Queue::main().exec_async(move || apply_refreshed_drawer_entries(favorites, entries)); - }); -} - -pub fn handle_card_favorite(index: usize) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(entry) = state.drawer_entries.get_mut(index) else { - return; - }; - if is_drawer_unavailable_placeholder(entry) { - return; - } - - entry.is_favorite = !entry.is_favorite; - let is_favorite = entry.is_favorite; - let key = drawer_entry_favorite_key(entry); - let thread_id = match &entry.source { - DrawerEntrySource::Thread { id } => Some(id.clone()), - DrawerEntrySource::LegacyFile => None, - }; - - if is_favorite { - state.favorites.insert(key); - } else { - state.favorites.remove(&key); - } - - // In-memory toggle + render stay on main for instant UI feedback. - update_favorites_button_with_state(&mut state); - let query = drawer_query_from_state(&state); - render_drawer_entries(&mut state, &query); - - // LATCH FIX: defer disk persistence (favorites file + ThreadStore favorite - // flag) off the main thread. Both writes previously ran on the main thread - // under the OVERLAY_STATE lock on every heart-toggle click. - let favorites_snapshot = state.favorites.clone(); - drop(state); - std::thread::spawn(move || { - save_favorites_to_disk(&favorites_snapshot); - if let Some(id) = thread_id - && let Ok(store) = ThreadStore::new() - && let Err(err) = store.set_thread_favorite(&id, is_favorite) - { - warn!("Failed to update thread favorite {id}: {err}"); - } - }); -} - -pub fn toggle_drawer_favorites_only_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.drawer_favorites_only = !state.drawer_favorites_only; - - // Jump to Drawer (this feature is Drawer-scoped). - update_active_tab_locked(&mut state, Tab::Drawer); - - update_favorites_button_with_state(&mut state); - - let query = drawer_query_from_state(&state); - render_drawer_entries(&mut state, &query); -} - -pub fn update_favorites_button_with_state(state: &mut VoiceChatOverlayState) { - unsafe { - let Some(btn_ptr) = state.favorites_button else { - return; - }; - let btn = btn_ptr as Id; - let symbol = if state.drawer_favorites_only { - "heart.fill" - } else { - "heart" - }; - let has_symbol = set_button_symbol(btn, symbol); - if !has_symbol { - let title = if state.drawer_favorites_only { - "♥" - } else { - "♡" - }; - let title = ns_string(title); - let _: () = msg_send![btn, setTitle: title]; - } - } -} - -pub fn drawer_query_from_state(state: &VoiceChatOverlayState) -> String { - state - .search_field - .map(|field| unsafe { get_text_field_string(field as Id) }) - .unwrap_or_default() -} - -pub fn drawer_entry_matches_query(entry: &DrawerEntry, query_lower: &str) -> bool { - if query_lower.is_empty() { - return true; - } - // Path pollution guard: do NOT push entry.path (absolute) into the haystack. - // Every ThreadStore entry lives under `~/.codescribe/` or `~/Library/Application - // Support/CodeScribe/`, so any query overlapping the app data dir name (e.g. - // "codescribe", "thread", "users") would match all entries via leaked path - // components. Operator flagged 2026-05-24 ("threadstore, wyszukiwanie codescribe - // nie odfiltrowuje nic"). Keep file_name (local, useful for legacy file dates) - // and thread id (specific to the entry), drop absolute path. - let file_name_str = entry - .path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(""); - let mut haystack = String::with_capacity( - file_name_str.len() + entry.preview.len() + entry.search_corpus.len() + 64, - ); - haystack.push_str(entry_type_label(entry)); - haystack.push(' '); - haystack.push_str(mode_label(entry.mode)); - haystack.push(' '); - if !file_name_str.is_empty() { - haystack.push_str(file_name_str); - haystack.push(' '); - } - if let DrawerEntrySource::Thread { id } = &entry.source { - haystack.push_str(id); - haystack.push(' '); - } - haystack.push_str(&entry.preview); - haystack.push(' '); - haystack.push_str(&entry.search_corpus); - haystack.to_lowercase().contains(query_lower) -} - -pub fn filtered_drawer_entries<'a>( - state: &'a VoiceChatOverlayState, - query: &str, -) -> Vec<(usize, &'a DrawerEntry)> { - let filter = query.trim().to_lowercase(); - let mut out = Vec::new(); - for (index, entry) in state.drawer_entries.iter().enumerate() { - if state.drawer_favorites_only && !entry.is_favorite { - continue; - } - if !drawer_entry_matches_query(entry, &filter) { - continue; - } - out.push((index, entry)); - } - out -} - -pub fn drawer_top_scroll_y(document_height: f64, clip_height: f64, is_flipped: bool) -> f64 { - if is_flipped { - 0.0 - } else { - (document_height - clip_height).max(0.0) - } -} - -pub fn render_drawer_entries(state: &mut VoiceChatOverlayState, query: &str) { - unsafe { - let Some(container_ptr) = state.drawer_container else { - return; - }; - let container = container_ptr as Id; - stack_view_clear(container); - - let visible = filtered_drawer_entries(state, query); - let now = SystemTime::now(); - let mut last_section: Option = None; - for (index, entry) in visible.iter() { - let section = section_for(entry.timestamp, now); - if last_section != Some(section) { - let header = create_drawer_section_header(section); - stack_view_add(container, header); - last_section = Some(section); - } - let row = create_drawer_row(entry, *index, state.action_handler, query); - stack_view_add(container, row); - } - - if visible.is_empty() { - let frame: CGRect = msg_send![container, frame]; - let empty_state = create_drawer_empty_state(frame.size.width, state.action_handler); - stack_view_add(container, empty_state); - } - - let _: () = msg_send![container, setNeedsLayout: true]; - let _: () = msg_send![container, layoutSubtreeIfNeeded]; - - // Keep the scroll document height in sync with its arranged subviews; otherwise the - // scroll view can end up showing an empty area (looks like the drawer "does nothing"). - if let Some(scroll_view_ptr) = state.drawer_scroll_view { - let scroll_view = scroll_view_ptr as Id; - let content_view: Id = msg_send![scroll_view, contentView]; - if !content_view.is_null() { - let fitting: CGSize = msg_send![container, fittingSize]; - let frame: CGRect = msg_send![container, frame]; - let clip_bounds: CGRect = msg_send![content_view, bounds]; - let document_width = frame.size.width.max(clip_bounds.size.width).max(1.0); - let document_height = fitting - .height - .ceil() - .max(frame.size.height) - .max(clip_bounds.size.height) - .max(1.0); - let new_size = CGSize::new(document_width, document_height); - let _: () = msg_send![container, setFrameSize: new_size]; - let _: () = msg_send![container, setNeedsLayout: true]; - let _: () = msg_send![container, layoutSubtreeIfNeeded]; - - // Scroll to the visual top on every refresh/filter. NSStackView is not flipped, - // so its top is `document_height - clip_height`, not y=0. - let is_flipped: bool = msg_send![container, isFlipped]; - let top_y = - drawer_top_scroll_y(document_height, clip_bounds.size.height, is_flipped); - let _: () = msg_send![content_view, scrollToPoint: CGPoint::new(0.0, top_y)]; - let _: () = msg_send![scroll_view, reflectScrolledClipView: content_view]; - let _: () = msg_send![scroll_view, tile]; - let _: () = msg_send![container, setNeedsDisplay: true]; - let _: () = msg_send![scroll_view, setNeedsDisplay: true]; - } - } - } -} - -pub fn create_drawer_empty_state(width: f64, handler: Option) -> Id { - fn overlay_hotkey_shortcuts_tooltip() -> String { - let (hold, toggle) = - crate::ui::voice_chat::shortcuts_lines(crate::os::hotkeys::ModeHotkeyBindings::load()); - format!("{hold}\n{toggle}") - } - - unsafe { - let ns_view = Class::get("NSView").unwrap(); - let frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(width.max(240.0), ui_tokens::EMPTY_STATE_HEIGHT), - ); - let view: Id = msg_send![ns_view, alloc]; - let view: Id = msg_send![view, initWithFrame: frame]; - let _: () = msg_send![view, setWantsLayer: true]; - let layer: Id = msg_send![view, layer]; - if !layer.is_null() { - let bg = ui_colors::empty_state_bg(); - let cg: Id = msg_send![bg, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg]; - apply_tafla_surface(layer, true); - } - - let pad = ui_tokens::EDGE_PADDING; - let title = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, frame.size.height - 36.0), - &CGSize::new(frame.size.width - pad * 2.0, 20.0), - ), - text: "No items yet".to_string(), - font_size: ui_tokens::BODY_FONT_SIZE, - bold: true, - text_color: color_label(), - background_color: None, - selectable: false, - editable: false, - }); - add_subview(view, title); - - let body = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, frame.size.height - 58.0), - &CGSize::new(frame.size.width - pad * 2.0, 18.0), - ), - text: "Start recording to capture a transcript.".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: false, - text_color: color_secondary_label(), - background_color: None, - selectable: false, - editable: false, - }); - add_subview(view, body); - - let body2 = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, frame.size.height - 76.0), - &CGSize::new(frame.size.width - pad * 2.0, 18.0), - ), - text: "Need permissions or hotkeys? Open Settings.".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: false, - text_color: color_secondary_label(), - background_color: None, - selectable: false, - editable: false, - }); - add_subview(view, body2); - - let button_h = ui_tokens::EMPTY_STATE_BUTTON_HEIGHT; - let button_w = ui_tokens::EMPTY_STATE_BUTTON_WIDTH; - let button_gap = ui_tokens::EMPTY_STATE_BUTTON_GAP; - let row_w = button_w * 2.0 + button_gap; - let row_x = ((frame.size.width - row_w) / 2.0).max(pad); - - let start_button = create_button( - CGRect::new(&CGPoint::new(row_x, pad), &CGSize::new(button_w, button_h)), - "Start recording", - button_style::ROUNDED, - ); - let overlay_button = create_button( - CGRect::new( - &CGPoint::new(row_x + button_w + button_gap, pad), - &CGSize::new(button_w, button_h), - ), - "Open Settings", - button_style::ROUNDED, - ); - - if let Some(handler_ptr) = handler { - let handler_id = handler_ptr as Id; - button_set_action(start_button, handler_id, sel!(onStartRecording:)); - button_set_action(overlay_button, handler_id, sel!(onTabSettings:)); - } - - let shortcuts_tooltip = overlay_hotkey_shortcuts_tooltip(); - set_tooltip(start_button, &shortcuts_tooltip); - set_tooltip( - overlay_button, - "Open Settings (permissions, hotkeys, and runtime services)", - ); - add_subview(view, start_button); - add_subview(view, overlay_button); - - view - } -} - -pub fn create_drawer_row( - entry: &DrawerEntry, - index: usize, - handler: Option, - query: &str, -) -> Id { - unsafe { - let ns_view = Class::get("NSView").expect("NSView class missing"); - let frame = core_graphics::geometry::CGRect::new( - &CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new( - ui_tokens::DRAWER_ROW_WIDTH, - ui_tokens::DRAWER_ROW_HEIGHT, - ), - ); - let row_class = drawer_row_view_class(); - let row: Id = msg_send![row_class, alloc]; - let row: Id = msg_send![row, initWithFrame: frame]; - let _: () = msg_send![row, setWantsLayer: true]; - let layer: Id = msg_send![row, layer]; - if !layer.is_null() { - let bg = ui_colors::surface_paper_cool(); - let cg: Id = msg_send![bg, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg]; - let border = ui_colors::surface_border(); - let border_cg: Id = msg_send![border, CGColor]; - let _: () = msg_send![layer, setBorderColor: border_cg]; - let _: () = msg_send![layer, setBorderWidth: ui_tokens::SURFACE_BORDER_WIDTH]; - let _: () = msg_send![layer, setCornerRadius: ui_tokens::DRAWER_ROW_RADIUS]; - apply_tafla_surface(layer, true); - } - - add_drawer_tracking_area(row, frame); - - let pad = ui_tokens::DRAWER_ROW_PAD_X; - let badge = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(pad, frame.size.height - 23.0), - &CGSize::new( - ui_tokens::DRAWER_BADGE_WIDTH, - ui_tokens::DRAWER_BADGE_HEIGHT, - ), - ), - text: drawer_badge_label(entry).to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - bold: true, - text_color: color_label(), - background_color: Some(ui_colors::accent_tint(0.12)), - selectable: false, - editable: false, - }); - let _: () = msg_send![badge, setWantsLayer: true]; - let badge_layer: Id = msg_send![badge, layer]; - if !badge_layer.is_null() { - let _: () = msg_send![badge_layer, setCornerRadius: ui_tokens::DRAWER_ROW_RADIUS - 2.0]; - let _: () = msg_send![badge_layer, setMasksToBounds: true]; - } - add_subview(row, badge); - - let row_action_layout = drawer_row_action_layout(frame.size.width); - let text_x = row_action_layout.title_x; - let text_w = row_action_layout.title_width; - let title = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(text_x, frame.size.height - 24.0), - &CGSize::new(text_w, 17.0), - ), - text: drawer_entry_title(entry), - font_size: ui_tokens::BODY_FONT_SIZE, - bold: true, - text_color: color_label(), - background_color: None, - selectable: false, - editable: false, - }); - let title_cell: Id = msg_send![title, cell]; - if !title_cell.is_null() { - let _: () = msg_send![title_cell, setLineBreakMode: 4_isize]; - let _: () = msg_send![title_cell, setTruncatesLastVisibleLine: true]; - } - let _: () = msg_send![title, setUsesSingleLineMode: true]; - add_subview(row, title); - - let preview = entry.preview.clone(); - let preview_field = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(row_action_layout.text_column_x, frame.size.height - 42.0), - &CGSize::new(row_action_layout.text_column_width, 16.0), - ), - text: preview.clone(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: false, - text_color: color_secondary_label(), - background_color: None, - selectable: false, - editable: false, - }); - let preview_identifier = ns_string("codescribe_drawer_preview"); - let _: () = msg_send![preview_field, setIdentifier: preview_identifier]; - add_subview(row, preview_field); - - let subtitle = drawer_entry_subtitle(entry); - let subtitle_field = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(row_action_layout.text_column_x, 7.0), - &CGSize::new(row_action_layout.text_column_width, 14.0), - ), - text: subtitle, - font_size: ui_tokens::MICRO_FONT_SIZE, - bold: false, - text_color: color_secondary_label(), - background_color: None, - selectable: false, - editable: false, - }); - add_subview(row, subtitle_field); - - // Highlight matching query text in the stable preview field, not by subview order. - if !query.is_empty() - && let Some(field) = find_drawer_preview_field(row) - { - apply_search_highlight(field, &preview, query); - } - - let actions_container: Id = msg_send![ns_view, alloc]; - let button_size = ui_tokens::DRAWER_ACTION_BUTTON_SIZE; - let button_gap = ui_tokens::DRAWER_ACTION_BUTTON_GAP; - let actions_frame = core_graphics::geometry::CGRect::new( - &CGPoint::new(row_action_layout.actions_x, frame.size.height - 31.0), - &core_graphics::geometry::CGSize::new(row_action_layout.actions_width, button_size), - ); - let actions_container: Id = msg_send![actions_container, initWithFrame: actions_frame]; - - let button_titles = ["", "", "", ""]; - let button_symbols = [ - "arrow.counterclockwise", - "doc.on.doc", - "square.and.pencil", - "trash", - ]; - let button_tooltips = ["Restore", "Copy", "Edit", "Delete"]; - let button_actions = [ - sel!(onCardRestore:), - sel!(onCardCopy:), - sel!(onCardEdit:), - sel!(onCardDelete:), - ]; - for (idx, title) in button_titles.iter().enumerate() { - let button = crate::ui_helpers::create_button( - core_graphics::geometry::CGRect::new( - &CGPoint::new((idx as f64) * (button_size + button_gap), 0.0), - &core_graphics::geometry::CGSize::new(button_size, button_size), - ), - title, - crate::ui_helpers::button_style::INLINE, - ); - let _ = set_button_symbol(button, button_symbols[idx]); - crate::ui_helpers::style_toolbar_icon_button(button); - let supports_image_scaling: bool = - msg_send![button, respondsToSelector: sel!(setImageScaling:)]; - if supports_image_scaling { - let _: () = msg_send![button, setImageScaling: 2_isize]; - } - let supports_control_size: bool = - msg_send![button, respondsToSelector: sel!(setControlSize:)]; - if supports_control_size { - let _: () = msg_send![button, setControlSize: 1_isize]; // NSSmallControlSize - } - if let Some(handler) = handler { - crate::ui_helpers::button_set_action(button, handler as Id, button_actions[idx]); - } - let _: () = msg_send![button, setTag: index as isize]; - let action_identifier = ns_string(DRAWER_ACTION_IDENTIFIER); - let _: () = msg_send![button, setIdentifier: action_identifier]; - set_tooltip(button, button_tooltips[idx]); - let _: () = msg_send![button, setHidden: true]; - let _: () = msg_send![actions_container, addSubview: button]; - } - - let favorite = crate::ui_helpers::create_button( - core_graphics::geometry::CGRect::new( - &CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new(1.0, 1.0), - ), - "", - crate::ui_helpers::button_style::INLINE, - ); - if let Some(handler) = handler { - crate::ui_helpers::button_set_action(favorite, handler as Id, sel!(onCardFavorite:)); - } - let _: () = msg_send![favorite, setTag: index as isize]; - let _: () = msg_send![favorite, setHidden: true]; - let _: () = msg_send![row, addSubview: favorite]; - - let source_label = drawer_entry_source_label(entry); - set_tooltip(row, &source_label); - let _: () = msg_send![row, addSubview: actions_container]; - row - } -} - -pub fn create_drawer_section_header(section: DrawerSection) -> Id { - unsafe { - let ns_view = Class::get("NSView").expect("NSView class missing"); - let frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new( - ui_tokens::DRAWER_ROW_WIDTH, - ui_tokens::DRAWER_SECTION_HEADER_HEIGHT, - ), - ); - let view: Id = msg_send![ns_view, alloc]; - let view: Id = msg_send![view, initWithFrame: frame]; - let label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(ui_tokens::DRAWER_ROW_PAD_X, 4.0), - &CGSize::new(frame.size.width - ui_tokens::DRAWER_ROW_PAD_X * 2.0, 16.0), - ), - text: section.title().to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - bold: true, - text_color: color_secondary_label(), - background_color: None, - selectable: false, - editable: false, - }); - add_subview(view, label); - view - } -} - -impl DrawerSection { - pub fn title(self) -> &'static str { - match self { - Self::Today => "Today", - Self::Yesterday => "Yesterday", - Self::ThisWeek => "This week", - Self::Older => "Older", - } - } -} - -pub fn section_for(timestamp: SystemTime, now: SystemTime) -> DrawerSection { - let entry_date = DateTime::::from(timestamp).date_naive(); - let today = DateTime::::from(now).date_naive(); - let days = today.signed_duration_since(entry_date).num_days(); - if days <= 0 { - DrawerSection::Today - } else if days == 1 { - DrawerSection::Yesterday - } else if days <= 6 { - DrawerSection::ThisWeek - } else { - DrawerSection::Older - } -} - -fn drawer_row_view_class() -> &'static Class { - static mut CLS: *const Class = std::ptr::null(); - static ONCE: std::sync::Once = std::sync::Once::new(); - ONCE.call_once(|| unsafe { - let superclass = Class::get("NSView").expect("NSView class missing"); - let mut decl = objc::declare::ClassDecl::new("CodeScribeDrawerRowView", superclass) - .expect("CodeScribeDrawerRowView already defined"); - decl.add_method( - sel!(mouseEntered:), - drawer_row_mouse_entered as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(mouseExited:), - drawer_row_mouse_exited as extern "C" fn(&Object, Sel, Id), - ); - let cls = decl.register(); - CLS = cls as *const Class; - }); - unsafe { &*CLS } -} - -extern "C" fn drawer_row_mouse_entered(this: &Object, _cmd: Sel, _event: Id) { - unsafe { - let view: Id = (this as *const Object) as Id; - toggle_drawer_action_buttons(view, true); - } -} - -extern "C" fn drawer_row_mouse_exited(this: &Object, _cmd: Sel, _event: Id) { - unsafe { - let view: Id = (this as *const Object) as Id; - toggle_drawer_action_buttons(view, false); - } -} - -unsafe fn add_drawer_tracking_area(row: Id, frame: CGRect) { - unsafe { - let ns_tracking_area = Class::get("NSTrackingArea").expect("NSTrackingArea class missing"); - let options = 1_usize | 32_usize | 512_usize; - let area: Id = msg_send![ns_tracking_area, alloc]; - let area: Id = msg_send![ - area, - initWithRect: frame - options: options - owner: row - userInfo: std::ptr::null::() - ]; - let _: () = msg_send![row, addTrackingArea: area]; - } -} - -unsafe fn toggle_drawer_action_buttons(view: Id, visible: bool) { - unsafe { - let subviews: Id = msg_send![view, subviews]; - if subviews.is_null() { - return; - } - let count: usize = msg_send![subviews, count]; - for i in 0..count { - let child: Id = msg_send![subviews, objectAtIndex: i]; - if child.is_null() { - continue; - } - if identifier_matches(child, DRAWER_ACTION_IDENTIFIER) { - let _: () = msg_send![child, setHidden: !visible]; - } - toggle_drawer_action_buttons(child, visible); - } - } -} - -unsafe fn find_drawer_preview_field(view: Id) -> Option { - unsafe { - if identifier_matches(view, DRAWER_PREVIEW_IDENTIFIER) { - return Some(view); - } - let subviews: Id = msg_send![view, subviews]; - if subviews.is_null() { - return None; - } - let count: usize = msg_send![subviews, count]; - for i in 0..count { - let child: Id = msg_send![subviews, objectAtIndex: i]; - if child.is_null() { - continue; - } - if let Some(found) = find_drawer_preview_field(child) { - return Some(found); - } - } - None - } -} - -unsafe fn identifier_matches(view: Id, expected: &str) -> bool { - unsafe { - let ident: Id = msg_send![view, identifier]; - if ident.is_null() { - return false; - } - let c_str: *const i8 = msg_send![ident, UTF8String]; - if c_str.is_null() { - return false; - } - let actual = std::ffi::CStr::from_ptr(c_str).to_string_lossy(); - actual == expected - } -} - -/// NSRange for Objective-C attributed string APIs. -#[repr(C)] -#[derive(Copy, Clone)] -pub struct NSRange { - location: usize, - length: usize, -} - -/// Apply search-term highlighting to a text field by bolding matching ranges. -/// -/// Uses `char_indices()` to safely iterate over Unicode characters, then maps -/// character offsets to UTF-16 code unit counts for `NSRange` (Cocoa convention). -pub unsafe fn apply_search_highlight(field: Id, text: &str, query: &str) { - let ns_mut_attr = Class::get("NSMutableAttributedString").unwrap(); - let ns_font_cls = Class::get("NSFont").unwrap(); - let text_ns = ns_string(text); - let attr_str: Id = msg_send![ns_mut_attr, alloc]; - let attr_str: Id = msg_send![attr_str, initWithString: text_ns]; - let bold_font: Id = msg_send![ns_font_cls, boldSystemFontOfSize: ui_tokens::BODY_FONT_SIZE]; - let font_key = ns_string("NSFont"); - // Build char-level lowercase for safe matching (no byte-index slicing). - let text_chars: Vec = text.chars().collect(); - let text_lower: Vec = text_chars - .iter() - .map(|c| c.to_lowercase().next().unwrap_or(*c)) - .collect(); - let query_lower: Vec = query - .chars() - .map(|c| c.to_lowercase().next().unwrap_or(c)) - .collect(); - if query_lower.is_empty() { - // Always set the plain attributed string to clear stale highlights. - let _: () = msg_send![field, setAttributedStringValue: attr_str]; - return; - } - // Build byte→utf16 offset map at char boundaries for NSRange conversion. - let mut char_to_utf16: Vec = Vec::with_capacity(text_chars.len() + 1); - let mut utf16_pos: usize = 0; - for ch in &text_chars { - char_to_utf16.push(utf16_pos); - utf16_pos += ch.len_utf16(); - } - char_to_utf16.push(utf16_pos); // sentinel for end - // Slide through char-level arrays to find matches. - let mut i = 0; - while i + query_lower.len() <= text_lower.len() { - if text_lower[i..i + query_lower.len()] == query_lower[..] { - let range = NSRange { - location: char_to_utf16[i], - length: char_to_utf16[i + query_lower.len()] - char_to_utf16[i], - }; - let _: () = msg_send![attr_str, addAttribute: font_key value: bold_font range: range]; - let highlight = ui_colors::search_highlight_bg(); - let bg_key = ns_string("NSBackgroundColor"); - let _: () = msg_send![attr_str, addAttribute: bg_key value: highlight range: range]; - i += query_lower.len(); - } else { - i += 1; - } - } - let _: () = msg_send![field, setAttributedStringValue: attr_str]; -} - -pub fn entry_type_label(entry: &DrawerEntry) -> &'static str { - if is_drawer_unavailable_placeholder(entry) { - return "Warning"; - } - match entry.source { - DrawerEntrySource::Thread { .. } => "ThreadStore", - DrawerEntrySource::LegacyFile => { - if entry.is_ai_formatted { - "Legacy AI" - } else { - "Legacy Raw" - } - } - } -} - -pub fn drawer_entry_source_label(entry: &DrawerEntry) -> String { - if is_drawer_unavailable_placeholder(entry) { - return "ThreadStore".to_string(); - } - match entry.source { - DrawerEntrySource::Thread { .. } => { - if entry.path.exists() { - "ThreadStore".to_string() - } else { - "ThreadStore (index-only)".to_string() - } - } - DrawerEntrySource::LegacyFile => "Legacy transcript file".to_string(), - } -} - -pub fn drawer_entry_subtitle(entry: &DrawerEntry) -> String { - if is_drawer_unavailable_placeholder(entry) { - return "just now • Shift/Cmd • unavailable".to_string(); - } - format_drawer_subtitle( - &format_relative_time(entry.timestamp), - mode_label(entry.mode), - entry.model.as_deref(), - entry.total_tokens, - entry.is_favorite, - ) -} - -pub fn format_drawer_subtitle( - relative_time: &str, - mode: &str, - model: Option<&str>, - total_tokens: Option, - favorite: bool, -) -> String { - let mut parts = vec![relative_time.to_string(), mode.to_string()]; - if let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) { - parts.push(model.to_string()); - } - if let Some(tokens) = total_tokens { - parts.push(format_token_count(tokens)); - } - if favorite { - parts.push("★".to_string()); - } - parts.join(" • ") -} - -pub fn format_token_count(tokens: u64) -> String { - if tokens >= 1_000_000 { - format!("{:.1}M tok", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - format!("{:.1}k tok", tokens as f64 / 1_000.0) - } else { - format!("{tokens} tok") - } -} - -pub fn drawer_entry_title(entry: &DrawerEntry) -> String { - if let Some(display_title) = entry - .title - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .and_then(drawer_display_title_from_candidate) - { - return display_title; - } - let fallback = normalize_preview(&entry.preview, 64); - if fallback.trim().is_empty() { - "Untitled thread".to_string() - } else { - fallback - } -} - -fn drawer_display_title_from_candidate(title: &str) -> Option { - if title.starts_with('/') { - return std::path::Path::new(title) - .file_name() - .and_then(|name| name.to_str()) - .map(|name| normalize_preview(name, 64)) - .filter(|name| !name.trim().is_empty()); - } - Some(normalize_preview(title, 64)) -} - -fn drawer_badge_label(entry: &DrawerEntry) -> &'static str { - match entry.source { - DrawerEntrySource::Thread { .. } => mode_label(entry.mode), - DrawerEntrySource::LegacyFile => entry_type_label(entry), - } -} - -pub fn mode_label(mode: TranscriptionMode) -> &'static str { - match mode { - TranscriptionMode::Hold => "Ctrl+Hold", - TranscriptionMode::Assistive => "Shift/Cmd", - TranscriptionMode::Toggle => "Toggle", - TranscriptionMode::Conversation => "Moshi", - } -} - -pub fn format_relative_time(timestamp: SystemTime) -> String { - let now = SystemTime::now(); - if let Ok(duration) = now.duration_since(timestamp) { - let minutes = duration.as_secs() / 60; - if minutes < 60 { - return format!("{} min", minutes.max(1)); - } - let hours = minutes / 60; - if hours < 24 { - return format!("{} h", hours); - } - let days = hours / 24; - return format!("{} d", days); - } - "just now".to_string() -} - -pub fn load_drawer_entries() -> Vec { - load_drawer_entries_with_query("") -} - -pub fn load_drawer_entries_with_query(query: &str) -> Vec { - let favorites = load_favorites_from_disk(); - let mut entries = load_thread_drawer_entries(&favorites); - entries.sort_by_key(|b| std::cmp::Reverse(b.timestamp)); - - let query_lower = query.trim().to_ascii_lowercase(); - if !query_lower.is_empty() { - entries.retain(|entry| drawer_entry_matches_query(entry, &query_lower)); - } - - entries -} - -pub fn thread_history_unavailable_drawer_entry() -> DrawerEntry { - DrawerEntry { - source: DrawerEntrySource::LegacyFile, - path: PathBuf::from(""), - timestamp: SystemTime::now(), - mode: TranscriptionMode::Assistive, - title: None, - model: None, - total_tokens: None, - preview: "Thread history unavailable — storage error".to_string(), - search_corpus: "thread history unavailable storage error".to_string(), - is_ai_formatted: false, - is_favorite: false, - } -} - -pub fn is_drawer_unavailable_placeholder(entry: &DrawerEntry) -> bool { - matches!(entry.source, DrawerEntrySource::LegacyFile) && entry.path.as_os_str().is_empty() -} - -pub fn load_thread_drawer_entries(favorites: &HashSet) -> Vec { - let Ok(store) = ThreadStore::new() else { - warn!("Drawer: failed to open ThreadStore; drawer entries unavailable"); - return vec![thread_history_unavailable_drawer_entry()]; - }; - let Ok(index) = ThreadIndex::load_or_create(store.threads_dir()) else { - warn!("Drawer: failed to load ThreadIndex; drawer entries unavailable"); - return vec![thread_history_unavailable_drawer_entry()]; - }; - - index - .list(None) - .into_iter() - .map(|summary| { - let id = summary.id.clone(); - let source = DrawerEntrySource::Thread { id: id.clone() }; - let favorite_key = format!("thread:{id}"); - let mut preview = summary - .latest_note - .as_deref() - .or(summary.latest_message.as_deref()) - .or(summary.summary.as_deref()) - .unwrap_or(summary.title.as_str()) - .to_string(); - let mut search_corpus = summary.search_text.clone(); - if (search_corpus.trim().is_empty() || preview.trim().is_empty()) - && let Ok(thread) = store.load_thread(&id) - { - if preview.trim().is_empty() { - preview = thread_preview_for_drawer(&thread); - } - if search_corpus.trim().is_empty() { - search_corpus = thread_search_corpus_for_drawer(&thread); - } - } - preview = normalize_preview(&preview, 120); - let path = store - .thread_file_path(&id) - .unwrap_or_else(|_| PathBuf::from(format!("thread_{id}.json"))); - let timestamp = system_time_from_unix_millis(summary.updated_at.timestamp_millis()); - let mode = transcription_mode_from_thread_mode(&summary.mode); - let mode_label = mode_label(mode); - if search_corpus.trim().is_empty() { - search_corpus = format!( - "{} {} {} {}", - summary.title, - summary.mode, - summary.summary.as_deref().unwrap_or_default(), - preview - ); - } - search_corpus = format!( - "threadstore source:thread {} thread:{} {}", - mode_label, id, search_corpus - ) - .to_ascii_lowercase(); - let thread_metadata = store.load_thread(&id).ok(); - let model = thread_metadata - .as_ref() - .map(|thread| thread.model.trim().to_string()) - .filter(|model| !model.is_empty()); - let total_tokens = thread_metadata - .as_ref() - .and_then(|thread| thread.total_tokens.as_ref()) - .map(|usage| usage.input.saturating_add(usage.output)); - - DrawerEntry { - source, - path, - timestamp, - mode, - title: Some(summary.title.clone()).filter(|title| !title.trim().is_empty()), - model, - total_tokens, - preview, - search_corpus, - is_ai_formatted: true, - is_favorite: summary.is_favorite || favorites.contains(&favorite_key), - } - }) - .collect() -} - -pub fn system_time_from_unix_millis(timestamp_millis: i64) -> SystemTime { - if timestamp_millis <= 0 { - return SystemTime::now(); - } - UNIX_EPOCH + Duration::from_millis(timestamp_millis as u64) -} - -pub fn transcription_mode_from_thread_mode(mode: &str) -> TranscriptionMode { - if mode.eq_ignore_ascii_case("conversation") || mode.eq_ignore_ascii_case("moshi") { - TranscriptionMode::Conversation - } else if mode.eq_ignore_ascii_case("assistive") || mode.eq_ignore_ascii_case("chat") { - TranscriptionMode::Assistive - } else if mode.eq_ignore_ascii_case("hold") || mode.eq_ignore_ascii_case("raw") { - TranscriptionMode::Hold - } else { - TranscriptionMode::Toggle - } -} - -pub fn normalize_preview(text: &str, max_chars: usize) -> String { - text.split_whitespace() - .collect::>() - .join(" ") - .chars() - .take(max_chars) - .collect::() -} - -pub fn thread_preview_for_drawer(thread: &Thread) -> String { - if let Some(summary) = &thread.summary - && !summary.trim().is_empty() - { - return normalize_preview(summary, 120); - } - if let Some(note) = thread - .notes - .iter() - .rev() - .find(|note| !note.text.trim().is_empty()) - { - return normalize_preview(¬e.text, 120); - } - for message in thread.messages.iter().rev() { - let text = thread_message_text_for_copy(message); - if !text.trim().is_empty() { - return normalize_preview(&text, 120); - } - } - normalize_preview(&thread.title, 120) -} - -pub fn thread_search_corpus_for_drawer(thread: &Thread) -> String { - let mut pieces = vec![thread.title.clone(), thread.mode.clone()]; - if let Some(summary) = &thread.summary { - pieces.push(summary.clone()); - } - for note in &thread.notes { - pieces.push(note.text.clone()); - } - for message in &thread.messages { - pieces.push(thread_message_text_for_copy(message)); - } - pieces - .join(" ") - .split_whitespace() - .collect::>() - .join(" ") - .to_ascii_lowercase() -} - -pub fn drawer_entry_favorite_key(entry: &DrawerEntry) -> String { - match &entry.source { - DrawerEntrySource::Thread { id } => format!("thread:{id}"), - DrawerEntrySource::LegacyFile => entry.path.to_string_lossy().to_string(), - } -} - -pub fn thread_markdown_for_copy(thread: &Thread) -> String { - let mut out = String::new(); - let title = thread.title.trim(); - let title = if title.is_empty() { - "Untitled Thread" - } else { - title - }; - out.push_str("# "); - out.push_str(title); - out.push_str("\n\n"); - - if let Some(summary) = &thread.summary - && !summary.trim().is_empty() - { - out.push_str("## Summary\n"); - out.push_str(summary.trim()); - out.push_str("\n\n"); - } - - if !thread.notes.is_empty() { - out.push_str("## Notes\n"); - for note in &thread.notes { - out.push_str("- "); - out.push_str(note.text.trim()); - if let Some(anchor) = note.anchored_to_message { - out.push_str(&format!(" (anchor: #{anchor})")); - } - out.push('\n'); - } - out.push('\n'); - } - - if !thread.messages.is_empty() { - out.push_str("## Messages\n"); - for message in &thread.messages { - out.push_str("### "); - out.push_str(&message.role.to_ascii_uppercase()); - out.push('\n'); - out.push_str(thread_message_text_for_copy(message).trim()); - out.push_str("\n\n"); - } - } - - out.trim_end().to_string() -} - -pub fn thread_messages_for_restore(thread: &Thread) -> Vec { - let mode = mode_label(transcription_mode_from_thread_mode(&thread.mode)).to_string(); - thread - .messages - .iter() - .filter_map(|message| { - let text = thread_message_text_for_restore(message); - if text.trim().is_empty() { - return None; - } - Some(ChatMessage { - role: chat_role_from_thread_role(&message.role), - text, - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: system_time_from_unix_millis(message.timestamp.timestamp_millis()), - mode: Some(mode.clone()), - is_pending_followup: false, - }) - }) - .collect() -} - -pub fn chat_role_from_thread_role(role: &str) -> ChatRole { - match role.to_ascii_lowercase().as_str() { - "assistant" => ChatRole::Assistant, - "system" => ChatRole::System, - _ => ChatRole::User, - } -} - -pub fn thread_message_text_for_restore(message: &codescribe_core::agent::ThreadMessage) -> String { - let mut chunks = Vec::new(); - for value in &message.content { - collect_restore_text(value, &mut chunks); - } - chunks.join(" ") -} - -pub fn collect_restore_text(value: &serde_json::Value, out: &mut Vec) { - match value { - serde_json::Value::String(text) if !text.trim().is_empty() => { - out.push(text.to_string()); - } - serde_json::Value::Array(items) => { - if items.iter().all(serde_json::Value::is_number) { - return; - } - for item in items { - collect_restore_text(item, out); - } - } - serde_json::Value::Object(map) => { - if let Some(text) = map.get("text").and_then(serde_json::Value::as_str) - && !text.trim().is_empty() - { - out.push(text.to_string()); - } - if let Some(content) = map.get("content") { - collect_restore_text(content, out); - } - if let Some(input) = map.get("input") { - collect_restore_text(input, out); - } - } - _ => {} - } -} - -pub fn thread_message_text_for_copy(message: &codescribe_core::agent::ThreadMessage) -> String { - let mut chunks = Vec::new(); - for value in &message.content { - collect_copy_text(value, &mut chunks); - } - let text = chunks.join(" "); - if text.trim().is_empty() { - "(non-text content)".to_string() - } else { - text - } -} - -pub fn collect_copy_text(value: &serde_json::Value, out: &mut Vec) { - match value { - serde_json::Value::String(text) if !text.trim().is_empty() => { - out.push(text.to_string()); - } - serde_json::Value::Array(items) => { - if items.iter().all(serde_json::Value::is_number) { - return; - } - for item in items { - collect_copy_text(item, out); - } - } - serde_json::Value::Object(map) => { - if let Some(text) = map.get("text").and_then(serde_json::Value::as_str) - && !text.trim().is_empty() - { - out.push(text.to_string()); - } - if let Some(content) = map.get("content") { - collect_copy_text(content, out); - } - if let Some(input) = map.get("input") { - collect_copy_text(input, out); - } - for (key, nested) in map { - if matches!(key.as_str(), "text" | "content" | "input" | "data") { - continue; - } - collect_copy_text(nested, out); - } - } - _ => {} - } -} - -#[derive(serde::Serialize, serde::Deserialize, Default)] -pub struct FavoritesFile { - version: u32, - paths: Vec, -} - -pub fn favorites_path() -> std::path::PathBuf { - let dir = codescribe_core::config::Config::config_dir(); - dir.join("voice_chat_favorites.json") -} - -pub fn load_favorites_from_disk() -> HashSet { - let path = favorites_path(); - let Ok(data) = std::fs::read_to_string(&path) else { - return HashSet::new(); - }; - let Ok(file) = serde_json::from_str::(&data) else { - return HashSet::new(); - }; - file.paths.into_iter().collect() -} - -pub fn save_favorites_to_disk(favorites: &HashSet) { - let path = favorites_path(); - if let Some(dir) = path.parent() { - let _ = std::fs::create_dir_all(dir); - } - let file = FavoritesFile { - version: 1, - paths: favorites.iter().cloned().collect(), - }; - if let Ok(json) = serde_json::to_string_pretty(&file) { - let _ = std::fs::write(&path, json); - } -} - -pub fn update_drawer_after_save(path: &std::path::Path) { - info!("Drawer entry saved: {}", path.display()); - refresh_drawer(); -} diff --git a/app/ui/voice_chat/api/export.rs b/app/ui/voice_chat/api/export.rs deleted file mode 100644 index 3e8eac80..00000000 --- a/app/ui/voice_chat/api/export.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Chat-to-Markdown export and history persistence. - -use super::*; - -/// Export the current Agent chat thread as Markdown. -/// -/// - `assistant_only=false` → include User + Assistant messages -/// - `assistant_only=true` → include only Assistant messages -pub fn export_chat_markdown(assistant_only: bool) -> String { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - chat_markdown_from_messages(&state.messages, assistant_only) -} - -/// Save the current Agent chat thread as a `.md` file in `~/.codescribe/transcriptions/YYYY-MM-DD/`. -/// -/// Returns the created path on success. -pub fn save_chat_markdown_to_history(assistant_only: bool) -> Option { - let md = export_chat_markdown(assistant_only); - if md.trim().is_empty() { - return None; - } - - let now = Local::now(); - let dir = crate::state::history::transcriptions_dir(&now); - let time_base = now.format("%H%M%S").to_string(); - let kind = if assistant_only { - "chat-assistant" - } else { - "chat" - }; - - let mut candidate = dir.join(format!("{}_{}.md", time_base, kind)); - for i in 1..=10_000 { - if !candidate.exists() { - break; - } - candidate = dir.join(format!("{}_{}_{}.md", time_base, kind, i)); - } - - if std::fs::write(&candidate, md).is_ok() { - Some(candidate) - } else { - None - } -} - -pub fn chat_markdown_from_messages(messages: &[ChatMessage], assistant_only: bool) -> String { - let exported_at = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - let mut out = String::new(); - out.push_str("# CodeScribe Chat Export\n\n"); - out.push_str(&format!("- exported_at: {}\n", exported_at)); - out.push_str(&format!( - "- scope: {}\n\n", - if assistant_only { - "assistant_only" - } else { - "all" - } - )); - - for msg in messages { - if assistant_only && msg.role != ChatRole::Assistant { - continue; - } - let role = match msg.role { - ChatRole::User => "User", - ChatRole::Assistant => "Assistant", - ChatRole::System => "System", - ChatRole::Reasoning => "Reasoning", - }; - out.push_str(&format!("## {}\n\n", role)); - out.push_str(msg.text.trim_end()); - out.push_str("\n\n"); - } - - out.trim_end().to_string() + "\n" -} diff --git a/app/ui/voice_chat/api/lifecycle.rs b/app/ui/voice_chat/api/lifecycle.rs deleted file mode 100644 index 94862501..00000000 --- a/app/ui/voice_chat/api/lifecycle.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Overlay visibility, hide/teardown and state clearing. - -use super::*; - -/// Check if auto-send is enabled -pub fn is_auto_send_enabled() -> bool { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.auto_send_enabled -} - -/// Check if the voice chat overlay is currently visible -pub fn is_voice_chat_overlay_visible() -> bool { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window.is_some() -} - -/// Reset the auto-hide timer (placeholder for future implementation) -pub fn reset_voice_chat_activity() { - debug!("reset_voice_chat_activity called"); -} - -/// Hide the voice chat overlay window -pub fn hide_voice_chat_overlay() { - Queue::main().exec_async(|| { - hide_voice_chat_overlay_impl(); - }); -} - -/// ObjC handles owned by the overlay state via `[cls new]` (+1 retain each). -/// These must receive a balancing `release` exactly once when the overlay is -/// permanently torn down. Subviews (`blur_view`, pills, drawer, etc.) are -/// retained by the window and need no explicit release. -/// -/// The reuse path in `voice_chat/mod.rs` and the AppKit `windowWillClose` -/// delegate callback in `voice_chat/handlers.rs` both clear the state without -/// taking ownership of these pointers — they call the lighter -/// `clear_overlay_state` directly. Only `hide_voice_chat_overlay_impl` is -/// authoritative for releasing them, so handle ownership transfer happens -/// here and nowhere else. -pub struct ReleasedOverlayHandles { - window_delegate: Option, - action_handler: Option, - window: Option, -} - -fn remove_notification_observer(observer: Option) { - let Some(observer) = observer else { - return; - }; - unsafe { - if let Some(notification_center) = Class::get("NSNotificationCenter") { - let center: Id = msg_send![notification_center, defaultCenter]; - let _: () = msg_send![center, removeObserver: observer as Id]; - } - } -} - -/// Drain the three owned ObjC handles out of the overlay state and clear all -/// other fields. Returns the handles for the caller to release after dropping -/// the state lock. Calling code MUST eventually `release` each `Some(ptr)` -/// exactly once or leak the underlying object. -pub fn take_handles_and_clear_overlay_state( - state: &mut VoiceChatOverlayState, -) -> ReleasedOverlayHandles { - let handles = ReleasedOverlayHandles { - window_delegate: state.window_delegate.take(), - action_handler: state.action_handler.take(), - window: state.window.take(), - }; - clear_overlay_state(state); - handles -} - -pub fn hide_voice_chat_overlay_impl() { - // IMPORTANT: do not hold OVERLAY_STATE while calling `window_close`. - // `window_close` triggers AppKit notifications/delegate callbacks (windowWillClose), - // and those callbacks also lock OVERLAY_STATE. Holding the lock here can deadlock - // the main thread (observed as a hard freeze/hang). - let handles = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - take_handles_and_clear_overlay_state(&mut state) - }; - - if let Some(window_ptr) = handles.window { - remove_notification_observer(handles.action_handler); - - // SAFETY: `window_ptr` was obtained from `[NSWindow alloc] init...]` - // / `[cls new]` on the main thread and stored in `handles.window` - // while still retained. We are on the main thread (overlay teardown - // runs from the AppKit run loop) and the pointer has not yet been - // released. - unsafe { - let window = window_ptr as Id; - crate::ui_helpers::animate_fade(window, 0.0, 0.15); - } - - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(200)); - dispatch::Queue::main().exec_async(move || { - debug!( - "voice_chat teardown: closing NSWindow (ptr={:#x}) + delegates", - window_ptr - ); - // SAFETY: each pointer was obtained from `[cls new]` (or - // equivalent alloc/init pair) on the main thread, retained at - // +1, and is still alive because `take_handles_and_clear_overlay_state` - // is the unique teardown site. We are on the main thread (dispatch - // queue is main). The explicit autoreleasepool scope ensures - // autoreleased temporaries from AppKit's `windowWillClose` / - // CoreAnimation fade cleanup drain in-scope, before this closure - // exits — without it, pendingowe autoreleases survive into the - // next runloop tick's pool pop and can hit pointers freed by - // these same release calls, producing EXC_BAD_ACCESS in - // `objc_release` during `_CFAutoreleasePoolPop` (observed as - // SIGSEGV on macOS Tahoe beta, 2026-05-10 and 2026-05-13). - objc2::rc::autoreleasepool(|_pool| unsafe { - let window = window_ptr as Id; - // Close window FIRST so AppKit dispatches `windowWillClose` - // delegate callbacks while delegate + action handler are - // still alive. Then release deps. The shared shell policy - // sets `released_when_closed = false`, so `window_close` - // does NOT balance the +1 retain — caller releases below. - crate::ui_helpers::window_close(window); - if let Some(ptr) = handles.window_delegate { - crate::ui_helpers::release_object(ptr as Id); - } - if let Some(ptr) = handles.action_handler { - crate::ui_helpers::release_object(ptr as Id); - } - crate::ui_helpers::release_object(window); - }); - }); - }); - } else { - remove_notification_observer(handles.action_handler); - - // If there was no window, we still need to release the delegate and action handler - unsafe { - if let Some(ptr) = handles.window_delegate { - let _: () = msg_send![ptr as Id, release]; - } - if let Some(ptr) = handles.action_handler { - let _: () = msg_send![ptr as Id, release]; - } - } - } - - clear_search_field(); -} - -/// Reset the overlay state to its default shape. Does NOT release ObjC retains -/// on `window`, `window_delegate`, or `action_handler` — the caller is -/// responsible for that via `take_handles_and_clear_overlay_state` when the -/// overlay is being permanently torn down. This entry point is safe for the -/// reuse path (stale dangling pointers) and the `windowWillClose` callback -/// (release already in flight from `hide_voice_chat_overlay_impl`). -pub fn clear_overlay_state(state: &mut VoiceChatOverlayState) { - state.window = None; - state.window_delegate = None; - state.action_handler = None; - state.blur_view = None; - state.split_view_controller = None; - state.split_sidebar_item = None; - state.split_content_item = None; - state.split_sidebar_container = None; - state.split_content_container = None; - state.title_label = None; - state.status_pill = None; - state.status_pill_label = None; - state.status_pill_dot = None; - state.tab_drawer_button = None; - state.tab_agent_button = None; - state.tab_settings_button = None; - state.favorites_button = None; - state.close_button = None; - state.drawer_scroll_view = None; - state.drawer_container = None; - state.drawer_edge_effect = None; - state.search_field = None; - state.search_label = None; - state.agent_scroll_view = None; - state.agent_container = None; - state.agent_bubble_views.clear(); - release_agent_bubble_click_recognizers(state); - state.agent_input_bar = None; - state.agent_input_scroll_view = None; - state.agent_input_text_view = None; - state.agent_input_field = None; - state.agent_attach_button = None; - state.agent_send_button = None; - state.agent_latest_button = None; - state.attachments.clear(); - state.attachments_last_sent = None; - state.attachment_chip_strip = None; - state.active_tab = Tab::Drawer; - state.pending_tab = None; - state.active_user_stream_index = None; - state.active_assistant_stream_index = None; - state.active_reasoning_stream_index = None; - state.is_sending = false; - state.scroll_pinned = true; - state.manual_draft.clear(); - state.conversation_state = ConversationModeState::Inactive; -} diff --git a/app/ui/voice_chat/api/messages.rs b/app/ui/voice_chat/api/messages.rs deleted file mode 100644 index cc5cdcec..00000000 --- a/app/ui/voice_chat/api/messages.rs +++ /dev/null @@ -1,1267 +0,0 @@ -//! Chat message lifecycle: streaming deltas, finalization, bubbles and chat view updates. - -use super::*; - -/// Append a delta to the user draft message (streaming transcription) -pub fn append_voice_chat_user_delta(delta: &str) { - let delta_owned = delta.to_string(); - Queue::main().exec_async(move || { - run_when_overlay_unlocked(move || append_voice_chat_user_delta_impl(&delta_owned)); - }); -} - -/// Append one finalized toggle dictation utterance to the live user draft. -pub fn append_voice_chat_user_utterance(text: &str) { - let text_owned = text.trim().to_string(); - if text_owned.is_empty() { - return; - } - Queue::main().exec_async(move || { - run_when_overlay_unlocked(move || append_voice_chat_user_utterance_impl(&text_owned)); - }); -} - -/// Finalize the user message text (stop streaming) -pub fn set_voice_chat_user_text(text: &str) { - let text_owned = text.to_string(); - Queue::main().exec_async(move || { - finalize_user_message_impl(&text_owned); - }); -} - -/// Finalize the latest user message without changing its text. -pub fn finalize_voice_chat_user_message() { - Queue::main().exec_async(|| { - finalize_user_message_state_only_impl(); - }); -} - -/// Append a delta to the assistant response (streaming) -pub fn append_voice_chat_assistant_delta(delta: &str) { - let delta_owned = delta.to_string(); - Queue::main().exec_async(move || { - run_when_overlay_unlocked(move || append_voice_chat_assistant_delta_impl(&delta_owned)); - }); -} - -/// Append a delta to the live agent reasoning summary (streaming). -/// -/// Reuses the exact same proven append path as the assistant/user lanes -/// (`TranscriptDelta::apply` + `get_or_create_streaming_message_index`), only on -/// the dedicated `Reasoning` lane — so the model's thinking is shown live instead -/// of a silent spinner. NOT mixed into the assistant text. -pub fn append_voice_chat_reasoning_delta(delta: &str) { - let delta_owned = delta.to_string(); - Queue::main().exec_async(move || { - run_when_overlay_unlocked(move || append_voice_chat_reasoning_delta_impl(&delta_owned)); - }); -} - -/// Set the full text in the overlay for the assistant response -pub fn set_voice_chat_text(text: &str) { - let text_owned = text.to_string(); - Queue::main().exec_async(move || { - finalize_assistant_message_impl(&text_owned, false); - }); -} - -/// Finalize the latest assistant message without changing its text. -pub fn finalize_voice_chat_assistant_message() { - Queue::main().exec_async(|| { - finalize_assistant_message_state_only_impl(false); - }); -} - -/// Add an error message to the chat log -pub fn add_voice_chat_error_message(text: &str) { - let text_owned = text.to_string(); - Queue::main().exec_async(move || { - // P2.10: build views via `run_when_overlay_unlocked`. This block locks - // OVERLAY_STATE and then calls `update_chat_view_with_state`, which spins - // AppKit layout/scroll work; running it while an OUTER main-thread frame - // still holds the non-reentrant guard self-deadlocks the mutex. - run_when_overlay_unlocked(move || add_voice_chat_error_message_impl(&text_owned)); - }); -} - -fn add_voice_chat_error_message_impl(text: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.active_assistant_stream_index = None; - state.active_reasoning_stream_index = None; - clear_agent_thinking_state(&mut state); - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::System, - text: text.to_string(), - is_streaming: false, - is_collapsed: false, - is_error: true, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - state.is_sending = false; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); -} - -/// Add a non-error system message to the chat log. -pub fn add_voice_chat_system_message(text: &str) { - let text_owned = text.to_string(); - Queue::main().exec_async(move || { - // P2.10: see `add_voice_chat_error_message` — guard the view build. - run_when_overlay_unlocked(move || add_voice_chat_system_message_impl(&text_owned)); - }); -} - -fn add_voice_chat_system_message_impl(text: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::System, - text: text.to_string(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - update_chat_view_with_state(&mut state, true); -} - -/// Add a user message to the chat -pub fn add_voice_chat_user_message(text: &str) { - let text_owned = text.to_string(); - Queue::main().exec_async(move || { - // P2.10: see `add_voice_chat_error_message` — guard the view build. - run_when_overlay_unlocked(move || add_voice_chat_user_message_impl(&text_owned)); - }); -} - -fn add_voice_chat_user_message_impl(text: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.active_user_stream_index = None; - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::User, - text: text.to_string(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - update_chat_view_with_state(&mut state, true); -} - -/// Seed chat with a transcript and submit it as a user message. -/// -/// This is used by transcription-overlay `Augment` to perform an explicit handoff -/// from dictation to chat. -pub fn handoff_transcript_to_chat(transcript: &str) { - let transcript_owned = transcript.trim().to_string(); - if transcript_owned.is_empty() { - return; - } - - Queue::main().exec_async(move || { - handoff_transcript_to_chat_impl(&transcript_owned); - }); -} - -/// Mark that the agent is currently thinking/reasoning after a voice transcript was sent. -/// Used to drive "Thinking..." UI in the Agent tab of the voice chat overlay. -pub fn set_voice_chat_agent_thinking(thinking: bool) { - Queue::main().exec_async(move || { - // P2.10: guard the lock-then-update path. `apply_agent_thinking` mutates - // the held guard and refreshes the status pill / send button; if an outer - // main-thread frame already holds OVERLAY_STATE, locking here would - // self-deadlock the non-reentrant mutex. - run_when_overlay_unlocked(move || { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - apply_agent_thinking(&mut state, thinking); - }); - }); -} - -/// Apply agent-thinking state on an already-held `OVERLAY_STATE` guard, then -/// refresh status + send button in place. Must NOT lock `OVERLAY_STATE`: the -/// caller already holds it. -/// -/// DEADLOCK PREVENTION: the previous inline version called -/// `update_voice_chat_status_impl` here, which re-locked the same non-reentrant -/// `std::sync::Mutex` on the main thread and froze it in `__psynch_mutexwait` -/// (overlay hang the moment Emil entered "Thinking…" after a voice handoff). -/// Status is now applied via `apply_voice_chat_status`, which operates on the -/// held guard without re-locking. -pub fn apply_agent_thinking(state: &mut VoiceChatOverlayState, thinking: bool) { - if thinking { - state.is_agent_thinking = true; - state.status_base_text = "Thinking…".to_string(); - } else { - clear_agent_thinking_state(state); - } - let base = state.status_base_text.clone(); - apply_voice_chat_status(state, &base); - update_send_button_with_state(state); -} - -pub fn handle_message_bubble_click_from_recognizer(sender: Id) { - if sender.is_null() { - return; - } - let recognizer_ptr = sender as usize; - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(index) = state - .agent_bubble_click_recognizers - .iter() - .find(|(ptr, _)| *ptr == recognizer_ptr) - .map(|(_, index)| *index) - else { - debug!("Bubble click did not resolve to a message recognizer"); - return; - }; - - let Some(message) = state.messages.get_mut(index) else { - debug!("Bubble click pointed outside message list"); - return; - }; - - match message.role { - ChatRole::Reasoning => { - message.is_collapsed = !message.is_collapsed; - update_chat_view_with_state(&mut state, false); - } - ChatRole::Assistant if !message.text.is_empty() => { - let text = message.text.clone(); - drop(state); - copy_to_clipboard(&text); - info!("Copied assistant bubble to clipboard"); - } - _ => { - debug!("Bubble click had no action for role {:?}", message.role); - } - } -} - -/// Minimum interval between layout passes during streaming (prevents main-thread saturation). -pub const DELTA_LAYOUT_THROTTLE: Duration = Duration::from_millis(50); -pub const SCROLL_BOTTOM_THRESHOLD: f64 = 24.0; -pub const AGENT_SCROLL_BOTTOM_CLEARANCE: f64 = 36.0; - -pub fn should_autoscroll(scroll_pinned: bool) -> bool { - scroll_pinned -} - -pub fn agent_document_height_for_bottom_clearance( - stack_height: f64, - bottom_inset: f64, - clearance: f64, -) -> f64 { - stack_height.max(1.0) + bottom_inset.max(0.0) + clearance.max(0.0) -} - -pub fn agent_stack_height_from_document(document_height: f64, bottom_inset: f64) -> f64 { - (document_height.max(1.0) - bottom_inset.max(0.0) - AGENT_SCROLL_BOTTOM_CLEARANCE).max(1.0) -} - -pub fn scrolled_to_bottom_math( - visible_y: f64, - visible_height: f64, - document_height: f64, - threshold: f64, -) -> bool { - let visible_max_y = visible_y.max(0.0) + visible_height.max(0.0); - let threshold = threshold.max(0.0); - let bottom_y = (document_height.max(0.0) - threshold).max(0.0); - visible_max_y + f64::EPSILON >= bottom_y -} - -pub unsafe fn is_scrolled_to_bottom(agent_scroll: Id) -> bool { - unsafe { - if agent_scroll.is_null() { - return true; - } - let document_view: Id = msg_send![agent_scroll, documentView]; - if document_view.is_null() { - return true; - } - let visible: CGRect = msg_send![agent_scroll, documentVisibleRect]; - let document_frame: CGRect = msg_send![document_view, frame]; - scrolled_to_bottom_math( - visible.origin.y, - visible.size.height, - document_frame.size.height, - SCROLL_BOTTOM_THRESHOLD, - ) - } -} - -pub fn latest_message_is_streaming(state: &VoiceChatOverlayState) -> bool { - state - .messages - .last() - .map(|message| message.is_streaming) - .unwrap_or(false) -} - -pub fn update_latest_pill_visibility(state: &VoiceChatOverlayState) { - let Some(button_ptr) = state.agent_latest_button else { - return; - }; - let should_show = state.active_tab == Tab::Agent - && !state.scroll_pinned - && latest_message_is_streaming(state); - unsafe { - crate::ui_helpers::set_hidden(button_ptr as Id, !should_show); - } -} - -pub unsafe fn scroll_agent_to_bottom( - last_bubble_ptr: Option, - scroll_view_ptr: Option, -) { - unsafe { - if let Some(bubble_ptr) = last_bubble_ptr { - let bubble = bubble_ptr as Id; - let bounds: CGRect = msg_send![bubble, bounds]; - let y = (bounds.size.height - 2.0).max(0.0); - let rect = CGRect::new( - &CGPoint::new(0.0, y), - &CGSize::new(bounds.size.width, AGENT_SCROLL_BOTTOM_CLEARANCE + 2.0), - ); - let _: () = msg_send![bubble, scrollRectToVisible: rect]; - return; - } - - if let Some(scroll_view_ptr) = scroll_view_ptr { - let scroll_view = scroll_view_ptr as Id; - let content_view: Id = msg_send![scroll_view, contentView]; - if !content_view.is_null() { - let _: () = msg_send![content_view, scrollToPoint: CGPoint::new(0.0, 0.0)]; - let _: () = msg_send![scroll_view, reflectScrolledClipView: content_view]; - } - } - } -} - -pub fn handle_agent_scroll_live() { - let scroll_view_ptr = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.agent_scroll_view - }; - let Some(scroll_view_ptr) = scroll_view_ptr else { - return; - }; - - let pinned = unsafe { is_scrolled_to_bottom(scroll_view_ptr as Id) }; - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.agent_scroll_view != Some(scroll_view_ptr) { - return; - } - state.scroll_pinned = pinned; - update_latest_pill_visibility(&state); -} - -pub fn pin_agent_scroll_to_latest_impl() { - let (last_bubble, scroll_view) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.scroll_pinned = true; - update_latest_pill_visibility(&state); - ( - state - .agent_bubble_views - .last() - .map(|(bubble_ptr, _)| *bubble_ptr), - state.agent_scroll_view, - ) - }; - - unsafe { - scroll_agent_to_bottom(last_bubble, scroll_view); - } -} - -pub fn resolve_delta_index( - state: &VoiceChatOverlayState, - requested: Option, -) -> Option { - if let Some(idx) = requested - && idx < state.messages.len() - && idx < state.agent_bubble_views.len() - { - return Some(idx); - } - if !state.messages.is_empty() && state.agent_bubble_views.len() == state.messages.len() { - return Some(state.messages.len() - 1); - } - None -} - -pub fn apply_delta_and_layout(state: &mut VoiceChatOverlayState, updated_index: Option) { - let now = Instant::now(); - let should_layout = state - .last_layout_time - .is_none_or(|t| now.duration_since(t) >= DELTA_LAYOUT_THROTTLE); - - if should_layout { - state.last_layout_time = Some(now); - state.layout_pending = false; - state.pending_delta_index = None; - let index = resolve_delta_index(state, updated_index); - if !index - .map(|idx| try_update_message_view_in_place(state, idx)) - .unwrap_or(false) - { - update_chat_view_with_state(state, false); - } - } else { - state.pending_delta_index = updated_index; - if !state.layout_pending { - // Schedule a deferred layout so the latest delta is always rendered. - state.layout_pending = true; - let remaining = - DELTA_LAYOUT_THROTTLE - now.duration_since(state.last_layout_time.unwrap_or(now)); - let millis = remaining.as_millis().max(5) as u64; - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(millis)); - Queue::main().exec_async(|| { - run_when_overlay_unlocked(|| { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.layout_pending { - state.layout_pending = false; - state.last_layout_time = Some(Instant::now()); - let index = resolve_delta_index(&state, state.pending_delta_index); - state.pending_delta_index = None; - if !index - .map(|idx| try_update_message_view_in_place(&mut state, idx)) - .unwrap_or(false) - { - update_chat_view_with_state(&mut state, false); - } - } - }) - }); - }); - } - } -} - -pub fn append_voice_chat_user_delta_impl(delta: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_agent_tab_visible(&mut state); - let idx = if should_capture_pending_followup(&state) { - get_or_create_pending_followup_index(&mut state) - } else { - get_or_create_streaming_message_index(&mut state, ChatRole::User) - }; - if let Some(msg) = state.messages.get_mut(idx) { - codescribe_core::pipeline::contracts::TranscriptDelta::from_raw(delta).apply(&mut msg.text); - msg.is_streaming = true; - msg.is_collapsed = false; - } - apply_delta_and_layout(&mut state, Some(idx)); -} - -pub fn append_voice_chat_user_utterance_impl(text: &str) { - let text = text.trim(); - if text.is_empty() { - return; - } - - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_agent_tab_visible(&mut state); - let idx = if should_capture_pending_followup(&state) { - get_or_create_pending_followup_index(&mut state) - } else { - get_or_create_streaming_message_index(&mut state, ChatRole::User) - }; - if let Some(msg) = state.messages.get_mut(idx) { - if !msg.text.trim().is_empty() && !msg.text.ends_with(char::is_whitespace) { - msg.text.push(' '); - } - msg.text.push_str(text); - msg.is_streaming = true; - msg.is_collapsed = false; - } - apply_delta_and_layout(&mut state, Some(idx)); -} - -pub fn append_voice_chat_assistant_delta_impl(delta: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_agent_tab_visible(&mut state); - - // First assistant token → stop the "Thinking" indicator and finalize any live - // reasoning lane (collapse it to its title — the answer is starting). - clear_agent_thinking_state(&mut state); - finalize_streaming_reasoning(&mut state); - - let idx = get_or_create_streaming_message_index(&mut state, ChatRole::Assistant); - if let Some(msg) = state.messages.get_mut(idx) { - codescribe_core::pipeline::contracts::TranscriptDelta::from_raw(delta).apply(&mut msg.text); - msg.is_streaming = true; - } - apply_delta_and_layout(&mut state, Some(idx)); -} - -pub fn append_voice_chat_reasoning_delta_impl(delta: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_agent_tab_visible(&mut state); - - // Reasoning IS the live "thinking" — surface it instead of a silent spinner. - // Do NOT clear the thinking indicator here; the first ASSISTANT token does that. - let idx = get_or_create_streaming_message_index(&mut state, ChatRole::Reasoning); - if let Some(msg) = state.messages.get_mut(idx) { - codescribe_core::pipeline::contracts::TranscriptDelta::from_raw(delta).apply(&mut msg.text); - msg.is_streaming = true; - } - apply_delta_and_layout(&mut state, Some(idx)); -} - -/// Stop streaming on any live reasoning message (called when the assistant answer -/// begins). The reasoning entry stays in the log as a finished, collapsible item. -pub fn finalize_streaming_reasoning(state: &mut VoiceChatOverlayState) { - for msg in state.messages.iter_mut() { - if msg.role == ChatRole::Reasoning && msg.is_streaming { - msg.is_streaming = false; - msg.is_collapsed = true; - } - } - state.active_reasoning_stream_index = None; -} - -pub fn display_text_for_message(message: &ChatMessage) -> String { - if message.role == ChatRole::Reasoning && message.is_collapsed { - reasoning_summary_header(message) - } else if message.is_streaming && message.text.is_empty() { - "• • •".to_string() - } else if message.is_streaming { - format!("{} …", message.text) - } else { - message.text.clone() - } -} - -pub fn bubble_text_for_message(message: &ChatMessage) -> String { - if message.role == ChatRole::Reasoning && message.is_collapsed { - reasoning_summary_header(message) - } else { - message.text.clone() - } -} - -pub fn bubble_streaming_for_message(message: &ChatMessage) -> bool { - !(message.role == ChatRole::Reasoning && message.is_collapsed) && message.is_streaming -} - -pub fn message_render_role(message: &ChatMessage) -> BubbleRole { - match message.role { - ChatRole::User => BubbleRole::User, - ChatRole::Assistant => BubbleRole::Assistant, - ChatRole::System | ChatRole::Reasoning => BubbleRole::System, - } -} - -pub fn message_render_mode_for( - overrides: &std::collections::HashMap, - index: usize, - message: &ChatMessage, -) -> RenderMode { - overrides.get(&index).copied().unwrap_or_else(|| { - streaming_render_mode( - bubble_streaming_for_message(message), - message_render_role(message), - ) - }) -} - -pub fn reasoning_summary_header(message: &ChatMessage) -> String { - let elapsed = SystemTime::now() - .duration_since(message.timestamp) - .unwrap_or_default() - .as_secs() - .max(1); - let chars = message.text.chars().count(); - format!("Reasoning · {elapsed}s / {chars} chars") -} - -pub fn message_mode_label(state: &VoiceChatOverlayState) -> String { - if !matches!(state.conversation_state, ConversationModeState::Inactive) { - "Moshi".to_string() - } else if state.auto_send_enabled { - "AI".to_string() - } else { - "Manual".to_string() - } -} - -pub fn message_role_label(role: ChatRole) -> &'static str { - match role { - ChatRole::User => "You", - ChatRole::Assistant => "Assistant", - ChatRole::System => "System", - ChatRole::Reasoning => "Reasoning", - } -} - -pub fn message_metadata(message: &ChatMessage) -> String { - let when: DateTime = message.timestamp.into(); - let time = when.format("%H:%M").to_string(); - let role = message_role_label(message.role); - let pending = message.is_pending_followup.then_some("Pending follow-up"); - if let (Some(mode), Some(pending)) = (message.mode.as_ref(), pending) { - format!("{role} · {time} · {mode} · {pending}") - } else if let Some(mode) = message.mode.as_ref() { - format!("{role} · {time} · {mode}") - } else if let Some(pending) = pending { - format!("{role} · {time} · {pending}") - } else { - format!("{role} · {time}") - } -} - -pub unsafe fn agent_max_width(state: &VoiceChatOverlayState) -> f64 { - let width = state - .agent_scroll_view - .map(|p| { - let scroll_view = p as Id; - let content_view: Id = msg_send![scroll_view, contentView]; - if content_view.is_null() { - let frame: CGRect = msg_send![scroll_view, frame]; - frame.size.width - } else { - let bounds: CGRect = msg_send![content_view, bounds]; - bounds.size.width - } - }) - .or_else(|| { - state.window.map(|p| { - let window = p as Id; - let frame: CGRect = msg_send![window, frame]; - (frame.size.width - 32.0).max(240.0) - }) - }) - .unwrap_or(390.0); - - width.max(240.0) -} - -pub fn update_cached_stack_height( - old_height: f64, - new_height: f64, - cached_height: Option, -) -> Option { - cached_height.map(|height| (height - old_height.max(0.0) + new_height.max(0.0)).max(1.0)) -} - -unsafe fn view_height(view: Id) -> f64 { - unsafe { - let frame: CGRect = msg_send![view, frame]; - frame.size.height.max(0.0) - } -} - -unsafe fn measure_agent_stack_height(container: Id) -> f64 { - unsafe { - // Ensure arrangedSubviews frames are up-to-date before measuring. - let _: () = msg_send![container, setNeedsLayout: true]; - let _: () = msg_send![container, layoutSubtreeIfNeeded]; - - // Prefer AppKit's own fittingSize; it accounts for stack layout and avoids cases where - // arrangedSubviews frames lag behind until interaction (which would make scroll "dead"). - let fitting: CGSize = msg_send![container, fittingSize]; - let mut total_h = fitting.height.max(1.0); - - // Defensive: also sum arranged subview heights + spacing, and take the max. - let arranged: Id = msg_send![container, arrangedSubviews]; - if !arranged.is_null() { - let count: usize = msg_send![arranged, count]; - let spacing: f64 = msg_send![container, spacing]; - let mut sum_h = 0.0; - for i in 0..count { - let v: Id = msg_send![arranged, objectAtIndex: i]; - if v.is_null() { - continue; - } - let frame: CGRect = msg_send![v, frame]; - sum_h += frame.size.height.max(0.0); - if i + 1 < count { - sum_h += spacing; - } - } - total_h = total_h.max(sum_h.max(1.0)); - } - - total_h - } -} - -pub unsafe fn sync_agent_document_view_size( - state: &mut VoiceChatOverlayState, - max_width: f64, - streaming_height_delta: Option<(f64, f64)>, -) { - let Some(container_ptr) = state.agent_container else { - return; - }; - let container = container_ptr as Id; - - let total_h = if let Some((old_height, new_height)) = streaming_height_delta - && let Some(cached) = - update_cached_stack_height(old_height, new_height, state.cached_agent_stack_height) - { - state.cached_agent_stack_height = Some(cached); - cached - } else { - let measured = unsafe { measure_agent_stack_height(container) }; - state.cached_agent_stack_height = Some(measured); - measured - }; - - unsafe { - let bottom_inset = if let Some(scroll_view_ptr) = state.agent_scroll_view { - let scroll_view = scroll_view_ptr as Id; - let insets: NSEdgeInsets = msg_send![scroll_view, contentInsets]; - insets.bottom - } else { - 0.0 - }; - let document_height = agent_document_height_for_bottom_clearance( - total_h, - bottom_inset, - AGENT_SCROLL_BOTTOM_CLEARANCE, - ); - let _: () = msg_send![container, setFrameSize: CGSize::new(max_width, document_height)]; - let _: () = msg_send![container, setNeedsLayout: true]; - let _: () = msg_send![container, layoutSubtreeIfNeeded]; - - // Ensure the scroll view updates its clip view after the document view changes size. - if let Some(scroll_view_ptr) = state.agent_scroll_view { - let scroll_view = scroll_view_ptr as Id; - let _: () = msg_send![scroll_view, tile]; - let content_view: Id = msg_send![scroll_view, contentView]; - if !content_view.is_null() { - let _: () = msg_send![scroll_view, reflectScrolledClipView: content_view]; - } - } - } -} - -pub fn try_update_message_view_in_place(state: &mut VoiceChatOverlayState, index: usize) -> bool { - unsafe { - // If the view list doesn't match messages, a full rebuild is safer. - if state.agent_bubble_views.len() != state.messages.len() { - return false; - } - if index >= state.messages.len() { - return false; - } - - let message = &state.messages[index]; - let (bubble_ptr, label_ptr) = state.agent_bubble_views[index]; - let render_mode = message_render_mode_for(&state.message_render_modes, index, message); - - let container = bubble_ptr as Id; - let label = label_ptr as Id; - let old_container_height = view_height(container); - let bubble_text = bubble_text_for_message(message); - let bubble_is_streaming = bubble_streaming_for_message(message); - let bubble_role = message_render_role(message); - update_bubble_text_with_render_mode( - label, - &bubble_text, - bubble_role, - bubble_is_streaming, - message.is_error, - render_mode, - ); - let display_text = display_text_for_message(message); - resize_bubble_container_for_text(container, label, &display_text); - let new_container_height = view_height(container); - let last_streaming_bubble_delta = - if bubble_is_streaming && index + 1 == state.agent_bubble_views.len() { - Some((old_container_height, new_container_height)) - } else { - None - }; - let max_width = agent_max_width(state); - sync_agent_document_view_size(state, max_width, last_streaming_bubble_delta); - - update_latest_pill_visibility(state); - - if should_autoscroll(state.scroll_pinned) && index + 1 == state.agent_bubble_views.len() { - scroll_agent_to_bottom(Some(container as usize), state.agent_scroll_view); - } - true - } -} - -pub fn toggle_message_render_mode_impl(index: usize) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(message) = state.messages.get(index) else { - debug!("Render toggle pointed outside message list"); - return; - }; - if !matches!( - message.role, - ChatRole::Assistant | ChatRole::System | ChatRole::Reasoning - ) { - debug!("Render toggle ignored for role {:?}", message.role); - return; - } - - let current = message_render_mode_for(&state.message_render_modes, index, message); - let next = next_render_mode(current); - state.message_render_modes.insert(index, next); - - if !try_update_message_view_in_place(&mut state, index) { - update_chat_view_with_state(&mut state, false); - } -} - -pub fn release_agent_bubble_click_recognizers(state: &mut VoiceChatOverlayState) { - let recognizers = std::mem::take(&mut state.agent_bubble_click_recognizers); - for (recognizer_ptr, _) in recognizers { - unsafe { - crate::ui_helpers::release_object(recognizer_ptr as Id); - } - } -} - -pub unsafe fn attach_message_bubble_click_recognizer( - state: &mut VoiceChatOverlayState, - bubble: Id, - message_index: usize, -) { - unsafe { - let Some(target_ptr) = state.action_handler else { - return; - }; - let Some(ns_click_gesture) = Class::get("NSClickGestureRecognizer") else { - return; - }; - let recognizer: Id = msg_send![ns_click_gesture, alloc]; - if recognizer.is_null() { - return; - } - let recognizer: Id = msg_send![ - recognizer, - initWithTarget: target_ptr as Id - action: sel!(onAssistantBubbleClick:) - ]; - if recognizer.is_null() { - return; - } - let _: () = msg_send![recognizer, setNumberOfClicksRequired: 1_isize]; - let _: () = msg_send![bubble, addGestureRecognizer: recognizer]; - state - .agent_bubble_click_recognizers - .push((recognizer as usize, message_index)); - } -} - -pub fn finalize_user_message_impl(text: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_agent_tab_visible(&mut state); - let idx = match state.active_user_stream_index.take() { - Some(idx) if is_valid_stream_message(&state, idx, ChatRole::User) => idx, - _ => reuse_or_push_finalized_user_message(&mut state, text), - }; - let preserve_pending_followup = state - .messages - .get(idx) - .map(|msg| msg.is_pending_followup) - .unwrap_or(false); - if let Some(msg) = state.messages.get_mut(idx) { - msg.text = text.to_string(); - msg.is_streaming = false; - msg.is_error = false; - msg.is_pending_followup = preserve_pending_followup; - } - update_chat_view_with_state(&mut state, true); -} - -/// Resolve the target index for a finalized user message when no live streaming -/// index is available. -/// -/// Reuses the last visible bubble only when it is itself a `User` message whose -/// trimmed text already equals the final transcript; otherwise pushes a fresh -/// empty user bubble. The assistive controller finalizes the same first utterance -/// twice (full-rewrite render, then again right before send), and a cold overlay -/// has no `active_user_stream_index` to reuse — without this guard the second -/// finalize pushed a second identical bubble, rendering the first message twice -/// (fix/assistive-double-send). Reuse never reaches back across an -/// assistant/system/reasoning message: a closed turn stays closed, so a genuinely -/// new utterance with identical text still gets its own bubble. -fn reuse_or_push_finalized_user_message(state: &mut VoiceChatOverlayState, text: &str) -> usize { - if let Some(last) = state.messages.last() - && last.role == ChatRole::User - && last.text.trim() == text.trim() - { - return state.messages.len() - 1; - } - - let mode = message_mode_label(state); - state.messages.push(ChatMessage { - role: ChatRole::User, - text: String::new(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - state.messages.len() - 1 -} - -pub fn finalize_user_message_state_only_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(idx) = state - .active_user_stream_index - .take() - .filter(|idx| is_valid_stream_message(&state, *idx, ChatRole::User)) - else { - return; - }; - if let Some(last) = state.messages.get_mut(idx) { - last.is_streaming = false; - last.is_error = false; - } - update_chat_view_with_state(&mut state, true); -} - -pub fn finalize_assistant_message_impl(text: &str, is_error: bool) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_agent_tab_visible(&mut state); - let idx = if let Some(idx) = state.active_assistant_stream_index.take() { - if is_valid_stream_message(&state, idx, ChatRole::Assistant) { - idx - } else { - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::Assistant, - text: String::new(), - is_streaming: false, - is_collapsed: false, - is_error, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - state.messages.len() - 1 - } - } else { - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::Assistant, - text: String::new(), - is_streaming: false, - is_collapsed: false, - is_error, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - state.messages.len() - 1 - }; - if let Some(msg) = state.messages.get_mut(idx) { - msg.text = text.to_string(); - msg.is_streaming = false; - msg.is_error = is_error; - } - clear_agent_thinking_state(&mut state); - state.is_sending = false; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); -} - -pub fn finalize_assistant_message_state_only_impl(is_error: bool) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(idx) = state - .active_assistant_stream_index - .take() - .filter(|idx| is_valid_stream_message(&state, *idx, ChatRole::Assistant)) - else { - return; - }; - if let Some(last) = state.messages.get_mut(idx) { - last.is_streaming = false; - last.is_error = is_error; - } - clear_agent_thinking_state(&mut state); - state.is_sending = false; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); -} - -pub fn clear_agent_thinking_state(state: &mut VoiceChatOverlayState) { - if state.is_agent_thinking { - state.is_agent_thinking = false; - if state.status_base_text == "Thinking…" { - state.status_base_text = "Ready".to_string(); - } - state.status_text = compose_runtime_status_text( - &state.status_base_text, - state.is_agent_degraded, - state.runtime_degraded_reason.as_deref(), - ); - state.status_kind = - status_kind_for_runtime(&state.status_base_text, state.is_agent_degraded); - apply_status_pill(state); - let _ = crate::tray::update_tray_status(state.status_kind.to_tray()); - } -} - -pub fn ensure_agent_tab_visible(state: &mut VoiceChatOverlayState) { - unsafe { - // Make sure the window is actually visible, even if it was previously hidden/closed. - if let Some(window_ptr) = state.window { - let window = window_ptr as Id; - window_set_alpha(window, 1.0); - window_show(window); - } - - // Force Agent tab for any live/assistive messaging. - if state.active_tab != Tab::Agent { - update_active_tab_locked(state, Tab::Agent); - } - } -} - -pub fn handoff_transcript_to_chat_impl(transcript: &str) { - let callback = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_agent_tab_visible(&mut state); - state.active_user_stream_index = None; - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::User, - text: transcript.to_string(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - state.is_sending = true; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - - let handler_guard = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - handler_guard.clone() - }; - - if let Some(handler) = callback { - handler(transcript.to_string()); - } else { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.is_sending = false; - update_send_button_with_state(&mut state); - warn!("No voice-chat send callback set; transcript handoff kept as user message"); - } -} - -pub fn active_stream_index_mut( - state: &mut VoiceChatOverlayState, - role: ChatRole, -) -> Option<&mut Option> { - match role { - ChatRole::User => Some(&mut state.active_user_stream_index), - ChatRole::Assistant => Some(&mut state.active_assistant_stream_index), - ChatRole::Reasoning => Some(&mut state.active_reasoning_stream_index), - ChatRole::System => None, - } -} - -pub fn active_stream_index(state: &VoiceChatOverlayState, role: ChatRole) -> Option { - match role { - ChatRole::User => state.active_user_stream_index, - ChatRole::Assistant => state.active_assistant_stream_index, - ChatRole::Reasoning => state.active_reasoning_stream_index, - ChatRole::System => None, - } -} - -pub fn is_valid_stream_message(state: &VoiceChatOverlayState, idx: usize, role: ChatRole) -> bool { - state - .messages - .get(idx) - .map(|msg| msg.role == role && msg.is_streaming) - .unwrap_or(false) -} - -pub fn should_capture_pending_followup(state: &VoiceChatOverlayState) -> bool { - state.is_sending || state.is_agent_thinking -} - -pub fn pending_followup_index(state: &VoiceChatOverlayState) -> Option { - state - .messages - .iter() - .rposition(|msg| msg.role == ChatRole::User && msg.is_pending_followup) -} - -pub fn get_or_create_pending_followup_index(state: &mut VoiceChatOverlayState) -> usize { - if let Some(idx) = pending_followup_index(state) { - if let Some(msg) = state.messages.get_mut(idx) { - msg.is_streaming = true; - msg.is_collapsed = false; - } - state.active_user_stream_index = Some(idx); - return idx; - } - - let mode = message_mode_label(state); - state.messages.push(ChatMessage { - role: ChatRole::User, - text: String::new(), - is_streaming: true, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: true, - }); - let idx = state.messages.len() - 1; - state.active_user_stream_index = Some(idx); - idx -} - -pub fn get_or_create_streaming_message_index( - state: &mut VoiceChatOverlayState, - role: ChatRole, -) -> usize { - if let Some(idx) = active_stream_index(state, role) - && is_valid_stream_message(state, idx, role) - { - return idx; - } - - let mode = message_mode_label(state); - state.messages.push(ChatMessage { - role, - text: String::new(), - is_streaming: true, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - let idx = state.messages.len() - 1; - if let Some(active_idx) = active_stream_index_mut(state, role) { - *active_idx = Some(idx); - } - idx -} - -pub fn update_chat_view_with_state(state: &mut VoiceChatOverlayState, scroll_to_bottom: bool) { - unsafe { - let Some(container_ptr) = state.agent_container else { - return; - }; - let container = container_ptr as Id; - release_agent_bubble_click_recognizers(state); - stack_view_clear(container); - state.agent_bubble_views.clear(); - let message_len = state.messages.len(); - state - .message_render_modes - .retain(|index, _| *index < message_len); - state.cached_agent_stack_height = None; - - // Size bubbles to the current visible content width (supports resizable overlay). - let max_width = agent_max_width(state); - let zoom = state.zoom_level; - let base_font = ui_tokens::BODY_FONT_SIZE; - - // Empty state CTA when no messages exist yet. - if state.messages.is_empty() { - let empty_label = create_label(LabelConfig { - frame: CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(max_width, 60.0)), - text: "Start a conversation\nPress your configured hotkey to record \u{2022} Type to send" - .to_string(), - font_size: base_font * zoom, - text_color: color_secondary_label(), - ..Default::default() - }); - let _: () = msg_send![empty_label, setAlignment: 1_isize]; // NSTextAlignmentCenter - stack_view_add(container, empty_label); - } - - let mut measure_cache = std::mem::take(&mut state.bubble_measure_cache); - let measure_cache_ptr: *mut BubbleMeasureCache = &mut measure_cache; - - let mut last_bubble: Option = None; - let message_count = state.messages.len(); - for index in 0..message_count { - let message = &state.messages[index]; - let message_role = message.role; - let message_text = bubble_text_for_message(message); - let message_is_streaming = bubble_streaming_for_message(message); - let message_is_error = message.is_error; - let message_is_pending_followup = message.is_pending_followup; - let message_render_mode = - message_render_mode_for(&state.message_render_modes, index, message); - let message_metadata = if message_role == ChatRole::Reasoning && message.is_collapsed { - None - } else { - Some(message_metadata(message)) - }; - let role = message_render_role(message); - let (bubble, text_label) = create_bubble_view(BubbleConfig { - text: message_text, - role, - max_width, - font_size: base_font * zoom, - is_streaming: message_is_streaming, - is_error: message_is_error, - render_mode: Some(message_render_mode), - metadata: message_metadata, - message_index: Some(index), - copy_action_target: state.action_handler.map(|p| p as Id), - measure_cache: Some(measure_cache_ptr), - }); - if matches!(message_role, ChatRole::Assistant | ChatRole::Reasoning) { - attach_message_bubble_click_recognizer(state, bubble, index); - } - stack_view_add(container, bubble); - last_bubble = Some(bubble); - state - .agent_bubble_views - .push((bubble as usize, text_label as usize)); - - // Add commit/discard action bar for draft user messages - if message_role == ChatRole::User - && index == message_count - 1 - && (message_is_pending_followup || (!state.auto_send_enabled && !state.is_sending)) - { - let action_bar = if message_is_pending_followup { - create_pending_followup_action_bar(state.action_handler) - } else { - create_commit_action_bar(state.action_handler) - }; - stack_view_add(container, action_bar); - } - } - state.bubble_measure_cache = measure_cache; - - // Ensure the document view size matches its arranged subviews; otherwise scrolling can - // be disabled and long messages will just "grow" out of view. - sync_agent_document_view_size(state, max_width, None); - - update_latest_pill_visibility(state); - - if scroll_to_bottom && should_autoscroll(state.scroll_pinned) { - if let Some(bubble) = last_bubble { - scroll_agent_to_bottom(Some(bubble as usize), state.agent_scroll_view); - } else if let Some(scroll_view_ptr) = state.agent_scroll_view { - scroll_agent_to_bottom(None, Some(scroll_view_ptr)); - } - } - } -} diff --git a/app/ui/voice_chat/api/mod.rs b/app/ui/voice_chat/api/mod.rs deleted file mode 100644 index cfe76b4f..00000000 --- a/app/ui/voice_chat/api/mod.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Public API and internal helpers for voice chat overlay -//! -//! Contains all the public functions for controlling the overlay and -//! internal helper functions for state updates. - -mod drawer; -mod export; -mod lifecycle; -mod messages; -mod send; -mod status; -mod tabs; -#[cfg(test)] -mod tests; - -pub use drawer::*; -pub use export::*; -pub use lifecycle::*; -pub use messages::*; -pub use send::*; -pub use status::*; -pub use tabs::*; - -use super::handlers::clear_search_field; -use super::state::{ - ChatMessage, ChatRole, ConversationModeState, DrawerEntry, DrawerEntrySource, OVERLAY_STATE, - SEND_CALLBACK, Tab, TranscriptionMode, VoiceChatOverlayState, -}; -use crate::ui::shared::status::{UiStatus, status_from_detail}; -use crate::ui_helpers::{ - BubbleConfig, BubbleMeasureCache, BubbleRole, LabelConfig, NSEdgeInsets, RenderMode, - add_subview, apply_tafla_surface, button_set_action, button_style, chat_header_layout, - color_label, color_rgba, color_secondary_label, copy_to_clipboard, create_bubble_view, - create_button, create_label, get_text_field_string, get_text_view_string, - layout_region_frame_for_view, next_render_mode, ns_string, open_file_in_editor, - resize_bubble_container_for_text, set_button_symbol, set_text_field_string, - set_text_view_string, set_tooltip, stack_view_add, stack_view_clear, streaming_render_mode, - ui_colors, ui_tokens, update_bubble_text_with_render_mode, window_set_alpha, window_show, -}; -use chrono::{DateTime, Local}; -use codescribe_core::agent::{Thread, ThreadIndex, ThreadStore}; -use codescribe_core::attachment::Attachment; -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use dispatch::Queue; -use objc::runtime::{Class, Object, Sel}; -use objc::{msg_send, sel, sel_impl}; -use std::collections::HashSet; -use std::path::PathBuf; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tracing::{debug, info, warn}; -// Type alias for Objective-C object pointers -pub use crate::ui_helpers::Id; - -// ═══════════════════════════════════════════════════════════ -// Public API -// ═══════════════════════════════════════════════════════════ - -/// Run `f` on the main queue only when `OVERLAY_STATE` is not already held. -/// -/// DEADLOCK PREVENTION: AppKit can spin a nested run-loop while an OUTER frame -/// on this same main thread still holds `OVERLAY_STATE` (see module docs). A -/// queued block that then calls `.lock()` self-deadlocks the non-reentrant -/// Mutex — this froze the whole app (sample: main thread 100% in -/// __psynch_mutexwait inside _dispatch_main_queue_drain). Instead of blocking, -/// probe with `try_lock`; if busy, requeue after a short delay and let the -/// holder finish its AppKit call. -pub fn run_when_overlay_unlocked(f: F) -where - F: FnOnce() + Send + 'static, -{ - match OVERLAY_STATE.try_lock() { - Ok(guard) => { - drop(guard); - f(); - } - Err(std::sync::TryLockError::Poisoned(err)) => { - drop(err); - f(); - } - Err(std::sync::TryLockError::WouldBlock) => { - Queue::main().exec_after(Duration::from_millis(5), move || { - run_when_overlay_unlocked(f); - }); - } - } -} diff --git a/app/ui/voice_chat/api/send.rs b/app/ui/voice_chat/api/send.rs deleted file mode 100644 index 5b9f290f..00000000 --- a/app/ui/voice_chat/api/send.rs +++ /dev/null @@ -1,1499 +0,0 @@ -//! Draft sending, send callback wiring, attachments and input sizing. - -use super::*; - -const PROMPT_HISTORY_LIMIT: usize = 100; - -/// Dispatch a payload through the registered chat send callback without mutating bubbles. -/// -/// Returns `true` when a callback was found and invoked. -pub fn dispatch_voice_chat_send(payload: &str) -> bool { - let payload = payload.trim(); - if payload.is_empty() { - return false; - } - let handler = { - let guard = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - guard.clone() - }; - if let Some(handler) = handler { - handler(payload.to_string()); - true - } else { - warn!("No voice-chat send callback set; cannot dispatch runtime send request"); - false - } -} - -/// Submit the current draft (manual send) -pub fn send_voice_chat_draft() { - Queue::main().exec_async(|| { - send_draft_message_impl(); - }); -} - -/// Submit the currently streaming user bubble without reading or clearing the input field. -pub fn commit_last_user_message() { - Queue::main().exec_async(|| { - run_when_overlay_unlocked(|| { - finalize_user_message_state_only_impl(); - commit_last_user_message_impl(); - }); - }); -} - -/// Explicitly submit the visible pending follow-up captured while the agent was busy. -pub fn commit_pending_followup_message() { - Queue::main().exec_async(|| { - run_when_overlay_unlocked(commit_pending_followup_message_impl); - }); -} - -/// Move the visible pending follow-up back into the editable draft without sending. -pub fn edit_pending_followup_message() { - Queue::main().exec_async(|| { - run_when_overlay_unlocked(edit_pending_followup_message_impl); - }); -} - -/// Set the send callback invoked when the user submits a message -pub fn set_voice_chat_send_callback( - callback: Option, -) { - let mut handler = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *handler = callback.clone(); - drop(handler); - - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.auto_send_enabled = callback.is_some(); - update_send_button_with_state(&mut state); -} - -/// Toggle loading state for sending -pub fn set_voice_chat_sending(is_sending: bool) { - Queue::main().exec_async(move || { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.is_sending = is_sending; - update_send_button_with_state(&mut state); - }); -} - -/// Clear all chat messages and reset input state -pub fn clear_voice_chat_text() { - Queue::main().exec_async(|| { - clear_voice_chat_text_impl(); - }); -} - -/// Start a fresh Agent thread by rotating backend runtime first, then clearing UI state. -pub fn start_new_thread_impl() { - update_voice_chat_status_impl("Starting new thread..."); - - std::thread::spawn(|| { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(error) => { - let reason = format!("Unable to initialize async runtime for New thread: {error}"); - Queue::main().exec_async(move || { - warn!("{reason}"); - update_voice_chat_status_impl("Thread reset failed"); - add_voice_chat_error_message(&reason); - }); - return; - } - }; - - let reset_result = rt.block_on(crate::controller::reset_agent_runtime_for_new_thread()); - Queue::main().exec_async(move || match reset_result { - Ok(generation) => { - clear_voice_chat_text_impl(); - update_voice_chat_status_impl("Ready"); - info!("New thread started (generation={generation})"); - } - Err(error) => { - warn!("Failed to start new thread: {error}"); - update_voice_chat_status_impl("Thread reset failed"); - add_voice_chat_error_message(&format!( - "Unable to start a new thread. Continuing the current thread. {error}" - )); - } - }); - }); -} - -pub fn clear_voice_chat_text_impl() { - let btn_ptr = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.messages.clear(); - state.active_user_stream_index = None; - state.active_assistant_stream_index = None; - state.active_reasoning_stream_index = None; - state.manual_draft.clear(); - state.prompt_history_cursor = None; - state.is_sending = false; - state.attachments.clear(); - state.attachments_last_sent = None; - render_attachment_chips_locked(&mut state); - let btn_ptr = state.agent_attach_button; - - if let Some(input_view) = state.agent_input_text_view { - unsafe { set_text_view_string(input_view as Id, "") }; - } else if let Some(input_field) = state.agent_input_field { - unsafe { set_text_field_string(input_field as Id, "") }; - } - resize_agent_input_locked(&mut state); - - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - btn_ptr - }; - update_attach_button_ui(btn_ptr, 0, Vec::new()); -} - -/// Send the draft message (called from handlers) -pub fn send_draft_message_impl() { - let callback = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let draft = if let Some(text_view) = state.agent_input_text_view { - unsafe { get_text_view_string(text_view as Id) } - } else if let Some(input_field) = state.agent_input_field { - unsafe { get_text_field_string(input_field as Id) } - } else { - return; - }; - let draft = draft.trim().to_string(); - if draft.is_empty() { - return; - } - - // Check handler BEFORE mutating state to avoid phantom messages - // when no connector is registered. - let handler_guard = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - let Some(handler) = handler_guard.clone() else { - // No send handler — leave state untouched so draft remains in input. - return; - }; - drop(handler_guard); - - let attachments_to_send = attachment_should_include_locked(&state); - // Commit fingerprint under same lock to prevent race with concurrent attachment changes. - if let Some((fp, _, _)) = attachments_to_send.as_ref() { - state.attachments_last_sent = Some(*fp); - } - if let Some((_fingerprint, _paths, summary)) = attachments_to_send.as_ref() { - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::System, - text: format!("Attachments (sent once): {}", summary), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - } - push_prompt_history_locked(&mut state, &draft); - follow_latest_after_manual_send_locked(&mut state); - - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::User, - text: draft.clone(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - state.manual_draft.clear(); - state.prompt_history_cursor = None; - state.is_sending = true; - if let Some(text_view) = state.agent_input_text_view { - unsafe { set_text_view_string(text_view as Id, "") }; - } else if let Some(input_field) = state.agent_input_field { - unsafe { set_text_field_string(input_field as Id, "") }; - } - resize_agent_input_locked(&mut state); - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - (handler, draft, attachments_to_send) - }; - - let (handler, draft, attachments_to_send) = callback; - if let Some((_fingerprint, paths, _summary)) = attachments_to_send { - std::thread::spawn(move || { - let block = build_attachments_block(&paths); - let payload = if block.is_empty() { - draft - } else { - format!("{draft}\n\n{block}") - }; - // The send callback uses `tokio::spawn`, which requires a runtime handle. - // Calling it from an arbitrary background thread can panic (release builds abort). - Queue::main().exec_async(move || handler(payload)); - }); - } else { - handler(draft); - } -} - -pub fn commit_last_user_message_impl() { - let callback = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(last_message) = state.messages.last() else { - return; - }; - if last_message.role != ChatRole::User { - return; - } - let text = last_message.text.clone(); - - // Check handler BEFORE mutating state to avoid phantom messages. - let handler_guard = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - let Some(handler) = handler_guard.clone() else { - return; - }; - drop(handler_guard); - - let attachments_to_send = attachment_should_include_locked(&state); - // Commit fingerprint under same lock to prevent race with concurrent attachment changes. - if let Some((fp, _, _)) = attachments_to_send.as_ref() { - state.attachments_last_sent = Some(*fp); - } - if let Some((_fingerprint, _paths, summary)) = attachments_to_send.as_ref() { - let mode = message_mode_label(&state); - state.messages.push(ChatMessage { - role: ChatRole::System, - text: format!("Attachments (sent once): {}", summary), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some(mode), - is_pending_followup: false, - }); - } - state.is_sending = true; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - (handler, text, attachments_to_send) - }; - - let (handler, text, attachments_to_send) = callback; - if let Some((_fingerprint, paths, _summary)) = attachments_to_send { - std::thread::spawn(move || { - let block = build_attachments_block(&paths); - let payload = if block.is_empty() { - text - } else { - format!("{text}\n\n{block}") - }; - Queue::main().exec_async(move || handler(payload)); - }); - } else { - handler(text); - } -} - -pub fn commit_pending_followup_message_impl() { - let callback = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(idx) = pending_followup_index(&state) else { - return; - }; - let text = state.messages[idx].text.trim().to_string(); - if text.is_empty() { - return; - } - - let handler_guard = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - let Some(handler) = handler_guard.clone() else { - return; - }; - drop(handler_guard); - - if let Some(message) = state.messages.get_mut(idx) { - message.text = text.clone(); - message.is_pending_followup = false; - message.is_streaming = false; - message.is_error = false; - } - if state.active_user_stream_index == Some(idx) { - state.active_user_stream_index = None; - } - state.is_sending = true; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - (handler, text) - }; - - let (handler, text) = callback; - handler(text); -} - -pub fn recall_previous_prompt() -> bool { - recall_prompt_history(true) -} - -pub fn recall_next_prompt() -> bool { - recall_prompt_history(false) -} - -fn recall_prompt_history(previous: bool) -> bool { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(next_cursor) = next_prompt_history_cursor( - state.prompt_history.len(), - state.prompt_history_cursor, - previous, - ) else { - return false; - }; - - let text = if next_cursor == state.prompt_history.len() { - state.prompt_history_cursor = None; - String::new() - } else { - state.prompt_history_cursor = Some(next_cursor); - state.prompt_history[next_cursor].clone() - }; - state.manual_draft = text.clone(); - - if let Some(text_view) = state.agent_input_text_view { - unsafe { set_text_view_string(text_view as Id, &text) }; - } else if let Some(input_field) = state.agent_input_field { - unsafe { set_text_field_string(input_field as Id, &text) }; - } - resize_agent_input_locked(&mut state); - update_send_button_with_state(&mut state); - true -} - -pub fn push_prompt_history_locked(state: &mut VoiceChatOverlayState, prompt: &str) { - let prompt = prompt.trim(); - if prompt.is_empty() { - return; - } - if state - .prompt_history - .last() - .is_some_and(|last| last == prompt) - { - return; - } - state.prompt_history.push(prompt.to_string()); - if state.prompt_history.len() > PROMPT_HISTORY_LIMIT { - let overflow = state.prompt_history.len() - PROMPT_HISTORY_LIMIT; - state.prompt_history.drain(0..overflow); - } -} - -pub fn follow_latest_after_manual_send_locked(state: &mut VoiceChatOverlayState) { - state.scroll_pinned = true; -} - -pub fn next_prompt_history_cursor( - len: usize, - cursor: Option, - previous: bool, -) -> Option { - if len == 0 { - return None; - } - if previous { - Some(match cursor { - Some(0) => 0, - Some(idx) if idx <= len => idx.saturating_sub(1), - _ => len - 1, - }) - } else { - match cursor { - None => None, - Some(idx) if idx + 1 < len => Some(idx + 1), - Some(_) => Some(len), - } - } -} - -pub fn edit_pending_followup_message_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let Some(idx) = pending_followup_index(&state) else { - return; - }; - let text = state.messages.remove(idx).text.trim().to_string(); - state.manual_draft = text.clone(); - state.prompt_history_cursor = None; - state.active_user_stream_index = state - .active_user_stream_index - .and_then(|active| match active.cmp(&idx) { - std::cmp::Ordering::Less => Some(active), - std::cmp::Ordering::Equal => None, - std::cmp::Ordering::Greater => Some(active - 1), - }); - if let Some(text_view) = state.agent_input_text_view { - unsafe { set_text_view_string(text_view as Id, &text) }; - } else if let Some(input_field) = state.agent_input_field { - unsafe { set_text_field_string(input_field as Id, &text) }; - } - resize_agent_input_locked(&mut state); - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); -} - -pub fn discard_last_message_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.messages.pop().is_some() { - if let Some(idx) = state.active_user_stream_index - && idx >= state.messages.len() - { - state.active_user_stream_index = None; - } - if let Some(idx) = state.active_assistant_stream_index - && idx >= state.messages.len() - { - state.active_assistant_stream_index = None; - } - if let Some(idx) = state.active_reasoning_stream_index - && idx >= state.messages.len() - { - state.active_reasoning_stream_index = None; - } - update_chat_view_with_state(&mut state, true); - } -} - -pub fn update_send_button_with_state(state: &mut VoiceChatOverlayState) { - unsafe { - if let Some(button_ptr) = state.agent_send_button { - let btn = button_ptr as Id; - let enabled = !state.is_sending && state.auto_send_enabled; - let _: () = msg_send![btn, setEnabled: enabled]; - let symbol = if state.is_sending { - "ellipsis.circle" - } else { - "arrow.up.circle.fill" - }; - let has_symbol = crate::ui_helpers::set_button_symbol(btn, symbol); - let title = if has_symbol { - "" - } else if state.is_sending { - "…" - } else { - "Send" - }; - let _: () = msg_send![btn, setTitle: ns_string(title)]; - } - } -} - -pub fn update_attach_button_ui(btn_ptr: Option, count: usize, mut names: Vec) { - unsafe { - let Some(btn_ptr) = btn_ptr else { - return; - }; - let btn = btn_ptr as Id; - let has_symbol = crate::ui_helpers::set_button_symbol(btn, "paperclip"); - let title = if count == 0 { - if has_symbol { - String::new() - } else { - "Attach".to_string() - } - } else if has_symbol { - String::new() - } else { - count.to_string() - }; - let _: () = msg_send![btn, setTitle: ns_string(&title)]; - - if count == 0 { - crate::ui_helpers::set_tooltip(btn, "Attach files (assistant context)"); - } else { - names.sort(); - let shown: Vec = names.into_iter().take(3).collect(); - let suffix = if count > 3 { "…" } else { "" }; - let tip = format!("Attached: {}{}", shown.join(", "), suffix); - let _: () = msg_send![btn, setToolTip: ns_string(&tip)]; - } - } -} - -pub fn attachment_should_include_locked( - state: &VoiceChatOverlayState, -) -> Option<(u64, Vec, String)> { - if state.attachments.is_empty() { - return None; - } - let fingerprint = attachment_fingerprint(&state.attachments); - if state.attachments_last_sent == Some(fingerprint) { - return None; - } - let summary = attachment_summary(&state.attachments); - let paths = Attachment::paths(&state.attachments); - Some((fingerprint, paths, summary)) -} - -pub fn attachment_summary(attachments: &[Attachment]) -> String { - let mut names: Vec = attachments.iter().map(|a| a.display_name.clone()).collect(); - names.sort(); - if names.len() <= 3 { - names.join(", ") - } else { - format!("{}, … (+{})", names[..3].join(", "), names.len() - 3) - } -} - -pub fn attachment_fingerprint(attachments: &[Attachment]) -> u64 { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - for a in attachments { - a.path.hash(&mut hasher); - if let Ok(meta) = std::fs::metadata(&a.path) { - meta.len().hash(&mut hasher); - meta.modified().ok().hash(&mut hasher); - } - } - hasher.finish() -} - -// ═══════════════════════════════════════════════════════════ -// Attachment Chip Strip -// ═══════════════════════════════════════════════════════════ - -pub const CHIP_STRIP_HEIGHT: f64 = 36.0; - -/// Remove an attachment by index, re-render chips, update button. -pub fn remove_attachment_at(index: usize) { - let (btn_ptr, count, names) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if index < state.attachments.len() { - state.attachments.remove(index); - state.attachments_last_sent = None; - } - let names: Vec = state - .attachments - .iter() - .map(|a| a.display_name.clone()) - .collect(); - render_attachment_chips_locked(&mut state); - (state.agent_attach_button, state.attachments.len(), names) - }; - update_attach_button_ui(btn_ptr, count, names); -} - -/// Rebuild chip strip views from current attachments. -/// Must be called from the main thread. -pub fn render_attachment_chips(state: &mut VoiceChatOverlayState) { - render_attachment_chips_locked(state); -} - -/// Rebuild the chip strip while `OVERLAY_STATE` is held by the caller. -/// -/// P2.11 (run-loop safety): this is intentionally safe to call under the lock. -/// It performs ONLY synchronous AppKit view mutations via `msg_send!` -/// (clear arranged subviews, `create_chip_view`, `addArrangedSubview:`, frame -/// reads/writes) plus `resize_agent_input_locked`. None of these — nor -/// `create_chip_view` — spin a nested run-loop (no `runModal`/`NSAlert`/ -/// `beginSheet`/`nextEventMatchingMask`), so AppKit cannot re-enter a queued -/// block that would try to re-`.lock()` the non-reentrant `OVERLAY_STATE` and -/// self-deadlock. Verifier: grep for run-loop primitives in this file returns 0. -/// If a future change adds a modal/sheet to the chip path, render OUTSIDE the -/// lock (snapshot state, drop guard, then render) instead. -pub fn render_attachment_chips_locked(state: &mut VoiceChatOverlayState) { - unsafe { - let Some(strip_ptr) = state.attachment_chip_strip else { - return; - }; - let strip = strip_ptr as Id; - - // Get the stack view (document view of the scroll view). - let stack: Id = msg_send![strip, documentView]; - if stack.is_null() { - return; - } - - // Clear existing chips. - let arranged: Id = msg_send![stack, arrangedSubviews]; - let old_count: usize = msg_send![arranged, count]; - for i in (0..old_count).rev() { - let view: Id = msg_send![arranged, objectAtIndex: i]; - let _: () = msg_send![stack, removeArrangedSubview: view]; - let _: () = msg_send![view, removeFromSuperview]; - } - - let has_attachments = !state.attachments.is_empty(); - let handler_ptr = match state.action_handler { - Some(p) => p as Id, - None => std::ptr::null_mut::(), - }; - - if has_attachments { - let mut total_width = 0.0f64; - for (idx, attachment) in state.attachments.iter().enumerate() { - let chip = create_chip_view(idx, &attachment.chip_label(20), handler_ptr); - let _: () = msg_send![stack, addArrangedSubview: chip]; - let chip_frame: CGRect = msg_send![chip, frame]; - total_width += chip_frame.size.width + 6.0; - } - // Size the stack view to fit all chips (enables horizontal scrolling). - let strip_frame: CGRect = msg_send![strip, frame]; - let stack_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(total_width.max(strip_frame.size.width), CHIP_STRIP_HEIGHT), - ); - let _: () = msg_send![stack, setFrame: stack_frame]; - } - - // Show/hide the strip. - let currently_hidden: bool = msg_send![strip, isHidden]; - if currently_hidden == has_attachments { - let _: () = msg_send![strip, setHidden: !has_attachments]; - } - } - // Reflow layout to account for chip strip height change. - resize_agent_input_locked(state); -} - -/// Create a single chip view: a styled button with the attachment name. -/// -/// # Safety -/// Requires main thread. -pub unsafe fn create_chip_view(index: usize, label: &str, handler: Id) -> Id { - let ns_button = Class::get("NSButton").unwrap(); - - // Measure text width (approximate: 7px per char + padding). - let text_width = (label.chars().count() as f64 * 7.0).clamp(40.0, 180.0); - let chip_width = text_width + 24.0; // padding - - let frame = CGRect::new(&CGPoint::new(0.0, 4.0), &CGSize::new(chip_width, 28.0)); - let btn: Id = msg_send![ns_button, alloc]; - let btn: Id = msg_send![btn, initWithFrame: frame]; - let _: () = msg_send![btn, setTitle: ns_string(label)]; - // NSBezelStyleInline = 15 (compact rounded) - let _: () = msg_send![btn, setBezelStyle: 15i64]; - let _: () = msg_send![btn, setControlSize: 1i64]; // NSControlSizeSmall - let ns_font = Class::get("NSFont").unwrap(); - let font: Id = msg_send![ns_font, systemFontOfSize: 11.0f64]; - let _: () = msg_send![btn, setFont: font]; - let _: () = msg_send![btn, setTag: index as isize]; - if !handler.is_null() { - let _: () = msg_send![btn, setTarget: handler]; - let _: () = msg_send![btn, setAction: sel!(onChipClick:)]; - } - let _: () = msg_send![btn, setTranslatesAutoresizingMaskIntoConstraints: false]; - - // Height constraint. - let height_anchor: Id = msg_send![btn, heightAnchor]; - let constraint: Id = msg_send![height_anchor, constraintEqualToConstant: 28.0f64]; - let _: () = msg_send![constraint, setActive: true]; - - btn -} - -pub fn build_attachments_block(paths: &[std::path::PathBuf]) -> String { - use std::io::Read; - use std::path::{Path, PathBuf}; - use std::process::Command; - - const MAX_TOTAL_CHARS: usize = 120_000; - const MAX_FILE_CHARS: usize = 40_000; - const MAX_FILE_BYTES: usize = 512 * 1024; // cap IO; we only inline a prefix anyway - const PDF_MIN_TEXT_CHARS: usize = 100; - - fn env_usize(key: &str, default_value: usize) -> usize { - std::env::var(key) - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|v| *v > 0) - .unwrap_or(default_value) - } - - fn env_bool(key: &str, default_value: bool) -> bool { - std::env::var(key) - .ok() - .map(|v| { - !matches!( - v.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "no" | "off" - ) - }) - .unwrap_or(default_value) - } - - fn tool_env_key(name: &str) -> String { - format!( - "CODESCRIBE_TOOL_{}", - name.to_ascii_uppercase().replace('-', "_") - ) - } - - fn tool_path(name: &str) -> Option { - if let Ok(v) = std::env::var(tool_env_key(name)) { - let v = v.trim(); - if !v.is_empty() { - let p = PathBuf::from(v); - if p.is_file() { - return Some(p); - } - } - } - - // macOS GUI apps often have a minimal PATH that doesn't include Homebrew. - // Prefer common install locations so this works when launched from Finder. - for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - let p = Path::new(dir).join(name); - if p.is_file() { - return Some(p); - } - } - - None - } - - fn tool_command(name: &str) -> Command { - if let Some(p) = tool_path(name) { - Command::new(p) - } else { - Command::new(name) - } - } - - fn command_exists(name: &str) -> bool { - tool_path(name).is_some() - } - - fn run_command_stdout(mut cmd: Command) -> Result, String> { - let output = cmd.output().map_err(|e| e.to_string())?; - if output.status.success() { - Ok(output.stdout) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(if stderr.is_empty() { - "command failed".to_string() - } else { - stderr - }) - } - } - - fn extract_pdf_text_pdftotext(path: &std::path::Path, pages: usize) -> Result { - let pages = pages.max(1); - let mut cmd = tool_command("pdftotext"); - cmd.args(["-f", "1", "-l", &pages.to_string()]) - .arg(path) - .arg("-"); - let stdout = run_command_stdout(cmd)?; - Ok(String::from_utf8_lossy(&stdout).into_owned()) - } - - fn temp_dir(prefix: &str) -> Result { - let pid = std::process::id(); - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| e.to_string())? - .as_millis(); - let dir = std::env::temp_dir().join(format!("{prefix}_{pid}_{stamp}")); - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - Ok(dir) - } - - fn extract_pdf_text_ocrmypdf( - path: &std::path::Path, - pages: usize, - language: &str, - ) -> Result { - let pages = pages.max(1); - let dir = temp_dir("codescribe_pdf_ocr")?; - let output_pdf = dir.join("ocr.pdf"); - - // NOTE: we OCR only first N pages to keep latency acceptable. - // `ocrmypdf` doesn't support "first N pages" directly, so we pre-split via `pdftk`/`qpdf` - // would add more deps; instead we run ocrmypdf on whole doc only when asked. - // Here: best-effort; if you want faster, disable OCR or raise pages and accept cost. - // - // We still respect `pages` when extracting with pdftotext after OCR. - let _ = pages; - let mut cmd = tool_command("ocrmypdf"); - cmd.args([ - "--language", - language, - "--force-ocr", - "--clean", - "--deskew", - "--remove-background", - ]) - .arg(path) - .arg(&output_pdf); - let _ = run_command_stdout(cmd)?; - - let text = extract_pdf_text_pdftotext(&output_pdf, pages).unwrap_or_default(); - let _ = std::fs::remove_dir_all(&dir); - Ok(text) - } - - fn extract_pdf_text_tesseract( - path: &std::path::Path, - pages: usize, - language: &str, - ) -> Result { - let pages = pages.max(1); - let dir = temp_dir("codescribe_pdf_pages")?; - let prefix = dir.join("page"); - - if command_exists("pdftoppm") { - let mut cmd = tool_command("pdftoppm"); - cmd.args(["-png", "-r", "300", "-f", "1", "-l", &pages.to_string()]) - .arg(path) - .arg(&prefix); - let _ = run_command_stdout(cmd)?; - } else if command_exists("convert") { - // ImageMagick fallback: convert first N pages (best-effort). - let output = dir.join("page-%03d.png"); - let mut cmd = tool_command("convert"); - cmd.args(["-density", "300"]) - .arg(path) - .args(["-quality", "100"]) - .arg(output); - let _ = run_command_stdout(cmd)?; - } else { - let _ = std::fs::remove_dir_all(&dir); - return Err("Missing pdftoppm/convert for PDF->image".to_string()); - } - - let mut images: Vec = std::fs::read_dir(&dir) - .map_err(|e| e.to_string())? - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| { - p.extension() - .and_then(|e| e.to_str()) - .map(|e| matches!(e.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg")) - .unwrap_or(false) - }) - .collect(); - images.sort(); - - if images.is_empty() { - let _ = std::fs::remove_dir_all(&dir); - return Err("PDF->image produced no pages".to_string()); - } - - if !command_exists("tesseract") { - let _ = std::fs::remove_dir_all(&dir); - return Err("Missing tesseract".to_string()); - } - - let mut out = String::new(); - for (i, img) in images.iter().take(pages).enumerate() { - let mut cmd = tool_command("tesseract"); - cmd.arg(img).arg("stdout").args(["-l", language]); - let stdout = run_command_stdout(cmd)?; - let text = String::from_utf8_lossy(&stdout); - out.push_str(&format!("=== PAGE {} ===\n", i + 1)); - out.push_str(text.trim()); - out.push_str("\n\n"); - } - - let _ = std::fs::remove_dir_all(&dir); - Ok(out) - } - - fn extract_pdf_text_auto( - path: &std::path::Path, - pages: usize, - ) -> Result<(String, &'static str), String> { - let text = extract_pdf_text_pdftotext(path, pages).unwrap_or_default(); - if text.trim().chars().count() >= PDF_MIN_TEXT_CHARS { - return Ok((text, "pdftotext")); - } - - let ocr_enabled = env_bool("CODESCRIBE_ATTACH_PDF_OCR", true); - if !ocr_enabled { - return Ok((text, "pdftotext (minimal text)")); - } - - fn default_ocr_lang() -> String { - if let Some(v) = std::env::var("CODESCRIBE_ATTACH_PDF_OCR_LANG") - .ok() - .filter(|v| !v.trim().is_empty()) - { - return v; - } - - // Prefer Polish+English when available, otherwise fall back to English. - let mut has_pol = false; - let mut has_eng = false; - if command_exists("tesseract") { - let mut cmd = tool_command("tesseract"); - cmd.arg("--list-langs"); - if let Ok(stdout) = run_command_stdout(cmd) { - for line in String::from_utf8_lossy(&stdout).lines() { - let l = line.trim(); - if l == "pol" { - has_pol = true; - } - if l == "eng" { - has_eng = true; - } - } - } - } - - if has_pol && has_eng { - "pol+eng".to_string() - } else if has_eng { - "eng".to_string() - } else if has_pol { - "pol".to_string() - } else { - "eng".to_string() - } - } - - let ocr_lang = default_ocr_lang(); - - if command_exists("ocrmypdf") - && command_exists("pdftotext") - && let Ok(ocr_text) = extract_pdf_text_ocrmypdf(path, pages, &ocr_lang) - && ocr_text.trim().chars().count() >= PDF_MIN_TEXT_CHARS - { - return Ok((ocr_text, "ocrmypdf+pdftotext")); - } - - if let Ok(ocr_text) = extract_pdf_text_tesseract(path, pages, &ocr_lang) - && ocr_text.trim().chars().count() >= PDF_MIN_TEXT_CHARS - { - return Ok((ocr_text, "tesseract")); - } - - Ok((text, "pdftotext (minimal text)")) - } - - fn extract_pdf_quicklook_ocr( - path: &std::path::Path, - language: &str, - ) -> Result<(String, &'static str), String> { - if !command_exists("qlmanage") { - return Err("Missing qlmanage".to_string()); - } - if !command_exists("tesseract") { - return Err("Missing tesseract".to_string()); - } - - let dir = temp_dir("codescribe_pdf_ql")?; - let mut cmd = tool_command("qlmanage"); - cmd.args(["-t", "-s", "1400", "-o"]).arg(&dir).arg(path); - let _ = run_command_stdout(cmd)?; - - let mut images: Vec = std::fs::read_dir(&dir) - .map_err(|e| e.to_string())? - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| { - p.extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("png")) - .unwrap_or(false) - }) - .collect(); - images.sort(); - - let Some(img) = images.first() else { - let _ = std::fs::remove_dir_all(&dir); - return Err("QuickLook produced no PNG".to_string()); - }; - - let mut cmd = tool_command("tesseract"); - cmd.arg(img).arg("stdout").args(["-l", language]); - let stdout = run_command_stdout(cmd)?; - let text = String::from_utf8_lossy(&stdout).into_owned(); - let _ = std::fs::remove_dir_all(&dir); - Ok((text, "quicklook+tesseract")) - } - - let mut out = String::new(); - out.push_str("ATTACHMENTS (file context)\n"); - - let mut total_chars = out.chars().count(); - let mut image_paths: Vec = Vec::new(); - let pdf_pages = env_usize("CODESCRIBE_ATTACH_PDF_PAGES", 3); - for path in paths { - if total_chars >= MAX_TOTAL_CHARS { - break; - } - - let display = path.to_string_lossy(); - out.push_str("\n---\n"); - out.push_str(&format!("FILE: {display}\n")); - - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()) - .unwrap_or_default(); - if ext == "pdf" { - let ocr_lang = std::env::var("CODESCRIBE_ATTACH_PDF_OCR_LANG") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(|| "pol+eng".to_string()); - - let extracted = if command_exists("pdftotext") { - extract_pdf_text_auto(path, pdf_pages).ok() - } else { - // Offline-friendly fallback: render first page via QuickLook and OCR it. - extract_pdf_quicklook_ocr(path, &ocr_lang).ok() - }; - - match extracted { - Some((mut text, method)) => { - // Cap per-file and total. - if text.chars().count() > MAX_FILE_CHARS { - text = text.chars().take(MAX_FILE_CHARS).collect(); - text.push_str("\n… (truncated)\n"); - } - - let remaining = MAX_TOTAL_CHARS.saturating_sub(total_chars); - if remaining == 0 { - break; - } - let mut snippet: String = text.chars().take(remaining).collect(); - if snippet.len() < text.len() { - snippet.push_str("\n… (truncated)\n"); - } - - let pages_hint = if method == "quicklook+tesseract" { - "1".to_string() - } else { - pdf_pages.to_string() - }; - out.push_str(&format!( - "(PDF text extracted via {method}; pages: {pages_hint})\n" - )); - out.push_str("```text\n"); - out.push_str(&snippet); - if !snippet.ends_with('\n') { - out.push('\n'); - } - out.push_str("```\n"); - } - None => { - out.push_str( - "(PDF: couldn't extract text right now. Quick fix: copy 1-2 pages as text or attach a screenshot (vision).\n\ -Tools (optional): `brew install poppler ocrmypdf tesseract-lang`.)\n", - ); - } - } - - total_chars = out.chars().count(); - continue; - } - - let Ok(mut f) = std::fs::File::open(path) else { - out.push_str("(failed to open)\n"); - continue; - }; - - let mut buf = Vec::new(); - let _ = (&mut f).take(MAX_FILE_BYTES as u64).read_to_end(&mut buf); - - let Ok(mut s) = String::from_utf8(buf) else { - let is_image = matches!( - ext.as_str(), - "png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" - ); - if is_image { - out.push_str("(image detected; will be sent as vision input)\n"); - image_paths.push(display.to_string()); - } else { - out.push_str("(skipped: not UTF-8 text)\n"); - } - continue; - }; - - // Normalize + cap per-file. - if s.chars().count() > MAX_FILE_CHARS { - s = s.chars().take(MAX_FILE_CHARS).collect(); - s.push_str("\n… (truncated)\n"); - } - - // Cap total. - let remaining = MAX_TOTAL_CHARS.saturating_sub(total_chars); - if remaining == 0 { - break; - } - let mut snippet: String = s.chars().take(remaining).collect(); - if snippet.len() < s.len() { - snippet.push_str("\n… (truncated)\n"); - } - - out.push_str("```text\n"); - out.push_str(&snippet); - if !snippet.ends_with('\n') { - out.push('\n'); - } - out.push_str("```\n"); - - total_chars = out.chars().count(); - } - - if !image_paths.is_empty() && total_chars < MAX_TOTAL_CHARS { - out.push_str("\n---\n"); - out.push_str("ATTACHMENTS (image paths)\n"); - image_paths.sort(); - for p in image_paths { - if total_chars >= MAX_TOTAL_CHARS { - break; - } - out.push_str("- "); - out.push_str(&p); - out.push('\n'); - total_chars = out.chars().count(); - } - } - - out -} - -/// Resize the Agent input bar based on current draft text. -/// -/// Keeps it compact by default, and grows it when the user types/pastes longer messages. -pub fn resize_agent_input_to_draft() { - let Ok(mut state) = OVERLAY_STATE.try_lock() else { - return; - }; - resize_agent_input_locked(&mut state); -} - -pub fn resize_agent_input_locked(state: &mut VoiceChatOverlayState) { - unsafe { - let ( - Some(window_ptr), - Some(bar_ptr), - Some(scroll_ptr), - Some(text_view_ptr), - Some(attach_ptr), - Some(send_ptr), - ) = ( - state.window, - state.agent_input_bar, - state.agent_input_scroll_view, - state.agent_input_text_view, - state.agent_attach_button, - state.agent_send_button, - ) - else { - return; - }; - - let input_bar = bar_ptr as Id; - let input_scroll = scroll_ptr as Id; - let text_view = text_view_ptr as Id; - let attach_btn = attach_ptr as Id; - let send_btn = send_ptr as Id; - - let (content_width, content_height) = - if let Some(container_ptr) = state.split_content_container { - let container = container_ptr as Id; - let _: () = msg_send![container, setNeedsLayout: true]; - let _: () = msg_send![container, layoutSubtreeIfNeeded]; - let bounds: CGRect = msg_send![container, bounds]; - (bounds.size.width, bounds.size.height) - } else { - let window = window_ptr as Id; - let window_frame: CGRect = msg_send![window, frame]; - (window_frame.size.width, window_frame.size.height) - }; - - let text = get_text_view_string(text_view); - - // Keep the input compact by default (single-line-ish), then grow smoothly up to a cap. - let min_h = 44.0; - let max_h = 180.0; - let desired_h = if text.trim().is_empty() { - min_h - } else { - // Prefer actual layout height from NSTextView; fall back to a simple heuristic. - let mut measured: Option = None; - let layout: Id = msg_send![text_view, layoutManager]; - let container: Id = msg_send![text_view, textContainer]; - if !layout.is_null() && !container.is_null() { - let _: () = msg_send![layout, ensureLayoutForTextContainer: container]; - let used: CGRect = msg_send![layout, usedRectForTextContainer: container]; - let text_h = used.size.height.max(0.0); - measured = Some((text_h + 20.0).clamp(min_h, max_h)); - } - - measured.unwrap_or_else(|| { - let hard_lines = (text.matches('\n').count() + 1).max(1); - // Heuristic for wrapped lines: assume ~52 chars per visual line at this width. - let wrapped_lines = text.chars().count().div_ceil(52).max(1); - let visual_lines = hard_lines.max(wrapped_lines); - let line_h = 18.0; - (min_h + (visual_lines.saturating_sub(1) as f64) * line_h).clamp(min_h, max_h) - }) - }; - - let pad = ui_tokens::EDGE_PADDING_TIGHT; - let gap = ui_tokens::CONTENT_GAP; - let input_gap = (gap * 0.5).max(4.0); - let footer_inset = ui_tokens::FOOTER_INSET; - let bar_width = (content_width - pad * 2.0).max(120.0); - let current_bar: CGRect = msg_send![input_bar, frame]; - let height_same = (current_bar.size.height - desired_h).abs() < 0.5; - let width_same = (current_bar.size.width - bar_width).abs() < 0.5; - // Check if the agent scroll bottom inset needs updating (e.g. chip strip - // toggled). The scroll frame is full-bleed (messages pass beneath the - // input bar), so the inset — not the frame origin — carries the layout. - // We compare against the actual inset rather than visibility flags, - // because setHidden may have already been called (by - // render_attachment_chips_locked) before we get here — making the - // visibility flag look "stable" even though the inset hasn't been - // adjusted yet. - let scroll_needs_reflow = if let Some(agent_scroll_ptr) = state.agent_scroll_view { - let agent_scroll = agent_scroll_ptr as Id; - let current_insets: NSEdgeInsets = msg_send![agent_scroll, contentInsets]; - let strip_extra = if let Some(strip_ptr) = state.attachment_chip_strip { - let strip = strip_ptr as Id; - let strip_visible: bool = !msg_send![strip, isHidden]; - if strip_visible { - CHIP_STRIP_HEIGHT + input_gap - } else { - 0.0 - } - } else { - 0.0 - }; - let expected_bottom = footer_inset + desired_h + input_gap + strip_extra; - (current_insets.bottom - expected_bottom).abs() > 0.5 - } else { - false - }; - if height_same && width_same && !scroll_needs_reflow { - return; - } - - // Resize input bar (anchored to bottom). - let new_bar_frame = CGRect::new( - &CGPoint::new(pad, footer_inset), - &CGSize::new(bar_width, desired_h), - ); - let _: () = msg_send![input_bar, setFrame: new_bar_frame]; - - // Resize the input row (attach left, text center, send right). - let row_layout = crate::ui_helpers::chat_input_row_layout(bar_width, desired_h); - let text_area_frame = CGRect::new( - &CGPoint::new(row_layout.text_x, row_layout.text_y), - &CGSize::new(row_layout.text_width, row_layout.text_height), - ); - let _: () = msg_send![input_scroll, setFrame: text_area_frame]; - - // Recenter buttons vertically. - let attach_frame = CGRect::new( - &CGPoint::new(row_layout.attach_x, row_layout.attach_y), - &CGSize::new(row_layout.button_width, row_layout.button_height), - ); - let _: () = msg_send![attach_btn, setFrame: attach_frame]; - let send_frame = CGRect::new( - &CGPoint::new(row_layout.send_x, row_layout.send_y), - &CGSize::new(row_layout.button_width, row_layout.button_height), - ); - let _: () = msg_send![send_btn, setFrame: send_frame]; - - // Position chip strip above input bar and resize agent scroll view. - let chip_strip_extra = if let Some(strip_ptr) = state.attachment_chip_strip { - let strip = strip_ptr as Id; - let strip_visible: bool = !msg_send![strip, isHidden]; - if strip_visible { - let strip_y = footer_inset + desired_h + input_gap; - let strip_frame = CGRect::new( - &CGPoint::new(pad, strip_y), - &CGSize::new(bar_width, CHIP_STRIP_HEIGHT), - ); - let _: () = msg_send![strip, setFrame: strip_frame]; - CHIP_STRIP_HEIGHT + input_gap - } else { - 0.0 - } - } else { - 0.0 - }; - - if let Some(latest_ptr) = state.agent_latest_button { - let latest_button = latest_ptr as Id; - let latest_frame = super::super::latest_pill_frame( - content_width, - footer_inset, - desired_h, - input_gap, - chip_strip_extra, - ); - let _: () = msg_send![latest_button, setFrame: latest_frame]; - } - - // Keep the agent scroll full-bleed and track input + chips with the - // bottom content inset, so messages scroll beneath the input bar while - // the last bubble stays clear of it. - if let Some(agent_scroll_ptr) = state.agent_scroll_view { - let agent_scroll = agent_scroll_ptr as Id; - let inset_bottom = footer_inset + desired_h + input_gap + chip_strip_extra; - let top = content_height - gap; - let new_agent_frame = CGRect::new( - &CGPoint::new(pad, 0.0), - &CGSize::new((content_width - pad * 2.0).max(0.0), top.max(0.0)), - ); - let _: () = msg_send![agent_scroll, setFrame: new_agent_frame]; - let mut agent_insets: NSEdgeInsets = msg_send![agent_scroll, contentInsets]; - let old_bottom_inset = agent_insets.bottom; - agent_insets.bottom = inset_bottom; - let _: () = msg_send![agent_scroll, setContentInsets: agent_insets]; - - if let Some(container_ptr) = state.agent_container { - let container = container_ptr as Id; - let container_frame: CGRect = msg_send![container, frame]; - let stack_height = state.cached_agent_stack_height.unwrap_or_else(|| { - agent_stack_height_from_document(container_frame.size.height, old_bottom_inset) - }); - state.cached_agent_stack_height = Some(stack_height); - // IMPORTANT: do NOT clamp the document view height to the visible clip height. - // That disables scrolling and makes long agent replies unscrollable. - let new_size = CGSize::new( - new_agent_frame.size.width, - agent_document_height_for_bottom_clearance( - stack_height, - inset_bottom, - AGENT_SCROLL_BOTTOM_CLEARANCE, - ), - ); - let _: () = msg_send![container, setFrameSize: new_size]; - } - - if state.scroll_pinned { - let last_bubble = state - .agent_bubble_views - .last() - .map(|(bubble_ptr, _)| *bubble_ptr); - scroll_agent_to_bottom(last_bubble, Some(agent_scroll_ptr)); - } else { - state.scroll_pinned = is_scrolled_to_bottom(agent_scroll); - } - update_latest_pill_visibility(state); - } - } -} - -pub fn create_commit_action_bar(action_handler: Option) -> Id { - unsafe { - let ns_view = - Class::get("NSView").expect("NSView class should exist for commit action bar"); - let max_width = 390.0; - let bar_height = 28.0; - - let bar: Id = msg_send![ns_view, alloc]; - let bar_frame = core_graphics::geometry::CGRect::new( - &CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new(max_width, bar_height), - ); - let bar: Id = msg_send![bar, initWithFrame: bar_frame]; - - let btn_width = 64.0; - let btn_height = 22.0; - let gap = 8.0; - let right_edge = max_width - 8.0; - - // Discard button (left of Commit) - let discard_x = right_edge - btn_width * 2.0 - gap; - let discard_btn = crate::ui_helpers::create_button( - core_graphics::geometry::CGRect::new( - &CGPoint::new(discard_x, 3.0), - &core_graphics::geometry::CGSize::new(btn_width, btn_height), - ), - "Discard", - crate::ui_helpers::button_style::SMALL_SQUARE, - ); - if let Some(handler) = action_handler { - crate::ui_helpers::button_set_action( - discard_btn, - handler as Id, - sel!(onDiscardMessage:), - ); - } - let _: () = msg_send![bar, addSubview: discard_btn]; - - // Commit button (rightmost) - let commit_x = right_edge - btn_width; - let commit_btn = crate::ui_helpers::create_button( - core_graphics::geometry::CGRect::new( - &CGPoint::new(commit_x, 3.0), - &core_graphics::geometry::CGSize::new(btn_width, btn_height), - ), - "Commit", - crate::ui_helpers::button_style::ROUNDED, - ); - if let Some(handler) = action_handler { - crate::ui_helpers::button_set_action(commit_btn, handler as Id, sel!(onCommitMessage:)); - } - let _: () = msg_send![bar, addSubview: commit_btn]; - - bar - } -} - -pub fn create_pending_followup_action_bar(action_handler: Option) -> Id { - unsafe { - let ns_view = Class::get("NSView") - .expect("NSView class should exist for pending follow-up action bar"); - let max_width = 390.0; - let bar_height = 28.0; - - let bar: Id = msg_send![ns_view, alloc]; - let bar_frame = core_graphics::geometry::CGRect::new( - &CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new(max_width, bar_height), - ); - let bar: Id = msg_send![bar, initWithFrame: bar_frame]; - - let btn_width = 72.0; - let btn_height = 22.0; - let gap = 8.0; - let right_edge = max_width - 8.0; - - let edit_x = right_edge - btn_width * 2.0 - gap; - let edit_btn = crate::ui_helpers::create_button( - core_graphics::geometry::CGRect::new( - &CGPoint::new(edit_x, 3.0), - &core_graphics::geometry::CGSize::new(btn_width, btn_height), - ), - "Edit", - crate::ui_helpers::button_style::SMALL_SQUARE, - ); - if let Some(handler) = action_handler { - crate::ui_helpers::button_set_action( - edit_btn, - handler as Id, - sel!(onEditPendingFollowup:), - ); - } - let _: () = msg_send![bar, addSubview: edit_btn]; - - let send_x = right_edge - btn_width; - let send_btn = crate::ui_helpers::create_button( - core_graphics::geometry::CGRect::new( - &CGPoint::new(send_x, 3.0), - &core_graphics::geometry::CGSize::new(btn_width, btn_height), - ), - "Send now", - crate::ui_helpers::button_style::ROUNDED, - ); - if let Some(handler) = action_handler { - crate::ui_helpers::button_set_action( - send_btn, - handler as Id, - sel!(onCommitPendingFollowup:), - ); - } - let _: () = msg_send![bar, addSubview: send_btn]; - - bar - } -} diff --git a/app/ui/voice_chat/api/status.rs b/app/ui/voice_chat/api/status.rs deleted file mode 100644 index 87c4316a..00000000 --- a/app/ui/voice_chat/api/status.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! Status pill, runtime-degraded banner, context summary and conversation state. - -use super::*; - -/// Update the status text in the overlay -pub fn update_voice_chat_status(status: &str) { - let status_owned = status.to_string(); - Queue::main().exec_async(move || { - run_when_overlay_unlocked(move || update_voice_chat_status_impl(&status_owned)); - }); -} - -/// Persist runtime degradation state used by status and tooltip rendering. -pub fn set_voice_chat_runtime_degraded(is_degraded: bool, reason: Option<&str>) { - let reason_owned = reason.map(str::to_string); - Queue::main().exec_async(move || { - set_voice_chat_runtime_degraded_impl(is_degraded, reason_owned); - }); -} - -/// Update the context summary shown in the overlay header (best-effort debug aid). -/// -/// Examples: -/// - `ctx: Visual Studio Code | sel: 123` -/// - `ctx: Finder | sel: 0` -pub fn update_voice_chat_context_summary(summary: &str) { - let summary_owned = summary.to_string(); - Queue::main().exec_async(move || { - update_voice_chat_context_summary_impl(&summary_owned); - }); -} - -/// Set the target app name to re-activate for paste actions. -/// -/// This is best-effort and primarily used to paste assistant output back into -/// the app where the user was working before interacting with the overlay. -pub fn set_voice_chat_target_app(app_name: Option) { - Queue::main().exec_async(move || { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.last_target_app = app_name; - }); -} - -/// Update the conversation mode state (Moshi full-duplex indicators) -pub fn update_conversation_state(new_state: ConversationModeState) { - Queue::main().exec_async(move || { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.conversation_state = new_state; - // Update status text based on conversation state - let status = match new_state { - ConversationModeState::Inactive => "Ready", - ConversationModeState::Listening => "Listening...", - ConversationModeState::UserSpeaking => "You're speaking...", - ConversationModeState::Processing => "Processing...", - ConversationModeState::AssistantSpeaking => "Moshi responding...", - ConversationModeState::Interrupted => "Interrupted", - }; - drop(state); - update_voice_chat_status_impl(status); - }); -} - -/// Check if conversation mode is active -pub fn is_conversation_active() -> bool { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - !matches!(state.conversation_state, ConversationModeState::Inactive) -} - -pub fn update_voice_chat_status_impl(status: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - apply_voice_chat_status(&mut state, status); -} - -/// Apply status text + pill + tray update on an already-held `OVERLAY_STATE` -/// guard. Must NOT lock `OVERLAY_STATE`: callers that already hold the guard -/// (e.g. `apply_agent_thinking`) rely on this to avoid a re-entrant deadlock on -/// the non-reentrant `std::sync::Mutex`. -pub fn apply_voice_chat_status(state: &mut VoiceChatOverlayState, status: &str) { - let trimmed = status.trim(); - state.status_base_text = if trimmed.is_empty() { - "Ready".to_string() - } else { - trimmed.to_string() - }; - state.status_text = compose_runtime_status_text( - &state.status_base_text, - state.is_agent_degraded, - state.runtime_degraded_reason.as_deref(), - ); - let next_kind = status_kind_for_runtime(&state.status_base_text, state.is_agent_degraded); - state.status_kind = next_kind; - apply_status_pill(state); - let _ = crate::tray::update_tray_status(next_kind.to_tray()); -} - -pub fn set_voice_chat_runtime_degraded_impl(is_degraded: bool, reason: Option) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.runtime_degraded = is_degraded; - state.is_agent_degraded = is_degraded; - state.runtime_degraded_reason = if is_degraded { - reason.and_then(|text| { - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) - } else { - None - }; - state.status_text = compose_runtime_status_text( - &state.status_base_text, - state.is_agent_degraded, - state.runtime_degraded_reason.as_deref(), - ); - state.status_kind = status_kind_for_runtime(&state.status_base_text, state.is_agent_degraded); - apply_status_pill(&state); - let _ = crate::tray::update_tray_status(state.status_kind.to_tray()); -} - -pub fn status_kind_for_runtime(base_status: &str, runtime_degraded: bool) -> UiStatus { - if runtime_degraded { - UiStatus::Error - } else { - status_from_detail(base_status) - } -} - -pub fn compose_runtime_status_text( - base_status: &str, - runtime_degraded: bool, - reason: Option<&str>, -) -> String { - let base = base_status.trim(); - if !runtime_degraded { - if base.is_empty() { - "Ready".to_string() - } else { - base.to_string() - } - } else if base.is_empty() { - "Runtime degraded (legacy fallback active)".to_string() - } else if let Some(reason) = reason.filter(|text| !text.trim().is_empty()) { - format!("{base} • Runtime degraded ({})", reason.trim()) - } else { - format!("{base} • Runtime degraded (legacy fallback active)") - } -} - -pub fn update_voice_chat_context_summary_impl(summary: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.context_text = summary.to_string(); - apply_status_pill(&state); -} - -pub fn apply_status_pill(state: &VoiceChatOverlayState) { - unsafe { - let Some(pill_ptr) = state.status_pill else { - return; - }; - let palette = state.status_kind.palette(); - let pill = pill_ptr as Id; - let layer: Id = msg_send![pill, layer]; - if !layer.is_null() { - let bg = ui_colors::panel_bg(); - let cg: Id = msg_send![bg, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg]; - let border = ui_colors::header_border(); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![layer, setBorderColor: cg_border]; - let _: () = msg_send![layer, setBorderWidth: ui_tokens::SURFACE_BORDER_WIDTH]; - } - - if let Some(label_ptr) = state.status_pill_label { - let label = label_ptr as Id; - let _: () = msg_send![label, setStringValue: ns_string(state.status_kind.label())]; - let text_color = ui_colors::bubble_meta_text(); - let _: () = msg_send![label, setTextColor: text_color]; - } - - if let Some(dot_ptr) = state.status_pill_dot { - let dot = dot_ptr as Id; - let dot_layer: Id = msg_send![dot, layer]; - if !dot_layer.is_null() { - let dot_color = color_rgba(palette.dot.0, palette.dot.1, palette.dot.2, 0.92); - let cg: Id = msg_send![dot_color, CGColor]; - let _: () = msg_send![dot_layer, setBackgroundColor: cg]; - // Pulse animation for Listening state - let pulse_key = ns_string("pulse"); - if state.status_kind == UiStatus::Listening { - let existing: Id = msg_send![dot_layer, animationForKey: pulse_key]; - if existing.is_null() { - let ca_anim = Class::get("CABasicAnimation").unwrap(); - let anim: Id = - msg_send![ca_anim, animationWithKeyPath: ns_string("opacity")]; - let from_val: Id = - msg_send![Class::get("NSNumber").unwrap(), numberWithFloat: 0.95f32]; - let to_val: Id = - msg_send![Class::get("NSNumber").unwrap(), numberWithFloat: 0.55f32]; - let _: () = msg_send![anim, setFromValue: from_val]; - let _: () = msg_send![anim, setToValue: to_val]; - let _: () = msg_send![anim, setDuration: 1.0f64]; - let _: () = msg_send![anim, setAutoreverses: true]; - let _: () = msg_send![anim, setRepeatCount: f32::INFINITY]; - let _: () = msg_send![dot_layer, addAnimation: anim forKey: pulse_key]; - } - } else { - let _: () = msg_send![dot_layer, removeAnimationForKey: pulse_key]; - } - } - } - - let detail = if state.context_text.trim().is_empty() { - format!("Status: {}", state.status_text) - } else { - format!( - "Status: {} • {}", - state.status_text, - state.context_text.trim() - ) - }; - set_tooltip(pill, &detail); - } -} diff --git a/app/ui/voice_chat/api/tabs.rs b/app/ui/voice_chat/api/tabs.rs deleted file mode 100644 index 5f32bce6..00000000 --- a/app/ui/voice_chat/api/tabs.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! Tab switching and header/footer control reflow. - -use super::*; - -/// Switch to Agent tab programmatically -pub fn show_agent_tab() { - Queue::main().exec_async(|| { - // If the overlay isn't created yet, defer tab selection until build completes. - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.window.is_none() { - state.pending_tab = Some(Tab::Agent); - state.active_tab = Tab::Agent; - return; - } - drop(state); - update_active_tab_impl(Tab::Agent); - }); -} - -/// Switch to Drawer tab programmatically -pub fn show_drawer_tab() { - Queue::main().exec_async(|| { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.window.is_none() { - state.pending_tab = Some(Tab::Drawer); - state.active_tab = Tab::Drawer; - return; - } - drop(state); - update_active_tab_impl(Tab::Drawer); - }); -} - -// ═══════════════════════════════════════════════════════════ -// Internal Implementation Functions -// ═══════════════════════════════════════════════════════════ - -pub fn update_active_tab_impl(tab: Tab) { - // DEADLOCK PREVENTION: extract widget pointers under lock, drop lock before - // AppKit calls (setCollapsed can animate and spin a nested run-loop). - let ( - _prev_tab, - tab_drawer_btn, - tab_agent_btn, - tab_settings_btn, - sidebar_item, - content_item, - split_vc, - drawer_sv, - search_f, - search_l, - fav_btn, - drawer_edge, - agent_sv, - agent_bar, - agent_attach, - agent_send, - latest_button, - title_label, - window_ptr, - agent_input_tv, - need_chat_update, - ) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let prev = state.active_tab; - state.active_tab = tab; - ( - prev, // kept to compute need_chat_update below - state.tab_drawer_button, - state.tab_agent_button, - state.tab_settings_button, - state.split_sidebar_item, - state.split_content_item, - state.split_view_controller, - state.drawer_scroll_view, - state.search_field, - state.search_label, - state.favorites_button, - state.drawer_edge_effect, - state.agent_scroll_view, - state.agent_input_bar, - state.agent_attach_button, - state.agent_send_button, - state.agent_latest_button, - state.title_label, - state.window, - state.agent_input_text_view, - tab == Tab::Agent && prev != Tab::Agent, - ) - }; // Lock dropped. - - let show_drawer = tab == Tab::Drawer; - let show_agent = tab == Tab::Agent; - - unsafe { - if let Some(b) = tab_drawer_btn { - crate::ui_helpers::set_tab_button_active(b as Id, show_drawer); - } - if let Some(b) = tab_agent_btn { - crate::ui_helpers::set_tab_button_active(b as Id, show_agent); - } - if let Some(b) = tab_settings_btn { - crate::ui_helpers::set_tab_button_active(b as Id, false); - } - if let Some(p) = sidebar_item { - let _: () = msg_send![p as Id, setCollapsed: show_agent]; - } - if let Some(p) = content_item { - let _: () = msg_send![p as Id, setCollapsed: !show_agent]; - } - if let Some(p) = split_vc { - let split_view: Id = msg_send![p as Id, view]; - if !split_view.is_null() { - crate::ui_helpers::set_hidden(split_view, false); - } - } - if let Some(p) = drawer_sv { - crate::ui_helpers::set_hidden(p as Id, !show_drawer); - } - if let Some(p) = search_f { - crate::ui_helpers::set_hidden(p as Id, !show_drawer); - } - if let Some(p) = search_l { - crate::ui_helpers::set_hidden(p as Id, !show_drawer); - } - if let Some(p) = fav_btn { - crate::ui_helpers::set_hidden(p as Id, !show_drawer); - } - if let Some(p) = drawer_edge { - crate::ui_helpers::set_hidden(p as Id, !show_drawer); - } - if let Some(p) = agent_sv { - crate::ui_helpers::set_hidden(p as Id, !show_agent); - } - if let Some(p) = agent_bar { - crate::ui_helpers::set_hidden(p as Id, !show_agent); - } - if let Some(p) = agent_attach { - crate::ui_helpers::set_hidden(p as Id, !show_agent); - } - if let Some(p) = agent_send { - crate::ui_helpers::set_hidden(p as Id, !show_agent); - } - if let Some(p) = latest_button { - crate::ui_helpers::set_hidden(p as Id, true); - } - if let Some(p) = title_label { - crate::ui_helpers::set_hidden(p as Id, !show_agent); - } - - // Complex agent-tab operations need full state access; re-lock briefly. - if show_agent { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if need_chat_update { - update_chat_view_with_state(&mut state, true); - } - resize_agent_input_locked(&mut state); - update_latest_pill_visibility(&state); - } - - // Nudge first responder to agent input when window is already key. - if tab == Tab::Agent - && let (Some(w), Some(inp)) = (window_ptr, agent_input_tv) - { - let window = w as Id; - let is_key: bool = msg_send![window, isKeyWindow]; - if is_key { - let _: bool = msg_send![window, makeFirstResponder: inp as Id]; - } - } - } -} - -pub fn update_active_tab_locked(state: &mut VoiceChatOverlayState, tab: Tab) { - unsafe { - let prev_tab = state.active_tab; - state.active_tab = tab; - - if let Some(button) = state.tab_drawer_button { - crate::ui_helpers::set_tab_button_active(button as Id, tab == Tab::Drawer); - } - if let Some(button) = state.tab_agent_button { - crate::ui_helpers::set_tab_button_active(button as Id, tab == Tab::Agent); - } - if let Some(button) = state.tab_settings_button { - crate::ui_helpers::set_tab_button_active(button as Id, false); - } - - let show_drawer = tab == Tab::Drawer; - let show_agent = tab == Tab::Agent; - - if let Some(sidebar_item) = state.split_sidebar_item { - let item = sidebar_item as Id; - let _: () = msg_send![item, setCollapsed: show_agent]; - } - if let Some(content_item) = state.split_content_item { - let item = content_item as Id; - let _: () = msg_send![item, setCollapsed: !show_agent]; - } - if let Some(split_controller) = state.split_view_controller { - let split_view: Id = msg_send![split_controller as Id, view]; - if !split_view.is_null() { - crate::ui_helpers::set_hidden(split_view, false); - } - } - if let Some(drawer_view) = state.drawer_scroll_view { - crate::ui_helpers::set_hidden(drawer_view as Id, !show_drawer); - } - if let Some(search_field) = state.search_field { - crate::ui_helpers::set_hidden(search_field as Id, !show_drawer); - } - if let Some(search_label) = state.search_label { - crate::ui_helpers::set_hidden(search_label as Id, !show_drawer); - } - if let Some(favorites_button) = state.favorites_button { - crate::ui_helpers::set_hidden(favorites_button as Id, !show_drawer); - } - if let Some(edge) = state.drawer_edge_effect { - crate::ui_helpers::set_hidden(edge as Id, !show_drawer); - } - if let Some(agent_view) = state.agent_scroll_view { - crate::ui_helpers::set_hidden(agent_view as Id, !show_agent); - } - if let Some(agent_input_bar) = state.agent_input_bar { - crate::ui_helpers::set_hidden(agent_input_bar as Id, !show_agent); - } - if let Some(agent_attach) = state.agent_attach_button { - crate::ui_helpers::set_hidden(agent_attach as Id, !show_agent); - } - if let Some(agent_send) = state.agent_send_button { - crate::ui_helpers::set_hidden(agent_send as Id, !show_agent); - } - if let Some(latest_button) = state.agent_latest_button { - crate::ui_helpers::set_hidden(latest_button as Id, true); - } - if let Some(title_label) = state.title_label { - crate::ui_helpers::set_hidden(title_label as Id, !show_agent); - } - - if show_agent { - // Populate the Agent view on tab switch so the empty-state CTA is visible. - // Important: do this only on transition to Agent; `ensure_agent_tab_visible` can - // call `update_active_tab_locked(Tab::Agent)` frequently during streaming. - if prev_tab != Tab::Agent { - update_chat_view_with_state(state, true); - } - resize_agent_input_locked(state); - update_latest_pill_visibility(state); - } - - // When switching to Agent, make sure the input field can actually receive text. - // We do NOT force activation (to avoid stealing focus), but if the window is already - // key, we nudge first responder to the input field for better UX. - if tab == Tab::Agent - && let (Some(window_ptr), Some(input_ptr)) = (state.window, state.agent_input_text_view) - { - let window = window_ptr as Id; - let is_key: bool = msg_send![window, isKeyWindow]; - if is_key { - let _: bool = msg_send![window, makeFirstResponder: input_ptr as Id]; - } - } - } -} - -/// Reflow Agent layout after the overlay window was resized. -/// -/// Without this, long messages can look clipped until the next message arrives. -pub fn reflow_agent_after_resize_impl() { - let Ok(mut state) = OVERLAY_STATE.try_lock() else { - return; - }; - if state.active_tab != Tab::Agent { - return; - } - - update_chat_view_with_state(&mut state, false); - resize_agent_input_locked(&mut state); -} - -/// Lightweight layout pass for window resizing (keeps inputs/footers aligned). -pub fn reflow_overlay_after_resize_impl() { - let Ok(mut state) = OVERLAY_STATE.try_lock() else { - return; - }; - reflow_header_controls_locked(&mut state); - reflow_footer_controls_locked(&mut state); - resize_agent_input_locked(&mut state); -} - -pub fn reflow_header_controls_locked(state: &mut VoiceChatOverlayState) { - unsafe { - let ( - Some(drawer_ptr), - Some(agent_ptr), - Some(settings_ptr), - Some(favorites_ptr), - Some(status_ptr), - ) = ( - state.tab_drawer_button, - state.tab_agent_button, - state.tab_settings_button, - state.favorites_button, - state.status_pill, - ) - else { - return; - }; - - let tab_drawer_button = drawer_ptr as Id; - let tab_agent_button = agent_ptr as Id; - let tab_settings_button = settings_ptr as Id; - let favorites_button = favorites_ptr as Id; - let status_pill = status_ptr as Id; - - let favorites_frame: CGRect = msg_send![favorites_button, frame]; - let right_cluster_start_x = favorites_frame.origin.x - - (ui_tokens::CHAT_HEADER_BUTTON_SIZE + ui_tokens::CHAT_HEADER_BUTTON_GAP); - - let header_safe_x = ui_tokens::TRAFFIC_LIGHTS_SPACER_WIDTH + 6.0; - let layout = chat_header_layout(header_safe_x, 0.0, right_cluster_start_x); - - let drawer_frame: CGRect = msg_send![tab_drawer_button, frame]; - let tab_y = drawer_frame.origin.y; - let tab_h = drawer_frame.size.height.max(20.0); - let tab_w = layout.tab_button_width.max(0.0); - let tab_gap = layout.tab_button_gap.max(0.0); - - let tab_drawer_frame = CGRect::new( - &CGPoint::new(layout.tab_cluster_x, tab_y), - &CGSize::new(tab_w, tab_h), - ); - let _: () = msg_send![tab_drawer_button, setFrame: tab_drawer_frame]; - - let tab_agent_frame = CGRect::new( - &CGPoint::new(layout.tab_cluster_x + tab_w + tab_gap, tab_y), - &CGSize::new(tab_w, tab_h), - ); - let _: () = msg_send![tab_agent_button, setFrame: tab_agent_frame]; - - let tab_settings_frame = CGRect::new( - &CGPoint::new(layout.tab_cluster_x + (tab_w + tab_gap) * 2.0, tab_y), - &CGSize::new(tab_w, tab_h), - ); - let _: () = msg_send![tab_settings_button, setFrame: tab_settings_frame]; - - let status_h = ui_tokens::STATUS_PILL_HEIGHT; - let status_y = (tab_y + (tab_h - status_h) * 0.5).max(0.0); - let status_frame = CGRect::new( - &CGPoint::new(layout.status_pill_x, status_y), - &CGSize::new(layout.status_pill_width.max(0.0), status_h), - ); - let _: () = msg_send![status_pill, setFrame: status_frame]; - let _: () = msg_send![status_pill, setHidden: !layout.show_status_pill]; - - if let Some(dot_ptr) = state.status_pill_dot { - let dot = dot_ptr as Id; - let dot_size = ui_tokens::STATUS_DOT_SIZE; - let dot_frame = CGRect::new( - &CGPoint::new( - ui_tokens::STATUS_PILL_DOT_INSET_X, - (status_h - dot_size) * 0.5, - ), - &CGSize::new(dot_size, dot_size), - ); - let _: () = msg_send![dot, setFrame: dot_frame]; - } - - if let Some(label_ptr) = state.status_pill_label { - let label = label_ptr as Id; - let label_width = (layout.status_pill_width - - ui_tokens::STATUS_PILL_LABEL_INSET_X - - ui_tokens::STATUS_PILL_LABEL_INSET_RIGHT) - .max(0.0); - let label_frame = CGRect::new( - &CGPoint::new(ui_tokens::STATUS_PILL_LABEL_INSET_X, 1.0), - &CGSize::new(label_width, (status_h - 2.0).max(0.0)), - ); - let _: () = msg_send![label, setFrame: label_frame]; - } - } -} - -pub fn reflow_footer_controls_locked(state: &mut VoiceChatOverlayState) { - unsafe { - let Some(blur_ptr) = state.blur_view else { - return; - }; - let blur_view = blur_ptr as Id; - let bounds: CGRect = msg_send![blur_view, bounds]; - let content_bounds = layout_region_frame_for_view(blur_view).unwrap_or(bounds); - - let footer_height = ui_tokens::FOOTER_HEIGHT; - let footer_base_y = content_bounds.origin.y; - let content_pad = ui_tokens::EDGE_PADDING; - let search_x = content_bounds.origin.x + content_pad; - let search_w = (content_bounds.size.width - content_pad * 2.0).max(160.0); - - if let Some(label_ptr) = state.search_label { - let label = label_ptr as Id; - let frame = CGRect::new( - &CGPoint::new(search_x, footer_base_y + footer_height - 20.0), - &CGSize::new(search_w, 16.0), - ); - let _: () = msg_send![label, setFrame: frame]; - } - - if let Some(field_ptr) = state.search_field { - let field = field_ptr as Id; - let frame = CGRect::new( - &CGPoint::new(search_x, footer_base_y + 12.0), - &CGSize::new(search_w, 24.0), - ); - let _: () = msg_send![field, setFrame: frame]; - } - - if let Some(label_ptr) = state.title_label { - let label = label_ptr as Id; - let label_w = ui_tokens::CHAT_TITLE_LABEL_WIDTH; - let label_h = 16.0; - let frame = CGRect::new( - &CGPoint::new( - content_bounds.origin.x + content_bounds.size.width - content_pad - label_w, - footer_base_y + ((footer_height - label_h) / 2.0).max(4.0), - ), - &CGSize::new(label_w, label_h), - ); - let _: () = msg_send![label, setFrame: frame]; - } - - let content_gap = ui_tokens::CONTENT_GAP; - let content_frame = CGRect::new( - &CGPoint::new( - content_bounds.origin.x + content_pad, - content_bounds.origin.y + footer_height + content_gap, - ), - &CGSize::new( - (content_bounds.size.width - content_pad * 2.0).max(0.0), - (content_bounds.size.height - footer_height - content_gap).max(0.0), - ), - ); - - if let Some(split_controller) = state.split_view_controller { - let split_view: Id = msg_send![split_controller as Id, view]; - if !split_view.is_null() { - let _: () = msg_send![split_view, setFrame: content_frame]; - } - } - } -} diff --git a/app/ui/voice_chat/api/tests.rs b/app/ui/voice_chat/api/tests.rs deleted file mode 100644 index cf673c9c..00000000 --- a/app/ui/voice_chat/api/tests.rs +++ /dev/null @@ -1,1232 +0,0 @@ -use super::*; -use crate::ui_helpers::{BubbleRole, RenderMode, next_render_mode, streaming_render_mode}; -use serial_test::serial; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; - -fn sample_drawer_entry( - path: &str, - preview: &str, - mode: TranscriptionMode, - is_ai_formatted: bool, - is_favorite: bool, -) -> DrawerEntry { - let mode_label = match mode { - TranscriptionMode::Hold => "Ctrl+Hold", - TranscriptionMode::Assistive => "Shift/Cmd", - TranscriptionMode::Toggle => "Toggle", - TranscriptionMode::Conversation => "Moshi", - }; - let entry_type = if is_ai_formatted { "AI" } else { "Tt" }; - let search_corpus = format!("{entry_type} {mode_label} {path} {preview}").to_ascii_lowercase(); - DrawerEntry { - source: DrawerEntrySource::LegacyFile, - path: PathBuf::from(path), - timestamp: SystemTime::now(), - mode, - title: None, - model: None, - total_tokens: None, - preview: preview.to_string(), - search_corpus, - is_ai_formatted, - is_favorite, - } -} - -#[test] -fn filtered_drawer_entries_matches_preview_path_and_title_case_insensitively() { - let mut state = VoiceChatOverlayState { - drawer_entries: vec![ - sample_drawer_entry( - "meeting_notes.md", - "Follow-up from team sync", - TranscriptionMode::Hold, - false, - false, - ), - sample_drawer_entry( - "roadmap.md", - "Architecture review memo", - TranscriptionMode::Assistive, - true, - true, - ), - ], - ..Default::default() - }; - - assert_eq!(filtered_drawer_entries(&state, "TEAM").len(), 1); - assert_eq!(filtered_drawer_entries(&state, "MEETING_NOTES").len(), 1); - assert_eq!(filtered_drawer_entries(&state, "shift/cmd").len(), 1); - assert_eq!(filtered_drawer_entries(&state, "AI").len(), 1); - - state.drawer_favorites_only = true; - assert_eq!(filtered_drawer_entries(&state, "").len(), 1); -} - -#[test] -fn prompt_history_cursor_walks_previous_and_next() { - assert_eq!(next_prompt_history_cursor(0, None, true), None); - assert_eq!(next_prompt_history_cursor(3, None, true), Some(2)); - assert_eq!(next_prompt_history_cursor(3, Some(2), true), Some(1)); - assert_eq!(next_prompt_history_cursor(3, Some(0), true), Some(0)); - assert_eq!(next_prompt_history_cursor(3, None, false), None); - assert_eq!(next_prompt_history_cursor(3, Some(0), false), Some(1)); - assert_eq!(next_prompt_history_cursor(3, Some(2), false), Some(3)); -} - -#[test] -fn prompt_history_keeps_recent_unique_sent_prompts() { - let mut state = VoiceChatOverlayState::default(); - - push_prompt_history_locked(&mut state, " first "); - push_prompt_history_locked(&mut state, "first"); - push_prompt_history_locked(&mut state, "second"); - push_prompt_history_locked(&mut state, ""); - - assert_eq!(state.prompt_history, vec!["first", "second"]); -} - -#[test] -fn manual_send_reenables_follow_latest_after_prior_scrollback() { - let mut state = VoiceChatOverlayState { - scroll_pinned: false, - ..Default::default() - }; - - follow_latest_after_manual_send_locked(&mut state); - - assert!(should_autoscroll(state.scroll_pinned)); -} - -#[test] -fn drawer_filter_in_memory_returns_correct_subset_without_disk_io() { - // P1.3: search-as-you-type must filter the already-loaded in-memory - // `drawer_entries` snapshot, never re-read ThreadStore from disk per - // keystroke. This exercises the same `filtered_drawer_entries` path the - // debounced `filter_drawer` callback renders from, proving the filter is a - // pure in-memory operation over a fixed `Vec` (no I/O). - let state = VoiceChatOverlayState { - drawer_entries: vec![ - sample_drawer_entry( - "alpha.md", - "renal panel recap", - TranscriptionMode::Hold, - false, - false, - ), - sample_drawer_entry( - "beta.md", - "surgical follow-up", - TranscriptionMode::Assistive, - false, - false, - ), - sample_drawer_entry( - "gamma.md", - "renal recheck tomorrow", - TranscriptionMode::Toggle, - false, - false, - ), - ], - ..Default::default() - }; - - // Two entries contain "renal", original indices preserved for card actions. - let renal = filtered_drawer_entries(&state, "renal"); - assert_eq!(renal.len(), 2); - assert_eq!(renal[0].0, 0); - assert_eq!(renal[1].0, 2); - - // Single match. - let surgical = filtered_drawer_entries(&state, "surgical"); - assert_eq!(surgical.len(), 1); - assert_eq!(surgical[0].0, 1); - - // No match → empty (still no I/O). - assert!(filtered_drawer_entries(&state, "cardiology").is_empty()); - - // Empty query → full set unchanged. - assert_eq!(filtered_drawer_entries(&state, "").len(), 3); -} - -#[test] -fn filtered_drawer_entries_returns_empty_when_query_has_no_match() { - let state = VoiceChatOverlayState { - drawer_entries: vec![ - sample_drawer_entry( - "draft-a.md", - "First transcript snippet", - TranscriptionMode::Hold, - false, - false, - ), - sample_drawer_entry( - "draft-b.md", - "Second transcript snippet", - TranscriptionMode::Toggle, - false, - false, - ), - ], - ..Default::default() - }; - - assert!(filtered_drawer_entries(&state, "missing phrase").is_empty()); -} - -#[test] -fn filtered_drawer_entries_clear_query_restores_full_list() { - let state = VoiceChatOverlayState { - drawer_entries: vec![ - sample_drawer_entry("first.md", "alpha", TranscriptionMode::Hold, false, false), - sample_drawer_entry( - "second.md", - "beta", - TranscriptionMode::Assistive, - false, - true, - ), - ], - ..Default::default() - }; - - assert_eq!(filtered_drawer_entries(&state, "alpha").len(), 1); - assert_eq!(filtered_drawer_entries(&state, "").len(), 2); - assert_eq!(filtered_drawer_entries(&state, " ").len(), 2); -} - -#[test] -fn filtered_drawer_entries_keeps_original_indices_for_card_actions() { - let state = VoiceChatOverlayState { - drawer_entries: vec![ - sample_drawer_entry("first.md", "alpha", TranscriptionMode::Hold, false, false), - sample_drawer_entry( - "second.md", - "alpha", - TranscriptionMode::Assistive, - false, - false, - ), - sample_drawer_entry("third.md", "alpha", TranscriptionMode::Toggle, false, false), - ], - ..Default::default() - }; - - let visible = filtered_drawer_entries(&state, "third"); - assert_eq!(visible.len(), 1); - assert_eq!(visible[0].0, 2); -} - -#[test] -fn filtered_drawer_entries_matches_thread_message_and_note_corpus() { - let state = VoiceChatOverlayState { - drawer_entries: vec![DrawerEntry { - source: DrawerEntrySource::Thread { - id: "t_2026-02-23_abc123".to_string(), - }, - path: PathBuf::from("thread_t_2026-02-23_abc123.json"), - timestamp: SystemTime::now(), - mode: TranscriptionMode::Assistive, - title: Some("Clinical recap".to_string()), - model: Some("gpt-5".to_string()), - total_tokens: Some(1536), - preview: "clinical recap".to_string(), - search_corpus: "renal values improved call owner tomorrow".to_string(), - is_ai_formatted: true, - is_favorite: false, - }], - ..Default::default() - }; - - assert_eq!(filtered_drawer_entries(&state, "renal values").len(), 1); - assert_eq!(filtered_drawer_entries(&state, "call owner").len(), 1); - assert_eq!(filtered_drawer_entries(&state, "missing phrase").len(), 0); -} - -#[test] -fn drawer_unavailable_placeholder_entry_has_expected_metadata() { - let entry = thread_history_unavailable_drawer_entry(); - - assert!(matches!(entry.source, DrawerEntrySource::LegacyFile)); - assert!(entry.path.as_os_str().is_empty()); - assert_eq!(entry.preview, "Thread history unavailable — storage error"); - assert!(!entry.is_ai_formatted); - assert!(entry.search_corpus.contains("unavailable")); - assert!(entry.search_corpus.contains("error")); - assert!(is_drawer_unavailable_placeholder(&entry)); - assert!(drawer_entry_matches_query(&entry, "unavailable")); - assert!(drawer_entry_matches_query(&entry, "error")); -} - -#[test] -fn drawer_entry_matches_query_does_not_leak_absolute_path() { - // Operator regression 2026-05-24: search field "codescribe" should NOT - // match a ThreadStore entry whose preview/corpus do not contain - // "codescribe", even though the entry's absolute path lives under - // `~/.codescribe/threads/...`. Path pollution must not bypass the filter. - let entry = DrawerEntry { - source: DrawerEntrySource::Thread { - id: "t_2026-04-21_h15b5t".to_string(), - }, - path: PathBuf::from( - "/Users/maciejgad/Library/Application Support/CodeScribe/threads/thread_t_2026-04-21_h15b5t.json", - ), - timestamp: SystemTime::now(), - mode: TranscriptionMode::Assistive, - title: Some("AI failed output".to_string()), - model: None, - total_tokens: None, - preview: "ai failed output_text".to_string(), - search_corpus: - "threadstore source:thread assistive thread:t_2026-04-21_h15b5t ai failed output_text" - .to_string(), - is_ai_formatted: false, - is_favorite: false, - }; - // Negative cases — these strings exist ONLY in the leaked absolute path, - // not in any legitimate search vector. - assert!( - !drawer_entry_matches_query(&entry, "codescribe"), - "absolute path component leaked into haystack — filter would match all threads", - ); - assert!( - !drawer_entry_matches_query(&entry, "library"), - "absolute path component leaked into haystack", - ); - assert!( - !drawer_entry_matches_query(&entry, "application support"), - "absolute path component leaked into haystack", - ); - // Positive cases — legitimate vectors still match. - assert!(drawer_entry_matches_query(&entry, "ai failed")); - assert!(drawer_entry_matches_query(&entry, "t_2026-04-21")); - assert!(drawer_entry_matches_query(&entry, "thread")); - assert!(drawer_entry_matches_query(&entry, "assistive")); - assert!(drawer_entry_matches_query(&entry, "threadstore")); -} - -#[test] -#[serial] -fn runtime_degraded_status_persists_across_status_updates() { - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - - set_voice_chat_runtime_degraded_impl( - true, - Some("Legacy formatter fallback is active.".to_string()), - ); - update_voice_chat_status_impl("Sending..."); - - { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert!(state.runtime_degraded); - assert!(state.is_agent_degraded); - assert_eq!(state.status_base_text, "Sending..."); - assert_eq!(state.status_kind, UiStatus::Error); - assert!(state.status_text.contains("Runtime degraded")); - } - - set_voice_chat_runtime_degraded_impl(false, None); - - { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert!(!state.runtime_degraded); - assert!(!state.is_agent_degraded); - assert_eq!(state.status_text, "Sending..."); - assert_eq!(state.status_kind, UiStatus::Processing); - } - - update_voice_chat_status_impl("AI Response:"); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.status_base_text, "AI Response:"); - assert_eq!(state.status_text, "AI Response:"); - assert_eq!(state.status_kind, UiStatus::Idle); -} - -#[test] -#[serial] -fn clear_agent_thinking_state_restores_ready_status() { - let mut state = VoiceChatOverlayState { - is_agent_thinking: true, - status_base_text: "Thinking…".to_string(), - status_text: "Thinking…".to_string(), - status_kind: UiStatus::Processing, - ..VoiceChatOverlayState::default() - }; - - clear_agent_thinking_state(&mut state); - - assert!(!state.is_agent_thinking); - assert_eq!(state.status_base_text, "Ready"); - assert_eq!(state.status_text, "Ready"); - assert_eq!(state.status_kind, UiStatus::Idle); -} - -/// Regression: `set_voice_chat_agent_thinking` holds `OVERLAY_STATE` while -/// refreshing status. The old body called `update_voice_chat_status_impl` -/// there, which re-locked the same non-reentrant mutex on the same thread -/// and froze the main thread in `__psynch_mutexwait`. `apply_agent_thinking` -/// must mutate the held guard without re-locking. Run on a worker thread -/// with a watchdog so a regression surfaces as a timeout instead of hanging -/// the whole test run. -#[test] -#[serial] -fn apply_agent_thinking_does_not_relock_overlay_state() { - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - - let (tx, rx) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - apply_agent_thinking(&mut state, true); - let snapshot = (state.is_agent_thinking, state.status_base_text.clone()); - drop(state); - let _ = tx.send(snapshot); - }); - - let (is_thinking, base_text) = rx - .recv_timeout(Duration::from_secs(5)) - .expect("apply_agent_thinking re-locked OVERLAY_STATE (re-entrant deadlock)"); - worker.join().unwrap(); - - assert!(is_thinking); - assert_eq!(base_text, "Thinking…"); -} - -#[test] -fn drawer_entry_subtitle_marks_threadstore_index_only_when_path_missing() { - let entry = DrawerEntry { - source: DrawerEntrySource::Thread { - id: "t_2026-02-23_missing".to_string(), - }, - path: PathBuf::from("__missing_thread_guardrail_test__.json"), - timestamp: SystemTime::now(), - mode: TranscriptionMode::Assistive, - title: Some("Missing thread".to_string()), - model: Some("gpt-5".to_string()), - total_tokens: Some(2048), - preview: "summary".to_string(), - search_corpus: "summary".to_string(), - is_ai_formatted: true, - is_favorite: true, - }; - - let subtitle = drawer_entry_subtitle(&entry); - assert!(subtitle.contains("Shift/Cmd")); - assert!(subtitle.contains("gpt-5")); - assert!(subtitle.contains("2.0k tok")); - assert!(subtitle.contains("★")); -} - -#[test] -fn drawer_entry_subtitle_omits_empty_optional_metadata() { - let subtitle = format_drawer_subtitle("4 h", "Toggle", None, None, false); - - assert_eq!(subtitle, "4 h • Toggle"); -} - -#[test] -fn drawer_entry_title_prefers_thread_title_then_preview() { - let mut entry = sample_drawer_entry( - "thread.md", - "preview fallback text", - TranscriptionMode::Assistive, - true, - false, - ); - entry.title = Some("Clinical plan".to_string()); - - assert_eq!(drawer_entry_title(&entry), "Clinical plan"); - - entry.title = Some(" ".to_string()); - assert_eq!(drawer_entry_title(&entry), "preview fallback text"); -} - -#[test] -fn drawer_entry_title_compacts_absolute_path_titles() { - let mut entry = sample_drawer_entry( - "thread.md", - "preview fallback text", - TranscriptionMode::Assistive, - true, - false, - ); - entry.title = - Some("/Users/maciejgad/Library/Application Support/CodeScribe/thread.json".to_string()); - - assert_eq!(drawer_entry_title(&entry), "thread.json"); -} - -#[test] -fn drawer_row_action_layout_reserves_title_truncation_space() { - let layout = drawer_row_action_layout(280.0); - - assert!(layout.title_width >= 24.0); - assert!( - layout.title_x + layout.title_width < layout.actions_x, - "title must end before hover action buttons begin" - ); - assert_eq!(layout.text_column_x, layout.title_x); - assert!( - layout.text_column_width > layout.title_width, - "preview/subtitle should breathe under the title instead of starting under the badge" - ); - assert_eq!( - layout.actions_width, - ui_tokens::DRAWER_ACTION_BUTTON_SIZE * 4.0 + ui_tokens::DRAWER_ACTION_BUTTON_GAP * 3.0 - ); -} - -#[test] -fn section_for_groups_drawer_entries_by_local_day_boundaries() { - let today = chrono::Local::now().date_naive(); - let now_local = today - .and_hms_opt(12, 0, 0) - .expect("valid noon timestamp") - .and_local_timezone(chrono::Local) - .single() - .expect("local noon must be unambiguous"); - let now = SystemTime::from(now_local); - - let today_early = today - .and_hms_opt(0, 1, 0) - .expect("valid today timestamp") - .and_local_timezone(chrono::Local) - .single() - .expect("local today timestamp must be unambiguous"); - let yesterday_late = (today - chrono::Duration::days(1)) - .and_hms_opt(23, 59, 0) - .expect("valid yesterday timestamp") - .and_local_timezone(chrono::Local) - .single() - .expect("local yesterday timestamp must be unambiguous"); - let six_days = (today - chrono::Duration::days(6)) - .and_hms_opt(12, 0, 0) - .expect("valid six-day timestamp") - .and_local_timezone(chrono::Local) - .single() - .expect("local six-day timestamp must be unambiguous"); - let eight_days = (today - chrono::Duration::days(8)) - .and_hms_opt(12, 0, 0) - .expect("valid eight-day timestamp") - .and_local_timezone(chrono::Local) - .single() - .expect("local eight-day timestamp must be unambiguous"); - - assert_eq!( - section_for(SystemTime::from(today_early), now), - DrawerSection::Today - ); - assert_eq!( - section_for(SystemTime::from(yesterday_late), now), - DrawerSection::Yesterday - ); - assert_eq!( - section_for(SystemTime::from(six_days), now), - DrawerSection::ThisWeek - ); - assert_eq!( - section_for(SystemTime::from(eight_days), now), - DrawerSection::Older - ); -} - -#[test] -fn thread_messages_for_restore_maps_thread_roles_and_text() { - let now = chrono::Utc::now(); - let thread = Thread { - id: "t_2026-06-02_restore".to_string(), - created_at: now, - updated_at: now, - title: "Clinical thread".to_string(), - mode: "assistive".to_string(), - tags: Vec::new(), - notes: Vec::new(), - messages: vec![ - codescribe_core::agent::ThreadMessage { - role: "user".to_string(), - content: vec![serde_json::json!({ - "type": "input_text", - "text": "Summarize labs" - })], - timestamp: now, - metadata: None, - }, - codescribe_core::agent::ThreadMessage { - role: "assistant".to_string(), - content: vec![serde_json::json!({ - "type": "output_text", - "text": "WBC improved." - })], - timestamp: now, - metadata: None, - }, - ], - summary: None, - total_tokens: None, - provider: "openai-responses".to_string(), - model: "gpt-5".to_string(), - }; - - let messages = thread_messages_for_restore(&thread); - - assert_eq!(messages.len(), 2); - assert_eq!(messages[0].role, ChatRole::User); - assert_eq!(messages[0].text, "Summarize labs"); - assert_eq!(messages[0].mode.as_deref(), Some("Shift/Cmd")); - assert_eq!(messages[1].role, ChatRole::Assistant); - assert_eq!(messages[1].text, "WBC improved."); - assert!(!messages[1].is_streaming); -} - -#[test] -fn display_text_for_message_handles_streaming() { - let streaming_empty = ChatMessage { - role: ChatRole::Assistant, - text: String::new(), - is_streaming: true, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: None, - is_pending_followup: false, - }; - assert_eq!(display_text_for_message(&streaming_empty), "• • •"); - - let streaming = ChatMessage { - text: "hello".to_string(), - ..streaming_empty - }; - assert_eq!(display_text_for_message(&streaming), "hello …"); - - let finished = ChatMessage { - is_streaming: false, - is_collapsed: false, - ..streaming - }; - assert_eq!(display_text_for_message(&finished), "hello"); -} - -#[test] -fn should_autoscroll_follows_pinned_state() { - assert!(should_autoscroll(true)); - assert!(!should_autoscroll(false)); - assert!(VoiceChatOverlayState::default().scroll_pinned); -} - -#[test] -fn render_mode_defaults_every_bubble_to_plain() { - assert_eq!( - streaming_render_mode(true, BubbleRole::User), - RenderMode::Plain - ); - assert_eq!( - streaming_render_mode(true, BubbleRole::Assistant), - RenderMode::Plain - ); - assert_eq!( - streaming_render_mode(true, BubbleRole::System), - RenderMode::Plain - ); - assert_eq!( - streaming_render_mode(false, BubbleRole::User), - RenderMode::Plain - ); - assert_eq!( - streaming_render_mode(false, BubbleRole::Assistant), - RenderMode::Plain - ); - assert_eq!( - streaming_render_mode(false, BubbleRole::System), - RenderMode::Plain - ); -} - -#[test] -fn next_render_mode_toggles_between_raw_and_rich() { - assert_eq!(next_render_mode(RenderMode::Plain), RenderMode::Markdown); - assert_eq!(next_render_mode(RenderMode::Markdown), RenderMode::Plain); -} - -#[test] -#[serial] -fn finalize_assistant_message_state_only_preserves_render_mode_override() { - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - state.messages.push(ChatMessage { - role: ChatRole::Assistant, - text: "**raw stays selected**".to_string(), - is_streaming: true, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some("AI".to_string()), - is_pending_followup: false, - }); - state.active_assistant_stream_index = Some(0); - state.message_render_modes.insert(0, RenderMode::Markdown); - } - - finalize_assistant_message_state_only_impl(false); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!( - state.message_render_modes.get(&0).copied(), - Some(RenderMode::Markdown) - ); - assert!(!state.messages[0].is_streaming); -} - -#[test] -fn update_cached_stack_height_applies_last_bubble_delta() { - assert_eq!( - update_cached_stack_height(40.0, 75.0, Some(200.0)), - Some(235.0) - ); - assert_eq!(update_cached_stack_height(40.0, 75.0, None), None); - assert_eq!( - update_cached_stack_height(120.0, 10.0, Some(50.0)), - Some(1.0) - ); -} - -#[test] -fn scrolled_to_bottom_math_uses_visible_max_y_threshold() { - assert!(scrolled_to_bottom_math(476.0, 300.0, 800.0, 24.0)); - assert!(scrolled_to_bottom_math(500.0, 300.0, 800.0, 24.0)); - assert!(!scrolled_to_bottom_math(450.0, 300.0, 800.0, 24.0)); - assert!(scrolled_to_bottom_math(0.0, 500.0, 300.0, 24.0)); -} - -#[test] -fn agent_document_height_preserves_bottom_clearance_above_input_bar() { - let stack_height = 920.0; - let bottom_inset = 96.0; - let clearance = AGENT_SCROLL_BOTTOM_CLEARANCE; - let document_height = - agent_document_height_for_bottom_clearance(stack_height, bottom_inset, clearance); - - assert_eq!(document_height, stack_height + bottom_inset + clearance); - assert_eq!( - agent_stack_height_from_document( - stack_height + bottom_inset + AGENT_SCROLL_BOTTOM_CLEARANCE, - bottom_inset, - ), - stack_height - ); -} - -#[test] -fn streaming_reasoning_collapses_when_finalized() { - let mut state = VoiceChatOverlayState::default(); - state.messages.push(ChatMessage { - role: ChatRole::Reasoning, - text: "checking patient context".to_string(), - is_streaming: true, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some("AI".to_string()), - is_pending_followup: false, - }); - state.active_reasoning_stream_index = Some(0); - - finalize_streaming_reasoning(&mut state); - - let message = &state.messages[0]; - assert!(!message.is_streaming); - assert!(message.is_collapsed); - assert_eq!(state.active_reasoning_stream_index, None); - assert!(display_text_for_message(message).starts_with("Reasoning · ")); -} - -#[test] -fn drawer_top_scroll_y_matches_flippedness() { - assert_eq!(drawer_top_scroll_y(900.0, 300.0, false), 600.0); - assert_eq!(drawer_top_scroll_y(900.0, 300.0, true), 0.0); - assert_eq!(drawer_top_scroll_y(200.0, 300.0, false), 0.0); -} - -#[test] -fn update_active_tab_switches_between_drawer_and_agent() { - let mut state = VoiceChatOverlayState::default(); - update_active_tab_locked(&mut state, Tab::Agent); - assert_eq!(state.active_tab, Tab::Agent); - - update_active_tab_locked(&mut state, Tab::Drawer); - assert_eq!(state.active_tab, Tab::Drawer); -} - -#[test] -#[serial] -fn handoff_transcript_to_chat_adds_user_message_without_callback() { - { - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; - } - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - - handoff_transcript_to_chat_impl("transcript payload"); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.messages.len(), 1); - assert_eq!(state.messages[0].role, ChatRole::User); - assert_eq!(state.messages[0].text, "transcript payload"); - assert!( - !state.is_sending, - "without callback, handoff must not stay in sending state" - ); -} - -#[test] -#[serial] -fn handoff_transcript_to_chat_invokes_callback() { - let call_count = Arc::new(AtomicUsize::new(0)); - let observed = Arc::new(Mutex::new(String::new())); - { - let count = Arc::clone(&call_count); - let observed = Arc::clone(&observed); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |text: String| { - count.fetch_add(1, Ordering::SeqCst); - let mut guard = observed.lock().unwrap_or_else(|e| e.into_inner()); - *guard = text; - })); - } - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - - handoff_transcript_to_chat_impl("augment this"); - - assert_eq!(call_count.load(Ordering::SeqCst), 1); - let payload = observed.lock().unwrap_or_else(|e| e.into_inner()).clone(); - assert_eq!(payload, "augment this"); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.messages.len(), 1); - assert!(state.is_sending); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} - -#[test] -#[serial] -fn dispatch_voice_chat_send_returns_false_without_callback() { - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; - drop(cb); - - assert!(!dispatch_voice_chat_send("payload")); - assert!(!dispatch_voice_chat_send(" ")); -} - -#[test] -#[serial] -fn dispatch_voice_chat_send_invokes_callback() { - let call_count = Arc::new(AtomicUsize::new(0)); - let observed = Arc::new(Mutex::new(String::new())); - { - let count = Arc::clone(&call_count); - let observed = Arc::clone(&observed); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |text: String| { - count.fetch_add(1, Ordering::SeqCst); - let mut guard = observed.lock().unwrap_or_else(|e| e.into_inner()); - *guard = text; - })); - } - - assert!(dispatch_voice_chat_send("runtime payload")); - assert_eq!(call_count.load(Ordering::SeqCst), 1); - let payload = observed.lock().unwrap_or_else(|e| e.into_inner()).clone(); - assert_eq!(payload, "runtime payload"); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} - -#[test] -#[serial] -fn toggle_callback_appends_finalized_utterances_to_user_draft() { - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - - append_voice_chat_user_utterance_impl("Pierwsze moje myśli."); - append_voice_chat_user_utterance_impl("Druga fraza już nie może zastąpić pierwszej."); - append_voice_chat_user_utterance_impl("Trzecia zostaje na końcu."); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.messages.len(), 1); - assert_eq!( - state.messages[0].text, - "Pierwsze moje myśli. Druga fraza już nie może zastąpić pierwszej. Trzecia zostaje na końcu." - ); - assert!(state.messages[0].is_streaming); -} - -#[test] -#[serial] -fn assistive_toggle_vad_end_appends_without_sending_until_explicit_send() { - let call_count = Arc::new(AtomicUsize::new(0)); - let observed = Arc::new(Mutex::new(String::new())); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - { - let count = Arc::clone(&call_count); - let observed = Arc::clone(&observed); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |text: String| { - count.fetch_add(1, Ordering::SeqCst); - *observed.lock().unwrap_or_else(|e| e.into_inner()) = text; - })); - } - - append_voice_chat_user_utterance_impl("Pierwszy segment."); - append_voice_chat_user_utterance_impl("Drugi segment."); - - assert_eq!( - call_count.load(Ordering::SeqCst), - 0, - "VAD-end utterance append must not dispatch the draft to the agent" - ); - { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.messages.len(), 1); - assert_eq!(state.messages[0].role, ChatRole::User); - assert_eq!(state.messages[0].text, "Pierwszy segment. Drugi segment."); - assert!( - state.messages[0].is_streaming, - "VAD-end keeps the user bubble open until toggle-stop finalizes/sends" - ); - } - - assert!(dispatch_voice_chat_send("Pierwszy segment. Drugi segment.")); - assert_eq!( - call_count.load(Ordering::SeqCst), - 1, - "toggle-stop/runtime boundary should send the accumulated transcript exactly once" - ); - assert_eq!( - observed.lock().unwrap_or_else(|e| e.into_inner()).as_str(), - "Pierwszy segment. Drugi segment." - ); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} - -#[test] -#[serial] -fn explicit_commit_sends_accumulated_draft_and_keeps_next_draft_open() { - let call_count = Arc::new(AtomicUsize::new(0)); - let observed = Arc::new(Mutex::new(String::new())); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - { - let count = Arc::clone(&call_count); - let observed = Arc::clone(&observed); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |text: String| { - count.fetch_add(1, Ordering::SeqCst); - *observed.lock().unwrap_or_else(|e| e.into_inner()) = text; - })); - } - - append_voice_chat_user_utterance_impl("Pierwszy segment."); - append_voice_chat_user_utterance_impl("Drugi segment."); - finalize_user_message_state_only_impl(); - commit_last_user_message_impl(); - - assert_eq!(call_count.load(Ordering::SeqCst), 1); - assert_eq!( - observed.lock().unwrap_or_else(|e| e.into_inner()).as_str(), - "Pierwszy segment. Drugi segment." - ); - - append_voice_chat_user_utterance_impl("Nowy segment po ciszy."); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.messages.len(), 2); - assert!(!state.messages[0].is_streaming); - assert_eq!(state.messages[1].text, "Nowy segment po ciszy."); - assert!(state.messages[1].is_streaming); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} - -#[test] -#[serial] -fn assistive_followup_busy_capture_waits_for_explicit_send() { - let call_count = Arc::new(AtomicUsize::new(0)); - let observed = Arc::new(Mutex::new(Vec::::new())); - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - state.messages.push(ChatMessage { - role: ChatRole::User, - text: "Already sent prompt".to_string(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some("AI".to_string()), - is_pending_followup: false, - }); - state.is_sending = true; - } - { - let count = Arc::clone(&call_count); - let observed = Arc::clone(&observed); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |text: String| { - count.fetch_add(1, Ordering::SeqCst); - observed - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(text); - })); - } - - append_voice_chat_user_delta_impl("Follow-up while busy"); - append_voice_chat_user_delta_impl(" please"); - - { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.messages.len(), 2); - assert_eq!(state.messages[0].text, "Already sent prompt"); - assert!(!state.messages[0].is_pending_followup); - assert_eq!(state.messages[1].role, ChatRole::User); - assert_eq!(state.messages[1].text, "Follow-up while busy please"); - assert!(state.messages[1].is_pending_followup); - assert!(state.messages[1].is_streaming); - assert!( - message_metadata(&state.messages[1]).contains("Pending follow-up"), - "metadata should expose the waiting affordance used by the bubble" - ); - } - assert_eq!(call_count.load(Ordering::SeqCst), 0); - - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.is_sending = false; - state.is_agent_thinking = false; - } - assert_eq!( - call_count.load(Ordering::SeqCst), - 0, - "captured follow-up must not auto-send when the agent becomes idle" - ); - - commit_pending_followup_message_impl(); - - assert_eq!(call_count.load(Ordering::SeqCst), 1); - assert_eq!( - observed - .lock() - .unwrap_or_else(|e| e.into_inner()) - .as_slice(), - &["Follow-up while busy please".to_string()] - ); - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(state.messages.len(), 2); - assert!(!state.messages[1].is_pending_followup); - assert!(!state.messages[1].is_streaming); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} - -#[test] -#[serial] -fn assistive_followup_finalization_keeps_pending_until_explicit_send() { - let call_count = Arc::new(AtomicUsize::new(0)); - { - let count = Arc::clone(&call_count); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |_text: String| { - count.fetch_add(1, Ordering::SeqCst); - })); - } - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - state.is_sending = true; - } - - append_voice_chat_user_utterance_impl("Follow-up after first send."); - finalize_user_message_state_only_impl(); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(call_count.load(Ordering::SeqCst), 0); - assert_eq!(state.messages.len(), 1); - assert_eq!(state.messages[0].text, "Follow-up after first send."); - assert!( - state.messages[0].is_pending_followup, - "finalizing a busy follow-up must keep the explicit follow-up affordance" - ); - assert!( - !state.messages[0].is_streaming, - "finalization may close transcription streaming without demoting the follow-up" - ); - assert_eq!(state.active_user_stream_index, None); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} - -#[test] -#[serial] -fn assistive_followup_text_finalization_keeps_pending_until_explicit_send() { - let call_count = Arc::new(AtomicUsize::new(0)); - { - let count = Arc::clone(&call_count); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |_text: String| { - count.fetch_add(1, Ordering::SeqCst); - })); - } - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - state.is_agent_thinking = true; - } - - append_voice_chat_user_delta_impl("partial follow-up"); - finalize_user_message_impl("Final follow-up transcript"); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(call_count.load(Ordering::SeqCst), 0); - assert_eq!(state.messages.len(), 1); - assert_eq!(state.messages[0].text, "Final follow-up transcript"); - assert!( - state.messages[0].is_pending_followup, - "text finalization must not demote a busy follow-up into a sent user bubble" - ); - assert!(!state.messages[0].is_streaming); - assert_eq!(state.active_user_stream_index, None); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} - -#[test] -#[serial] -fn assistive_first_message_double_finalize_renders_single_user_bubble() { - // Regression (fix/assistive-double-send): the assistive controller finalizes - // the same first utterance twice — once on the full-rewrite render, once - // again right before send (`set_voice_chat_user_text` → this impl). A cold - // overlay has no active streaming index to reuse, so the second call used to - // push a second identical User bubble, rendering the first message twice. - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - } - - let text = "Dobra, wez zacznij w schowek i tylko przyjmij do wiadomosci."; - finalize_user_message_impl(text); - finalize_user_message_impl(text); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!( - state.messages.len(), - 1, - "two finalizes of the same first utterance must render exactly one user bubble" - ); - assert_eq!(state.messages[0].role, ChatRole::User); - assert_eq!(state.messages[0].text, text); - assert!(!state.messages[0].is_streaming); -} - -#[test] -#[serial] -fn finalize_user_message_does_not_merge_across_assistant_boundary() { - // Guard against over-reach: reuse must never reach back past a non-user - // message. A prior user turn already answered by the assistant is a closed - // turn; a fresh utterance with identical text is a new bubble, not a merge. - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - state.messages.push(ChatMessage { - role: ChatRole::User, - text: "Repeat that".to_string(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some("AI".to_string()), - is_pending_followup: false, - }); - state.messages.push(ChatMessage { - role: ChatRole::Assistant, - text: "Sure, here it is.".to_string(), - is_streaming: false, - is_collapsed: false, - is_error: false, - timestamp: SystemTime::now(), - mode: Some("AI".to_string()), - is_pending_followup: false, - }); - } - - finalize_user_message_impl("Repeat that"); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!( - state.messages.len(), - 3, - "identical text behind an assistant reply must push a new user bubble" - ); - assert_eq!(state.messages[2].role, ChatRole::User); - assert_eq!(state.messages[2].text, "Repeat that"); -} - -#[test] -#[serial] -fn assistive_followup_edit_moves_pending_text_to_draft_without_send() { - let call_count = Arc::new(AtomicUsize::new(0)); - { - let count = Arc::clone(&call_count); - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = Some(Arc::new(move |_text: String| { - count.fetch_add(1, Ordering::SeqCst); - })); - } - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *state = VoiceChatOverlayState::default(); - state.is_agent_thinking = true; - } - - append_voice_chat_user_delta_impl("Needs correction"); - edit_pending_followup_message_impl(); - - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!(call_count.load(Ordering::SeqCst), 0); - assert!(state.messages.is_empty()); - assert_eq!(state.manual_draft, "Needs correction"); - - let mut cb = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - *cb = None; -} diff --git a/app/ui/voice_chat/handlers/actions.rs b/app/ui/voice_chat/handlers/actions.rs deleted file mode 100644 index 57b4eaaf..00000000 --- a/app/ui/voice_chat/handlers/actions.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! Plain action trampolines and text-input command handling. -//! -//! Send/close/tab switches, message and card actions, drawer search -//! filtering, recording CTAs, shortcut display, NSTextView delegate -//! callbacks (Enter-to-send, paste interception) and search field reset. - -use super::*; - -// ═══════════════════════════════════════════════════════════ -// Action Handlers -// ═══════════════════════════════════════════════════════════ - -pub extern "C" fn on_send(_this: &Object, _cmd: Sel, _sender: Id) { - send_draft_message_impl(); -} -pub extern "C" fn on_close(_this: &Object, _cmd: Sel, _sender: Id) { - crate::ui::voice_chat::api::hide_voice_chat_overlay(); -} -pub extern "C" fn on_tab_drawer(_this: &Object, _cmd: Sel, _sender: Id) { - update_active_tab_impl(Tab::Drawer); - info!("Tab changed to: {:?}", Tab::Drawer); -} - -pub extern "C" fn on_tab_agent(_this: &Object, _cmd: Sel, _sender: Id) { - update_active_tab_impl(Tab::Agent); - info!("Tab changed to: {:?}", Tab::Agent); -} - -pub extern "C" fn on_tab_settings(_this: &Object, _cmd: Sel, _sender: Id) { - crate::ui::settings::show_settings_window(); - info!("Settings window opened from chat overlay"); -} - -pub extern "C" fn on_copy_last_response(_this: &Object, _cmd: Sel, _sender: Id) { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(msg) = state - .messages - .iter() - .rev() - .find(|m| m.role == ChatRole::Assistant) - { - copy_to_clipboard(&msg.text); - info!("Copied last assistant response to clipboard"); - } else { - info!("No assistant response to copy"); - } -} - -#[cfg(target_os = "macos")] -fn activate_target_app(app_name: &str) { - // Activate via NSWorkspace — no shell, no injection surface. - unsafe { - let ns_workspace = Class::get("NSWorkspace").unwrap(); - let workspace: Id = msg_send![ns_workspace, sharedWorkspace]; - let running: Id = msg_send![workspace, runningApplications]; - let count: usize = msg_send![running, count]; - for i in 0..count { - let app: Id = msg_send![running, objectAtIndex: i]; - let name: Id = msg_send![app, localizedName]; - if !name.is_null() { - let name_cstr: *const std::ffi::c_char = msg_send![name, UTF8String]; - if !name_cstr.is_null() { - let name_str = std::ffi::CStr::from_ptr(name_cstr).to_string_lossy(); - if name_str == app_name { - let _: bool = msg_send![app, activateWithOptions: 1u64]; // NSApplicationActivateIgnoringOtherApps - break; - } - } - } - } - } -} - -#[cfg(target_os = "macos")] -fn paste_last_response_text(text: &str) { - // Best-effort: if activation fails, paste will likely go nowhere useful; - // clipboard still contains the response. - if let Err(e) = crate::os::clipboard::paste_text(text) { - info!("Paste failed: {}", e); - copy_to_clipboard(text); - } -} - -pub extern "C" fn on_paste_last_response(_this: &Object, _cmd: Sel, _sender: Id) { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let text = state - .messages - .iter() - .rev() - .find(|m| m.role == ChatRole::Assistant) - .map(|m| m.text.clone()); - let target_app = state.last_target_app.clone(); - drop(state); - - let Some(text) = text else { - info!("No assistant response to paste"); - return; - }; - - #[cfg(target_os = "macos")] - { - let paste_delay_ms = if let Some(app_name) = target_app.as_deref() { - let app_name = app_name.to_string(); - Queue::main().exec_async(move || activate_target_app(&app_name)); - Some(80_u64) - } else { - None - }; - - if let Some(delay_ms) = paste_delay_ms { - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(delay_ms)); - Queue::main().exec_async(move || paste_last_response_text(&text)); - }); - } else { - Queue::main().exec_async(move || paste_last_response_text(&text)); - } - } - - #[cfg(not(target_os = "macos"))] - { - copy_to_clipboard(&text); - } -} - -pub extern "C" fn on_copy_message(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(message) = state.messages.get(index) { - copy_to_clipboard(&message.text); - } -} - -pub extern "C" fn on_toggle_bubble_render(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - toggle_message_render_mode_impl(index); -} - -pub extern "C" fn on_assistant_bubble_click(_this: &Object, _cmd: Sel, sender: Id) { - handle_message_bubble_click_from_recognizer(sender); -} - -pub extern "C" fn on_agent_scroll_live(_this: &Object, _cmd: Sel, _notification: Id) { - handle_agent_scroll_live(); -} - -pub extern "C" fn on_latest_message(_this: &Object, _cmd: Sel, _sender: Id) { - pin_agent_scroll_to_latest_impl(); -} - -pub extern "C" fn on_card_copy(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - handle_card_copy(index); -} - -pub extern "C" fn on_card_restore(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - handle_card_restore(index); -} - -pub extern "C" fn on_card_edit(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - handle_card_edit(index); -} - -pub extern "C" fn on_card_delete(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - handle_card_delete(index); -} - -pub extern "C" fn on_card_favorite(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - handle_card_favorite(index); -} - -fn filter_drawer_for_search_field(search_field: Id) { - if search_field.is_null() { - return; - } - let is_active_search_field = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.search_field == Some(search_field as usize) - }; - if !is_active_search_field { - return; - } - let query = unsafe { get_text_field_string(search_field) }; - filter_drawer(&query); -} - -pub extern "C" fn on_search_changed(_this: &Object, _cmd: Sel, sender: Id) { - filter_drawer_for_search_field(sender); -} - -pub extern "C" fn on_control_text_did_change(_this: &Object, _cmd: Sel, notification: Id) { - let search_field: Id = unsafe { msg_send![notification, object] }; - filter_drawer_for_search_field(search_field); -} - -pub extern "C" fn on_new_thread(_this: &Object, _cmd: Sel, _sender: Id) { - start_new_thread_impl(); - info!("New thread requested (backend reset + UI clear)"); -} - -pub extern "C" fn on_toggle_favorites_only(_this: &Object, _cmd: Sel, _sender: Id) { - toggle_drawer_favorites_only_impl(); - info!("Toggled Drawer favorites-only filter"); -} - -pub extern "C" fn on_start_recording(_this: &Object, _cmd: Sel, _sender: Id) { - crate::controller::request_toggle_recording_start(false); - info!("CTA: start recording"); -} - -pub extern "C" fn on_header_record(_this: &Object, _cmd: Sel, _sender: Id) { - // Header record button is chat-native: keep the session in assistive/chat mode. - crate::ui::overlay::hide_transcription_overlay(); - crate::controller::request_toggle_recording_start(true); - info!("Header CTA: toggle assistive recording"); -} - -pub extern "C" fn on_show_overlay(_this: &Object, _cmd: Sel, _sender: Id) { - if !crate::ui::voice_chat::api::is_voice_chat_overlay_visible() { - crate::ui::voice_chat::show_voice_chat_overlay(); - } - crate::ui::voice_chat::show_agent_tab(); - info!("CTA: show/focus overlay"); -} - -pub extern "C" fn on_commit_message(_this: &Object, _cmd: Sel, _sender: Id) { - commit_last_user_message_impl(); - info!("Draft message committed"); -} - -pub extern "C" fn on_commit_pending_followup(_this: &Object, _cmd: Sel, _sender: Id) { - crate::ui::voice_chat::api::commit_pending_followup_message_impl(); - info!("Pending follow-up sent"); -} - -pub extern "C" fn on_edit_pending_followup(_this: &Object, _cmd: Sel, _sender: Id) { - crate::ui::voice_chat::api::edit_pending_followup_message_impl(); - info!("Pending follow-up moved to draft"); -} - -pub extern "C" fn on_discard_message(_this: &Object, _cmd: Sel, _sender: Id) { - discard_last_message_impl(); - info!("Draft message discarded"); -} -pub extern "C" fn on_show_shortcuts(_this: &Object, _cmd: Sel, _sender: Id) { - let (hold, toggle) = - crate::ui::voice_chat::shortcuts_lines(crate::os::hotkeys::ModeHotkeyBindings::load()); - if !crate::ui::voice_chat::api::is_voice_chat_overlay_visible() { - // This action is wired to overlay/header UI. If it fires while hidden - // (e.g. stale responder chain), ignore it instead of spawning a ghost window. - info!("Ignored shortcuts action while overlay hidden"); - return; - } - crate::ui::voice_chat::show_agent_tab(); - crate::ui::voice_chat::add_voice_chat_system_message(&format!( - "Keyboard shortcuts:\n{}\n{}", - hold, toggle - )); - crate::ui::voice_chat::update_voice_chat_status("Shortcuts"); - info!("Displayed keyboard shortcuts inline (non-modal)"); -} -pub extern "C" fn on_text_did_change(_this: &Object, _cmd: Sel, _notification: Id) { - // Runs on main thread. Keep lightweight and only re-layout when height changes. - crate::ui::voice_chat::api::resize_agent_input_to_draft(); -} - -/// NSTextView delegate: intercept Enter to send, allow Shift+Enter for newline. -/// Respects `agent_enter_sends` config: -/// true → Enter sends, Shift+Enter newline (default / Discord-style) -/// false → Enter newline, Cmd+Enter sends (Mail / Messages-style) -pub extern "C" fn on_do_command_by_selector( - _this: &Object, - _cmd: Sel, - _text_view: Id, - selector: Sel, -) -> bool { - // ── Defense-in-depth paste interception ── - // The Agent input text view overrides paste: for the Edit menu / Cmd+V path. - // This delegate hook fires only for key-binding initiated paste commands. - if selector == sel!(paste:) { - let handled = unsafe { try_paste_as_attachment() }; - if handled { - return true; - } - return false; // default NSTextView paste - } - - if selector == sel!(moveUp:) { - return crate::ui::voice_chat::api::recall_previous_prompt(); - } - if selector == sel!(moveDown:) { - return crate::ui::voice_chat::api::recall_next_prompt(); - } - - if selector == sel!(insertNewline:) { - let (shift_held, cmd_held) = unsafe { - let ns_app = Class::get("NSApplication").unwrap(); - let app: Id = msg_send![ns_app, sharedApplication]; - let event: Id = msg_send![app, currentEvent]; - if event.is_null() { - (false, false) - } else { - let flags: u64 = msg_send![event, modifierFlags]; - // NSEventModifierFlagShift = 1 << 17 - // NSEventModifierFlagCommand = 1 << 20 - ((flags & (1 << 17)) != 0, (flags & (1 << 20)) != 0) - } - }; - let config = Config::load(); - let should_send = if config.agent_enter_sends { - !shift_held // Enter sends, Shift+Enter newline - } else { - cmd_held // Cmd+Enter sends, Enter newline - }; - if should_send { - send_draft_message_impl(); - return true; // Handled: send message. - } - return false; // Let NSTextView insert a newline. - } - false // All other commands: default behaviour. -} -pub fn clear_search_field() { - // Extract pointer under lock, then drop lock BEFORE AppKit calls - // to avoid deadlock (AppKit callbacks may re-lock OVERLAY_STATE). - let search_field = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.search_field - }; - if let Some(sf) = search_field { - unsafe { - set_text_field_string(sf as Id, ""); - set_hidden(sf as Id, false); - } - } -} diff --git a/app/ui/voice_chat/handlers/attachments.rs b/app/ui/voice_chat/handlers/attachments.rs deleted file mode 100644 index 025f676a..00000000 --- a/app/ui/voice_chat/handlers/attachments.rs +++ /dev/null @@ -1,561 +0,0 @@ -//! Attachment intake for the agent input bar. -//! -//! Drag & drop onto the overlay, file picker, clipboard paste-as-attachment -//! (file URLs and standalone images), oversized-file policy and the -//! attachment chip context-menu actions. - -use super::*; - -const NS_DRAG_OP_COPY: u64 = 1; -const MAX_ATTACHMENT_BYTES_UI: u64 = 50 * 1024 * 1024; -fn extract_paths_from_pasteboard(pasteboard: Id) -> Vec { - unsafe { - let mut out = Vec::new(); - if pasteboard.is_null() { - return out; - } - - // Preferred path: read file URLs. - let ns_url = Class::get("NSURL").unwrap(); - let ns_array = Class::get("NSArray").unwrap(); - let ns_dict = Class::get("NSDictionary").unwrap(); - let ns_number = Class::get("NSNumber").unwrap(); - - let classes: Id = msg_send![ns_array, arrayWithObject: ns_url]; - let key = ns_string("NSPasteboardURLReadingFileURLsOnlyKey"); - let yes: Id = msg_send![ns_number, numberWithBool: true]; - let options: Id = msg_send![ns_dict, dictionaryWithObject: yes forKey: key]; - let urls: Id = msg_send![pasteboard, readObjectsForClasses: classes options: options]; - if !urls.is_null() { - let count: usize = msg_send![urls, count]; - for i in 0..count { - let url: Id = msg_send![urls, objectAtIndex: i]; - if url.is_null() { - continue; - } - let ns_path: Id = msg_send![url, path]; - if ns_path.is_null() { - continue; - } - let c_str: *const i8 = msg_send![ns_path, UTF8String]; - if c_str.is_null() { - continue; - } - let s = std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .to_string(); - if !s.is_empty() { - out.push(PathBuf::from(s)); - } - } - } - - // Fallback: legacy filenames pasteboard type. - if out.is_empty() { - let filenames_type = ns_string("NSFilenamesPboardType"); - let files: Id = msg_send![pasteboard, propertyListForType: filenames_type]; - if !files.is_null() { - let count: usize = msg_send![files, count]; - for i in 0..count { - let ns_path: Id = msg_send![files, objectAtIndex: i]; - if ns_path.is_null() { - continue; - } - let c_str: *const i8 = msg_send![ns_path, UTF8String]; - if c_str.is_null() { - continue; - } - let s = std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .to_string(); - if !s.is_empty() { - out.push(PathBuf::from(s)); - } - } - } - } - - // Fallback for drag sources that provide raw file-url strings per pasteboard item. - if out.is_empty() { - let items: Id = msg_send![pasteboard, pasteboardItems]; - if !items.is_null() { - let count: usize = msg_send![items, count]; - let file_url_type = ns_string("public.file-url"); - for i in 0..count { - let item: Id = msg_send![items, objectAtIndex: i]; - if item.is_null() { - continue; - } - let url_str: Id = msg_send![item, stringForType: file_url_type]; - if url_str.is_null() { - continue; - } - let url: Id = msg_send![ns_url, URLWithString: url_str]; - if url.is_null() { - continue; - } - let is_file: bool = msg_send![url, isFileURL]; - if !is_file { - continue; - } - let ns_path: Id = msg_send![url, path]; - if ns_path.is_null() { - continue; - } - let c_str: *const i8 = msg_send![ns_path, UTF8String]; - if c_str.is_null() { - continue; - } - let s = std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .to_string(); - if !s.is_empty() { - out.push(PathBuf::from(s)); - } - } - } - } - - out - } -} - -fn format_attachment_size_mb(size_bytes: u64) -> String { - format!("{:.1} MB", size_bytes as f64 / (1024.0 * 1024.0)) -} - -pub fn show_oversized_attachments_alert(skipped: &[String]) { - if skipped.is_empty() { - return; - } - let shown = skipped - .iter() - .take(3) - .cloned() - .collect::>() - .join(", "); - let extra = skipped.len().saturating_sub(3); - let suffix = if extra > 0 { - format!(" (+{} more)", extra) - } else { - String::new() - }; - let msg = format!( - "Max attachment size is 50 MB.\nSkipped: {}{}", - shown, suffix - ); - show_error_alert("Attachment Too Large", &msg); -} - -pub fn push_attachment_if_allowed( - state: &mut VoiceChatOverlayState, - attachment: Attachment, - skipped: &mut Vec, -) { - if attachment.is_oversized() { - skipped.push(format!( - "{} ({}, max {} MB)", - attachment.display_name, - format_attachment_size_mb(attachment.size_bytes), - MAX_ATTACHMENT_BYTES_UI / (1024 * 1024) - )); - return; - } - if !state - .attachments - .iter() - .any(|a| a.same_path(&attachment.path)) - { - state.attachments.push(attachment); - } -} - -pub extern "C" fn on_dragging_entered(_this: &Object, _cmd: Sel, dragging_info: Id) -> u64 { - unsafe { - let pasteboard: Id = msg_send![dragging_info, draggingPasteboard]; - let paths = extract_paths_from_pasteboard(pasteboard); - if paths.is_empty() { 0 } else { NS_DRAG_OP_COPY } - } -} - -pub extern "C" fn on_perform_drag_operation(_this: &Object, _cmd: Sel, dragging_info: Id) -> bool { - unsafe { - let pasteboard: Id = msg_send![dragging_info, draggingPasteboard]; - let paths = extract_paths_from_pasteboard(pasteboard); - if paths.is_empty() { - return false; - } - let (btn_ptr, count, names, skipped) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let mut skipped = Vec::new(); - for p in paths { - let attachment = Attachment::from_path(p, AttachmentSource::DragDrop); - push_attachment_if_allowed(&mut state, attachment, &mut skipped); - } - state.attachments_last_sent = None; - render_attachment_chips(&mut state); - let names: Vec = state - .attachments - .iter() - .map(|a| a.display_name.clone()) - .collect(); - ( - state.agent_attach_button, - state.attachments.len(), - names, - skipped, - ) - }; - update_attach_button_ui(btn_ptr, count, names); - show_oversized_attachments_alert(&skipped); - true - } -} -pub extern "C" fn on_attach_pick(_this: &Object, _cmd: Sel, _sender: Id) { - let picked = crate::ui_helpers::pick_files_open_panel("Attach files"); - if picked.is_empty() { - return; - } - - let (btn_ptr, count, names, skipped) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let mut skipped = Vec::new(); - for p in picked { - let attachment = Attachment::from_path(p, AttachmentSource::FilePicker); - push_attachment_if_allowed(&mut state, attachment, &mut skipped); - } - state.attachments_last_sent = None; - render_attachment_chips(&mut state); - let names: Vec = state - .attachments - .iter() - .map(|a| a.display_name.clone()) - .collect(); - ( - state.agent_attach_button, - state.attachments.len(), - names, - skipped, - ) - }; - update_attach_button_ui(btn_ptr, count, names); - show_oversized_attachments_alert(&skipped); -} - -pub extern "C" fn on_attach_clear(_this: &Object, _cmd: Sel, _sender: Id) { - let (btn_ptr, count, names) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.attachments.clear(); - state.attachments_last_sent = None; - render_attachment_chips(&mut state); - (state.agent_attach_button, 0, Vec::new()) - }; - update_attach_button_ui(btn_ptr, count, names); -} -// ═══════════════════════════════════════════════════════════ -// Attachment Chip Handlers -// ═══════════════════════════════════════════════════════════ - -/// Chip body click → show context menu with Preview / Remove / Reveal / Copy Path. -pub extern "C" fn on_chip_click(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - - unsafe { - let ns_menu = Class::get("NSMenu").unwrap(); - let ns_menu_item = Class::get("NSMenuItem").unwrap(); - let menu: Id = msg_send![ns_menu, new]; - let handler = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.action_handler - }; - let Some(handler_ptr) = handler else { return }; - let target = handler_ptr as Id; - - let preview: Id = msg_send![ns_menu_item, alloc]; - let preview: Id = msg_send![ - preview, - initWithTitle: ns_string("Preview (QuickLook)") - action: sel!(onChipPreview:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![preview, setTarget: target]; - let _: () = msg_send![preview, setTag: index as isize]; - let _: () = msg_send![menu, addItem: preview]; - - let remove: Id = msg_send![ns_menu_item, alloc]; - let remove: Id = msg_send![ - remove, - initWithTitle: ns_string("Remove") - action: sel!(onChipRemove:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![remove, setTarget: target]; - let _: () = msg_send![remove, setTag: index as isize]; - let _: () = msg_send![menu, addItem: remove]; - - let sep: Id = msg_send![ns_menu_item, separatorItem]; - let _: () = msg_send![menu, addItem: sep]; - - let reveal: Id = msg_send![ns_menu_item, alloc]; - let reveal: Id = msg_send![ - reveal, - initWithTitle: ns_string("Reveal in Finder") - action: sel!(onChipReveal:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![reveal, setTarget: target]; - let _: () = msg_send![reveal, setTag: index as isize]; - let _: () = msg_send![menu, addItem: reveal]; - - let copy_path: Id = msg_send![ns_menu_item, alloc]; - let copy_path: Id = msg_send![ - copy_path, - initWithTitle: ns_string("Copy Path") - action: sel!(onChipCopyPath:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![copy_path, setTarget: target]; - let _: () = msg_send![copy_path, setTag: index as isize]; - let _: () = msg_send![menu, addItem: copy_path]; - - // Pop up at mouse location. - let ns_event = Class::get("NSEvent").unwrap(); - let location: CGPoint = msg_send![ns_event, mouseLocation]; - let _: bool = msg_send![ - menu, - popUpMenuPositioningItem: std::ptr::null_mut::() - atLocation: location - inView: std::ptr::null_mut::() - ]; - } -} - -pub extern "C" fn on_chip_remove(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - crate::ui::voice_chat::api::remove_attachment_at(index); -} - -pub extern "C" fn on_chip_preview(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - let path = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.attachments.get(index).map(|a| a.path.clone()) - }; - if let Some(path) = path { - // Use macOS QuickLook for native preview. - std::thread::spawn(move || { - let _ = std::process::Command::new("qlmanage") - .arg("-p") - .arg(&path) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn(); - }); - } -} - -pub extern "C" fn on_chip_reveal(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - let path = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.attachments.get(index).map(|a| a.path.clone()) - }; - if let Some(path) = path { - let _ = std::process::Command::new("open") - .arg("-R") - .arg(&path) - .spawn(); - } -} - -pub extern "C" fn on_chip_copy_path(_this: &Object, _cmd: Sel, sender: Id) { - let index: isize = unsafe { msg_send![sender, tag] }; - let index = index.max(0) as usize; - let path = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state - .attachments - .get(index) - .map(|a| a.path.display().to_string()) - }; - if let Some(path) = path { - copy_to_clipboard(&path); - } -} -// ═══════════════════════════════════════════════════════════ -// Paste-as-attachment -// ═══════════════════════════════════════════════════════════ - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum PasteDisposition { - Attachment, - TextPaste, -} - -pub(super) fn paste_disposition( - has_files: bool, - has_image: bool, - has_text: bool, -) -> PasteDisposition { - if has_files || (has_image && !has_text) { - PasteDisposition::Attachment - } else { - PasteDisposition::TextPaste - } -} - -/// Check the general pasteboard and, if it contains file URLs or a standalone image -/// (no accompanying text), treat the paste as an attachment instead of text insertion. -/// -/// Returns `true` when the paste was consumed as an attachment (caller should suppress -/// the default NSTextView paste), or `false` to let the default paste proceed. -/// -/// # Safety -/// Must be called on the main thread. Uses Objective-C messaging. -pub unsafe fn try_paste_as_attachment() -> bool { - let ns_pasteboard = Class::get("NSPasteboard").unwrap(); - let pasteboard: Id = msg_send![ns_pasteboard, generalPasteboard]; - - // 1. File URLs → always treat as attachments - let file_paths = extract_paths_from_pasteboard(pasteboard); - if paste_disposition(!file_paths.is_empty(), false, false) == PasteDisposition::Attachment { - let (btn_ptr, count, names, skipped) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let mut skipped = Vec::new(); - for p in file_paths { - let attachment = Attachment::from_path(p, AttachmentSource::Clipboard); - push_attachment_if_allowed(&mut state, attachment, &mut skipped); - } - state.attachments_last_sent = None; - render_attachment_chips(&mut state); - let names: Vec = state - .attachments - .iter() - .map(|a| a.display_name.clone()) - .collect(); - ( - state.agent_attach_button, - state.attachments.len(), - names, - skipped, - ) - }; - update_attach_button_ui(btn_ptr, count, names); - show_oversized_attachments_alert(&skipped); - debug!("Paste intercepted: {} file(s) attached", count); - return true; - } - - // 2. Check for image data WITHOUT accompanying text - let has_image = unsafe { pasteboard_has_type(pasteboard, "public.tiff") } - || unsafe { pasteboard_has_type(pasteboard, "public.png") }; - let has_text = unsafe { pasteboard_has_type(pasteboard, "public.utf8-plain-text") }; - - if paste_disposition(false, has_image, has_text) == PasteDisposition::Attachment { - // Read PNG data from pasteboard (try PNG first, then TIFF→PNG conversion) - if let Some(image_data) = unsafe { read_image_from_pasteboard(pasteboard) } { - match AttachmentStore::save_clipboard_image(&image_data, "png") { - Ok(path) => { - let (btn_ptr, count, names, skipped) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let mut skipped = Vec::new(); - let attachment = Attachment::from_path(path, AttachmentSource::Clipboard); - push_attachment_if_allowed(&mut state, attachment, &mut skipped); - state.attachments_last_sent = None; - render_attachment_chips(&mut state); - let names: Vec = state - .attachments - .iter() - .map(|a| a.display_name.clone()) - .collect(); - ( - state.agent_attach_button, - state.attachments.len(), - names, - skipped, - ) - }; - update_attach_button_ui(btn_ptr, count, names); - show_oversized_attachments_alert(&skipped); - debug!("Paste intercepted: clipboard image saved as attachment"); - return true; - } - Err(e) => { - info!("Failed to save clipboard image: {e}"); - // Fall through to default paste - } - } - } - } - - // 3. Text paste (with or without image) → default behaviour - false -} - -/// Check if the pasteboard contains a given UTI type. -/// -/// # Safety -/// Requires main thread and valid pasteboard pointer. -unsafe fn pasteboard_has_type(pasteboard: Id, uti: &str) -> bool { - let ns_array = Class::get("NSArray").unwrap(); - let type_str = ns_string(uti); - let types: Id = msg_send![ns_array, arrayWithObject: type_str]; - let available: Id = msg_send![pasteboard, availableTypeFromArray: types]; - !available.is_null() -} - -/// Read image data from the pasteboard as PNG bytes. -/// -/// Tries `public.png` first. Falls back to `public.tiff` and converts to PNG -/// via `NSBitmapImageRep`. -/// -/// # Safety -/// Requires main thread and valid pasteboard pointer. -unsafe fn read_image_from_pasteboard(pasteboard: Id) -> Option> { - // Try PNG first - let png_type = ns_string("public.png"); - let png_data: Id = msg_send![pasteboard, dataForType: png_type]; - if !png_data.is_null() { - let length: usize = msg_send![png_data, length]; - let bytes: *const u8 = msg_send![png_data, bytes]; - if !bytes.is_null() && length > 0 { - return Some(unsafe { std::slice::from_raw_parts(bytes, length) }.to_vec()); - } - } - - // Fallback: TIFF → convert to PNG via NSBitmapImageRep - let tiff_type = ns_string("public.tiff"); - let tiff_data: Id = msg_send![pasteboard, dataForType: tiff_type]; - if tiff_data.is_null() { - return None; - } - - let ns_bitmap = Class::get("NSBitmapImageRep")?; - let rep: Id = msg_send![ns_bitmap, imageRepWithData: tiff_data]; - if rep.is_null() { - return None; - } - - // NSBitmapImageFileType.png = 4 - let png_file_type: usize = 4; - let nil: Id = std::ptr::null_mut(); - let result: Id = msg_send![ - rep, - representationUsingType: png_file_type - properties: nil - ]; - if result.is_null() { - return None; - } - - let length: usize = msg_send![result, length]; - let bytes: *const u8 = msg_send![result, bytes]; - if bytes.is_null() || length == 0 { - return None; - } - Some(unsafe { std::slice::from_raw_parts(bytes, length) }.to_vec()) -} diff --git a/app/ui/voice_chat/handlers/classes.rs b/app/ui/voice_chat/handlers/classes.rs deleted file mode 100644 index 1322e062..00000000 --- a/app/ui/voice_chat/handlers/classes.rs +++ /dev/null @@ -1,433 +0,0 @@ -//! Objective-C class registration for the voice chat overlay. -//! -//! Declares and caches the action handler, window delegate, overlay window -//! subclass and attachment drop-target classes, plus the CGRect ABI encodings -//! their selector signatures require. - -use super::*; - -static ACTION_HANDLER_INIT: Once = Once::new(); -static mut ACTION_HANDLER_CLASS: *const Class = std::ptr::null(); -static WINDOW_DELEGATE_INIT: Once = Once::new(); -static mut WINDOW_DELEGATE_CLASS: *const Class = std::ptr::null(); -static OVERLAY_WINDOW_INIT: Once = Once::new(); -static mut OVERLAY_WINDOW_CLASS: *const Class = std::ptr::null(); -static AGENT_INPUT_TEXT_VIEW_INIT: Once = Once::new(); -static mut AGENT_INPUT_TEXT_VIEW_CLASS: *const Class = std::ptr::null(); -static DROP_TARGET_INIT: Once = Once::new(); -static mut DROP_TARGET_CLASS: *const Class = std::ptr::null(); -#[repr(C)] -#[derive(Clone, Copy)] -pub struct ObjcCGPoint { - x: CGFloat, - y: CGFloat, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct ObjcCGSize { - width: CGFloat, - height: CGFloat, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct ObjcCGRect { - origin: ObjcCGPoint, - size: ObjcCGSize, -} - -#[cfg(target_pointer_width = "64")] -const OBJC_POINT_ENCODING: &str = "{CGPoint=dd}"; -#[cfg(target_pointer_width = "32")] -const OBJC_POINT_ENCODING: &str = "{CGPoint=ff}"; - -#[cfg(target_pointer_width = "64")] -const OBJC_SIZE_ENCODING: &str = "{CGSize=dd}"; -#[cfg(target_pointer_width = "32")] -const OBJC_SIZE_ENCODING: &str = "{CGSize=ff}"; - -#[cfg(target_pointer_width = "64")] -const OBJC_RECT_ENCODING: &str = "{CGRect={CGPoint=dd}{CGSize=dd}}"; -#[cfg(target_pointer_width = "32")] -const OBJC_RECT_ENCODING: &str = "{CGRect={CGPoint=ff}{CGSize=ff}}"; - -unsafe impl objc::Encode for ObjcCGPoint { - fn encode() -> objc::Encoding { - unsafe { objc::Encoding::from_str(OBJC_POINT_ENCODING) } - } -} - -unsafe impl objc::Encode for ObjcCGSize { - fn encode() -> objc::Encoding { - unsafe { objc::Encoding::from_str(OBJC_SIZE_ENCODING) } - } -} - -unsafe impl objc::Encode for ObjcCGRect { - fn encode() -> objc::Encoding { - unsafe { objc::Encoding::from_str(OBJC_RECT_ENCODING) } - } -} - -impl From for ObjcCGRect { - fn from(value: CGRect) -> Self { - Self { - origin: ObjcCGPoint { - x: value.origin.x, - y: value.origin.y, - }, - size: ObjcCGSize { - width: value.size.width, - height: value.size.height, - }, - } - } -} - -impl From for CGRect { - fn from(value: ObjcCGRect) -> Self { - CGRect::new( - &CGPoint::new(value.origin.x, value.origin.y), - &CGSize::new(value.size.width, value.size.height), - ) - } -} -/// Get or create the action handler class for UI controls -pub fn action_handler_class() -> *const Class { - unsafe { - ACTION_HANDLER_INIT.call_once(|| { - let superclass = Class::get("NSObject").expect("NSObject not found"); - let mut decl = ClassDecl::new("VoiceChatOverlayActionHandler", superclass) - .expect("Failed to declare handler class"); - decl.add_method(sel!(onSend:), on_send as extern "C" fn(&Object, Sel, Id)); - decl.add_method( - sel!(onInputSubmit:), - on_send as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAttachMenu:), - on_attach_menu as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAttachPick:), - on_attach_pick as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAttachClear:), - on_attach_clear as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabDrawer:), - on_tab_drawer as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabAgent:), - on_tab_agent as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onTabSettings:), - on_tab_settings as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method(sel!(onClose:), on_close as extern "C" fn(&Object, Sel, Id)); - decl.add_method( - sel!(onCopyLastResponse:), - on_copy_last_response as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onPasteLastResponse:), - on_paste_last_response as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCopyMessage:), - on_copy_message as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onToggleBubbleRender:), - on_toggle_bubble_render as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAssistantBubbleClick:), - on_assistant_bubble_click as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAgentScrollLive:), - on_agent_scroll_live as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onLatestMessage:), - on_latest_message as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCardCopy:), - on_card_copy as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCardRestore:), - on_card_restore as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCardEdit:), - on_card_edit as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCardDelete:), - on_card_delete as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCardFavorite:), - on_card_favorite as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onSearchChanged:), - on_search_changed as extern "C" fn(&Object, Sel, Id), - ); - // NSTextField/NSSearchField delegate callback for per-keystroke filtering. - decl.add_method( - sel!(controlTextDidChange:), - on_control_text_did_change as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onNewThread:), - on_new_thread as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onToggleFavoritesOnly:), - on_toggle_favorites_only as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onStartRecording:), - on_start_recording as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onHeaderRecord:), - on_header_record as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onShowOverlay:), - on_show_overlay as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCommitMessage:), - on_commit_message as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onCommitPendingFollowup:), - on_commit_pending_followup as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onEditPendingFollowup:), - on_edit_pending_followup as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onDiscardMessage:), - on_discard_message as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onExportMenu:), - on_export_menu as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onExportAllCopy:), - on_export_all_copy as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onExportAllSave:), - on_export_all_save as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onExportAssistantCopy:), - on_export_assistant_copy as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onExportAssistantSave:), - on_export_assistant_save as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onMoreMenu:), - on_more_menu as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onShowShortcuts:), - on_show_shortcuts as extern "C" fn(&Object, Sel, Id), - ); - // Connector actions (GitHub, URL) - decl.add_method( - sel!(onAttachGitHub:), - on_attach_github as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onAttachURL:), - on_attach_url as extern "C" fn(&Object, Sel, Id), - ); - // Attachment chip actions - decl.add_method( - sel!(onChipClick:), - on_chip_click as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onChipRemove:), - on_chip_remove as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onChipPreview:), - on_chip_preview as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onChipReveal:), - on_chip_reveal as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(onChipCopyPath:), - on_chip_copy_path as extern "C" fn(&Object, Sel, Id), - ); - // NSTextView delegate (auto-resize input bar as content grows/shrinks). - decl.add_method( - sel!(textDidChange:), - on_text_did_change as extern "C" fn(&Object, Sel, Id), - ); - // Intercept Enter → send, Shift+Enter → newline. - decl.add_method( - sel!(textView:doCommandBySelector:), - on_do_command_by_selector as extern "C" fn(&Object, Sel, Id, Sel) -> bool, - ); - let cls = decl.register(); - ACTION_HANDLER_CLASS = cls; - }); - ACTION_HANDLER_CLASS - } -} - -/// Get or create the window delegate class -pub fn window_delegate_class() -> *const Class { - unsafe { - WINDOW_DELEGATE_INIT.call_once(|| { - let superclass = Class::get("NSObject").expect("NSObject not found"); - let mut decl = ClassDecl::new("VoiceChatOverlayWindowDelegate", superclass) - .expect("Failed to declare window delegate class"); - decl.add_method( - sel!(windowWillClose:), - on_window_will_close as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(windowDidEndLiveResize:), - on_window_did_end_live_resize as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(windowDidResize:), - on_window_did_resize as extern "C" fn(&Object, Sel, Id), - ); - decl.add_method( - sel!(windowDidChangeScreen:), - on_window_did_change_screen as extern "C" fn(&Object, Sel, Id), - ); - let cls = decl.register(); - WINDOW_DELEGATE_CLASS = cls; - }); - WINDOW_DELEGATE_CLASS - } -} - -/// Get or create the overlay window subclass. -/// -/// We use a borderless floating window for the overlay. On macOS, borderless NSWindow -/// instances are often not keyable by default, which prevents typing into NSTextField -/// controls. This subclass opts into key/main status so the Agent input field works -/// when the user clicks the overlay. -pub fn overlay_window_class() -> *const Class { - unsafe { - OVERLAY_WINDOW_INIT.call_once(|| { - let superclass = Class::get("NSWindow").expect("NSWindow not found"); - let mut decl = ClassDecl::new("VoiceChatOverlayWindow", superclass) - .expect("Failed to declare overlay window class"); - decl.add_method( - sel!(canBecomeKeyWindow), - can_become_key_window as extern "C" fn(&Object, Sel) -> bool, - ); - decl.add_method( - sel!(canBecomeMainWindow), - can_become_main_window as extern "C" fn(&Object, Sel) -> bool, - ); - decl.add_method( - sel!(performKeyEquivalent:), - perform_key_equivalent as extern "C" fn(&Object, Sel, Id) -> bool, - ); - decl.add_method( - sel!(constrainFrameRect:toScreen:), - constrain_frame_rect_to_screen - as extern "C" fn(&Object, Sel, ObjcCGRect, Id) -> ObjcCGRect, - ); - decl.add_method( - sel!(draggingEntered:), - on_dragging_entered as extern "C" fn(&Object, Sel, Id) -> u64, - ); - decl.add_method( - sel!(draggingUpdated:), - // Keep the same operation semantics while cursor moves over the drop target. - on_dragging_entered as extern "C" fn(&Object, Sel, Id) -> u64, - ); - decl.add_method( - sel!(performDragOperation:), - on_perform_drag_operation as extern "C" fn(&Object, Sel, Id) -> bool, - ); - let cls = decl.register(); - OVERLAY_WINDOW_CLASS = cls; - }); - OVERLAY_WINDOW_CLASS - } -} - -/// NSTextView subclass for the Agent input bar. -/// -/// The Edit menu dispatches Cmd+V as `paste:` directly through the responder -/// chain, bypassing `textView:doCommandBySelector:`. Override `paste:` here so -/// file URLs and standalone clipboard images reach the attachment interceptor. -pub fn agent_input_text_view_class() -> *const Class { - unsafe { - AGENT_INPUT_TEXT_VIEW_INIT.call_once(|| { - let superclass = Class::get("NSTextView").expect("NSTextView not found"); - let mut decl = ClassDecl::new("VoiceChatAgentInputTextView", superclass) - .expect("Failed to declare agent input text view class"); - decl.add_method( - sel!(paste:), - on_agent_input_paste as extern "C" fn(&Object, Sel, Id), - ); - let cls = decl.register(); - AGENT_INPUT_TEXT_VIEW_CLASS = cls; - }); - AGENT_INPUT_TEXT_VIEW_CLASS - } -} - -pub extern "C" fn on_agent_input_paste(this: &Object, _cmd: Sel, sender: Id) { - unsafe { - if try_paste_as_attachment() { - return; - } - - let superclass = Class::get("NSTextView").expect("NSTextView not found"); - let _: () = msg_send![super(this, superclass), paste: sender]; - } -} - -/// Drop target view for attachments (supports dragging files into the Agent input bar). -pub fn drop_target_view_class() -> *const Class { - unsafe { - DROP_TARGET_INIT.call_once(|| { - let superclass = Class::get("NSView").expect("NSView not found"); - let mut decl = ClassDecl::new("VoiceChatAttachmentDropView", superclass) - .expect("Failed to declare drop target class"); - decl.add_method( - sel!(draggingEntered:), - on_dragging_entered as extern "C" fn(&Object, Sel, Id) -> u64, - ); - decl.add_method( - sel!(performDragOperation:), - on_perform_drag_operation as extern "C" fn(&Object, Sel, Id) -> bool, - ); - let cls = decl.register(); - DROP_TARGET_CLASS = cls; - }); - DROP_TARGET_CLASS - } -} diff --git a/app/ui/voice_chat/handlers/connectors.rs b/app/ui/voice_chat/handlers/connectors.rs deleted file mode 100644 index 02804ff4..00000000 --- a/app/ui/voice_chat/handlers/connectors.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Connector attachments: fetch remote content as local attachment files. -//! -//! GitHub blob references and arbitrary URLs, fetched on a background thread -//! and registered as attachments on the main thread. - -use super::*; - -// ═══════════════════════════════════════════════════════════ -// Connector Handlers (GitHub, URL) -// ═══════════════════════════════════════════════════════════ - -/// Show an input dialog and fetch a file from GitHub, adding it as an attachment. -pub extern "C" fn on_attach_github(_this: &Object, _cmd: Sel, _sender: Id) { - let input = show_text_input_dialog( - "Attach from GitHub", - "Enter a GitHub file URL or spec:\n\ - \u{2022} https://github.com/owner/repo/blob/branch/path\n\ - \u{2022} owner/repo@branch:path/to/file", - "https://github.com/...", - ); - let Some(input) = input else { return }; - let input = input.trim().to_string(); - if input.is_empty() { - return; - } - - let Some(gh_ref) = codescribe_core::connectors::github::parse_github_ref(&input) else { - show_error_alert( - "Invalid GitHub reference", - &format!("Could not parse: {input}"), - ); - return; - }; - - // Fetch in background thread, then add attachment on main thread. - // - // P2.4 (DEFERRED — cross-cut): ideally this would reuse the main - // multi-threaded tokio runtime (bin: worker_threads = 4) via a cached - // `Handle`. But this is an objc `extern "C"` action firing on the AppKit - // main thread, which is NOT a tokio worker, so `Handle::current()` would - // panic and no global `Handle` is exposed to the voice_chat domain. - // Caching one requires a startup-side `OnceLock` in bin/controller - // (GROUP controller/concurrency). Until that lands, keep the per-fetch - // current-thread runtime: connector fetches are rare and short-lived. - std::thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - let msg = format!("Failed to start async runtime: {e}"); - Queue::main().exec_async(move || show_error_alert("GitHub Fetch Error", &msg)); - return; - } - }; - let result = rt.block_on(async { - let token = codescribe_core::connectors::github::load_github_token(); - codescribe_core::connectors::github::fetch_github_blob(&gh_ref, token.as_deref()).await - }); - match result { - Ok((data, filename)) => { - match codescribe_core::attachment::AttachmentStore::save_fetched( - &data, &filename, "gh", - ) { - Ok(path) => { - Queue::main().exec_async(move || { - let (btn_ptr, count, names, skipped) = { - let mut state = - OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let mut skipped = Vec::new(); - let attachment = Attachment::from_path( - path, - AttachmentSource::Connector("github".into()), - ); - push_attachment_if_allowed(&mut state, attachment, &mut skipped); - state.attachments_last_sent = None; - // P2.11: safe to render under the held lock — - // render_attachment_chips is run-loop-free (see - // its doc-comment in send.rs); no nested run-loop - // can re-enter and re-lock OVERLAY_STATE. - render_attachment_chips(&mut state); - let names: Vec = state - .attachments - .iter() - .map(|a| a.display_name.clone()) - .collect(); - ( - state.agent_attach_button, - state.attachments.len(), - names, - skipped, - ) - }; - update_attach_button_ui(btn_ptr, count, names); - show_oversized_attachments_alert(&skipped); - }); - } - Err(e) => { - let msg = format!("Failed to save: {e}"); - Queue::main() - .exec_async(move || show_error_alert("GitHub Fetch Error", &msg)); - } - } - } - Err(e) => { - let msg = format!("{e}"); - Queue::main().exec_async(move || show_error_alert("GitHub Fetch Error", &msg)); - } - } - }); -} - -/// Show an input dialog and fetch content from a URL, adding it as an attachment. -pub extern "C" fn on_attach_url(_this: &Object, _cmd: Sel, _sender: Id) { - let input = show_text_input_dialog( - "Attach from URL", - "Enter a URL to fetch as attachment context:", - "https://...", - ); - let Some(input) = input else { return }; - let input = input.trim().to_string(); - if input.is_empty() { - return; - } - if !codescribe_core::connectors::web::looks_like_url(&input) { - show_error_alert("Invalid URL", "URL must start with http:// or https://"); - return; - } - - // Fetch in background thread, then add attachment on main thread. - // - // P2.4 (DEFERRED — cross-cut): see `on_attach_github` above. Reusing the - // main runtime needs a startup-cached `Handle` (GROUP controller/concurrency); - // `Handle::current()` panics on the AppKit main thread. Per-fetch runtime - // retained until that cross-cut lands. - std::thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - let msg = format!("Failed to start async runtime: {e}"); - Queue::main().exec_async(move || show_error_alert("URL Fetch Error", &msg)); - return; - } - }; - let result = rt.block_on(codescribe_core::connectors::web::fetch_url_as_text(&input)); - match result { - Ok((text, title)) => { - let display_name = if title.is_empty() { - "webpage.txt".to_string() - } else { - format!("{}.txt", title.chars().take(40).collect::()) - }; - match codescribe_core::attachment::AttachmentStore::save_fetched( - text.as_bytes(), - &display_name, - "url", - ) { - Ok(path) => { - Queue::main().exec_async(move || { - let (btn_ptr, count, names, skipped) = { - let mut state = - OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let mut skipped = Vec::new(); - let att = Attachment::with_kind( - path, - codescribe_core::attachment::AttachmentKind::UrlSnapshot, - AttachmentSource::Connector("web".into()), - ); - push_attachment_if_allowed(&mut state, att, &mut skipped); - state.attachments_last_sent = None; - // P2.11: safe under the held lock — chip render is - // run-loop-free (see render_attachment_chips_locked - // doc-comment in send.rs). - render_attachment_chips(&mut state); - let names: Vec = state - .attachments - .iter() - .map(|a| a.display_name.clone()) - .collect(); - ( - state.agent_attach_button, - state.attachments.len(), - names, - skipped, - ) - }; - update_attach_button_ui(btn_ptr, count, names); - show_oversized_attachments_alert(&skipped); - }); - } - Err(e) => { - let msg = format!("Failed to save: {e}"); - Queue::main().exec_async(move || show_error_alert("URL Fetch Error", &msg)); - } - } - } - Err(e) => { - let msg = format!("{e}"); - Queue::main().exec_async(move || show_error_alert("URL Fetch Error", &msg)); - } - } - }); -} diff --git a/app/ui/voice_chat/handlers/dialogs.rs b/app/ui/voice_chat/handlers/dialogs.rs deleted file mode 100644 index 17336494..00000000 --- a/app/ui/voice_chat/handlers/dialogs.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Modal NSAlert dialogs used by attachment and connector flows. - -use super::*; - -/// Show a modal text input dialog using NSAlert with an accessory NSTextField. -/// Returns the entered text, or None if the user cancelled. -pub fn show_text_input_dialog(title: &str, message: &str, placeholder: &str) -> Option { - unsafe { - let ns_alert = Class::get("NSAlert").unwrap(); - let alert: Id = msg_send![ns_alert, new]; - let _: () = msg_send![alert, setMessageText: ns_string(title)]; - let _: () = msg_send![alert, setInformativeText: ns_string(message)]; - let _: () = msg_send![alert, addButtonWithTitle: ns_string("OK")]; - let _: () = msg_send![alert, addButtonWithTitle: ns_string("Cancel")]; - let _: () = msg_send![alert, setAlertStyle: 1_isize]; // NSAlertStyleInformational - - // Add a text field as accessory view. - let ns_text_field = Class::get("NSTextField").unwrap(); - let field: Id = msg_send![ns_text_field, alloc]; - let field_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new(300.0, 24.0), - ); - let field: Id = msg_send![field, initWithFrame: field_frame]; - let _: () = msg_send![field, setPlaceholderString: ns_string(placeholder)]; - let _: () = msg_send![alert, setAccessoryView: field]; - - // Make the text field first responder so it's focused. - let window: Id = msg_send![alert, window]; - let _: () = msg_send![window, setInitialFirstResponder: field]; - - // NSModalResponseOK (first button) = 1000 - let response: isize = msg_send![alert, runModal]; - if response != 1000 { - return None; - } - let text: Id = msg_send![field, stringValue]; - if text.is_null() { - return None; - } - let c_str: *const i8 = msg_send![text, UTF8String]; - if c_str.is_null() { - return None; - } - let s = std::ffi::CStr::from_ptr(c_str) - .to_string_lossy() - .to_string(); - Some(s) - } -} - -/// Show a simple error alert. -pub fn show_error_alert(title: &str, message: &str) { - unsafe { - let ns_alert = Class::get("NSAlert").unwrap(); - let alert: Id = msg_send![ns_alert, new]; - let _: () = msg_send![alert, setMessageText: ns_string(title)]; - let _: () = msg_send![alert, setInformativeText: ns_string(message)]; - let _: () = msg_send![alert, setAlertStyle: 2_isize]; // NSAlertStyleCritical - let _: () = msg_send![alert, runModal]; - } -} diff --git a/app/ui/voice_chat/handlers/menus.rs b/app/ui/voice_chat/handlers/menus.rs deleted file mode 100644 index c6c76034..00000000 --- a/app/ui/voice_chat/handlers/menus.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! Popup menus anchored to overlay buttons. -//! -//! Attach menu (files / GitHub / URL / clear), export menu with All and -//! Assistant-only submenus, the overflow "more" menu, and the export -//! trampoline actions they target. - -use super::*; - -pub extern "C" fn on_attach_menu(this: &Object, _cmd: Sel, sender: Id) { - unsafe { - let ns_menu = Class::get("NSMenu").unwrap(); - let ns_menu_item = Class::get("NSMenuItem").unwrap(); - - let menu: Id = msg_send![ns_menu, new]; - let target: Id = (this as *const Object) as Id; - - let attach: Id = msg_send![ns_menu_item, alloc]; - let attach: Id = msg_send![ - attach, - initWithTitle: ns_string("Attach files…") - action: sel!(onAttachPick:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![attach, setTarget: target]; - let _: () = msg_send![menu, addItem: attach]; - - let github: Id = msg_send![ns_menu_item, alloc]; - let github: Id = msg_send![ - github, - initWithTitle: ns_string("Attach from GitHub…") - action: sel!(onAttachGitHub:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![github, setTarget: target]; - let _: () = msg_send![menu, addItem: github]; - - let url: Id = msg_send![ns_menu_item, alloc]; - let url: Id = msg_send![ - url, - initWithTitle: ns_string("Attach from URL…") - action: sel!(onAttachURL:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![url, setTarget: target]; - let _: () = msg_send![menu, addItem: url]; - - let count = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.attachments.len() - }; - if count > 0 { - let sep: Id = msg_send![ns_menu_item, separatorItem]; - let _: () = msg_send![menu, addItem: sep]; - - let clear_title = format!("Clear attachments ({})", count); - let clear: Id = msg_send![ns_menu_item, alloc]; - let clear: Id = msg_send![ - clear, - initWithTitle: ns_string(&clear_title) - action: sel!(onAttachClear:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![clear, setTarget: target]; - let _: () = msg_send![menu, addItem: clear]; - } - - // Pop up anchored at the button. - let bounds: CGRect = msg_send![sender, bounds]; - let location = CGPoint::new(0.0, bounds.size.height); - let nil_item: *mut Object = std::ptr::null_mut(); - let _: bool = msg_send![ - menu, - popUpMenuPositioningItem: nil_item - atLocation: location - inView: sender - ]; - } -} -pub extern "C" fn on_export_menu(this: &Object, _cmd: Sel, sender: Id) { - unsafe { - let ns_menu = Class::get("NSMenu").unwrap(); - let ns_menu_item = Class::get("NSMenuItem").unwrap(); - - let menu: Id = msg_send![ns_menu, new]; - let target: Id = (this as *const Object) as Id; - - // Submenu: All - let all_item: Id = msg_send![ns_menu_item, alloc]; - let all_item: Id = msg_send![ - all_item, - initWithTitle: ns_string("All") - action: std::ptr::null_mut::() - keyEquivalent: ns_string("") - ]; - let all_menu: Id = msg_send![ns_menu, new]; - - let all_copy: Id = msg_send![ns_menu_item, alloc]; - let all_copy: Id = msg_send![ - all_copy, - initWithTitle: ns_string("Copy as Markdown") - action: sel!(onExportAllCopy:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![all_copy, setTarget: target]; - let _: () = msg_send![all_menu, addItem: all_copy]; - - let all_save: Id = msg_send![ns_menu_item, alloc]; - let all_save: Id = msg_send![ - all_save, - initWithTitle: ns_string("Save as Markdown (to history)") - action: sel!(onExportAllSave:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![all_save, setTarget: target]; - let _: () = msg_send![all_menu, addItem: all_save]; - - let _: () = msg_send![all_item, setSubmenu: all_menu]; - let _: () = msg_send![menu, addItem: all_item]; - - // Submenu: Assistant only - let asst_item: Id = msg_send![ns_menu_item, alloc]; - let asst_item: Id = msg_send![ - asst_item, - initWithTitle: ns_string("Assistant only") - action: std::ptr::null_mut::() - keyEquivalent: ns_string("") - ]; - let asst_menu: Id = msg_send![ns_menu, new]; - - let asst_copy: Id = msg_send![ns_menu_item, alloc]; - let asst_copy: Id = msg_send![ - asst_copy, - initWithTitle: ns_string("Copy as Markdown") - action: sel!(onExportAssistantCopy:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![asst_copy, setTarget: target]; - let _: () = msg_send![asst_menu, addItem: asst_copy]; - - let asst_save: Id = msg_send![ns_menu_item, alloc]; - let asst_save: Id = msg_send![ - asst_save, - initWithTitle: ns_string("Save as Markdown (to history)") - action: sel!(onExportAssistantSave:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![asst_save, setTarget: target]; - let _: () = msg_send![asst_menu, addItem: asst_save]; - - let _: () = msg_send![asst_item, setSubmenu: asst_menu]; - let _: () = msg_send![menu, addItem: asst_item]; - - // Pop up anchored at the button. - let bounds: CGRect = msg_send![sender, bounds]; - let location = CGPoint::new(0.0, bounds.size.height); - let nil_item: *mut Object = std::ptr::null_mut(); - let _: bool = msg_send![ - menu, - popUpMenuPositioningItem: nil_item - atLocation: location - inView: sender - ]; - } -} - -pub extern "C" fn on_export_all_copy(_this: &Object, _cmd: Sel, _sender: Id) { - let md = crate::ui::voice_chat::api::export_chat_markdown(false); - copy_to_clipboard(&md); - info!("Exported chat (all) to clipboard as Markdown"); -} - -pub extern "C" fn on_export_all_save(_this: &Object, _cmd: Sel, _sender: Id) { - if let Some(path) = crate::ui::voice_chat::api::save_chat_markdown_to_history(false) { - info!("Saved chat (all) export to {}", path.display()); - crate::ui::voice_chat::api::refresh_drawer(); - } else { - info!("Failed to save chat (all) export"); - } -} - -pub extern "C" fn on_export_assistant_copy(_this: &Object, _cmd: Sel, _sender: Id) { - let md = crate::ui::voice_chat::api::export_chat_markdown(true); - copy_to_clipboard(&md); - info!("Exported chat (assistant-only) to clipboard as Markdown"); -} - -pub extern "C" fn on_export_assistant_save(_this: &Object, _cmd: Sel, _sender: Id) { - if let Some(path) = crate::ui::voice_chat::api::save_chat_markdown_to_history(true) { - info!("Saved chat (assistant-only) export to {}", path.display()); - crate::ui::voice_chat::api::refresh_drawer(); - } else { - info!("Failed to save chat (assistant-only) export"); - } -} -pub extern "C" fn on_more_menu(this: &Object, _cmd: Sel, sender: Id) { - unsafe { - let ns_menu = Class::get("NSMenu").unwrap(); - let ns_menu_item = Class::get("NSMenuItem").unwrap(); - - let menu: Id = msg_send![ns_menu, new]; - let target: Id = (this as *const Object) as Id; - - let new_thread: Id = msg_send![ns_menu_item, alloc]; - let new_thread: Id = msg_send![ - new_thread, - initWithTitle: ns_string("New thread") - action: sel!(onNewThread:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![new_thread, setTarget: target]; - let _: () = msg_send![menu, addItem: new_thread]; - - let sep: Id = msg_send![ns_menu_item, separatorItem]; - let _: () = msg_send![menu, addItem: sep]; - - let copy_last: Id = msg_send![ns_menu_item, alloc]; - let copy_last: Id = msg_send![ - copy_last, - initWithTitle: ns_string("Copy last response") - action: sel!(onCopyLastResponse:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![copy_last, setTarget: target]; - let _: () = msg_send![menu, addItem: copy_last]; - - let paste_last: Id = msg_send![ns_menu_item, alloc]; - let paste_last: Id = msg_send![ - paste_last, - initWithTitle: ns_string("Paste last response") - action: sel!(onPasteLastResponse:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![paste_last, setTarget: target]; - let _: () = msg_send![menu, addItem: paste_last]; - - let sep2: Id = msg_send![ns_menu_item, separatorItem]; - let _: () = msg_send![menu, addItem: sep2]; - - let export_all_copy: Id = msg_send![ns_menu_item, alloc]; - let export_all_copy: Id = msg_send![ - export_all_copy, - initWithTitle: ns_string("Export all (copy markdown)") - action: sel!(onExportAllCopy:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![export_all_copy, setTarget: target]; - let _: () = msg_send![menu, addItem: export_all_copy]; - - let export_all_save: Id = msg_send![ns_menu_item, alloc]; - let export_all_save: Id = msg_send![ - export_all_save, - initWithTitle: ns_string("Export all (save markdown)") - action: sel!(onExportAllSave:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![export_all_save, setTarget: target]; - let _: () = msg_send![menu, addItem: export_all_save]; - - let export_assistant_copy: Id = msg_send![ns_menu_item, alloc]; - let export_assistant_copy: Id = msg_send![ - export_assistant_copy, - initWithTitle: ns_string("Export assistant (copy markdown)") - action: sel!(onExportAssistantCopy:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![export_assistant_copy, setTarget: target]; - let _: () = msg_send![menu, addItem: export_assistant_copy]; - - let export_assistant_save: Id = msg_send![ns_menu_item, alloc]; - let export_assistant_save: Id = msg_send![ - export_assistant_save, - initWithTitle: ns_string("Export assistant (save markdown)") - action: sel!(onExportAssistantSave:) - keyEquivalent: ns_string("") - ]; - let _: () = msg_send![export_assistant_save, setTarget: target]; - let _: () = msg_send![menu, addItem: export_assistant_save]; - - let sep3: Id = msg_send![ns_menu_item, separatorItem]; - let _: () = msg_send![menu, addItem: sep3]; - - let shortcuts_item: Id = msg_send![ns_menu_item, alloc]; - let shortcuts_item: Id = msg_send![ - shortcuts_item, - initWithTitle: ns_string("Keyboard Shortcuts") - action: sel!(onShowShortcuts:) - keyEquivalent: ns_string("?") - ]; - let _: () = msg_send![shortcuts_item, setTarget: target]; - let _: () = msg_send![shortcuts_item, setKeyEquivalentModifierMask: (1u64 << 20)]; - let _: () = msg_send![menu, addItem: shortcuts_item]; - - // Pop up anchored at the button. - let bounds: CGRect = msg_send![sender, bounds]; - let location = CGPoint::new(0.0, bounds.size.height); - let nil_item: *mut Object = std::ptr::null_mut(); - let _: bool = msg_send![ - menu, - popUpMenuPositioningItem: nil_item - atLocation: location - inView: sender - ]; - } -} diff --git a/app/ui/voice_chat/handlers/mod.rs b/app/ui/voice_chat/handlers/mod.rs deleted file mode 100644 index f722aad2..00000000 --- a/app/ui/voice_chat/handlers/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Action handlers for voice chat overlay -//! -//! Contains Objective-C class registration and action handler functions, -//! decomposed by responsibility: class registration, window geometry and -//! zoom, attachment intake, connector fetches, modal dialogs, popup menus -//! and plain action trampolines. - -mod actions; -mod attachments; -mod classes; -mod connectors; -mod dialogs; -mod menus; -mod window; - -#[cfg(test)] -mod tests; - -pub use actions::*; -pub use attachments::*; -pub use classes::*; -pub use connectors::*; -pub use dialogs::*; -pub use menus::*; -pub use window::*; - -use core_graphics::base::CGFloat; -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use dispatch::Queue; -use objc::declare::ClassDecl; -use objc::runtime::{Class, Object, Sel}; -use objc::{msg_send, sel, sel_impl}; -use std::path::PathBuf; -use std::sync::Once; -use tracing::{debug, info}; - -use codescribe_core::attachment::{Attachment, AttachmentSource, AttachmentStore}; -use codescribe_core::config::UserSettings; - -use crate::config::Config; -use crate::ui_helpers::{ - clamp_overlay_position, copy_to_clipboard, get_text_field_string, ns_string, set_hidden, - set_text_field_string, -}; - -use super::api::{ - clear_overlay_state, commit_last_user_message_impl, discard_last_message_impl, filter_drawer, - handle_agent_scroll_live, handle_card_copy, handle_card_delete, handle_card_edit, - handle_card_favorite, handle_card_restore, handle_message_bubble_click_from_recognizer, - pin_agent_scroll_to_latest_impl, reflow_agent_after_resize_impl, - reflow_overlay_after_resize_impl, render_attachment_chips, send_draft_message_impl, - start_new_thread_impl, toggle_drawer_favorites_only_impl, toggle_message_render_mode_impl, - update_active_tab_impl, update_attach_button_ui, -}; -use super::state::{ChatRole, OVERLAY_STATE, Tab, VoiceChatOverlayState}; - -// Type alias for Objective-C object pointers -pub use crate::ui_helpers::Id; diff --git a/app/ui/voice_chat/handlers/tests.rs b/app/ui/voice_chat/handlers/tests.rs deleted file mode 100644 index e0cc2643..00000000 --- a/app/ui/voice_chat/handlers/tests.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Unit tests for overlay handler classes and zoom canonicalization. - -use super::*; - -fn assert_selector_registered(class: *const Class, selector: Sel, label: &str) { - // SAFETY: `overlay_window_class` registers a valid Objective-C class, and this - // only asks the runtime whether instances handle the selector. - let responds: bool = unsafe { msg_send![class, instancesRespondToSelector: selector] }; - assert!( - responds, - "VoiceChatOverlayWindow missing selector `{label}`" - ); -} - -#[test] -fn test_canonical_zoom_level_rounds_and_clamps() { - assert!((canonical_zoom_level(1.0) - 1.0).abs() < f64::EPSILON); - assert!((canonical_zoom_level(1.129) - 1.13).abs() < 0.0001); - assert!((canonical_zoom_level(0.2) - 0.75).abs() < 0.0001); - assert!((canonical_zoom_level(2.8) - 2.0).abs() < 0.0001); -} - -#[test] -fn overlay_window_subclass_keeps_floating_input_keyable() { - assert!(overlay_window_allows_key_input()); - assert!(overlay_window_allows_main_status()); - - let class = overlay_window_class(); - assert!( - !class.is_null(), - "VoiceChatOverlayWindow class should be registered" - ); - - assert_selector_registered(class, sel!(canBecomeKeyWindow), "canBecomeKeyWindow"); - assert_selector_registered(class, sel!(canBecomeMainWindow), "canBecomeMainWindow"); -} - -#[test] -fn agent_input_text_view_overrides_paste() { - let class = agent_input_text_view_class(); - assert!( - !class.is_null(), - "VoiceChatAgentInputTextView class should be registered" - ); - - assert_selector_registered(class, sel!(paste:), "paste:"); -} - -#[test] -fn paste_disposition_matches_attachment_policy() { - use PasteDisposition::{Attachment, TextPaste}; - - let cases = [ - (true, false, false, Attachment), - (true, true, false, Attachment), - (true, true, true, Attachment), - (false, true, false, Attachment), - (false, false, false, TextPaste), - (false, false, true, TextPaste), - (false, true, true, TextPaste), - ]; - - for (has_files, has_image, has_text, expected) in cases { - assert_eq!( - paste_disposition(has_files, has_image, has_text), - expected, - "has_files={has_files}, has_image={has_image}, has_text={has_text}" - ); - } -} diff --git a/app/ui/voice_chat/handlers/window.rs b/app/ui/voice_chat/handlers/window.rs deleted file mode 100644 index ab5c35e0..00000000 --- a/app/ui/voice_chat/handlers/window.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Overlay window geometry and zoom behavior. -//! -//! Keyability of the borderless window, screen-frame clamping, content max -//! size enforcement, post-resize settling, window delegate callbacks and the -//! Cmd +/-/0 chat zoom with debounced settings save. - -use super::*; - -static RESIZE_SETTLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -pub fn overlay_window_allows_key_input() -> bool { - true -} - -pub fn overlay_window_allows_main_status() -> bool { - true -} - -pub extern "C" fn can_become_key_window(_this: &Object, _cmd: Sel) -> bool { - overlay_window_allows_key_input() -} - -pub extern "C" fn can_become_main_window(_this: &Object, _cmd: Sel) -> bool { - overlay_window_allows_main_status() -} - -pub extern "C" fn constrain_frame_rect_to_screen( - this: &Object, - _cmd: Sel, - frame_rect: ObjcCGRect, - screen: Id, -) -> ObjcCGRect { - unsafe { - let superclass = Class::get("NSWindow").unwrap(); - let super_rect: ObjcCGRect = - msg_send![super(this, superclass), constrainFrameRect: frame_rect toScreen: screen]; - let constrained: CGRect = super_rect.into(); - let Some(visible_frame) = visible_frame_for_screen(screen) else { - return super_rect; - }; - - let (x, y) = clamp_overlay_position( - visible_frame, - constrained.size.width, - constrained.size.height, - 0.0, - constrained.origin.x, - constrained.origin.y, - ); - - if (x - constrained.origin.x).abs() <= 0.5 && (y - constrained.origin.y).abs() <= 0.5 { - super_rect - } else { - ObjcCGRect::from(CGRect::new(&CGPoint::new(x, y), &constrained.size)) - } - } -} - -pub extern "C" fn perform_key_equivalent(_this: &Object, _cmd: Sel, event: Id) -> bool { - unsafe { - let flags: u64 = msg_send![event, modifierFlags]; - let has_cmd = (flags & (1 << 20)) != 0; // NSEventModifierFlagCommand - if !has_cmd { - return false; - } - - let chars: Id = msg_send![event, charactersIgnoringModifiers]; - if chars.is_null() { - return false; - } - let c_str: *const i8 = msg_send![chars, UTF8String]; - if c_str.is_null() { - return false; - } - let key = std::ffi::CStr::from_ptr(c_str).to_string_lossy(); - - match key.as_ref() { - "=" | "+" => { - adjust_chat_zoom(0.125); - true - } - "-" => { - adjust_chat_zoom(-0.125); - true - } - "0" => { - set_chat_zoom(1.0); - true - } - _ => false, - } - } -} - -/// Monotonic generation counter for zoom save debounce. -static ZOOM_SAVE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -pub fn canonical_zoom_level(zoom: f64) -> f64 { - UserSettings::normalized_chat_zoom(zoom).unwrap_or(1.0) -} - -fn adjust_chat_zoom(delta: f64) { - let zoom = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let prev = canonical_zoom_level(state.zoom_level); - let next = canonical_zoom_level(state.zoom_level + delta); - if (next - prev).abs() < f64::EPSILON { - None - } else { - state.zoom_level = next; - Some(next) - } - }; - let Some(zoom) = zoom else { - return; - }; - reflow_agent_after_resize_impl(); - schedule_zoom_save(zoom); -} - -fn set_chat_zoom(level: f64) { - let zoom = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let next = canonical_zoom_level(level); - if (state.zoom_level - next).abs() < f64::EPSILON { - None - } else { - state.zoom_level = next; - Some(next) - } - }; - let Some(zoom) = zoom else { - return; - }; - reflow_agent_after_resize_impl(); - schedule_zoom_save(zoom); -} - -/// Debounced save: waits 500ms, then saves only if no newer zoom change occurred. -fn schedule_zoom_save(zoom: f64) { - let generation = ZOOM_SAVE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(500)); - if ZOOM_SAVE_GEN.load(std::sync::atomic::Ordering::Relaxed) != generation { - return; // newer zoom change supersedes - } - let mut settings = UserSettings::load(); - if !settings.set_chat_zoom(zoom) { - debug!("Chat zoom unchanged after debounce; skipping settings save"); - } - }); -} -pub extern "C" fn on_window_will_close(_this: &Object, _cmd: Sel, _notification: Id) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - clear_overlay_state(&mut state); - debug!("Voice chat overlay closed by user"); -} - -fn window_visible_frame(window: Id) -> Option { - unsafe { - let screen: Id = msg_send![window, screen]; - visible_frame_for_screen(screen) - } -} - -fn visible_frame_for_screen(screen: Id) -> Option { - unsafe { - let ns_screen = Class::get("NSScreen").unwrap(); - let mut target_screen = screen; - if target_screen.is_null() { - target_screen = msg_send![ns_screen, mainScreen]; - } - if target_screen.is_null() { - None - } else { - Some(msg_send![target_screen, visibleFrame]) - } - } -} - -fn update_overlay_content_max_size(window: Id) -> Option { - let visible = window_visible_frame(window)?; - let max_size = CGSize::new(visible.size.width.min(1000.0), visible.size.height); - unsafe { - let _: () = msg_send![window, setContentMaxSize: max_size]; - } - Some(max_size) -} - -fn enforce_overlay_content_max_size(window: Id, animate: bool) { - let Some(max_size) = update_overlay_content_max_size(window) else { - return; - }; - - let frame: CGRect = unsafe { msg_send![window, frame] }; - let mut new_frame = frame; - let mut changed = false; - - if frame.size.width > max_size.width { - new_frame.size.width = max_size.width; - changed = true; - } - - if frame.size.height > max_size.height { - // Keep top edge visually stable while shrinking height. - new_frame.origin.y += frame.size.height - max_size.height; - new_frame.size.height = max_size.height; - changed = true; - } - - if changed { - unsafe { - let _: () = msg_send![window, setFrame: new_frame display: true animate: animate]; - } - } -} - -fn clamp_overlay_window_to_visible(window: Id) { - let Some(visible_frame) = window_visible_frame(window) else { - return; - }; - let frame: CGRect = unsafe { msg_send![window, frame] }; - // Keep native snap/tile edge alignment; only guarantee visibility. - let margin = 0.0; - - let (x, y) = clamp_overlay_position( - visible_frame, - frame.size.width, - frame.size.height, - margin, - frame.origin.x, - frame.origin.y, - ); - - if (x - frame.origin.x).abs() > 0.5 || (y - frame.origin.y).abs() > 0.5 { - unsafe { - let _: () = msg_send![window, setFrameOrigin: CGPoint::new(x, y)]; - } - } -} - -fn schedule_post_resize_settle(window: Id) { - let window_ptr = window as usize; - let generation = RESIZE_SETTLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(120)); - if RESIZE_SETTLE_GEN.load(std::sync::atomic::Ordering::Relaxed) != generation { - return; - } - Queue::main().exec_async(move || { - let active_window = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window - }; - if active_window != Some(window_ptr) { - return; - } - let window = window_ptr as Id; - enforce_overlay_content_max_size(window, false); - clamp_overlay_window_to_visible(window); - reflow_overlay_after_resize_impl(); - reflow_agent_after_resize_impl(); - }); - }); -} - -pub extern "C" fn on_window_did_end_live_resize(_this: &Object, _cmd: Sel, notification: Id) { - unsafe { - let window: Id = msg_send![notification, object]; - if !window.is_null() { - enforce_overlay_content_max_size(window, true); - clamp_overlay_window_to_visible(window); - } - - // Reflow footer/input and bubbles after resize settles. - Queue::main().exec_async(|| { - reflow_overlay_after_resize_impl(); - reflow_agent_after_resize_impl(); - }); - } -} - -pub extern "C" fn on_window_did_resize(_this: &Object, _cmd: Sel, notification: Id) { - unsafe { - let window: Id = msg_send![notification, object]; - if window.is_null() { - return; - } - let in_live_resize: bool = msg_send![window, inLiveResize]; - if in_live_resize { - return; - } - schedule_post_resize_settle(window); - } -} - -pub extern "C" fn on_window_did_change_screen(_this: &Object, _cmd: Sel, notification: Id) { - unsafe { - let window: Id = msg_send![notification, object]; - if window.is_null() { - return; - } - enforce_overlay_content_max_size(window, false); - clamp_overlay_window_to_visible(window); - } - Queue::main().exec_async(|| { - reflow_overlay_after_resize_impl(); - reflow_agent_after_resize_impl(); - }); -} diff --git a/app/ui/voice_chat/mod.rs b/app/ui/voice_chat/mod.rs deleted file mode 100644 index 25d11892..00000000 --- a/app/ui/voice_chat/mod.rs +++ /dev/null @@ -1,1365 +0,0 @@ -//! Voice Chat UI overlay for displaying streaming responses. -//! -//! This module provides a floating overlay window with: -//! - Drawer tab: clipboard-style transcription cards -//! - Agent tab: chat bubbles with streaming LLM responses -//! - Settings button: opens the persistent settings window - -mod api; -mod handlers; -mod state; - -// Re-export public API -pub use api::{ - add_voice_chat_error_message, add_voice_chat_system_message, add_voice_chat_user_message, - append_voice_chat_assistant_delta, append_voice_chat_reasoning_delta, - append_voice_chat_user_delta, append_voice_chat_user_utterance, clear_voice_chat_text, - commit_last_user_message, commit_pending_followup_message, dispatch_voice_chat_send, - edit_pending_followup_message, filter_drawer, finalize_voice_chat_assistant_message, - finalize_voice_chat_user_message, handoff_transcript_to_chat, hide_voice_chat_overlay, - is_auto_send_enabled, is_conversation_active, is_voice_chat_overlay_visible, refresh_drawer, - reset_voice_chat_activity, send_voice_chat_draft, set_voice_chat_agent_thinking, - set_voice_chat_runtime_degraded, set_voice_chat_send_callback, set_voice_chat_sending, - set_voice_chat_target_app, set_voice_chat_text, set_voice_chat_user_text, show_agent_tab, - show_drawer_tab, update_conversation_state, update_drawer_after_save, - update_voice_chat_context_summary, update_voice_chat_status, -}; -pub use state::{ConversationModeState, VoiceChatOverlayConfig}; - -use codescribe_core::config::{Config, OverlayPositionMode}; -use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use dispatch::Queue; -use objc::runtime::Class; -use objc::{msg_send, sel, sel_impl}; -use objc2_app_kit::{NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState}; -use std::thread; -use std::time::Duration; -use tracing::{debug, info, warn}; - -use crate::config::ShortcutBinding; -use crate::os::hotkeys::ModeHotkeyBindings; - -use crate::ui_helpers::{ - LabelConfig, NSEdgeInsets, add_subview, agent_chat_shell_frame, agent_chat_shell_panel_policy, - apply_shared_shell_panel_policy, apply_tafla_surface, button_set_action, button_style, - chat_header_layout, chat_input_row_layout, color_secondary_label, create_button, - create_flipped_vertical_stack_view, create_glass_effect_view_with, create_label, - create_scrollable_text_view_with_class, create_vertical_stack_view, - layout_region_frame_for_view, main_screen_visible_frame, ns_string, present_shared_shell_panel, - set_button_symbol, set_focus_ring, set_glass_effect_content_view, set_hidden, set_tooltip, - style_toolbar_icon_button, ui_colors, ui_tokens, window_set_alpha, -}; - -use api::update_active_tab_impl; -use handlers::{ - action_handler_class, agent_input_text_view_class, drop_target_view_class, - overlay_window_class, window_delegate_class, -}; -use state::{OVERLAY_STATE, Tab}; - -// Type alias for Objective-C object pointers -pub use crate::ui_helpers::Id; - -// NSViewAutoresizingMaskOptions (bitmask) -const NSVIEW_MIN_X_MARGIN: isize = 1; -const NSVIEW_WIDTH_SIZABLE: isize = 2; -const NSVIEW_MAX_X_MARGIN: isize = 4; -const NSVIEW_MIN_Y_MARGIN: isize = 8; -const NSVIEW_HEIGHT_SIZABLE: isize = 16; -const NSVIEW_MAX_Y_MARGIN: isize = 32; - -fn chat_content_height_under_header( - content_height: f64, - footer_height: f64, - content_gap: f64, -) -> f64 { - (content_height - footer_height - content_gap).max(0.0) -} - -fn chat_scroll_inset_above_input(input_height: f64, inner_pad: f64, content_gap: f64) -> f64 { - (input_height + inner_pad + content_gap).max(0.0) -} - -fn latest_pill_frame( - content_width: f64, - footer_inset: f64, - input_height: f64, - content_gap: f64, - extra_above_input: f64, -) -> CGRect { - let width = 96.0; - let height = 28.0; - let x = ((content_width - width) / 2.0).max(0.0); - let y = footer_inset + input_height + content_gap.max(6.0) + extra_above_input; - CGRect::new(&CGPoint::new(x, y), &CGSize::new(width, height)) -} - -pub(super) fn shortcuts_lines(bindings: ModeHotkeyBindings) -> (String, String) { - let hold_line = match bindings.dictation { - ShortcutBinding::HoldFn => "Hold Fn — record • Fn+Shift — chat • Fn+Cmd — selection", - ShortcutBinding::HoldCtrl => "Hold Ctrl — record", - ShortcutBinding::HoldCtrlAlt => { - "Hold Ctrl — record • Ctrl+Option — format • Ctrl+Shift — chat • Ctrl+Cmd — selection" - } - ShortcutBinding::HoldCtrlShift => "Hold Ctrl+Shift — record", - ShortcutBinding::HoldCtrlCmd => "Hold Ctrl+Cmd — record", - ShortcutBinding::Disabled - | ShortcutBinding::DoubleCtrl - | ShortcutBinding::DoubleLeftOption - | ShortcutBinding::DoubleRightOption => "Hold-to-talk disabled", - }; - - let toggle_line = if bindings.dictation == ShortcutBinding::DoubleCtrl { - "Ctrl Ctrl — toggle (raw)" - } else { - let formatting_left = bindings.formatting == ShortcutBinding::DoubleLeftOption; - let assistive_right = bindings.assistive == ShortcutBinding::DoubleRightOption; - match (formatting_left, assistive_right) { - (true, true) => "⌥⌥ — toggle • Right ⌥⌥ — AI", - (true, false) => "Left ⌥⌥ — toggle", - (false, true) => "Right ⌥⌥ — AI", - (false, false) => "Toggle disabled", - } - }; - - (hold_line.to_string(), toggle_line.to_string()) -} - -/// Show the voice chat overlay window -pub fn show_voice_chat_overlay() { - Queue::main().exec_async(|| { - show_voice_chat_overlay_impl(); - }); -} - -/// Show the voice chat overlay with custom configuration -pub fn show_voice_chat_overlay_with_config(_config: VoiceChatOverlayConfig) { - Queue::main().exec_async(|| { - show_voice_chat_overlay_impl(); - }); -} - -fn show_voice_chat_overlay_impl() { - unsafe { - // DEADLOCK PREVENTION: Do NOT hold OVERLAY_STATE across AppKit calls. - // AppKit can spin a nested runloop during window_show / animate_fade / - // orderFront, and pending Queue::main().exec_async blocks that also lock - // OVERLAY_STATE will deadlock on the non-reentrant Mutex. - // (Same pattern documented in hide_voice_chat_overlay_impl.) - - // Phase 1 — check / reuse existing window (short lock scope). - { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let ns_window = Class::get("NSWindow").unwrap(); - if let Some(window_ptr) = state.window { - let window = window_ptr as Id; - let is_window: bool = msg_send![window, isKindOfClass: ns_window]; - if is_window { - // Reuse path: extract pointers, release lock, THEN do AppKit. - let blur_ptr = state.blur_view; - drop(state); - - if let Some(visible_frame) = main_screen_visible_frame() { - let shell_policy = agent_chat_shell_panel_policy(visible_frame); - apply_shared_shell_panel_policy(window, &shell_policy); - } - present_shared_shell_panel(window); - let _: () = msg_send![window, setAlphaValue: 1.0f64]; - - if let Some(blur_ptr) = blur_ptr { - let blur_view = blur_ptr as Id; - let w_frame: CGRect = msg_send![window, frame]; - let blur_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(w_frame.size.width, w_frame.size.height), - ); - let _: () = msg_send![blur_view, setFrame: blur_frame]; - } - - info!("Voice chat overlay reused"); - return; - } - warn!("Voice chat overlay pointer invalid; recreating window"); - api::clear_overlay_state(&mut state); - } - } // OVERLAY_STATE released — UI construction below is lock-free. - - // Phase 2 — build the entire overlay UI without holding OVERLAY_STATE. - let config = VoiceChatOverlayConfig::default(); - let window_width = config.width; - let window_height = config.height; - let margin = 20.0; - - let Some(visible_frame) = main_screen_visible_frame() else { - warn!("No NSScreen available for voice chat overlay"); - return; - }; - - // Single Config::load(): both arms need it, and `Config::load` walks - // the filesystem (env + settings.json). Hoisting trims a redundant - // disk read per overlay open. - let config = Config::load(); - let (raw_x, raw_y) = match config.overlay_position_mode { - OverlayPositionMode::SnappedTopRight => { - let right_x = visible_frame.origin.x + visible_frame.size.width; - let top_y = visible_frame.origin.y + visible_frame.size.height; - ( - right_x - window_width - margin, - top_y - window_height - margin, - ) - } - OverlayPositionMode::Custom => { - let right_x = visible_frame.origin.x + visible_frame.size.width; - let top_y = visible_frame.origin.y + visible_frame.size.height; - let def_x = right_x - window_width - margin; - let def_y = top_y - window_height - margin; - ( - config.overlay_custom_x.unwrap_or(def_x), - config.overlay_custom_y.unwrap_or(def_y), - ) - } - }; - - let frame = agent_chat_shell_frame( - visible_frame, - window_width, - window_height, - margin, - raw_x, - raw_y, - ); - - info!( - "Voice chat overlay frame x={:.1} y={:.1} w={:.1} h={:.1}", - frame.origin.x, frame.origin.y, window_width, window_height - ); - - let shell_policy = agent_chat_shell_panel_policy(visible_frame); - let overlay_window_class = overlay_window_class(); - let window: Id = msg_send![overlay_window_class, alloc]; - let window: Id = msg_send![ - window, - initWithContentRect: frame - styleMask: shell_policy.style_mask - backing: shell_policy.backing_store - defer: false - ]; - - let _: () = msg_send![window, setTitle: ns_string("CodeScribe")]; - apply_shared_shell_panel_policy(window, &shell_policy); - - let delegate_class = window_delegate_class(); - let window_delegate: Id = msg_send![delegate_class, new]; - let _: () = msg_send![window, setDelegate: window_delegate]; - - let window_content_view: Id = msg_send![window, contentView]; - let ns_mut_array = Class::get("NSMutableArray").unwrap(); - let window_drag_types: Id = msg_send![ns_mut_array, array]; - let _: () = msg_send![window_drag_types, addObject: ns_string("public.file-url")]; - let _: () = msg_send![window_drag_types, addObject: ns_string("NSFilenamesPboardType")]; - let _: () = msg_send![window, registerForDraggedTypes: window_drag_types]; - - let blur_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(window_width, window_height), - ); - let blur_view: Id = create_glass_effect_view_with( - blur_frame, - NSVisualEffectMaterial::HUDWindow, - NSVisualEffectBlendingMode::BehindWindow, - NSVisualEffectState::Active, - ); - let _: () = msg_send![ - blur_view, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let layer: Id = msg_send![blur_view, layer]; - if !layer.is_null() { - let bg = ui_colors::surface_glass(); - let cg_bg: Id = msg_send![bg, CGColor]; - let _: () = msg_send![layer, setBackgroundColor: cg_bg]; - apply_tafla_surface(layer, true); - let _: () = msg_send![layer, setMasksToBounds: true]; - } - add_subview(window_content_view, blur_view); - let glass_content_view: Id = msg_send![Class::get("NSView").unwrap(), alloc]; - let glass_content_view: Id = msg_send![glass_content_view, initWithFrame: blur_frame]; - let _: () = msg_send![ - glass_content_view, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let _: bool = set_glass_effect_content_view(blur_view, glass_content_view); - let bounds: CGRect = msg_send![blur_view, bounds]; - let content_bounds = layout_region_frame_for_view(blur_view).unwrap_or(bounds); - - let action_handler_class = action_handler_class(); - let action_handler: Id = msg_send![action_handler_class, new]; - - let content_pad = ui_tokens::EDGE_PADDING; - - let header_height = ui_tokens::HEADER_HEIGHT_COMPACT; - // Slim, brand-only footer: the transcript filter lives in the sidebar - // column, so the chat pane (and the scroll-under-input effect) reaches - // almost to the window edge instead of dying on a 40pt dead strip. - let footer_height = ui_tokens::CHAT_FOOTER_HEIGHT; - // Start compact; grows dynamically as the user types/pastes more content. - // Agent input starts compact and can grow with content (see `resize_agent_input_locked`). - let agent_input_height = ui_tokens::AGENT_INPUT_HEIGHT; - - // Header is an overlay: messages scroll underneath and the header blur remains clickable. - let header_frame = CGRect::new( - &CGPoint::new( - content_bounds.origin.x, - content_bounds.origin.y + content_bounds.size.height - header_height, - ), - &CGSize::new(content_bounds.size.width.max(0.0), header_height), - ); - let header_bg: Id = create_glass_effect_view_with( - header_frame, - NSVisualEffectMaterial::HUDWindow, - NSVisualEffectBlendingMode::WithinWindow, - NSVisualEffectState::Active, - ); - let _: () = msg_send![ - header_bg, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MIN_Y_MARGIN - ]; - let header_layer: Id = msg_send![header_bg, layer]; - if !header_layer.is_null() { - let header_bg_color = ui_colors::surface_glass(); - let cg_header_bg: Id = msg_send![header_bg_color, CGColor]; - let _: () = msg_send![header_layer, setBackgroundColor: cg_header_bg]; - } - let header_separator: Id = msg_send![Class::get("NSView").unwrap(), alloc]; - let header_separator: Id = msg_send![ - header_separator, - initWithFrame: CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(header_frame.size.width.max(0.0), 1.0), - ) - ]; - let _: () = msg_send![header_separator, setWantsLayer: true]; - let _: () = msg_send![ - header_separator, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MAX_Y_MARGIN - ]; - let separator_layer: Id = msg_send![header_separator, layer]; - if !separator_layer.is_null() { - let border = ui_colors::header_border(); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![separator_layer, setBackgroundColor: cg_border]; - } - let header_controls: Id = msg_send![Class::get("NSView").unwrap(), alloc]; - let header_controls: Id = msg_send![ - header_controls, - initWithFrame: CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(header_frame.size.width, header_frame.size.height), - ) - ]; - let _: () = msg_send![header_controls, setWantsLayer: true]; - let _: () = msg_send![ - header_controls, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - // Header right-side controls (right-aligned, consistent spacing). - let btn_w = ui_tokens::CHAT_HEADER_BUTTON_SIZE; - let btn_h = ui_tokens::CHAT_HEADER_BUTTON_SIZE; - let gap = ui_tokens::CHAT_HEADER_BUTTON_GAP; - let right_pad = ui_tokens::EDGE_PADDING_TIGHT; - let header_btn_y = ((header_height - btn_h) / 2.0).max(0.0); - - let mut x = header_frame.size.width - right_pad - btn_w; - let more_button_x = x; - x -= gap + btn_w; - let help_button_x = x; - x -= gap + btn_w; - let favorites_button_x = x; - x -= gap + btn_w; - let record_button_x = x; - - // Keep the tab control outside the native traffic-light zone and before - // the right-side icon cluster. The visible brand label lives in the - // footer, because the native titlebar owns the top-left corner. - let right_cluster_start_x = record_button_x; - let header_safe_x = ui_tokens::TRAFFIC_LIGHTS_SPACER_WIDTH + 6.0; - let header_layout = chat_header_layout(header_safe_x, 0.0, right_cluster_start_x); - let tab_cluster_x = header_layout.tab_cluster_x; - let tab_btn_w = header_layout.tab_button_width; - let tab_gap = header_layout.tab_button_gap; - let status_pill_x = header_layout.status_pill_x; - let status_pill_w = header_layout.status_pill_width; - - let tab_drawer_button = create_button( - CGRect::new( - &CGPoint::new(tab_cluster_x, header_btn_y), - &CGSize::new(tab_btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let _ = set_button_symbol(tab_drawer_button, "archivebox"); - style_toolbar_icon_button(tab_drawer_button); - button_set_action(tab_drawer_button, action_handler, sel!(onTabDrawer:)); - set_tooltip(tab_drawer_button, "Drawer"); - let _: () = msg_send![ - tab_drawer_button, - setAutoresizingMask: NSVIEW_MAX_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, tab_drawer_button); - - let tab_agent_button = create_button( - CGRect::new( - &CGPoint::new(tab_cluster_x + (tab_btn_w + tab_gap) * 1.0, header_btn_y), - &CGSize::new(tab_btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let _ = set_button_symbol(tab_agent_button, "bubble.left.and.bubble.right"); - style_toolbar_icon_button(tab_agent_button); - button_set_action(tab_agent_button, action_handler, sel!(onTabAgent:)); - set_tooltip(tab_agent_button, "Agent"); - let _: () = msg_send![ - tab_agent_button, - setAutoresizingMask: NSVIEW_MAX_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, tab_agent_button); - - let tab_settings_button = create_button( - CGRect::new( - &CGPoint::new(tab_cluster_x + (tab_btn_w + tab_gap) * 2.0, header_btn_y), - &CGSize::new(tab_btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let _ = set_button_symbol(tab_settings_button, "gearshape"); - style_toolbar_icon_button(tab_settings_button); - button_set_action(tab_settings_button, action_handler, sel!(onTabSettings:)); - set_tooltip(tab_settings_button, "Settings"); - let _: () = msg_send![ - tab_settings_button, - setAutoresizingMask: NSVIEW_MAX_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, tab_settings_button); - - // Status pill (global status: Idle / Listening / Processing / Error). - let status_pill_h = ui_tokens::STATUS_PILL_HEIGHT; - let status_pill_y = ((header_height - status_pill_h) / 2.0).max(0.0); - let status_pill_frame = CGRect::new( - &CGPoint::new(status_pill_x, status_pill_y), - &CGSize::new(status_pill_w, status_pill_h), - ); - let status_pill: Id = msg_send![Class::get("NSView").unwrap(), alloc]; - let status_pill: Id = msg_send![status_pill, initWithFrame: status_pill_frame]; - let _: () = msg_send![status_pill, setWantsLayer: true]; - let status_layer: Id = msg_send![status_pill, layer]; - if !status_layer.is_null() { - let bg = ui_colors::panel_bg(); - let cg_bg: Id = msg_send![bg, CGColor]; - let _: () = msg_send![status_layer, setBackgroundColor: cg_bg]; - apply_tafla_surface(status_layer, false); - let border = ui_colors::header_border(); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![status_layer, setBorderColor: cg_border]; - let _: () = msg_send![status_layer, setBorderWidth: ui_tokens::SURFACE_BORDER_WIDTH]; - let _: () = msg_send![status_layer, setMasksToBounds: true]; - } - let _: () = msg_send![ - status_pill, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - let _: () = msg_send![status_pill, setHidden: !header_layout.show_status_pill]; - - let dot_size = ui_tokens::STATUS_DOT_SIZE; - let dot: Id = msg_send![Class::get("NSView").unwrap(), alloc]; - let dot: Id = msg_send![ - dot, - initWithFrame: CGRect::new( - &CGPoint::new( - ui_tokens::STATUS_PILL_DOT_INSET_X, - (status_pill_h - dot_size) / 2.0, - ), - &CGSize::new(dot_size, dot_size), - ) - ]; - let _: () = msg_send![dot, setWantsLayer: true]; - let dot_layer: Id = msg_send![dot, layer]; - if !dot_layer.is_null() { - let _: () = msg_send![dot_layer, setCornerRadius: (dot_size / 2.0).max(2.0)]; - let _: () = msg_send![dot_layer, setMasksToBounds: true]; - } - add_subview(status_pill, dot); - - let status_label = create_label(LabelConfig { - frame: CGRect::new( - &CGPoint::new(ui_tokens::STATUS_PILL_LABEL_INSET_X, 1.0), - &CGSize::new( - (status_pill_w - - ui_tokens::STATUS_PILL_LABEL_INSET_X - - ui_tokens::STATUS_PILL_LABEL_INSET_RIGHT) - .max(0.0), - status_pill_h - 2.0, - ), - ), - text: "Idle".to_string(), - font_size: ui_tokens::MICRO_FONT_SIZE, - bold: false, - text_color: ui_colors::bubble_meta_text(), - background_color: None, - selectable: false, - editable: false, - }); - let _: () = msg_send![ - status_label, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(status_pill, status_label); - add_subview(header_controls, status_pill); - - let record_button = create_button( - CGRect::new( - &CGPoint::new(record_button_x, header_btn_y), - &CGSize::new(btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let has_symbol = set_button_symbol(record_button, "mic.fill"); - if !has_symbol { - let _: () = msg_send![record_button, setTitle: ns_string("Rec")]; - } - style_toolbar_icon_button(record_button); - button_set_action(record_button, action_handler, sel!(onHeaderRecord:)); - set_tooltip(record_button, "Start/stop recording"); - let _: () = msg_send![ - record_button, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, record_button); - - // Drawer favorites filter (hearts on/off) - let favorites_button = create_button( - CGRect::new( - &CGPoint::new(favorites_button_x, header_btn_y), - &CGSize::new(btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let _ = set_button_symbol(favorites_button, "heart.circle"); - style_toolbar_icon_button(favorites_button); - button_set_action( - favorites_button, - action_handler, - sel!(onToggleFavoritesOnly:), - ); - set_tooltip(favorites_button, "Show favorites only"); - let _: () = msg_send![ - favorites_button, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, favorites_button); - - let help_button = create_button( - CGRect::new( - &CGPoint::new(help_button_x, header_btn_y), - &CGSize::new(btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let has_symbol = set_button_symbol(help_button, "questionmark.circle"); - if !has_symbol { - let _: () = msg_send![help_button, setTitle: ns_string("?")]; - } - style_toolbar_icon_button(help_button); - button_set_action(help_button, action_handler, sel!(onShowShortcuts:)); - set_tooltip(help_button, "Keyboard shortcuts"); - let _: () = msg_send![ - help_button, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, help_button); - - let more_button = create_button( - CGRect::new( - &CGPoint::new(more_button_x, header_btn_y), - &CGSize::new(btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let has_symbol = set_button_symbol(more_button, "ellipsis.circle"); - if !has_symbol { - let _: () = msg_send![more_button, setTitle: ns_string("More")]; - } - style_toolbar_icon_button(more_button); - button_set_action(more_button, action_handler, sel!(onMoreMenu:)); - set_tooltip(more_button, "More actions"); - let _: () = msg_send![ - more_button, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, more_button); - - let close_button = create_button( - CGRect::new( - &CGPoint::new(header_frame.size.width + btn_w, header_btn_y), - &CGSize::new(btn_w, btn_h), - ), - "", - button_style::INLINE, - ); - let has_symbol = set_button_symbol(close_button, "xmark"); - if !has_symbol { - let _: () = msg_send![close_button, setTitle: ns_string("Close")]; - } - style_toolbar_icon_button(close_button); - button_set_action(close_button, action_handler, sel!(onClose:)); - set_tooltip(close_button, "Close window"); - let _: () = msg_send![close_button, setHidden: true]; - let _: () = msg_send![ - close_button, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(header_controls, close_button); - - // Drawer/Agent split view on top of the single root glass. - let content_gap = ui_tokens::CONTENT_GAP; - let content_frame = CGRect::new( - &CGPoint::new( - content_bounds.origin.x + content_pad, - content_bounds.origin.y + footer_height + content_gap, - ), - &CGSize::new( - (content_bounds.size.width - content_pad * 2.0).max(0.0), - chat_content_height_under_header( - content_bounds.size.height, - footer_height, - content_gap, - ), - ), - ); - - let ns_scroll = Class::get("NSScrollView").unwrap(); - let ns_view = Class::get("NSView").unwrap(); - let ns_view_controller = Class::get("NSViewController").unwrap(); - let split_cls = Class::get("NSSplitViewController").unwrap(); - let split_item_cls = Class::get("NSSplitViewItem").unwrap(); - - let split_controller: Id = msg_send![split_cls, alloc]; - let split_controller: Id = msg_send![split_controller, init]; - - let sidebar_controller: Id = msg_send![ns_view_controller, alloc]; - let sidebar_controller: Id = msg_send![sidebar_controller, init]; - let sidebar_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(content_frame.size.width, content_frame.size.height), - ); - let sidebar_view: Id = msg_send![ns_view, alloc]; - let sidebar_view: Id = msg_send![sidebar_view, initWithFrame: sidebar_frame]; - let _: () = msg_send![sidebar_view, setWantsLayer: true]; - let _: () = msg_send![ - sidebar_view, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let sidebar_layer: Id = msg_send![sidebar_view, layer]; - if !sidebar_layer.is_null() { - let sidebar_bg = ui_colors::sidebar_bg(); - let cg_sidebar_bg: Id = msg_send![sidebar_bg, CGColor]; - let _: () = msg_send![sidebar_layer, setBackgroundColor: cg_sidebar_bg]; - } - let _: () = msg_send![sidebar_controller, setView: sidebar_view]; - - let content_controller: Id = msg_send![ns_view_controller, alloc]; - let content_controller: Id = msg_send![content_controller, init]; - let content_view: Id = msg_send![ns_view, alloc]; - let content_view: Id = msg_send![ - content_view, - initWithFrame: CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(content_frame.size.width, content_frame.size.height), - ) - ]; - let _: () = msg_send![content_view, setWantsLayer: true]; - let content_layer: Id = msg_send![content_view, layer]; - if !content_layer.is_null() { - let content_bg = ui_colors::surface_glass(); - let cg_content_bg: Id = msg_send![content_bg, CGColor]; - let _: () = msg_send![content_layer, setBackgroundColor: cg_content_bg]; - } - let _: () = msg_send![content_controller, setView: content_view]; - - let has_sidebar_ctor: bool = - msg_send![split_item_cls, respondsToSelector: sel!(sidebarWithViewController:)]; - let sidebar_item: Id = if has_sidebar_ctor { - msg_send![split_item_cls, sidebarWithViewController: sidebar_controller] - } else { - msg_send![split_item_cls, splitViewItemWithViewController: sidebar_controller] - }; - let content_item: Id = - msg_send![split_item_cls, splitViewItemWithViewController: content_controller]; - - let sidebar_pref = (content_frame.size.width * 0.45) - .clamp(ui_tokens::SIDEBAR_MIN_WIDTH, ui_tokens::SIDEBAR_MAX_WIDTH); - let responds_pref: bool = - msg_send![sidebar_item, respondsToSelector: sel!(setPreferredThickness:)]; - if responds_pref { - let _: () = msg_send![sidebar_item, setPreferredThickness: sidebar_pref]; - } - let responds_min: bool = - msg_send![sidebar_item, respondsToSelector: sel!(setMinimumThickness:)]; - if responds_min { - let _: () = msg_send![sidebar_item, setMinimumThickness: ui_tokens::SIDEBAR_MIN_WIDTH]; - } - let responds_behavior: bool = - msg_send![sidebar_item, respondsToSelector: sel!(setBehavior:)]; - if responds_behavior { - let _: () = msg_send![sidebar_item, setBehavior: 1_isize]; - } - - let _: () = msg_send![split_controller, addSplitViewItem: sidebar_item]; - let _: () = msg_send![split_controller, addSplitViewItem: content_item]; - - let split_view: Id = msg_send![split_controller, view]; - let _: () = msg_send![split_view, setFrame: content_frame]; - let _: () = msg_send![ - split_view, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let responds_vertical: bool = msg_send![split_view, respondsToSelector: sel!(setVertical:)]; - if responds_vertical { - let _: () = msg_send![split_view, setVertical: true]; - } - // Divider polish: thin (1pt) + subtle separator color. - // Guard with respondsToSelector because [splitController view] - // may return a plain NSView wrapper rather than NSSplitView. - let responds_divider: bool = - msg_send![split_view, respondsToSelector: sel!(setDividerStyle:)]; - if responds_divider { - let _: () = msg_send![split_view, setDividerStyle: 1_isize]; // NSSplitViewDividerStyleThin - let ns_color = Class::get("NSColor").unwrap(); - let divider_color: Id = msg_send![ns_color, separatorColor]; - let responds_divider_color: bool = - msg_send![split_view, respondsToSelector: sel!(setDividerColor:)]; - if responds_divider_color && !divider_color.is_null() { - let _: () = msg_send![split_view, setDividerColor: divider_color]; - } - } - add_subview(glass_content_view, split_view); - // Ensure header controls stay above the split view content. - add_subview(glass_content_view, header_bg); - add_subview(header_bg, header_separator); - add_subview(header_bg, header_controls); - - let inner_pad = ui_tokens::EDGE_PADDING_TIGHT; - // The transcript filter sits at the bottom of the sidebar column (it - // filters the drawer, and its width tracks the split divider); the - // drawer scroll starts above it. - let search_field_height = 24.0; - let drawer_bottom_inset = inner_pad + search_field_height + 8.0; - let drawer_frame = CGRect::new( - &CGPoint::new(inner_pad, drawer_bottom_inset), - &CGSize::new( - (content_frame.size.width - inner_pad * 2.0).max(0.0), - (content_frame.size.height - drawer_bottom_inset - inner_pad).max(0.0), - ), - ); - - // Drawer scroll view + stack - let drawer_scroll: Id = msg_send![ns_scroll, alloc]; - let drawer_scroll: Id = msg_send![drawer_scroll, initWithFrame: drawer_frame]; - // Keep scrolling enabled; hide scrollbars via overlay + autohide. - let _: () = msg_send![drawer_scroll, setHasVerticalScroller: true]; - let _: () = msg_send![drawer_scroll, setDrawsBackground: false]; - let _: () = msg_send![drawer_scroll, setAutohidesScrollers: true]; - // NSScrollerStyleOverlay == 1 - let _: () = msg_send![drawer_scroll, setScrollerStyle: 1_isize]; - let _: () = msg_send![ - drawer_scroll, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - - let drawer_container = create_vertical_stack_view(CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(drawer_frame.size.width, drawer_frame.size.height), - )); - // Document views inside NSScrollView must NOT be height-resizable, otherwise AppKit will - // keep them pinned to the clip view height and effectively disable scrolling. - let _: () = msg_send![drawer_container, setAutoresizingMask: NSVIEW_WIDTH_SIZABLE]; - let _: () = msg_send![drawer_scroll, setDocumentView: drawer_container]; - add_subview(sidebar_view, drawer_scroll); - - let drawer_edge_frame = CGRect::new( - &CGPoint::new( - drawer_frame.origin.x, - drawer_frame.origin.y + drawer_frame.size.height - 18.0, - ), - &CGSize::new(drawer_frame.size.width, 18.0), - ); - let drawer_edge_effect = create_scroll_edge_effect(drawer_edge_frame); - let _: () = msg_send![ - drawer_edge_effect, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MIN_Y_MARGIN - ]; - add_subview(sidebar_view, drawer_edge_effect); - - // Agent scroll view + stack — full-bleed to the pane bottom so messages - // scroll beneath the floating input bar; the bottom content inset keeps - // the last bubble clear of it (mirrors the header inset above). - let agent_scroll_inset_bottom = - chat_scroll_inset_above_input(agent_input_height, inner_pad, content_gap); - let agent_scroll_frame_top = (content_frame.size.height - inner_pad).max(0.0); - let agent_scroll_frame = CGRect::new( - &CGPoint::new(inner_pad, 0.0), - &CGSize::new( - (content_frame.size.width - inner_pad * 2.0).max(0.0), - agent_scroll_frame_top, - ), - ); - let agent_scroll: Id = msg_send![ns_scroll, alloc]; - let agent_scroll: Id = msg_send![agent_scroll, initWithFrame: agent_scroll_frame]; - // Keep scrolling enabled; hide scrollbars via overlay + autohide. - let _: () = msg_send![agent_scroll, setHasVerticalScroller: true]; - let _: () = msg_send![agent_scroll, setDrawsBackground: false]; - let _: () = msg_send![agent_scroll, setAutohidesScrollers: true]; - // NSScrollerStyleOverlay == 1 - let _: () = msg_send![agent_scroll, setScrollerStyle: 1_isize]; - let agent_insets = NSEdgeInsets { - top: header_height, - left: 0.0, - bottom: agent_scroll_inset_bottom, - right: 0.0, - }; - let _: () = msg_send![agent_scroll, setContentInsets: agent_insets]; - let _: () = msg_send![ - agent_scroll, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let agent_container = create_flipped_vertical_stack_view(CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new( - agent_scroll_frame.size.width, - agent_scroll_frame.size.height, - ), - )); - // Same rule: keep the document view width-resizable, but let its height expand to content. - let _: () = msg_send![agent_container, setAutoresizingMask: NSVIEW_WIDTH_SIZABLE]; - let _: () = msg_send![agent_scroll, setDocumentView: agent_container]; - if let Some(notification_center) = Class::get("NSNotificationCenter") { - let center: Id = msg_send![notification_center, defaultCenter]; - let live_scroll = ns_string("NSScrollViewDidLiveScrollNotification"); - let _: () = msg_send![ - center, - addObserver: action_handler - selector: sel!(onAgentScrollLive:) - name: live_scroll - object: agent_scroll - ]; - } - add_subview(content_view, agent_scroll); - - // Drawer footer (search) — lives inside the sidebar column so its - // width tracks the split divider and it collapses with the drawer. - let search_x = inner_pad; - let search_w = (sidebar_frame.size.width - inner_pad * 2.0).max(160.0); - - // Search label removed: NSSearchField.setPlaceholderString("Filter transcripts") - // on the field below already renders the same text; the redundant label - // produced a visual duplicate/ghosting under Liquid Glass (Image #16/#17). - - let ns_search = Class::get("NSSearchField").unwrap(); - let search_field: Id = msg_send![ns_search, alloc]; - let search_frame = CGRect::new( - &CGPoint::new(search_x, inner_pad), - &CGSize::new(search_w, search_field_height), - ); - let search_field: Id = msg_send![search_field, initWithFrame: search_frame]; - let placeholder = ns_string("Filter transcripts"); - let _: () = msg_send![search_field, setPlaceholderString: placeholder]; - let _: () = msg_send![search_field, setDelegate: action_handler]; - let _: () = msg_send![search_field, setTarget: action_handler]; - let _: () = msg_send![search_field, setAction: sel!(onSearchChanged:)]; - let search_cell: Id = msg_send![search_field, cell]; - if !search_cell.is_null() { - let supports_immediate: bool = - msg_send![search_cell, respondsToSelector: sel!(setSendsSearchStringImmediately:)]; - if supports_immediate { - let _: () = msg_send![search_cell, setSendsSearchStringImmediately: true]; - } - let supports_whole: bool = - msg_send![search_cell, respondsToSelector: sel!(setSendsWholeSearchString:)]; - if supports_whole { - let _: () = msg_send![search_cell, setSendsWholeSearchString: false]; - } - } - let _: () = msg_send![ - search_field, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MAX_Y_MARGIN - ]; - set_focus_ring(search_field); - add_subview(sidebar_view, search_field); - - let footer_brand_w = ui_tokens::CHAT_TITLE_LABEL_WIDTH; - let footer_brand_h = 16.0; - let footer_brand_frame = CGRect::new( - &CGPoint::new( - content_bounds.origin.x + content_bounds.size.width - content_pad - footer_brand_w, - content_bounds.origin.y + ((footer_height - footer_brand_h) / 2.0).max(4.0), - ), - &CGSize::new(footer_brand_w, footer_brand_h), - ); - let title_label = create_label(LabelConfig { - frame: footer_brand_frame, - text: "CodeScribe".to_string(), - font_size: ui_tokens::SMALL_FONT_SIZE, - bold: true, - text_color: color_secondary_label(), - background_color: None, - selectable: false, - editable: false, - }); - let _: () = msg_send![ - title_label, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MAX_Y_MARGIN - ]; - add_subview(glass_content_view, title_label); - - // Agent input bar - let drop_target_cls = drop_target_view_class(); - let input_bar: Id = msg_send![drop_target_cls, alloc]; - // Sit flush with the footer edge to match native rounded search/input controls. - let input_bar_y = (ui_tokens::FOOTER_INSET - 4.0).max(0.0); - let input_frame = CGRect::new( - &CGPoint::new(inner_pad, input_bar_y), - &CGSize::new( - (content_frame.size.width - inner_pad * 2.0).max(0.0), - agent_input_height, - ), - ); - let input_bar: Id = msg_send![input_bar, initWithFrame: input_frame]; - let _: () = msg_send![input_bar, setWantsLayer: true]; - let _: () = - msg_send![input_bar, setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MAX_Y_MARGIN]; - // Glass backing so messages scrolling beneath stay readable through the - // blur; the bar layer's masksToBounds clips it to the tafla corners. - let input_glass = create_glass_effect_view_with( - CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(input_frame.size.width, agent_input_height), - ), - NSVisualEffectMaterial::HUDWindow, - NSVisualEffectBlendingMode::WithinWindow, - NSVisualEffectState::Active, - ); - let _: () = msg_send![ - input_glass, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let _: () = msg_send![input_bar, addSubview: input_glass]; - let drag_types: Id = msg_send![ns_mut_array, array]; - let _: () = msg_send![drag_types, addObject: ns_string("public.file-url")]; - let _: () = msg_send![drag_types, addObject: ns_string("NSFilenamesPboardType")]; - let _: () = msg_send![input_bar, registerForDraggedTypes: drag_types]; - let input_layer: Id = msg_send![input_bar, layer]; - if !input_layer.is_null() { - let color = ui_colors::input_bar_bg(); - let cg_color: Id = msg_send![color, CGColor]; - let _: () = msg_send![input_layer, setBackgroundColor: cg_color]; - apply_tafla_surface(input_layer, false); - let border = ui_colors::input_bar_border(); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![input_layer, setBorderColor: cg_border]; - let _: () = msg_send![input_layer, setBorderWidth: ui_tokens::SURFACE_BORDER_WIDTH]; - let _: () = msg_send![input_layer, setMasksToBounds: true]; - // Keep the field crisp like native NSSearchField: border-only, no heavy drop shadow. - let _: () = msg_send![input_layer, setShadowOpacity: 0.0f64]; - let _: () = msg_send![input_layer, setShadowRadius: 0.0f64]; - let _: () = msg_send![input_layer, setShadowOffset: CGSize::new(0.0, 0.0)]; - } - add_subview(content_view, input_bar); - - let input_width = input_frame.size.width; - let input_row = chat_input_row_layout(input_width, agent_input_height); - let text_area_frame = CGRect::new( - &CGPoint::new(input_row.text_x, input_row.text_y), - &CGSize::new(input_row.text_width, input_row.text_height), - ); - let (agent_input_scroll, agent_input_text_view) = create_scrollable_text_view_with_class( - text_area_frame, - true, - &*agent_input_text_view_class(), - ); - let _: () = msg_send![ - agent_input_scroll, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_HEIGHT_SIZABLE - ]; - let ns_font = Class::get("NSFont").unwrap(); - let jb_name = ns_string("JetBrainsMono-Regular"); - let jb_font: Id = msg_send![ns_font, fontWithName: jb_name size: 13.0f64]; - let text_font: Id = if jb_font.is_null() { - msg_send![ns_font, monospacedSystemFontOfSize: 13.0f64 weight: 0.0f64] - } else { - jb_font - }; - let _: () = msg_send![agent_input_text_view, setFont: text_font]; - let _: () = msg_send![ - agent_input_text_view, - setTextContainerInset: CGSize::new(0.0, 4.0) - ]; - // Plain text: avoid rich text / style surprises when pasting. - let _: () = msg_send![agent_input_text_view, setRichText: false]; - let _: () = msg_send![agent_input_text_view, setDelegate: action_handler]; - set_focus_ring(agent_input_text_view); - let _: () = msg_send![input_bar, addSubview: agent_input_scroll]; - - // Attach button (file context for Agent) — anchored left. - let agent_attach_button = create_button( - CGRect::new( - &CGPoint::new(input_row.attach_x, input_row.attach_y), - &CGSize::new(input_row.button_width, input_row.button_height), - ), - "", - button_style::INLINE, - ); - let has_symbol = set_button_symbol(agent_attach_button, "paperclip"); - if !has_symbol { - let _: () = msg_send![agent_attach_button, setTitle: ns_string("Attach")]; - } - style_toolbar_icon_button(agent_attach_button); - button_set_action(agent_attach_button, action_handler, sel!(onAttachMenu:)); - let _: () = msg_send![ - agent_attach_button, - setAutoresizingMask: NSVIEW_MAX_X_MARGIN | NSVIEW_MAX_Y_MARGIN - ]; - set_tooltip(agent_attach_button, "Attach files (assistant context)"); - let _: () = msg_send![input_bar, addSubview: agent_attach_button]; - - let agent_send_button = create_button( - CGRect::new( - &CGPoint::new(input_row.send_x, input_row.send_y), - &CGSize::new(input_row.button_width, input_row.button_height), - ), - "", - button_style::INLINE, - ); - let has_symbol = set_button_symbol(agent_send_button, "arrow.up.circle.fill"); - if !has_symbol { - let _: () = msg_send![agent_send_button, setTitle: ns_string("Send")]; - } - style_toolbar_icon_button(agent_send_button); - button_set_action(agent_send_button, action_handler, sel!(onSend:)); - set_tooltip(agent_send_button, "Send (Enter)"); - let _: () = msg_send![ - agent_send_button, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MAX_Y_MARGIN - ]; - let _: () = msg_send![input_bar, addSubview: agent_send_button]; - - let latest_button = create_button( - latest_pill_frame( - content_frame.size.width, - ui_tokens::FOOTER_INSET, - agent_input_height, - ui_tokens::CONTENT_GAP, - 0.0, - ), - "↓ Latest", - button_style::ROUNDED, - ); - let _: () = msg_send![latest_button, setWantsLayer: true]; - let latest_layer: Id = msg_send![latest_button, layer]; - if !latest_layer.is_null() { - let color = ui_colors::input_bar_bg(); - let cg_color: Id = msg_send![color, CGColor]; - let _: () = msg_send![latest_layer, setBackgroundColor: cg_color]; - let border = ui_colors::input_bar_border(); - let cg_border: Id = msg_send![border, CGColor]; - let _: () = msg_send![latest_layer, setBorderColor: cg_border]; - let _: () = msg_send![latest_layer, setBorderWidth: ui_tokens::SURFACE_BORDER_WIDTH]; - apply_tafla_surface(latest_layer, false); - let _: () = msg_send![latest_layer, setMasksToBounds: true]; - } - button_set_action(latest_button, action_handler, sel!(onLatestMessage:)); - set_tooltip(latest_button, "Jump to latest message"); - let _: () = msg_send![ - latest_button, - setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MAX_X_MARGIN | NSVIEW_MAX_Y_MARGIN - ]; - set_hidden(latest_button, true); - add_subview(content_view, latest_button); - - // Attachment chip strip (horizontal, above input bar, hidden when empty). - let chip_strip_height = 36.0f64; - let chip_strip_y = ui_tokens::FOOTER_INSET + agent_input_height + ui_tokens::CONTENT_GAP; - let chip_strip_frame = CGRect::new( - &CGPoint::new(inner_pad, chip_strip_y), - &CGSize::new( - (content_frame.size.width - inner_pad * 2.0).max(0.0), - chip_strip_height, - ), - ); - let ns_scroll_cls = Class::get("NSScrollView").unwrap(); - let chip_scroll: Id = msg_send![ns_scroll_cls, alloc]; - let chip_scroll: Id = msg_send![chip_scroll, initWithFrame: chip_strip_frame]; - let _: () = msg_send![chip_scroll, setHasVerticalScroller: false]; - let _: () = msg_send![chip_scroll, setHasHorizontalScroller: false]; - let _: () = msg_send![chip_scroll, setDrawsBackground: false]; - let _: () = msg_send![ - chip_scroll, - setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MAX_Y_MARGIN - ]; - // Horizontal stack view as document view - let ns_stack_cls = Class::get("NSStackView").unwrap(); - let chip_stack: Id = msg_send![ns_stack_cls, alloc]; - let stack_inner = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(chip_strip_frame.size.width, chip_strip_height), - ); - let chip_stack: Id = msg_send![chip_stack, initWithFrame: stack_inner]; - // NSUserInterfaceLayoutOrientationHorizontal = 0 - let _: () = msg_send![chip_stack, setOrientation: 0i64]; - let _: () = msg_send![chip_stack, setSpacing: 6.0f64]; - // NSLayoutAttributeLeading alignment = gravity top - let _: () = msg_send![chip_stack, setAlignment: 7i64]; // NSLayoutAttributeTop - let _: () = msg_send![chip_scroll, setDocumentView: chip_stack]; - set_hidden(chip_scroll, true); - add_subview(content_view, chip_scroll); - - // Initial visibility - set_hidden(agent_scroll, true); - set_hidden(input_bar, true); - set_hidden(latest_button, true); - set_hidden(title_label, true); - - // Phase 3 — store widget pointers into state (short lock scope). - let (has_messages, desired_tab, status_base_text) = { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window = Some(window as usize); - state.window_delegate = Some(window_delegate as usize); - state.blur_view = Some(blur_view as usize); - state.split_view_controller = Some(split_controller as usize); - state.split_sidebar_item = Some(sidebar_item as usize); - state.split_content_item = Some(content_item as usize); - state.split_sidebar_container = Some(sidebar_view as usize); - state.split_content_container = Some(content_view as usize); - state.title_label = Some(title_label as usize); - state.status_pill = Some(status_pill as usize); - state.status_pill_label = Some(status_label as usize); - state.status_pill_dot = Some(dot as usize); - state.tab_drawer_button = Some(tab_drawer_button as usize); - state.tab_agent_button = Some(tab_agent_button as usize); - state.tab_settings_button = Some(tab_settings_button as usize); - state.favorites_button = Some(favorites_button as usize); - state.help_button = Some(help_button as usize); - state.close_button = Some(close_button as usize); - state.drawer_scroll_view = Some(drawer_scroll as usize); - state.drawer_container = Some(drawer_container as usize); - state.drawer_edge_effect = Some(drawer_edge_effect as usize); - state.search_field = Some(search_field as usize); - state.agent_scroll_view = Some(agent_scroll as usize); - state.agent_container = Some(agent_container as usize); - state.agent_input_bar = Some(input_bar as usize); - state.agent_input_scroll_view = Some(agent_input_scroll as usize); - state.agent_input_text_view = Some(agent_input_text_view as usize); - state.agent_input_field = None; - state.agent_attach_button = Some(agent_attach_button as usize); - state.agent_send_button = Some(agent_send_button as usize); - state.agent_latest_button = Some(latest_button as usize); - state.attachment_chip_strip = Some(chip_scroll as usize); - state.action_handler = Some(action_handler as usize); - // Restore persisted zoom level from settings.json. - if let Some(zoom) = codescribe_core::config::UserSettings::load().chat_zoom { - state.zoom_level = zoom.clamp(0.75, 2.0); - } - let pending_tab = state.pending_tab.take(); - let has_messages = !state.messages.is_empty(); - let desired_tab = if let Some(tab) = pending_tab { - tab - } else if has_messages { - Tab::Agent - } else { - state.active_tab - }; - state.active_tab = desired_tab; - let status_base_text = state.status_base_text.clone(); - (has_messages, desired_tab, status_base_text) - }; // OVERLAY_STATE released — safe to perform AppKit window operations. - - // Phase 4 — show window (no lock held; avoids nested-runloop deadlock). - window_set_alpha(window, 0.0); - present_shared_shell_panel(window); - crate::ui_helpers::animate_fade(window, 1.0, 0.2); - let is_visible: bool = msg_send![window, isVisible]; - let alpha: f64 = msg_send![window, alphaValue]; - debug!( - "Voice chat overlay visible={} alpha={:.2}", - is_visible, alpha - ); - - // Safety fallback: ensure the overlay becomes visible even if the fade animation stalls. - let window_ptr = window as usize; - thread::spawn(move || { - thread::sleep(Duration::from_millis(250)); - Queue::main().exec_async(move || { - let expected = { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.window - }; - if expected != Some(window_ptr) { - return; - } - let ns_window = Class::get("NSWindow").unwrap(); - let window = window_ptr as Id; - let is_window: bool = msg_send![window, isKindOfClass: ns_window]; - if !is_window { - return; - } - let _: () = msg_send![window, setAlphaValue: 1.0f64]; - present_shared_shell_panel(window); - }); - }); - - // Phase 5 — post-show updates. - api::refresh_drawer(); - api::update_voice_chat_status(&status_base_text); - update_active_tab_impl(desired_tab); - if has_messages || matches!(desired_tab, Tab::Agent) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - api::update_chat_view_with_state(&mut state, true); - } - } -} - -fn create_scroll_edge_effect(frame: CGRect) -> Id { - unsafe { - let ns_view = Class::get("NSView").unwrap(); - let ns_color = Class::get("NSColor").unwrap(); - let ns_array = Class::get("NSArray").unwrap(); - let gradient_cls = Class::get("CAGradientLayer"); - - let view: Id = msg_send![ns_view, alloc]; - let view: Id = msg_send![view, initWithFrame: frame]; - let _: () = msg_send![view, setWantsLayer: true]; - let layer: Id = msg_send![view, layer]; - if layer.is_null() { - return view; - } - - if let Some(gradient_cls) = gradient_cls { - let gradient: Id = msg_send![gradient_cls, layer]; - let base: Id = msg_send![ns_color, separatorColor]; - let top_color: Id = msg_send![base, colorWithAlphaComponent: 0.0f64]; - let edge_alpha = if crate::ui_helpers::glass_effect_supported() { - 0.08f64 - } else { - 0.14f64 - }; - let bottom_color: Id = msg_send![base, colorWithAlphaComponent: edge_alpha]; - let cg_top: Id = msg_send![top_color, CGColor]; - let cg_bottom: Id = msg_send![bottom_color, CGColor]; - let color_objs: [Id; 2] = [cg_top, cg_bottom]; - let colors: Id = msg_send![ - ns_array, - arrayWithObjects: color_objs.as_ptr() - count: color_objs.len() - ]; - let _: () = msg_send![gradient, setColors: colors]; - let _: () = msg_send![gradient, setStartPoint: CGPoint::new(0.5, 1.0)]; - let _: () = msg_send![gradient, setEndPoint: CGPoint::new(0.5, 0.0)]; - let gradient_frame = CGRect::new( - &CGPoint::new(0.0, 0.0), - &CGSize::new(frame.size.width, frame.size.height), - ); - let _: () = msg_send![gradient, setFrame: gradient_frame]; - let _: () = msg_send![layer, addSublayer: gradient]; - } - - view - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shortcuts_lines_reflect_modifiers() { - let (hold, toggle) = shortcuts_lines(ModeHotkeyBindings { - dictation: ShortcutBinding::HoldCtrlAlt, - formatting: ShortcutBinding::Disabled, - assistive: ShortcutBinding::DoubleRightOption, - }); - assert!(hold.contains("Ctrl+Option")); - assert!(toggle.contains("Right")); - } - - #[test] - fn chat_content_extends_under_header() { - let content_height = 720.0; - let footer_height = 64.0; - let content_gap = 10.0; - let header_height = 54.0; - - let under_header = - chat_content_height_under_header(content_height, footer_height, content_gap); - let old_below_header = - (content_height - header_height - footer_height - content_gap * 2.0).max(0.0); - - assert_eq!(under_header, 646.0); - assert_eq!(under_header - old_below_header, header_height + content_gap); - } - - #[test] - fn chat_scroll_extends_under_input_bar() { - let agent_input_height = 44.0; - let inner_pad = 12.0; - let content_gap = 10.0; - - let inset = chat_scroll_inset_above_input(agent_input_height, inner_pad, content_gap); - let old_frame_bottom = agent_input_height + inner_pad + content_gap; - - // The frame now starts at the pane bottom (y = 0); the bottom inset - // equals the old frame bottom, so messages gained exactly that much - // scroll-under room without losing clearance above the input bar. - assert_eq!(inset, 66.0); - assert_eq!(inset, old_frame_bottom); - assert_eq!(chat_scroll_inset_above_input(-100.0, 12.0, 10.0), 0.0); - } - - #[test] - fn chat_pane_bottom_band_is_slim() { - // The dead strip below the chat pane (footer + gap) must stay a slim - // watermark line, not the full drawer footer — otherwise content - // scrolling under the input bar clips visibly far above the window - // edge (Image #6/#7). - let band = crate::ui_helpers::ui_tokens::CHAT_FOOTER_HEIGHT - + crate::ui_helpers::ui_tokens::CONTENT_GAP; - assert!(band <= 24.0, "bottom band grew back to {band}pt"); - let chat_footer = crate::ui_helpers::ui_tokens::CHAT_FOOTER_HEIGHT; - let footer = crate::ui_helpers::ui_tokens::FOOTER_HEIGHT; - assert!( - chat_footer < footer, - "chat footer {chat_footer}pt must stay slimmer than drawer footer {footer}pt" - ); - - // Pane height gained exactly the footer slimming delta. - let tall = chat_content_height_under_header( - 720.0, - crate::ui_helpers::ui_tokens::FOOTER_HEIGHT, - crate::ui_helpers::ui_tokens::CONTENT_GAP, - ); - let slim = chat_content_height_under_header( - 720.0, - crate::ui_helpers::ui_tokens::CHAT_FOOTER_HEIGHT, - crate::ui_helpers::ui_tokens::CONTENT_GAP, - ); - assert_eq!( - slim - tall, - crate::ui_helpers::ui_tokens::FOOTER_HEIGHT - - crate::ui_helpers::ui_tokens::CHAT_FOOTER_HEIGHT - ); - } -} diff --git a/app/ui/voice_chat/state.rs b/app/ui/voice_chat/state.rs deleted file mode 100644 index e1f3e191..00000000 --- a/app/ui/voice_chat/state.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! Voice Chat UI state and types -//! -//! Contains overlay state, configuration, and message types. - -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::{Instant, SystemTime}; - -use codescribe_core::attachment::Attachment; - -use crate::ui::shared::status::UiStatus; -use crate::ui_helpers::{BubbleMeasureCache, RenderMode}; - -/// Type alias for voice chat send callback -pub type VoiceChatSendCallback = Arc; - -/// Configuration for the voice chat overlay -#[derive(Debug, Clone)] -pub struct VoiceChatOverlayConfig { - /// Width of the overlay window in pixels - pub width: f64, - /// Height of the overlay window in pixels - pub height: f64, - /// Auto-hide timeout in seconds (0 = no auto-hide) - pub auto_hide_timeout_secs: u64, -} - -impl Default for VoiceChatOverlayConfig { - fn default() -> Self { - Self { - width: 450.0, - height: 520.0, - auto_hide_timeout_secs: 0, - } - } -} - -/// Role of a chat message -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChatRole { - User, - Assistant, - System, - /// Live reasoning summary streamed from the agent (its own lane, rendered - /// as a collapsible "thinking" entry — NOT mixed into the assistant text). - Reasoning, -} - -/// A single chat message -#[derive(Debug, Clone)] -pub struct ChatMessage { - pub role: ChatRole, - pub text: String, - pub is_streaming: bool, - pub is_collapsed: bool, - pub is_error: bool, - pub timestamp: SystemTime, - pub mode: Option, - pub is_pending_followup: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Tab { - Drawer, - Agent, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TranscriptionMode { - Hold, - Assistive, - Toggle, - /// Full-duplex conversation mode (Moshi) - Conversation, -} - -/// State of the conversation mode (Moshi full-duplex) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ConversationModeState { - /// Not in conversation mode - #[default] - Inactive, - /// Listening for user speech - Listening, - /// User is speaking - UserSpeaking, - /// Processing user input - Processing, - /// Assistant is responding (audio playing) - AssistantSpeaking, - /// User interrupted assistant - Interrupted, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DrawerEntrySource { - LegacyFile, - Thread { id: String }, -} - -pub struct DrawerEntry { - pub source: DrawerEntrySource, - pub path: PathBuf, - pub timestamp: SystemTime, - pub mode: TranscriptionMode, - pub title: Option, - pub model: Option, - pub total_tokens: Option, - pub preview: String, - pub search_corpus: String, - pub is_ai_formatted: bool, - pub is_favorite: bool, -} - -/// Voice chat overlay state -pub struct VoiceChatOverlayState { - // Window - pub window: Option, - pub window_delegate: Option, - pub blur_view: Option, - pub split_view_controller: Option, - pub split_sidebar_item: Option, - pub split_content_item: Option, - pub split_sidebar_container: Option, - pub split_content_container: Option, - - // Header - pub title_label: Option, - pub status_pill: Option, - pub status_pill_label: Option, - pub status_pill_dot: Option, - pub tab_drawer_button: Option, - pub tab_agent_button: Option, - pub tab_settings_button: Option, - pub favorites_button: Option, - pub help_button: Option, - pub close_button: Option, - - // Drawer tab - pub drawer_scroll_view: Option, - pub drawer_container: Option, - pub drawer_entries: Vec, - pub drawer_edge_effect: Option, - pub search_field: Option, - pub search_label: Option, - pub drawer_favorites_only: bool, - pub favorites: HashSet, - - // Agent tab - pub agent_scroll_view: Option, - pub agent_container: Option, - pub agent_bubble_views: Vec<(usize, usize)>, - pub agent_bubble_click_recognizers: Vec<(usize, usize)>, - /// Explicit per-message render overrides. Missing entries use the raw Markdown default. - pub message_render_modes: HashMap, - /// Cached document stack height for amortized streaming layout updates. - pub cached_agent_stack_height: Option, - /// Per-bubble text-height measurement cache. Avoids re-running AppKit's - /// `__NSStringDrawingEngine` for unchanged bubbles on every chat-view - /// rebuild (the main-thread hang on large transcripts). - pub bubble_measure_cache: BubbleMeasureCache, - pub agent_input_bar: Option, - pub agent_input_scroll_view: Option, - pub agent_input_text_view: Option, - pub agent_input_field: Option, - pub agent_attach_button: Option, - pub agent_send_button: Option, - pub agent_latest_button: Option, - /// Attachments (files, images, URLs, GitHub blobs) for Agent chat context. - pub attachments: Vec, - /// Fingerprint of the last attachment set sent to the assistant. - pub attachments_last_sent: Option, - /// Chip strip scroll view (horizontal list of attachment chips above input bar). - pub attachment_chip_strip: Option, - - // Active tab - pub active_tab: Tab, - /// Requested tab to apply after overlay is created. - pub pending_tab: Option, - - // Chat state - pub messages: Vec, - /// Active streaming user message index (if any). - pub active_user_stream_index: Option, - /// Active streaming assistant message index (if any). - pub active_assistant_stream_index: Option, - /// Active streaming reasoning-summary message index (if any). - pub active_reasoning_stream_index: Option, - pub manual_draft: String, - /// Manual prompts sent from the Agent input, newest at the end. - pub prompt_history: Vec, - /// Cursor into `prompt_history` while navigating with up/down arrows. - /// `None` means the editable draft is live rather than a recalled prompt. - pub prompt_history_cursor: Option, - pub is_sending: bool, - /// True while the agent is reasoning after a voice transcript was handed off / sent. - /// Used to show "Thinking..." / reasoning indicator in the Agent tab. - pub is_agent_thinking: bool, - /// True when the user is pinned to the bottom of the agent transcript. - pub scroll_pinned: bool, - pub auto_send_enabled: bool, - /// Last status text provided by caller before runtime-health decoration is applied. - pub status_base_text: String, - pub status_text: String, - pub status_kind: UiStatus, - pub context_text: String, - /// True when agent runtime is unavailable and legacy fallback is active. - pub runtime_degraded: bool, - /// Explicit UI flag for showing persistent "fallback active" indicators. - pub is_agent_degraded: bool, - /// Optional diagnostic context for degraded runtime state. - pub runtime_degraded_reason: Option, - /// Best-effort app name to reactivate when performing paste actions. - pub last_target_app: Option, - - // Conversation mode (Moshi) - pub conversation_state: ConversationModeState, - - // Handler - pub action_handler: Option, - - // Throttling: last time we ran a layout pass for streaming deltas - pub last_layout_time: Option, - pub layout_pending: bool, - pub pending_delta_index: Option, - - // Zoom - pub zoom_level: f64, -} - -impl Default for VoiceChatOverlayState { - fn default() -> Self { - Self { - window: None, - window_delegate: None, - blur_view: None, - split_view_controller: None, - split_sidebar_item: None, - split_content_item: None, - split_sidebar_container: None, - split_content_container: None, - title_label: None, - status_pill: None, - status_pill_label: None, - status_pill_dot: None, - tab_drawer_button: None, - tab_agent_button: None, - tab_settings_button: None, - favorites_button: None, - help_button: None, - close_button: None, - drawer_scroll_view: None, - drawer_container: None, - drawer_entries: Vec::new(), - drawer_edge_effect: None, - search_field: None, - search_label: None, - drawer_favorites_only: false, - favorites: HashSet::new(), - agent_scroll_view: None, - agent_container: None, - agent_bubble_views: Vec::new(), - agent_bubble_click_recognizers: Vec::new(), - message_render_modes: HashMap::new(), - cached_agent_stack_height: None, - bubble_measure_cache: BubbleMeasureCache::new(), - agent_input_bar: None, - agent_input_scroll_view: None, - agent_input_text_view: None, - agent_input_field: None, - agent_attach_button: None, - agent_send_button: None, - agent_latest_button: None, - attachments: Vec::new(), - attachments_last_sent: None, - attachment_chip_strip: None, - active_tab: Tab::Drawer, - pending_tab: None, - messages: Vec::new(), - active_user_stream_index: None, - active_assistant_stream_index: None, - active_reasoning_stream_index: None, - manual_draft: String::new(), - prompt_history: Vec::new(), - prompt_history_cursor: None, - is_sending: false, - is_agent_thinking: false, - scroll_pinned: true, - auto_send_enabled: true, - status_base_text: "Ready".to_string(), - status_text: "Ready".to_string(), - status_kind: UiStatus::Idle, - context_text: String::new(), - runtime_degraded: false, - is_agent_degraded: false, - runtime_degraded_reason: None, - last_target_app: None, - conversation_state: ConversationModeState::default(), - action_handler: None, - last_layout_time: None, - layout_pending: false, - pending_delta_index: None, - zoom_level: 1.0, - } - } -} - -lazy_static::lazy_static! { - pub static ref OVERLAY_STATE: Mutex = Mutex::new(VoiceChatOverlayState::default()); - pub static ref SEND_CALLBACK: Mutex> = Mutex::new(None); -} diff --git a/app/ui_helpers.rs b/app/ui_helpers.rs deleted file mode 100644 index fdbaa44f..00000000 --- a/app/ui_helpers.rs +++ /dev/null @@ -1 +0,0 @@ -pub use crate::ui::shared::helpers::*; diff --git a/assets/operator_vocabulary.jsonl b/assets/operator_vocabulary.jsonl new file mode 100644 index 00000000..22f558e7 --- /dev/null +++ b/assets/operator_vocabulary.jsonl @@ -0,0 +1,8 @@ +{"id": "op-clipboard", "canonical": "clipboard", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["schowek", "schowku", "schowka", "schofku", "schofka", "schopku", "schopka", "s howek", "klipbord", "klip bord", "klipboard"], "whole_word_only": true, "case_sensitive": false}} +{"id": "op-selection", "canonical": "selection", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["zaznaczenie", "zaznaczenia", "zaznaczeniu", "zaznaczeniem", "selekszyn", "selekszon"], "whole_word_only": true, "case_sensitive": false}} +{"id": "op-paste", "canonical": "paste", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["wklej", "wkleić", "wklejone", "fklej", "f klej", "fkleić", "pejst"], "whole_word_only": true, "case_sensitive": false}} +{"id": "op-screenshot", "canonical": "screenshot", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["skrinszot", "skrin szot", "skrinszota", "zrzut ekranu", "zżut ekranu", "z żut ekranu"], "whole_word_only": true, "case_sensitive": false}} +{"id": "op-transcript", "canonical": "transcript", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["transkrypt", "transkryptu", "transkrypcie", "transkrypcja", "trans skrypt", "transkript"], "whole_word_only": true, "case_sensitive": false}} +{"id": "op-frontmost", "canonical": "frontmost", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["frontmołst", "front mołst", "front most", "frontmoust"], "whole_word_only": true, "case_sensitive": false}} +{"id": "op-prompt", "canonical": "prompt", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["prompta", "promptem", "prompcie", "promt"], "whole_word_only": true, "case_sensitive": false}} +{"id": "op-agent", "canonical": "agent", "semantic_type": "ui_command", "register": "operator", "language": "pl", "normalization": {"enabled": true, "input_variants": ["agenta", "agentem", "agentowi", "ejdżent", "ejdzent"], "whole_word_only": true, "case_sensitive": false}} diff --git a/assets/programming.jsonl b/assets/programming.jsonl index b6a0cf9a..94768276 100644 --- a/assets/programming.jsonl +++ b/assets/programming.jsonl @@ -42,7 +42,7 @@ {"term": "Tauri", "mispronunciations": ["taurii", "tałri", "taury", "taury real"], "category": "tech"} {"term": "Tokio", "mispronunciations": ["tokijo", "tokjo"], "category": "rust"} {"term": "TypeScript", "mispronunciations": ["tajpskrypt", "type script"], "category": "tech"} -{"term": "VistaScribe", "mispronunciations": ["wistaszkrajb", "vista scribe", "wissie skype", "wissie scribe", "vista skribe", "vista skrajb"], "category": "project"} +{"term": "Codescribe", "mispronunciations": ["kod skrajb", "kołdskrajb", "code scribe", "kod skrybe", "code skrajb", "kod skrajb"], "category": "project"} {"term": "Vite", "mispronunciations": ["vajt", "vit", "fajt"], "category": "tooling"} {"term": "WebSocket", "mispronunciations": ["websoket", "web socket"], "category": "tech"} {"term": "YAML", "mispronunciations": ["jamel", "jaml"], "category": "data"} @@ -73,7 +73,7 @@ {"term": "idempotent", "mispronunciations": ["ajdempotent", "idem potent", "idem potent"], "category": "net"} {"term": "jq", "mispronunciations": ["dżej ku", "jot ku", "dżej kiu"], "category": "cmd"} {"term": "latency", "mispronunciations": ["lejten si", "lejten sji", "lej ten si"], "category": "net"} -{"term": "loctree", "mispronunciations": ["log tree", "log three", "log 3", "log3", "loc tree", "lock tree", "locktree", "lock three", "logTree", "logtri", "locktri", "loktree", "loktri", "eloksy", "e loksy", "eloksji", "lock3", "LOCK3", "lok 3"], "category": "tools"} +{"term": "Loctree", "mispronunciations": ["loctree", "log tree", "log three", "log 3", "log3", "loc tree", "lock tree", "locktree", "lock three", "logTree", "logtri", "locktri", "loktree", "loktri", "eloksy", "e loksy", "eloksji", "lock3", "LOCK3", "lok 3", "luxury", "luxery", "luxujri", "lux tree", "luxe tree", "lakszeri", "lukstri"], "category": "tools"} {"term": "merge", "mispronunciations": ["merdż", "merdżu"], "category": "git"} {"term": "mlx_embeddings", "mispronunciations": ["em el eks embeddings", "mlx embeddings", "emeliks embeddings"], "category": "ai"} {"term": "multipart/form-data", "mispronunciations": ["multi part form data", "multi part formdejta", "multipart form data"], "category": "net"} diff --git a/assets/protected_terms.jsonl b/assets/protected_terms.jsonl new file mode 100644 index 00000000..9f32c99b --- /dev/null +++ b/assets/protected_terms.jsonl @@ -0,0 +1,21 @@ +{"term": "Loctree", "mispronunciations": ["loctree", "loktree", "luxury", "luxery", "lux tree"], "category": "protected"} +{"term": "Codescribe", "mispronunciations": ["code scribe", "codescribe", "Codescribe", "codeScribe", "kod scribe", "kod skrajb", "kodeskrajb", "kod-scribe"], "category": "protected"} +{"term": "Vetcoders", "mispronunciations": ["vet coders", "vetcoders", "Vetcoders", "vet koders", "wetkoders", "vetkoderzy"], "category": "protected"} +{"term": "Vibecrafted", "mispronunciations": ["vibe crafted", "vibecrafted", "vibekrafted", "wibe krafted", "vajbkrafted", "wajbkrafted"], "category": "protected"} +{"term": "AICX", "mispronunciations": ["a i c x", "aicx", "Aicx", "ai cx", "ai-cx", "a-i-c-x", "ajsiks", "ej aj si eks"], "category": "protected"} +{"term": "MCP", "mispronunciations": ["m c p", "mcp", "Mcp", "m-c-p", "em see pee", "em si pi"], "category": "protected"} +{"term": "GitHub", "mispronunciations": ["git hub", "github", "Github", "git-hub", "githab", "git hab"], "category": "protected"} +{"term": "Brave Search", "mispronunciations": ["brave search", "brave serch", "brejw search", "brave-search"], "category": "protected"} +{"term": "Responses API", "mispronunciations": ["responses api", "response api", "responses a p i", "responses ej pi aj"], "category": "protected"} +{"term": "Fn Shift", "mispronunciations": ["fn shift", "fun shift", "fan shift", "effen shift", "ef en shift", "f n shift"], "category": "protected"} +{"term": "Living Intent Queue", "mispronunciations": ["living intent queue", "living intent kju", "livingintent queue", "living intent kju"], "category": "protected"} +{"term": "Assistive Talk Anytime", "mispronunciations": ["assistive talk anytime", "asistive talk anytime", "assistive talk any time", "assistiv talk anytime"], "category": "protected"} +{"term": "Collapsible Tool Evidence", "mispronunciations": ["collapsible tool evidence", "collapsable tool evidence", "collapsible tool evidens"], "category": "protected"} +{"term": "Light Mode Semantic Contrast", "mispronunciations": ["light mode semantic contrast", "light-mode semantic contrast", "light mode semantik contrast"], "category": "protected"} +{"term": "Emil", "mispronunciations": ["emil", "emyl", "emiel"], "category": "protected"} +{"term": "Emilio", "mispronunciations": ["emilio", "emiljo", "emilijo"], "category": "protected"} +{"term": "Claude", "mispronunciations": ["claude", "klod", "klałd", "kload", "klat", "klołd"], "category": "protected"} +{"term": "Codex", "mispronunciations": ["kodeks", "kodex", "codeks", "codex'a", "codeksu"], "category": "protected"} +{"term": "Grok", "mispronunciations": ["grock", "grok ej aj", "grok ai"], "category": "protected"} +{"term": "Gemini", "mispronunciations": ["gemini", "Gemini ej aj", "dżemini", "dzemini", "dżeminaj"], "category": "protected"} +{"term": "Whisper", "mispronunciations": ["łisper", "łysper", "uisper", "łispr"], "category": "protected"} diff --git a/assets/seed.jsonl b/assets/seed.jsonl index 4fd41c8c..4188d3e1 100644 --- a/assets/seed.jsonl +++ b/assets/seed.jsonl @@ -70,7 +70,7 @@ {"id": "stt-chłoniakwieloogniskowy-6d25ecdccfbb", "canonical": "Chłoniak wieloogniskowy", "semantic_type": "disease", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["cchłoniak wieloogniskowy", "cgłoniak wieloogniskowy", "chhłoniak wieloogniskowy", "chlonniak", "chłiniak wieloogniskowy", "chłobiak wieloogniskowy", "chłomiak wieloogniskowy", "chłoniaak wieloogniskowy"], "variant_sources": {}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Nowotwór układu limfatycznego często zajmujący wiele narządów jednocześnie.", "ipa": "/xwɔˈɲak vʲɛlɔɔɡˈnʲiskɔvɨ/", "synonyms": ["CW", "chłoniak złośliwy"], "examples": ["Chłoniak wieloogniskowy jest jednym z najczęstszych nowotworów u psów.", "Pacjent ma powiększone węzły chłonne w kilku miejscach, podejrzewam chłoniaka wieloogniskowego.", "Czy pobieramy aspiraty do cytologii?"]}, "metadata": {"source_files": ["dictionary_v2_no_dialogs.jsonl"], "legacy_category": "inne", "legacy_category_type": "disease", "legacy_category_confidence": null, "confidence": 0.7, "status": "draft", "review_notes": [], "flags": ["source_file_missing"]}} {"id": "stt-chłoniakziarnisty-22a5dff45b4b", "canonical": "Chłoniak ziarnisty", "semantic_type": "disease", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["cchłoniak ziarnisty", "cgłoniak ziarnisty", "chhłoniak ziarnisty", "chłiniak ziarnisty", "chłobiak ziarnisty", "chłomiak ziarnisty", "chłoniaak ziarnisty", "chłoniaj ziarnisty"], "variant_sources": {}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Typ chłoniaka zawierający komórki z ziarnistościami cytoplazmatycznymi.", "ipa": "/xwɔniak ziarnistɨ/", "synonyms": ["CZ"], "examples": []}, "metadata": {"source_files": ["dictionary_v2_no_dialogs.jsonl"], "legacy_category": "choroby", "legacy_category_type": "disease", "legacy_category_confidence": null, "confidence": 0.7, "status": "draft", "review_notes": [], "flags": ["source_file_missing"]}} {"id": "stt-cisapryd-ae22fe91df32", "canonical": "Cisapryd", "semantic_type": "other", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["ccisapryd", "ciaapryd", "cidapryd", "ciisapryd", "cisaapryd", "cisaoryd", "cisapeyd", "cisappryd"], "variant_sources": {}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Silny prokinetyk dawniej stosowany u ludzi w weterynarii nadal używany w megakolonach i zaparciach opornych.", "ipa": "/t͡sisaprˈɨd/", "synonyms": ["Cisapride", "Propulsid"], "examples": []}, "metadata": {"source_files": ["Custom_dickt_Custom_diAA3.json"], "legacy_category": "inne", "legacy_category_type": "misc", "legacy_category_confidence": 0.0, "confidence": 0.7, "status": "draft", "review_notes": [], "flags": []}} -{"id": "stt-clindamycyna-fa74aa500fc1", "canonical": "Clindamycyna", "semantic_type": "drug", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["cclindamycyna", "ckindamycyna", "clibdamycyna", "cliindamycyna", "climdamycyna", "clindaamycyna", "clindamicina", "clindammycyna", "klindamicyna", "clindamycin", "klindamycyna", "klindamicyn", "klindamycin", "klindamacyn", "jlindamycyna", "kkindamycyna", "kklindamycyna", "klibdamycyna", "kliindamycyna", "klimdamycyna", "klindaamycyna"], "variant_sources": {"klindamicyna": ["observed"], "clindamycin": ["manual", "observed"], "klindamycyna": ["manual"], "klindamicyn": ["manual"], "klindamycin": ["manual"], "klindamacyn": ["manual"], "jlindamycyna": ["manual"], "kkindamycyna": ["manual"], "kklindamycyna": ["manual"], "klibdamycyna": ["manual"], "kliindamycyna": ["manual"], "klimdamycyna": ["manual"], "klindaamycyna": ["manual"]}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Antybiotyk z grupy linkozamidów stosowany w leczeniu zakażeń skóry i tkanek miękkich.", "ipa": "/klin.daˈmɨ.t͡sɨ.na/", "synonyms": [], "examples": ["Clindamycyna jest skuteczna przy leczeniu ropnych zakażeń tkanek miękkich.", "Przepiszę clindamycynę w leczeniu infekcji skórnych.", "Sprawdzę dawkowanie clindamycyny zgodnie z wytycznymi."]}, "metadata": {"source_files": ["CUSTOM_DI.json"], "legacy_category": "inne", "legacy_category_type": "drug", "legacy_category_confidence": null, "confidence": 0.7, "status": "draft", "review_notes": ["Observed apply-ready preview: dodano warianty klindamicyna na podstawie promotion_ready evidence.", "Observed evidence: `klindamicyna` (evidence_count=2, unique_source_ids=2).", "Observed apply proposal: gotowe do ręcznego apply po akceptacji review dla wariantów klindamicyna.", "Observed apply approved: ręcznie zaakceptowano warianty klindamicyna do seed preview na podstawie reviewed decision packet.", "Alias promotion preview: zaakceptowano warianty clindamycin, klindamycyna, klindamicyn, klindamycin, klindamacyn dla existing canonicala Clindamycyna.", "Alias source canonical: clindamycin.", "Reviewer note: Packet przygotowany automatycznie z rekomendowanego routingu canonical gaps.", "Reviewer note: Packet przygotowany automatycznie z rekomendowanego routingu alias policy review.", "Reviewer note: Packet przygotowany automatycznie z rekomendowanego routingu mixed conflict review.", "Reviewer note: Monika approved: canonical zostaje Clindamycyna; Klindamycyna traktujemy jako variant surface form.", "Reviewer note: Monika approved: clindamycin domyślnie mapujemy do Clindamycyna, bez splitu.", "Reviewer note: Monika approved canonical merge target: Clindamycyna.", "Alias promotion preview: zaakceptowano warianty jlindamycyna, kkindamycyna, kklindamycyna, klibdamycyna, kliindamycyna, klimdamycyna, klindaamycyna dla existing canonicala Clindamycyna.", "Observed apply-ready preview: dodano warianty klindamicyna, clindamycin na podstawie promotion_ready evidence.", "Observed evidence: `clindamycin` (evidence_count=3, unique_source_ids=3).", "Observed apply proposal: gotowe do ręcznego apply po akceptacji review dla wariantów klindamicyna, clindamycin.", "Observed apply approved: ręcznie zaakceptowano warianty klindamicyna, clindamycin do seed preview na podstawie reviewed decision packet."], "flags": ["alias_promotion_apply_preview", "observed_apply_applied", "observed_apply_proposal", "observed_apply_ready"]}} +{"id": "stt-clindamycyna-fa74aa500fc1", "canonical": "Clindamycyna", "semantic_type": "drug", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["cclindamycyna", "ckindamycyna", "clibdamycyna", "cliindamycyna", "climdamycyna", "clindaamycyna", "clindamicina", "clindammycyna", "klindamicyna", "clindamycin", "klindamycyna", "klindamicyn", "klindamycin", "klindamacyn", "jlindamycyna", "kkindamycyna", "kklindamycyna", "klibdamycyna", "kliindamycyna", "klimdamycyna", "klindaamycyna"], "variant_sources": {"klindamicyna": ["observed"], "clindamycin": ["manual", "observed"], "klindamycyna": ["manual"], "klindamicyn": ["manual"], "klindamycin": ["manual"], "klindamacyn": ["manual"], "jlindamycyna": ["manual"], "kkindamycyna": ["manual"], "kklindamycyna": ["manual"], "klibdamycyna": ["manual"], "kliindamycyna": ["manual"], "klimdamycyna": ["manual"], "klindaamycyna": ["manual"]}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Antybiotyk z grupy linkozamidów stosowany w leczeniu zakażeń skóry i tkanek miękkich.", "ipa": "/klin.daˈmɨ.t͡sɨ.na/", "synonyms": [], "examples": ["Clindamycyna jest skuteczna przy leczeniu ropnych zakażeń tkanek miękkich.", "Przepiszę clindamycynę w leczeniu infekcji skórnych.", "Sprawdzę dawkowanie clindamycyny zgodnie z wytycznymi."]}, "metadata": {"source_files": ["CUSTOM_DI.json"], "legacy_category": "inne", "legacy_category_type": "drug", "legacy_category_confidence": null, "confidence": 0.7, "status": "draft", "review_notes": ["Observed apply-ready preview: dodano warianty klindamicyna na podstawie promotion_ready evidence.", "Observed evidence: `klindamicyna` (evidence_count=2, unique_source_ids=2).", "Observed apply proposal: gotowe do ręcznego apply po akceptacji review dla wariantów klindamicyna.", "Observed apply approved: ręcznie zaakceptowano warianty klindamicyna do seed preview na podstawie reviewed decision packet.", "Alias promotion preview: zaakceptowano warianty clindamycin, klindamycyna, klindamicyn, klindamycin, klindamacyn dla existing canonicala Clindamycyna.", "Alias source canonical: clindamycin.", "Reviewer note: Packet przygotowany automatycznie z rekomendowanego routingu canonical gaps.", "Reviewer note: Packet przygotowany automatycznie z rekomendowanego routingu alias policy review.", "Reviewer note: Packet przygotowany automatycznie z rekomendowanego routingu mixed conflict review.", "Reviewer note: Vetcoders approved: canonical zostaje Clindamycyna; Klindamycyna traktujemy jako variant surface form.", "Reviewer note: Vetcoders approved: clindamycin domyślnie mapujemy do Clindamycyna, bez splitu.", "Reviewer note: Vetcoders approved canonical merge target: Clindamycyna.", "Alias promotion preview: zaakceptowano warianty jlindamycyna, kkindamycyna, kklindamycyna, klibdamycyna, kliindamycyna, klimdamycyna, klindaamycyna dla existing canonicala Clindamycyna.", "Observed apply-ready preview: dodano warianty klindamicyna, clindamycin na podstawie promotion_ready evidence.", "Observed evidence: `clindamycin` (evidence_count=3, unique_source_ids=3).", "Observed apply proposal: gotowe do ręcznego apply po akceptacji review dla wariantów klindamicyna, clindamycin.", "Observed apply approved: ręcznie zaakceptowano warianty klindamicyna, clindamycin do seed preview na podstawie reviewed decision packet."], "flags": ["alias_promotion_apply_preview", "observed_apply_applied", "observed_apply_proposal", "observed_apply_ready"]}} {"id": "stt-cukrzyca-f57925d5f544", "canonical": "Cukrzyca", "semantic_type": "disease", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["ccukrzyca", "cikrzyca", "cujrzyca", "cukezyca", "cukkrzyca", "cukrowka", "cukrrzyca", "cukrxyca"], "variant_sources": {}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Choroba metaboliczna charakteryzująca się podwyższonym poziomem glukozy we krwi. Glukoza wyszła 450 to wygląda na cukrzycę. Brak definicji.", "ipa": "/t͡sukʐɨˈt͡sa/", "synonyms": ["DM", "Diabetes mellitus", "cukrowka", "diabetes"], "examples": ["Tak, i od razu zmierzymy ketony w moczu.", "Jasne, pobiorę próbkę od razu.", "U pacjenta zdiagnozowano cukrzycę."]}, "metadata": {"source_files": ["dictionary_v2_no_dialogs.jsonl"], "legacy_category": "inne", "legacy_category_type": "disease", "legacy_category_confidence": null, "confidence": 0.7, "status": "draft", "review_notes": [], "flags": ["source_file_missing"]}} {"id": "stt-cystocenteza-a5a96405311e", "canonical": "Cystocenteza", "semantic_type": "procedure", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["ccystocenteza", "cistocenteza", "ctstocenteza", "custocenteza", "cyatocenteza", "cydtocenteza", "cysrocenteza", "cysstocenteza"], "variant_sources": {}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Nakłucie pęcherza moczowego przez ścianę brzuszną w celu pobrania sterylnej próbki moczu, unikając skażenia z cewki moczowej lub zewnętrznych narządów płciowych.", "ipa": "/t͡sɨstɔt͡sɛntɛza/", "synonyms": ["aspiracja moczu", "punkcja pęcherza moczowego"], "examples": ["Cystocenteza jest najlepszą metodą pobrania sterylnej próbki moczu do analizy.", "Cystocenteza jest zalecana do pobrania próbki moczu w diagnostyce infekcji dróg moczowych.", "Potrzebujemy sterylnego moczu, robimy cystocentezę."]}, "metadata": {"source_files": ["dictionary_v2_no_dialogs.jsonl"], "legacy_category": "procedury", "legacy_category_type": "procedure", "legacy_category_confidence": null, "confidence": 0.7, "status": "draft", "review_notes": [], "flags": ["source_file_missing"]}} {"id": "stt-cystoskopsztywny-db14bee94592", "canonical": "Cystoskop sztywny", "semantic_type": "other", "register": "clinical", "language": "pl", "normalization": {"enabled": true, "input_variants": ["ccystoskop sztywny", "cistoskop sztiwni", "ctstoskop sztywny", "custoskop sztywny", "cyatoskop sztywny", "cydtoskop sztywny", "cysroskop sztywny", "cysstoskop sztywny"], "variant_sources": {}, "whole_word_only": true, "case_sensitive": false, "priority": 50, "ambiguity": "medium", "context": {"required": false, "include_any": [], "exclude_any": []}}, "knowledge": {"definition": "Rodzaj endoskopu o sztywnej konstrukcji stosowany do diagnostyki pęcherza moczowego.", "ipa": "/t͡sɨstɔskɔp ʂtɨvnɨ/", "synonyms": ["CS"], "examples": []}, "metadata": {"source_files": ["dictionary_v2_no_dialogs.jsonl"], "legacy_category": "sprzęt", "legacy_category_type": "misc", "legacy_category_confidence": null, "confidence": 0.7, "status": "draft", "review_notes": [], "flags": ["source_file_missing"]}} diff --git a/bin/codescribe.rs b/bin/codescribe.rs deleted file mode 100644 index a18ebc81..00000000 --- a/bin/codescribe.rs +++ /dev/null @@ -1,970 +0,0 @@ -//! CodeScribe CLI - Local speech-to-text transcription -//! -//! Lightweight CLI for direct audio file transcription. -//! For the tray app + overlay, use CodeScribe.app. - -use anyhow::Result; -use clap::{Parser, Subcommand}; -use codescribe::os::hotkeys; -use codescribe::{ai_formatting, audio, whisper}; -use codescribe_core::pipeline::contracts::{EngineEvent, EventSink, TranscriptDelta}; -use codescribe_core::vad; -use std::borrow::Cow; -use std::env; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use tracing::info; - -/// CodeScribe CLI - Local speech-to-text transcription -/// -/// For the full app with tray icon and hotkeys, run CodeScribe.app -#[derive(Parser)] -#[command(name = "codescribe")] -#[command(version)] -#[command(author = "VetCoders ")] -#[command(about = "Local speech-to-text transcription", long_about = None)] -struct Cli { - /// Open config file in editor (creates default if missing) - #[arg(long)] - config: bool, - - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand)] -enum Commands { - /// Transcribe an audio file using local Whisper - Transcribe(TranscribeArgs), - - /// Run as daemon with tray icon (default when no args) - Daemon, - - /// Migrate transcript/audio filenames to ASCII + suffix naming - MigrateHistory { - /// Only print planned changes without renaming files - #[arg(long)] - dry_run: bool, - - /// Assume kind for files without suffix - #[arg(long, value_enum, default_value = "raw")] - assume_kind: MigrateKind, - }, -} - -#[derive(clap::Args)] -struct TranscribeArgs { - /// Path to audio file (wav, mp3, m4a) - file: Option, - - /// Language code (e.g., pl, en). Default: auto-detect - #[arg(short, long, global = true)] - language: Option, - - /// Stream transcription to stdout (chunked, with flush) - #[arg(long)] - stream: bool, - - /// Format output using AI (Ollama) - #[arg(short, long)] - format: bool, - - /// LLM model for formatting (overrides LLM_FORMATTING_MODEL for this run) - #[arg(long)] - llm: Option, - - /// Skip Silero VAD speech pre-filtering - #[arg(long)] - no_vad: bool, - - #[command(subcommand)] - mode: Option, -} - -#[derive(Subcommand)] -enum TranscribeMode { - /// Live transcription from microphone to stdout - Live, -} - -#[derive(clap::ValueEnum, Clone, Copy, Debug)] -enum MigrateKind { - Raw, - Cloud, - Ai, - Formatted, - AiFailed, - FormattingFailed, - Interpretation, - Failed, -} - -// Cap the multi-thread tokio runtime worker count. Default is one worker per -// CPU core — on M3 Pro (11 cores) that spawns ~11 workers, each idle in -// `__psynch_cvwait` consuming kernel resources + scheduler overhead. CodeScribe -// is not compute-parallel (whisper runs on Metal device, audio on cpal thread, -// LLM is single SSE stream); 4 workers cover IPC server + HTTP client + LLM -// stream + occasional background tasks with headroom. Confirmed empirically: -// crash dump from PID 52228 (2026-05-13 12:11:17) showed ~24 tokio workers -// alive simultaneously despite low task load. -#[tokio::main(flavor = "multi_thread", worker_threads = 4)] -async fn main() -> Result<()> { - init_tracing(); - install_panic_hook(); - - // Build identity — first line in ~/.codescribe/logs/codescribe.log so every session - // is unambiguously tied to a build. The 8-char commit matches the About dialog. - tracing::info!( - "CodeScribe v{} build {} ({})", - env!("CARGO_PKG_VERSION"), - env!("CODESCRIBE_BUILD_COMMIT"), - env!("CODESCRIBE_RUSTC_VERSION"), - ); - - let cli = Cli::parse(); - - // Handle --config flag - if cli.config { - return handle_config_command(); - } - - // Handle subcommands - match cli.command { - Some(Commands::Transcribe(args)) => handle_transcribe_command(args).await, - Some(Commands::MigrateHistory { - dry_run, - assume_kind, - }) => handle_migrate_history_command(dry_run, assume_kind), - Some(Commands::Daemon) | None => run_daemon().await, - } -} - -/// Install a global panic hook that logs every panic through `tracing` before -/// the process unwinds or aborts. -/// -/// This is the only diagnostic that survives `panic="abort"` in the release -/// profile (Cargo.toml `[profile.release]`): `std::panic::set_hook` runs the -/// hook BEFORE the abort, so even a panic crossing an `extern "C"` boundary — -/// where `catch_unwind` is useless — leaves a symbolizable trace -/// (payload + location + thread name + backtrace) in -/// `~/.codescribe/logs/codescribe.log`. -/// -/// MUST be installed AFTER `init_tracing()` (so a subscriber exists) and BEFORE -/// the first task/thread is spawned, otherwise early panics would be silent. -fn install_panic_hook() { - std::panic::set_hook(Box::new(|info| { - // Extract a human-readable payload (panic message). - let payload = info.payload(); - let message = payload - .downcast_ref::<&str>() - .map(|s| s.to_string()) - .or_else(|| payload.downcast_ref::().cloned()) - .unwrap_or_else(|| "".to_string()); - - let location = info - .location() - .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) - .unwrap_or_else(|| "".to_string()); - - let thread = std::thread::current(); - let thread_name = thread.name().unwrap_or("").to_string(); - - let backtrace = std::backtrace::Backtrace::force_capture(); - - tracing::error!( - target: "panic", - thread = %thread_name, - location = %location, - "PANIC: {message}\nbacktrace:\n{backtrace}" - ); - })); -} - -fn init_tracing() { - use tracing_subscriber::prelude::*; - use tracing_subscriber::{EnvFilter, fmt}; - - // Prefer `RUST_LOG`, fall back to legacy `LOG_LEVEL`. - let filter = match env::var("RUST_LOG") { - Ok(v) => v, - Err(_) => match env::var("LOG_LEVEL") { - Ok(v) => v.to_lowercase(), - Err(_) => "info".to_string(), - }, - }; - - 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) - .with_thread_ids(true) - .with_thread_names(true); - - 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); - - if let Ok(file) = file { - let file = std::sync::Arc::new(file); - let file_layer = fmt::layer() - .with_ansi(false) - .with_target(true) - .with_thread_ids(true) - .with_thread_names(true) - .with_writer(move || (*file).try_clone().expect("Failed to clone log file")); - - let _ = tracing_subscriber::registry() - .with(filter_layer) - .with(stderr_layer) - .with(file_layer) - .try_init(); - } else { - let _ = tracing_subscriber::registry() - .with(filter_layer) - .with(stderr_layer) - .try_init(); - } -} - -/// Handle --config flag: create default config and open in editor -fn handle_config_command() -> Result<()> { - use std::fs; - use std::process::Command; - - let config_dir = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string())) - .join(".codescribe"); - let config_path = config_dir.join(".env"); - - // Create directory if needed - fs::create_dir_all(&config_dir)?; - - // Create default config if missing - if !config_path.exists() { - let default_config = include_str!("../core/config/default_env.txt"); - fs::write(&config_path, default_config)?; - println!("Created default config: {}", config_path.display()); - } else { - println!("Config exists: {}", config_path.display()); - } - - // Open in editor - #[cfg(target_os = "macos")] - { - use std::io::IsTerminal; - if !std::io::stdin().is_terminal() { - println!("Opening in default text editor"); - Command::new("open").arg("-t").arg(&config_path).status()?; - return Ok(()); - } - } - - let editor = std::env::var("EDITOR") - .or_else(|_| std::env::var("VISUAL")) - .unwrap_or_else(|_| { - for editor in &["code", "nvim", "vim", "nano"] { - if Command::new("which") - .arg(editor) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - { - return editor.to_string(); - } - } - "nano".to_string() - }); - - println!("Opening in: {}", editor); - Command::new(&editor).arg(&config_path).status()?; - - Ok(()) -} - -fn handle_migrate_history_command(dry_run: bool, assume_kind: MigrateKind) -> Result<()> { - let kind = match assume_kind { - MigrateKind::Raw => codescribe::state::history::TranscriptKind::Raw, - MigrateKind::Cloud => codescribe::state::history::TranscriptKind::Cloud, - MigrateKind::Ai | MigrateKind::Formatted => { - codescribe::state::history::TranscriptKind::FormattedTranscript - } - MigrateKind::AiFailed | MigrateKind::FormattingFailed => { - codescribe::state::history::TranscriptKind::FormattingFailed - } - MigrateKind::Interpretation => { - codescribe::state::history::TranscriptKind::AssistantInterpretation - } - MigrateKind::Failed => codescribe::state::history::TranscriptKind::Failed, - }; - let report = codescribe::state::history::migrate_transcriptions(kind, dry_run)?; - - println!( - "Migration summary: {} transcripts renamed, {} audio renamed, {} skipped, {} errors", - report.renamed_text, report.renamed_audio, report.skipped, report.errors - ); - - Ok(()) -} - -/// Handle `codescribe transcribe ` command -async fn handle_transcribe_command(args: TranscribeArgs) -> Result<()> { - match args.mode { - Some(TranscribeMode::Live) => handle_transcribe_live(args.language).await, - None => { - let file = args.file.ok_or_else(|| { - anyhow::anyhow!("Missing (or use `codescribe transcribe live`)") - })?; - handle_transcribe_file( - file, - args.language, - args.format, - args.llm, - args.stream, - args.no_vad, - ) - .await - } - } -} - -async fn handle_transcribe_file( - file: PathBuf, - language: Option, - format: bool, - llm_model: Option, - stream: bool, - no_vad: bool, -) -> Result<()> { - use std::time::Instant; - - // Check file exists - if !file.exists() { - anyhow::bail!("File not found: {}", file.display()); - } - - eprintln!("CodeScribe Local Transcription"); - eprintln!("Audio: {}", file.display()); - - // Initialize Whisper - eprintln!("Loading Whisper model..."); - let start = Instant::now(); - whisper::init()?; - - if whisper::embedded::is_embedded_available() { - eprintln!("Model: embedded (zero I/O)"); - } else if let Ok(path) = whisper::get_model_path() { - eprintln!("Model: {}", path.display()); - } - eprintln!("Language: {}", language.as_deref().unwrap_or("auto-detect")); - eprintln!("Model loaded in {:?}", start.elapsed()); - - // Always load audio (needed for VAD pre-filter + language detection) - let (samples, sample_rate) = audio::load_audio_file(&file)?; - let total_sec = samples.len() as f32 / sample_rate as f32; - - // ── Silero VAD pre-filter: extract speech-only regions ── - let speech_samples = if no_vad { - eprintln!("VAD: skipped (--no-vad)"); - Cow::Borrowed(samples.as_slice()) - } else { - let (filtered_samples, vad_stats) = vad::extract_speech(&samples, sample_rate); - let speech_sec = filtered_samples.len() as f32 / sample_rate as f32; - eprintln!( - "Silero VAD: {:.1}s speech / {:.1}s total ({:.0}% speech) | {}", - speech_sec, total_sec, vad_stats.speech_pct, vad_stats.sparkline - ); - Cow::Owned(filtered_samples) - }; - - if !no_vad && speech_samples.is_empty() { - eprintln!("No speech detected by Silero VAD. Skipping Whisper transcription."); - return Ok(()); - } - - // Detect language if not specified - let lang = if let Some(l) = language { - l - } else { - eprintln!("Detecting language..."); - let start = Instant::now(); - let detected = whisper::detect_language(speech_samples.as_ref(), sample_rate)?; - eprintln!("Detected: {} ({:?})", detected, start.elapsed()); - detected - }; - - if stream { - if format || llm_model.is_some() { - eprintln!("Warning: --stream ignores --format/--llm (raw streaming only)"); - } - - eprintln!("Transcribing (streaming)..."); - let start = Instant::now(); - let emitter = StreamEmitter::new(); - let callback = { - let emitter = Arc::clone(&emitter); - move |cumulative: &str| { - emitter.emit_cumulative(cumulative); - } - }; - let _raw_text = whisper::transcribe_streaming( - speech_samples.as_ref(), - sample_rate, - Some(&lang), - Some(&callback), - )?; - eprintln!("Transcription time: {:?}", start.elapsed()); - emitter.finish(); - return Ok(()); - } - - // Transcribe (non-streaming) — speech-only samples - eprintln!("Transcribing..."); - let start = Instant::now(); - let raw_text = whisper::transcribe(speech_samples.as_ref(), sample_rate, Some(&lang))?; - eprintln!("Transcription time: {:?}", start.elapsed()); - - // Format with AI if requested - let final_text = if format { - let mut prev_model: Option = None; - if let Some(model) = llm_model.as_ref() { - prev_model = std::env::var("LLM_FORMATTING_MODEL").ok(); - // SAFETY: CLI is single-process; scoped override for this run only. - unsafe { std::env::set_var("LLM_FORMATTING_MODEL", model) }; - eprintln!("Formatting with AI (model: {})...", model); - } else { - eprintln!("Formatting with AI..."); - } - - let start = Instant::now(); - let result = - ai_formatting::format_text_with_status(&raw_text, Some(&lang), false, None).await; - - let formatted = match result.status { - ai_formatting::AiFormatStatus::Applied => { - eprintln!("Formatted in {:?}", start.elapsed()); - result.text - } - ai_formatting::AiFormatStatus::AiNoop => { - eprintln!("AI returned no-op - using raw text"); - raw_text - } - ai_formatting::AiFormatStatus::Failed => { - eprintln!("Formatting failed - using raw text"); - raw_text - } - ai_formatting::AiFormatStatus::Skipped => raw_text, - }; - - if llm_model.is_some() { - match prev_model { - Some(prev) => unsafe { std::env::set_var("LLM_FORMATTING_MODEL", prev) }, - None => unsafe { std::env::remove_var("LLM_FORMATTING_MODEL") }, - } - } - - formatted - } else { - raw_text - }; - - eprintln!(); - - // Output transcription to stdout (pipeable) - emit_stdout(&final_text)?; - emit_stdout("\n")?; - - Ok(()) -} - -async fn handle_transcribe_live(language: Option) -> Result<()> { - use std::io::Write; - - // CLI flag takes priority; fall back to settings.json speech.language. - let language = - language.or_else(|| codescribe_core::config::UserSettings::load().whisper_language); - - eprintln!("CodeScribe Live Transcription"); - eprintln!("Press Ctrl+C to stop."); - - whisper::init()?; - - // Create transcript log file — clean text only, one utterance per line. - let log_dir = std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) - .join(".codescribe/logs"); - std::fs::create_dir_all(&log_dir)?; - let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); - let log_path = log_dir.join(format!("live_{timestamp}.log")); - let log_file = Arc::new(Mutex::new( - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path)?, - )); - eprintln!("Transcript log: {}", log_path.display()); - - // Auto-open log in Console.app for live tailing. - let _ = std::process::Command::new("open") - .arg("-a") - .arg("Console") - .arg(&log_path) - .spawn(); - - let mut recorder = codescribe::audio::streaming_recorder::StreamingRecorder::new()?; - // Disable auto-silence stop — live mode runs until Ctrl+C. - // Silero VAD still acts as supervisor for utterance segmentation - // inside the streaming pipeline (same as the daemon app). - recorder.recorder.config.auto_silence = false; - - let emitter = StreamEmitter::new(); - let sink = LiveCliEventSink::new(Arc::clone(&emitter)); - let log_sink = LiveLogEventSink { - inner: sink, - log_file: log_file.clone(), - }; - recorder.set_event_sink(Some(Arc::new(log_sink) as Arc)); - recorder.start_event_session(language).await?; - - tokio::signal::ctrl_c().await.ok(); - eprintln!("\nStopping live transcription..."); - - let _ = recorder.stop().await?; - emitter.finish(); - - // Write session footer. - if let Ok(mut f) = log_file.lock() { - let _ = writeln!( - f, - "\n--- session ended {} ---", - chrono::Local::now().format("%H:%M:%S") - ); - } - - eprintln!("Transcript saved: {}", log_path.display()); - Ok(()) -} - -async fn run_daemon() -> Result<()> { - use codescribe::tray; - - eprintln!("CodeScribe daemon starting..."); - - // ── Build metadata ── - info!( - "CodeScribe {} | build={} | profile={} | rustc={} | exe={}", - env!("CARGO_PKG_VERSION"), - option_env!("CODESCRIBE_BUILD_COMMIT").unwrap_or("dev"), - if cfg!(debug_assertions) { - "debug" - } else { - "release" - }, - option_env!("CODESCRIBE_RUSTC_VERSION").unwrap_or("unknown"), - std::env::current_exe() - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| "unknown".into()), - ); - - tray::run_with_startup(None, || { - tokio::spawn(async { - if let Err(e) = initialize_daemon_runtime().await { - tracing::error!("CodeScribe startup failed: {e:?}"); - let _ = codescribe::tray::update_tray_status(codescribe::tray::TrayStatus::Error); - #[cfg(target_os = "macos")] - codescribe::os::notifications::notify("CodeScribe startup failed", &format!("{e}")); - } - }); - })?; - - Ok(()) -} - -async fn initialize_daemon_runtime() -> Result<()> { - use anyhow::Context; - use codescribe::config::{Config, UserSettings}; - use codescribe::controller::RecordingController; - use codescribe::os::hotkeys::HotkeyEvent; - use codescribe::{ipc, tray}; - use crossbeam_channel::unbounded; - use std::sync::Arc; - use tokio::runtime::Handle; - - let config = Config::load(); - let _user_settings = UserSettings::load(); - let menu_rx = tray::menu_event_receiver()?; - let _ = codescribe::qube_lifecycle::start_if_enabled(); - - #[cfg(target_os = "macos")] - { - codescribe::os::thermal::install_thermal_probe(); - codescribe::set_dock_icon(); - codescribe::apply_dock_icon_visibility(config.show_dock_icon); - codescribe::install_basic_edit_menu(); - } - - tokio::task::spawn_blocking(|| { - codescribe_core::attachment::AttachmentStore::cleanup_old(7); - }); - - codescribe::whisper::init().context("Failed to initialize Whisper")?; - let controller = Arc::new(RecordingController::new()); - #[cfg(target_os = "macos")] - codescribe::controller::register_overlay_controller(Arc::clone(&controller)); - #[cfg(target_os = "macos")] - { - codescribe::os::permissions::check_all_permissions(); - - if codescribe::should_show_onboarding() { - codescribe::show_onboarding_wizard(); - } - } - - sync_hotkey_config(&config); - - let ipc_controller = Arc::clone(&controller); - tokio::spawn(async move { - if let Err(e) = ipc::run_server(ipc_controller).await { - eprintln!("IPC server error: {}", e); - } - }); - - let menu_controller = Arc::clone(&controller); - let menu_handle = Handle::current(); - std::thread::spawn(move || { - for event in menu_rx { - let event_for_async = event.clone(); - let controller = Arc::clone(&menu_controller); - let handle = menu_handle.clone(); - handle.spawn(async move { - // Apply menu-driven config changes deterministically. - // The tray handler writes to `~/.codescribe/.env`, but reading it back immediately - // can race on some systems. So we use the event payload as the source of truth - // for hotkey-related fields and only reload the rest from disk. - let mut config = Config::load(); - match &event_for_async { - tray::TrayMenuEvent::SetQuickNotesEnabled(enabled) => { - config.quick_notes_enabled = *enabled; - } - tray::TrayMenuEvent::SetQuickNotesSaveOnly(save_only) => { - config.quick_notes_save_only = *save_only; - } - tray::TrayMenuEvent::InstallSileroVad => { - eprintln!("Installing Silero VAD model…"); - match codescribe_core::vad::ensure_downloaded_to_user_dir().await { - Ok(path) => { - eprintln!("Silero VAD downloaded and ready: {}", path.display()); - #[cfg(target_os = "macos")] - { - codescribe::os::notifications::notify( - "CodeScribe", - "Silero VAD is ready", - ); - } - } - Err(e) => { - eprintln!("Silero VAD download failed: {}", e); - #[cfg(target_os = "macos")] - { - codescribe::os::notifications::notify( - "CodeScribe", - &format!("Silero VAD download failed: {e}"), - ); - } - } - } - } - _ => {} - } - sync_hotkey_config(&config); - controller.set_config(config).await; - }); - - if matches!(event, tray::TrayMenuEvent::Quit) { - break; - } - } - }); - - let (tx, rx) = unbounded::(); - let hotkey_controller = Arc::clone(&controller); - let hotkey_handle = Handle::current(); - std::thread::spawn(move || { - for event in rx { - let controller = Arc::clone(&hotkey_controller); - let handle = hotkey_handle.clone(); - handle.spawn(async move { - if let Err(e) = dispatch_hotkey_event(event, controller).await { - eprintln!("Hotkey event error: {}", e); - } - }); - } - }); - - if let Err(e) = hotkeys::install_global_hotkey_manager(tx) { - eprintln!( - "Hotkeys waiting on permissions ({}). Grant Accessibility + Input Monitoring and CodeScribe will reinitialize them live.", - e - ); - } - - // VAD monitor task - auto-finish recording when silence detected - let vad_controller = Arc::clone(&controller); - tokio::spawn(async move { - loop { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - if vad_controller.is_vad_triggered() { - // IMPORTANT: - // - In toggle mode, VAD-based auto-finish is the intended UX. - // - In hold-to-talk mode, the user's key-down is the source of truth; do NOT stop early. - let state = vad_controller.current_state().await; - if state == codescribe::controller::State::RecToggle { - eprintln!("VAD triggered - auto-finishing recording"); - vad_controller.clear_vad_triggered(); - if let Err(e) = vad_controller.finish_recording().await { - eprintln!("VAD finish_recording error: {}", e); - } - } else { - // Clear so it doesn't "carry over" into a later toggle session. - vad_controller.clear_vad_triggered(); - } - } - } - }); - - let _ = tray::update_tray_status(tray::TrayStatus::Idle); - info!("CodeScribe daemon ready"); - - Ok(()) -} - -fn emit_stdout(text: &str) -> Result<()> { - use std::io::Write; - - let mut out = std::io::stdout(); - out.write_all(text.as_bytes())?; - out.flush()?; - Ok(()) -} - -struct StreamEmitter { - last_len: Mutex, - had_output: AtomicBool, -} - -impl StreamEmitter { - fn new() -> Arc { - Arc::new(Self { - last_len: Mutex::new(0), - had_output: AtomicBool::new(false), - }) - } - - fn emit_raw(&self, text: &str) { - if text.is_empty() { - return; - } - if emit_stdout(text).is_ok() { - self.had_output.store(true, Ordering::SeqCst); - } - } - - fn emit_cumulative(&self, cumulative: &str) { - let mut last_len = self.last_len.lock().unwrap_or_else(|e| e.into_inner()); - let total_len = cumulative.len(); - if total_len <= *last_len { - return; - } - let delta = &cumulative[*last_len..]; - *last_len = total_len; - self.emit_raw(delta); - } - - fn finish(&self) { - if self.had_output.load(Ordering::SeqCst) { - let _ = emit_stdout("\n"); - } - } -} - -struct LiveCliEventSink { - emitter: Arc, - last_preview: Mutex, -} - -impl LiveCliEventSink { - fn new(emitter: Arc) -> Self { - Self { - emitter, - last_preview: Mutex::new(String::new()), - } - } - - fn emit_diff_from_last(&self, next: &str) { - let mut last = self.last_preview.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(delta) = TranscriptDelta::from_diff(&last, next) { - self.emitter.emit_raw(&delta.delta); - } - *last = next.to_string(); - } - - fn clear_preview(&self) { - let mut last = self.last_preview.lock().unwrap_or_else(|e| e.into_inner()); - last.clear(); - } -} - -impl EventSink for LiveCliEventSink { - fn on_event(&self, event: &EngineEvent) { - match event { - EngineEvent::Preview { text, .. } => { - self.emit_diff_from_last(text); - } - EngineEvent::Correction { - text, - previous_text, - .. - } => { - let mut last = self.last_preview.lock().unwrap_or_else(|e| e.into_inner()); - if last.is_empty() || *last != *previous_text { - return; - } - if let Some(delta) = TranscriptDelta::from_diff(&last, text) { - self.emitter.emit_raw(&delta.delta); - } - *last = text.clone(); - } - EngineEvent::UtteranceFinal { text, .. } => { - self.emit_diff_from_last(text); - self.clear_preview(); - } - EngineEvent::NoSpeech { .. } => { - self.clear_preview(); - } - _ => {} - } - } -} - -/// Wraps `LiveCliEventSink` and appends UtteranceFinal text to a log file. -struct LiveLogEventSink { - inner: LiveCliEventSink, - log_file: Arc>, -} - -impl EventSink for LiveLogEventSink { - fn on_event(&self, event: &EngineEvent) { - // Delegate to the CLI sink for stdout output. - self.inner.on_event(event); - - // Append clean transcript text to log file on utterance boundaries. - if let EngineEvent::UtteranceFinal { text, .. } = event { - let trimmed = text.trim(); - if !trimmed.is_empty() - && let Ok(mut f) = self.log_file.lock() - { - use std::io::Write; - let _ = writeln!(f, "{}", trimmed); - let _ = f.flush(); - } - } - } -} - -fn sync_hotkey_config(config: &codescribe::config::Config) { - codescribe::os::hotkeys::apply_hotkey_config(config); -} - -async fn dispatch_hotkey_event( - event: codescribe::os::hotkeys::HotkeyEvent, - controller: std::sync::Arc, -) -> Result<()> { - use codescribe::controller::{HotkeyAction, HotkeyInput, HotkeyType}; - use codescribe::os::hotkeys::{HoldAction, HoldMode, HotkeyEvent}; - - match event { - HotkeyEvent::Hold { - action, - mode, - force_ai, - } => { - let mapped_action = match action { - HoldAction::Down => HotkeyAction::Down, - HoldAction::Up => HotkeyAction::Up, - }; - let input = HotkeyInput { - key_type: HotkeyType::Hold, - action: mapped_action, - assistive: !matches!(mode, HoldMode::Raw), - hold_mode: mode, - force_raw: matches!(mode, HoldMode::Raw) && !force_ai, - force_ai, - }; - controller.handle_hotkey_event(input).await?; - } - HotkeyEvent::HoldUpdate { mode, force_ai } => { - let input = HotkeyInput { - key_type: HotkeyType::Hold, - action: HotkeyAction::Press, - assistive: !matches!(mode, HoldMode::Raw), - hold_mode: mode, - force_raw: matches!(mode, HoldMode::Raw) && !force_ai, - force_ai, - }; - controller.handle_hotkey_event(input).await?; - } - HotkeyEvent::ToggleNormal => { - let input = HotkeyInput { - key_type: HotkeyType::Toggle, - action: HotkeyAction::Press, - assistive: false, - hold_mode: HoldMode::Raw, - force_raw: false, - force_ai: true, - }; - controller.handle_hotkey_event(input).await?; - } - HotkeyEvent::ToggleRaw => { - let input = HotkeyInput { - key_type: HotkeyType::Toggle, - action: HotkeyAction::Press, - assistive: false, - hold_mode: HoldMode::Raw, - force_raw: true, - force_ai: false, - }; - controller.handle_hotkey_event(input).await?; - } - HotkeyEvent::ToggleAssistive => { - let input = HotkeyInput { - key_type: HotkeyType::Toggle, - action: HotkeyAction::Press, - assistive: true, - hold_mode: HoldMode::Raw, - force_raw: false, - force_ai: false, - }; - controller.handle_hotkey_event(input).await?; - } - HotkeyEvent::DoubleTapBlocked { gesture, reason } => { - let body = format!( - "{} was detected, but {}.", - gesture.label(), - reason.message() - ); - tracing::warn!("Hotkey double-tap blocked: {}", body); - let _ = - codescribe::tray::update_tray_status(codescribe::tray::TrayStatus::HotkeyConflict); - codescribe::os::notifications::notify("CodeScribe hotkey conflict", &body); - } - } - - Ok(()) -} diff --git a/bin/qube_daemon.rs b/bin/qube_daemon.rs index 4ec2147b..0e92a495 100644 --- a/bin/qube_daemon.rs +++ b/bin/qube_daemon.rs @@ -265,8 +265,7 @@ async fn run_daemon(args: Args) -> Result<()> { // Run with today's date filter, comparing local vs cloud let check_args = build_daemon_check_args(&args, date_filter); - match run_single_with_transcription(&check_args, LocalTranscriptionMode::CodeScribeIpc) - .await + match run_single_with_transcription(&check_args, LocalTranscriptionMode::LocalWhisper).await { Ok(()) => { // Load the latest report to count mismatches @@ -360,7 +359,7 @@ fn count_mismatches_from_latest_report(config_dir: &Path) -> usize { /// Send macOS notification using osascript fn send_macos_notification(message: &str, subtitle: &str) { let script = format!( - r#"display notification "{}" with title "CodeScribe Quality" subtitle "{}""#, + r#"display notification "{}" with title "Codescribe Quality" subtitle "{}""#, message.replace('"', r#"\""#), subtitle.replace('"', r#"\""#) ); diff --git a/bin/qube_report.rs b/bin/qube_report.rs index de479804..71370cb2 100644 --- a/bin/qube_report.rs +++ b/bin/qube_report.rs @@ -13,7 +13,7 @@ use codescribe::qube_report::{LocalTranscriptionMode, MetricsReference, QualityR #[derive(Parser)] #[command(name = "qube-report")] #[command(version)] -#[command(about = "Generate a quality report for CodeScribe transcriptions")] +#[command(about = "Generate a quality report for Codescribe transcriptions")] struct Args { /// Input directory with date subfolders containing WAV+TXT pairs #[arg(long)] diff --git a/bridge/.gitignore b/bridge/.gitignore new file mode 100644 index 00000000..a0150781 --- /dev/null +++ b/bridge/.gitignore @@ -0,0 +1 @@ +swift-bindings/ diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml new file mode 100644 index 00000000..1106f4cc --- /dev/null +++ b/bridge/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "codescribe-ffi" +version = "0.12.2" +edition = "2024" +authors = ["Vetcoders "] +description = "UniFFI bridge exposing the codescribe engine (agent streaming, STT, config) to Swift" + +[lib] +name = "codescribe_ffi" +crate-type = ["staticlib", "cdylib"] +path = "src/lib.rs" + +# Binary used to generate the Swift bindings from the built dylib. +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" + +[dependencies] +codescribe_core = { package = "codescribe-core", path = "../core" } +codescribe = { path = ".." } +uniffi = { version = "0.30", features = ["cli", "tokio"] } +tokio = { version = "1", features = ["full"] } +tracing = { workspace = true } +anyhow = "1" +chrono = "0.4" +serde_json = { workspace = true } +crossbeam-channel = "0.5" + +[dev-dependencies] +async-trait = "0.1" +serial_test = "3" diff --git a/bridge/smoke/main.swift b/bridge/smoke/main.swift new file mode 100644 index 00000000..d3bbfd9d --- /dev/null +++ b/bridge/smoke/main.swift @@ -0,0 +1,40 @@ +import Foundation + +// Live smoke: drive the REAL codescribe agent engine through the generated Swift +// bindings. Proves the keystone end-to-end (engine -> UniFFI -> Swift streaming). +final class Printer: CsAgentListener, @unchecked Sendable { + func onTextDelta(delta: String) { + FileHandle.standardOutput.write(delta.data(using: .utf8)!) + } + func onTextDone(text: String) {} + func onReasoningDelta(delta: String) {} + func onToolExecuting(name: String, id: String) { print("\n[tool: \(name)]") } + func onToolResult(name: String, id: String, summary: String, isError: Bool) { + print("\n[tool result: \(name) error=\(isError)] \(summary.prefix(80))") + } + func onDone() { print("\n[DONE]") } + func onError(message: String) { print("\n[ERROR] \(message)") } +} + +let agent = CodescribeAgent() +print("is_available:", agent.isAvailable()) +guard agent.isAvailable() else { + print("provider not available — no LLM_ASSISTIVE_* in env / keychain not readable by this binary") + exit(0) +} + +let sem = DispatchSemaphore(value: 0) +Task { + do { + print("--- streaming reply ---") + let final = try await agent.streamReply( + text: "Say hello in exactly three words.", + listener: Printer() + ) + print("\n--- FINAL ---\n\(final)") + } catch { + print("\nstream error: \(error)") + } + sem.signal() +} +sem.wait() diff --git a/bridge/src/agent.rs b/bridge/src/agent.rs new file mode 100644 index 00000000..10bf8c83 --- /dev/null +++ b/bridge/src/agent.rs @@ -0,0 +1,1171 @@ +//! Agent streaming surface — thin UniFFI wrapper over the live codescribe +//! `AgentSession` (token/reasoning/tool-call streaming). Moved out of `lib.rs` +//! in W3 cut #0 so each bridge slice owns a disjoint file. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use codescribe_core::agent::{ + AgentSession, AgentUiEvent, ContentBlock, ImageAttachment, Message, Role, StreamOptions, + Thread, ThreadMessage, ThreadStore, ToolRegistry, +}; +use codescribe_core::attachment::{MAX_VISION_IMAGE_BYTES, load_image_for_vision}; +use codescribe_core::llm::provider::{LlmMode, provider_supports_vision, resolve_provider}; +use tokio::task::AbortHandle; + +use crate::CsError; + +/// Maximum number of image attachments the composer may forward in one message. +/// Matches the live app controller's `MAX_AGENT_VISION_IMAGES` so both send paths +/// behave alike; exceeding it is surfaced as a readable error rather than a silent +/// truncation. +const MAX_COMPOSER_VISION_IMAGES: usize = 16; + +/// One outgoing composer attachment. Path-based on purpose: the bridge reads and +/// validates the file on the Rust side (via `load_image_for_vision`), which is +/// cheaper than marshalling raw image bytes across FFI and reuses core's single +/// vision-loading path. Swift persists clipboard images to disk before handing a +/// path here, so every attachment reduces to a filesystem path. +#[derive(uniffi::Record)] +pub struct CsAttachment { + /// Absolute filesystem path to the attached image. + pub path: String, +} + +/// Foreign callback trait — agent streaming events forwarded to Swift. +/// Mirrors `AgentUiEvent`; the Swift side must hop these onto the main actor. +#[uniffi::export(with_foreign)] +pub trait CsAgentListener: Send + Sync { + fn on_text_delta(&self, delta: String); + fn on_text_done(&self, text: String); + fn on_reasoning_delta(&self, delta: String); + fn on_tool_executing(&self, name: String, id: String); + fn on_tool_result(&self, name: String, id: String, summary: String, is_error: bool); + fn on_done(&self); + fn on_error(&self, message: String); +} + +/// Thin handle to the codescribe agent engine. +#[derive(uniffi::Object, Default)] +pub struct CodescribeAgent { + /// In-flight turns keyed by thread id, so `cancel_turn` can abort them. + /// Shared (`Arc`) because each turn's RAII guard must be able to deregister + /// itself even while the FFI object stays borrowed by other calls. + turns: Arc, +} + +#[uniffi::export(async_runtime = "tokio")] +impl CodescribeAgent { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self::default() + } + + /// True when the assistive LLM provider can be built from the environment + /// (LLM_ASSISTIVE_ENDPOINT / _MODEL / _API_KEY present). Same gate the live + /// app uses before agent replies are possible. + pub fn is_available(&self) -> bool { + // Warm settings + Keychain only when the agent surface is actually used. + // Constructing the Swift app model must not trigger a keychain prompt. + let _ = codescribe_core::config::Config::load(); + codescribe::agent::create_default_provider().is_ok() + } + + /// Stream one agent reply for `text` on the conversation identified by + /// `thread_id`, forwarding token/reasoning/tool events to `listener` as they + /// arrive. Returns the final assembled assistant text. + /// + /// Memory + persistence: prior turns stored under `thread_id` are restored + /// into the session before sending (so the model sees the conversation + /// history), and the updated thread is written back after a successful reply + /// so the SwiftUI app's conversations survive restart. Persistence is + /// best-effort: a load/save failure never fails the reply the user already + /// saw. + /// + /// Full native tool set + MCP are registered, so the agent can actually act + /// (clipboard, selection, screenshot, filesystem, typing, github, search, + /// transcribe). Tools execute on demand when the model calls them. + pub async fn stream_reply( + &self, + text: String, + thread_id: String, + listener: Arc, + ) -> Result { + self.run_stream(text, thread_id, Vec::new(), listener).await + } + + /// Stream one agent reply for `text` with `attachments` forwarded as real + /// vision input (the composer 📎 path). Attachments are path-based; the bridge + /// loads + validates each one via core's single `load_image_for_vision` path + /// (PNG/JPEG/GIF/WebP/BMP/TIFF, ≤ 8 MB each) so the send never routes raw + /// bytes through FFI and never produces a second attachment pipeline. + /// + /// Degradation is explicit, never a silent drop: + /// - the selected model is not vision-capable ⇒ readable error, nothing sent; + /// - any attachment is missing / unsupported / too large / empty ⇒ readable + /// error naming the offending file(s), nothing sent; + /// - more than 16 images ⇒ readable error. + pub async fn stream_reply_with_attachments( + &self, + text: String, + thread_id: String, + attachments: Vec, + listener: Arc, + ) -> Result { + let images = validate_composer_attachments(&attachments)?; + self.run_stream(text, thread_id, images, listener).await + } + + /// Abort the in-flight turn(s) for `thread_id`. Returns `true` when an + /// active turn was found and aborted, `false` when the thread was idle + /// (the call is a safe no-op then). + /// + /// This explicit call is the ONLY working cancel path from Swift: the + /// generated UniFFI Swift bindings poll a Rust future to completion and + /// never propagate Swift `Task` cancellation (`uniffiRustCallAsync` has no + /// `rust_future_cancel` wiring), so cancelling the Swift task alone leaves + /// the turn — and its tool side effects — running. + /// + /// The abort lands on the turn task's next `.await` point (tokio abort + /// semantics): an in-flight tool future is dropped there, so side effects + /// scheduled after that point never run; a synchronous section already + /// executing finishes its current poll segment first. The aborted turn is + /// NOT persisted — the thread on disk keeps its last completed-turn state, + /// so the next turn on the same thread restores clean history. + pub fn cancel_turn(&self, thread_id: String) -> bool { + self.turns.cancel(&thread_id) + } +} + +impl CodescribeAgent { + /// Shared streaming core behind [`stream_reply`] and + /// [`stream_reply_with_attachments`]. `attachments` are already loaded + + /// validated `ImageAttachment`s (empty for the text-only path). + async fn run_stream( + &self, + text: String, + thread_id: String, + attachments: Vec, + listener: Arc, + ) -> Result { + // Keep provider construction behavior identical to the old eager + // constructor path, but delay it until the user sends a message. + let config = codescribe_core::config::Config::load(); + let provider = codescribe::agent::create_default_provider()?; + let mut registry = ToolRegistry::new(); + codescribe::agent::tools::register_all_tools(&mut registry); + let (ui_tx, ui_rx) = tokio::sync::mpsc::channel::(64); + let mut session = AgentSession::new(provider, Arc::new(registry), ui_tx); + + // Restore prior turns for cross-turn memory. ThreadStore does blocking + // fs I/O, so the load runs on a blocking pool thread and is awaited + // before the agent loop starts. A missing/corrupt thread yields an empty + // history (best-effort: a first turn simply has nothing to restore). + let thread_id_for_load = thread_id.clone(); + let restored: Vec = tokio::task::spawn_blocking(move || { + ThreadStore::new() + .ok() + .and_then(|store| store.load_thread(&thread_id_for_load).ok()) + .map(|thread| { + thread + .messages + .iter() + .map(ThreadMessage::to_message) + .collect::>() + }) + .unwrap_or_default() + }) + .await + .unwrap_or_default(); + if !restored.is_empty() { + // Seeds the conversation history; resets the provider chain id to + // None (the persistence id is `thread_id`, separate from the + // provider's response-chain id). + session.restore_messages(restored); + } + + // Honor the same assistive system prompt + token cap the in-app + // controller path uses (build_agent_stream_options), so a Swift chat send + // is not stripped of the WORKSPACE-augmented assistive prompt and the + // configured `ai_assistive_max_tokens`. + let options = build_bridge_stream_options(config.ai_assistive_max_tokens); + + let turn = PreparedTurn { + session, + text, + attachments, + options, + ui_rx, + }; + let (final_text, messages) = + drive_turn(turn, listener, Arc::clone(&self.turns), thread_id.clone()).await?; + + // Persist the updated thread (best-effort). The reply already streamed + // to the user, so a save failure is logged-and-ignored rather than + // surfaced as an error. A cancelled turn never reaches this point on + // purpose: its partial messages are discarded, so the thread on disk + // keeps the last completed-turn state (today's only cancel trigger is + // thread deletion, where persisting would resurrect the thread). + persist_thread(thread_id, messages).await; + Ok(final_text) + } +} + +/// Everything a spawned agent turn needs, bundled so [`drive_turn`] stays a +/// single testable unit (tests build one from a scripted provider + mock tools). +struct PreparedTurn { + session: AgentSession, + text: String, + attachments: Vec, + options: StreamOptions, + ui_rx: tokio::sync::mpsc::Receiver, +} + +/// Spawn the agent loop for one turn, forward its UI events to `listener`, and +/// join the task for the final message log. +/// +/// Cancellation contract (2.15): +/// - The spawned task is tied to this future through a [`TurnGuard`]: if this +/// future is dropped mid-turn, the task is aborted at its next `.await` point +/// instead of running detached to completion (the pre-fix bug: tools kept +/// typing/pasting after a "cancelled" turn). +/// - The guard also registers the task in `turns`, so an explicit +/// [`CodescribeAgent::cancel_turn`] can abort it by thread id. +/// - An aborted turn surfaces as a readable `Err` and hands back no messages, +/// so the caller never persists a half-finished turn. +/// - A turn that already completed cannot be broken retroactively: aborting a +/// finished tokio task is a documented no-op and the join below still yields +/// its result. +async fn drive_turn( + turn: PreparedTurn, + listener: Arc, + turns: Arc, + thread_id: String, +) -> Result<(String, Vec), CsError> { + let PreparedTurn { + session, + text, + attachments, + options, + mut ui_rx, + } = turn; + + // Drive the agent loop on a task so the channel closes when it finishes, + // letting the drain loop below terminate cleanly. The task hands back the + // session's final message log so the caller can persist the thread. + let send_handle = tokio::spawn(async move { + let mut session = session; + let attachments = attachments; + session.send(text, attachments, &options).await?; + Ok::, anyhow::Error>(session.messages().to_vec()) + }); + let _turn_guard = turns.register(&thread_id, send_handle.abort_handle()); + + let mut final_text = String::new(); + while let Some(event) = ui_rx.recv().await { + match event { + AgentUiEvent::TextDelta(delta) => listener.on_text_delta(delta), + AgentUiEvent::TextDone(t) => { + final_text = t.clone(); + listener.on_text_done(t); + } + AgentUiEvent::ReasoningDelta(delta) => listener.on_reasoning_delta(delta), + AgentUiEvent::ToolExecuting { name, id } => listener.on_tool_executing(name, id), + AgentUiEvent::ToolResult { + name, + id, + summary, + is_error, + } => listener.on_tool_result(name, id, summary, is_error), + AgentUiEvent::Done => listener.on_done(), + AgentUiEvent::Error(message) => listener.on_error(message), + } + } + + match send_handle.await { + Ok(Ok(messages)) => Ok((final_text, messages)), + Ok(Err(error)) => Err(CsError::Agent { + msg: error.to_string(), + }), + Err(join_error) if join_error.is_cancelled() => Err(CsError::Agent { + msg: "Turn cancelled".to_string(), + }), + Err(join_error) => Err(CsError::Agent { + msg: format!("agent task join error: {join_error}"), + }), + } +} + +/// In-flight turn bookkeeping behind [`CodescribeAgent::cancel_turn`]. +/// +/// One thread id can briefly hold several entries (the composer allows firing a +/// new send while a previous one is draining), so entries carry a unique token: +/// `cancel` aborts every turn on the thread, while each turn's guard removes +/// only its own entry on completion. +#[derive(Default)] +struct TurnRegistry { + turns: Mutex>>, + next_token: AtomicU64, +} + +struct TurnEntry { + token: u64, + abort: AbortHandle, +} + +impl TurnRegistry { + /// Track a spawned turn task and return the RAII guard that owns both the + /// abort-on-drop semantics and the registry entry's lifetime. + fn register(self: &Arc, thread_id: &str, abort: AbortHandle) -> TurnGuard { + let token = self.next_token.fetch_add(1, Ordering::Relaxed); + self.turns + .lock() + .expect("turn registry lock poisoned") + .entry(thread_id.to_string()) + .or_default() + .push(TurnEntry { + token, + abort: abort.clone(), + }); + TurnGuard { + registry: Arc::clone(self), + thread_id: thread_id.to_string(), + token, + abort, + } + } + + fn deregister(&self, thread_id: &str, token: u64) { + let mut turns = self.turns.lock().expect("turn registry lock poisoned"); + if let Some(entries) = turns.get_mut(thread_id) { + entries.retain(|entry| entry.token != token); + if entries.is_empty() { + turns.remove(thread_id); + } + } + } + + /// Abort every in-flight turn on `thread_id`; `false` when idle. Entries are + /// left in place — each aborted turn's guard deregisters it as the turn's + /// `drive_turn` future unwinds (aborting an already-finished task is a + /// no-op, so a turn that completed just before this call is unaffected). + fn cancel(&self, thread_id: &str) -> bool { + let turns = self.turns.lock().expect("turn registry lock poisoned"); + let Some(entries) = turns.get(thread_id) else { + return false; + }; + for entry in entries { + entry.abort.abort(); + } + !entries.is_empty() + } +} + +/// RAII guard tying a spawned turn task to the [`drive_turn`] future that owns +/// it. Dropping the guard — on normal completion, on error, or because the +/// UniFFI-held future was dropped — aborts the task (no-op once it finished) +/// and removes its registry entry, so cancelled and completed turns never leak +/// stale abort handles. +struct TurnGuard { + registry: Arc, + thread_id: String, + token: u64, + abort: AbortHandle, +} + +impl Drop for TurnGuard { + fn drop(&mut self) { + self.abort.abort(); + self.registry.deregister(&self.thread_id, self.token); + } +} + +/// Build the assistive stream options for a bridge chat send, honoring the same +/// assistive system prompt and token cap the in-app controller path uses +/// (`app/controller/helpers.rs::build_agent_stream_options`). Model is left empty +/// so the provider resolves it from `LLM_ASSISTIVE_MODEL` (identical default to +/// the controller), keeping both send paths behaviorally aligned. +fn build_bridge_stream_options(ai_assistive_max_tokens: i32) -> StreamOptions { + let max_tokens = u32::try_from(ai_assistive_max_tokens) + .ok() + .filter(|tokens| *tokens > 0); + StreamOptions { + model: String::new(), + system_prompt: Some(compose_agent_system_prompt()), + max_tokens, + temperature: None, + reset_chain: false, + } +} + +/// 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 +/// review-tool + connector doctrine for long-running MCP review calls and +/// GitHub-connector fallback. +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}") +} + +/// Load + validate composer attachments into vision `ImageAttachment`s. +/// +/// All-or-nothing on purpose: a partial success would silently drop images the +/// user chose to attach. Any failure returns a readable [`CsError`] naming the +/// offending files so the composer surfaces it instead of sending a quietly +/// degraded message. Also gates on the selected model's vision capability. +fn validate_composer_attachments( + attachments: &[CsAttachment], +) -> Result, CsError> { + if attachments.is_empty() { + return Ok(Vec::new()); + } + + if attachments.len() > MAX_COMPOSER_VISION_IMAGES { + return Err(CsError::Agent { + msg: format!( + "Too many images ({}). Attach at most {} per message.", + attachments.len(), + MAX_COMPOSER_VISION_IMAGES + ), + }); + } + + // Vision gate: refuse (readable error) rather than silently drop the images + // when the configured assistive model cannot read them. + let provider = resolve_provider(LlmMode::Assistive); + let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_default(); + if !provider_supports_vision(provider, &model) { + return Err(CsError::Agent { + msg: "The selected model can't read images. Switch to a vision-capable \ + model in Settings, or remove the attachment before sending." + .to_string(), + }); + } + + let mut images = Vec::with_capacity(attachments.len()); + let mut failed: Vec = Vec::new(); + for attachment in attachments { + let path = std::path::Path::new(&attachment.path); + match load_image_for_vision(path, MAX_VISION_IMAGE_BYTES) { + Some((data, media_type)) => images.push(ImageAttachment { data, media_type }), + None => failed.push(attachment_label(&attachment.path)), + } + } + + if !failed.is_empty() { + return Err(CsError::Agent { + msg: format!( + "Couldn't attach {}: image must be PNG, JPEG, GIF, WebP, BMP, or \ + TIFF and 8 MB or smaller.", + failed.join(", ") + ), + }); + } + + Ok(images) +} + +/// Short, user-facing label (file name, path fallback) for an attachment path. +fn attachment_label(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()) +} + +/// Persist (create or update) the thread identified by `thread_id` from the +/// session's final `messages`. Mirrors the live app's `persist_runtime_thread` +/// (app/controller/helpers.rs): load-or-build a `Thread`, refresh title/summary/ +/// messages, and save. Runs the blocking fs work on a blocking pool thread and +/// swallows any error — persistence is best-effort. +async fn persist_thread(thread_id: String, messages: Vec) { + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let store = ThreadStore::new()?; + + // `now` is sourced from the freshest message timestamp the session + // stamped (`Some(Utc::now())` per turn), avoiding a direct `chrono` + // dependency in the bridge crate. With nothing to anchor the thread to, + // skip the write. + let Some(now) = messages.iter().rev().find_map(|message| message.timestamp) else { + return Ok(()); + }; + + let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_default(); + // Reflect the resolved assistive provider so persisted thread metadata is + // accurate when Anthropic is active (was hardcoded "openai-responses"). + let provider = resolve_provider(LlmMode::Assistive).as_str().to_string(); + + let mut thread = store.load_thread(&thread_id).unwrap_or_else(|_| Thread { + id: thread_id.clone(), + created_at: now, + updated_at: now, + title: "Codescribe Agent Chat".to_string(), + title_is_custom: false, + mode: "assistive".to_string(), + tags: vec!["agent".to_string(), "overlay".to_string()], + notes: Vec::new(), + messages: Vec::new(), + summary: None, + total_tokens: None, + provider: provider.clone(), + model: model.clone(), + }); + + thread.updated_at = now; + // Never clobber a title the user set by hand from the rail. + if !thread.title_is_custom { + thread.title = derive_thread_title(&messages); + } + thread.summary = derive_thread_summary(&messages); + thread.messages = messages.iter().map(ThreadMessage::from).collect(); + thread.provider = provider; + thread.model = model; + + store.save_thread(&thread)?; + Ok(()) + }) + .await; + + if let Ok(Err(error)) = result { + // Bridge crate has no logging dep; stderr keeps the best-effort failure + // visible without taking the reply down. + eprintln!("Failed to persist agent thread (best-effort): {error}"); + } +} + +/// First user message, boilerplate-stripped and trimmed to a title-length slice. +/// +/// Every agent conversation is seeded with a pasted instruction preamble +/// ("INSTRUKCJA UŻYTKOWNIKA: JESTEŚ AGENTEM…"), so a naive first-line title makes +/// every thread read identically. We first try the newline-preserving raw text +/// and skip leading instruction/header lines; only if the whole message looks +/// like boilerplate do we fall back to the collapsed full text. +fn derive_thread_title(messages: &[Message]) -> String { + let first_user = messages.iter().find(|message| message.role == Role::User); + + let candidate = first_user + .and_then(raw_text_from_message) + .and_then(|raw| strip_boilerplate_title(&raw)) + .or_else(|| first_user.and_then(extract_text_from_message)) + .unwrap_or_else(|| "Codescribe Agent Chat".to_string()); + + let mut title = candidate.chars().take(72).collect::(); + if title.trim().is_empty() { + title = "Codescribe Agent Chat".to_string(); + } + title +} + +/// Newline-preserving flatten of a message's textual content (unlike +/// `extract_text_from_message`, which collapses all whitespace). Lets the title +/// heuristic reason about the first "real" line. +fn raw_text_from_message(message: &Message) -> Option { + let mut out = Vec::new(); + for block in &message.content { + extract_text_from_block(block, &mut out); + } + let text = out.join("\n"); + if text.trim().is_empty() { + None + } else { + Some(text) + } +} + +/// Known leading-boilerplate line prefixes, matched case-insensitively against +/// the trimmed line. Pasted agent preambles open with one of these. +const BOILERPLATE_LINE_PREFIXES: &[&str] = &[ + "instrukcja", + "instruction", + "jesteś agentem", + "jestes agentem", + "you are an agent", + "system prompt", + "system:", +]; + +/// Drop leading instruction/header lines and return the first meaningful line, +/// whitespace-normalized. Returns `None` when every line looks like boilerplate +/// (the caller then falls back to the collapsed full text). +fn strip_boilerplate_title(raw: &str) -> Option { + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_boilerplate_line(trimmed) { + continue; + } + let normalized = trimmed.split_whitespace().collect::>().join(" "); + if !normalized.is_empty() { + return Some(normalized); + } + } + None +} + +/// A line is boilerplate when it opens with a known preamble prefix or reads as +/// an all-caps header (letters present, none lowercase — e.g. +/// "INSTRUKCJA UŻYTKOWNIKA:"). +fn is_boilerplate_line(line: &str) -> bool { + let lower = line.to_lowercase(); + if BOILERPLATE_LINE_PREFIXES + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return true; + } + is_all_caps_header(line) +} + +/// True when the line has alphabetic characters and none of them are lowercase. +fn is_all_caps_header(line: &str) -> bool { + let mut has_alpha = false; + for ch in line.chars() { + if ch.is_alphabetic() { + has_alpha = true; + if ch.is_lowercase() { + return false; + } + } + } + has_alpha +} + +/// Latest assistant message, trimmed to a summary-length slice. Replica of +/// `derive_thread_summary` in app/controller/helpers.rs. +fn derive_thread_summary(messages: &[Message]) -> Option { + messages + .iter() + .rev() + .find(|message| message.role == Role::Assistant) + .and_then(extract_text_from_message) + .map(|text| { + let mut clipped = text.chars().take(240).collect::(); + if clipped.is_empty() { + clipped = "Assistant response".to_string(); + } + clipped + }) +} + +/// Flatten a message's textual content into a single normalized string. Replica +/// of `extract_text_from_message` in app/controller/helpers.rs. +fn extract_text_from_message(message: &Message) -> Option { + let mut out = Vec::new(); + for block in &message.content { + extract_text_from_block(block, &mut out); + } + let text = out.join(" "); + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +/// Collect text from a content block (recursing into tool results). Replica of +/// `extract_text_from_block` in app/controller/helpers.rs. +fn extract_text_from_block(block: &ContentBlock, out: &mut Vec) { + match block { + ContentBlock::Text(text) if !text.trim().is_empty() => { + out.push(text.to_string()); + } + ContentBlock::ToolResult { content, .. } => { + for nested in content { + extract_text_from_block(nested, out); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(tag: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("cs_bridge_attach_{}_{tag}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + dir + } + + fn cs(path: &std::path::Path) -> CsAttachment { + CsAttachment { + path: path.to_string_lossy().into_owned(), + } + } + + #[test] + fn empty_attachments_yield_no_images() { + let images = validate_composer_attachments(&[]).unwrap(); + assert!(images.is_empty()); + } + + #[test] + fn valid_image_loads_as_vision_attachment() { + let dir = tmp_dir("valid"); + let png = dir.join("shot.png"); + std::fs::write(&png, b"\x89PNG\r\n\x1a\nfake").unwrap(); + + let images = validate_composer_attachments(&[cs(&png)]).unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].media_type, "image/png"); + assert!(!images[0].data.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unreadable_or_nonimage_is_a_readable_error_not_a_silent_drop() { + let dir = tmp_dir("bad"); + let txt = dir.join("note.txt"); + std::fs::write(&txt, b"hello").unwrap(); + let missing = dir.join("gone.png"); + + let err = validate_composer_attachments(&[cs(&txt), cs(&missing)]).unwrap_err(); + let CsError::Agent { msg } = err else { + panic!("expected a readable agent error"); + }; + assert!( + msg.contains("note.txt"), + "names the unsupported file: {msg}" + ); + assert!(msg.contains("gone.png"), "names the missing file: {msg}"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn too_many_images_is_rejected() { + let attachments: Vec = (0..=MAX_COMPOSER_VISION_IMAGES) + .map(|i| CsAttachment { + path: format!("/tmp/x{i}.png"), + }) + .collect(); + let err = validate_composer_attachments(&attachments).unwrap_err(); + let CsError::Agent { msg } = err else { + panic!("expected a readable agent error"); + }; + assert!(msg.contains("Too many"), "explains the cap: {msg}"); + } + + fn user_message(text: &str) -> Message { + Message { + role: Role::User, + content: vec![ContentBlock::Text(text.to_string())], + timestamp: None, + } + } + + #[test] + fn title_skips_boilerplate_preamble() { + let text = "INSTRUKCJA UŻYTKOWNIKA: JESTEŚ AGENTEM\n\nNapraw hang na starcie sesji"; + let title = derive_thread_title(&[user_message(text)]); + assert_eq!(title, "Napraw hang na starcie sesji"); + } + + #[test] + fn title_keeps_plain_first_line() { + let title = derive_thread_title(&[user_message("Fix the rate limiter double-fire")]); + assert_eq!(title, "Fix the rate limiter double-fire"); + } + + #[test] + fn title_falls_back_when_all_boilerplate() { + // Single merged line: prefix-flagged, so stripping yields nothing and we + // fall back to the collapsed full text (never worse than before). + let text = "INSTRUKCJA: zrób coś"; + let title = derive_thread_title(&[user_message(text)]); + assert_eq!(title, "INSTRUKCJA: zrób coś"); + } + + // ── Turn cancellation (2.15) ───────────────────────────────────────── + + use std::collections::VecDeque; + use std::sync::atomic::{AtomicBool, AtomicUsize}; + use std::time::Duration; + + use codescribe_core::agent::{AgentEvent, AgentProvider, ToolDefinition, ToolResultContent}; + + /// Provider that replays one scripted event batch per `stream` call — + /// the same shape core's session tests use, local to the bridge so these + /// tests exercise the real `drive_turn` unit without a live provider. + struct ScriptedProvider { + scripts: Mutex>>, + } + + impl ScriptedProvider { + fn new(scripts: Vec>) -> Self { + Self { + scripts: Mutex::new(scripts.into()), + } + } + } + + #[async_trait::async_trait] + impl AgentProvider for ScriptedProvider { + async fn stream( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _options: &StreamOptions, + ) -> anyhow::Result> { + let events = self + .scripts + .lock() + .expect("script lock should not be poisoned") + .pop_front() + .unwrap_or_default(); + let (tx, rx) = tokio::sync::mpsc::channel(16); + for event in events { + tx.send(event) + .await + .expect("test stream channel should accept scripted event"); + } + Ok(rx) + } + + fn build_tool_result( + &self, + call_id: &str, + content: Vec, + is_error: bool, + ) -> Message { + Message::new( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: call_id.to_string(), + content, + is_error, + }], + ) + } + + fn build_image_block(&self, data: &[u8], media_type: &str) -> ContentBlock { + ContentBlock::Image { + data: data.to_vec(), + media_type: media_type.to_string(), + } + } + + fn name(&self) -> &str { + "scripted-provider" + } + } + + /// Listener that only records that a tool started executing — the signal + /// the cancellation tests key their cancel timing on. + #[derive(Default)] + struct RecordingListener { + tool_started: AtomicBool, + error_count: AtomicUsize, + } + + impl CsAgentListener for RecordingListener { + fn on_text_delta(&self, _delta: String) {} + fn on_text_done(&self, _text: String) {} + fn on_reasoning_delta(&self, _delta: String) {} + fn on_tool_executing(&self, _name: String, _id: String) { + self.tool_started.store(true, Ordering::SeqCst); + } + fn on_tool_result(&self, _name: String, _id: String, _summary: String, _is_error: bool) {} + fn on_done(&self) {} + fn on_error(&self, _message: String) { + self.error_count.fetch_add(1, Ordering::SeqCst); + } + } + + /// Registry with one tool whose observable side effect fires only AFTER + /// `delay` — the stand-in for typing/clipboard/fs effects that a cancelled + /// turn must never execute. + fn slow_tool_registry(side_effect: Arc, delay: Duration) -> ToolRegistry { + let mut registry = ToolRegistry::new(); + registry + .register( + ToolDefinition { + name: "slow_side_effect".to_string(), + description: "test tool with a delayed side effect".to_string(), + input_schema: serde_json::json!({"type": "object", "properties": {}}), + }, + Box::new(move |_input| { + let side_effect = Arc::clone(&side_effect); + Box::pin(async move { + tokio::time::sleep(delay).await; + side_effect.store(true, Ordering::SeqCst); + vec![ToolResultContent::Text("side effect done".to_string())] + }) + }), + ) + .expect("registering the test tool must succeed"); + registry + } + + /// Script driving one slow tool call; the second batch is only consumed + /// when the turn survives to iteration 2 (i.e. was NOT cancelled). + fn tool_turn_script() -> Vec> { + vec![ + vec![ + AgentEvent::ToolCallReady { + id: "call_1".to_string(), + name: "slow_side_effect".to_string(), + arguments: serde_json::json!({}), + }, + AgentEvent::ResponseDone { + response_id: Some("resp_1".to_string()), + clean: true, + }, + ], + vec![ + AgentEvent::TextDone("late full run".to_string()), + AgentEvent::ResponseDone { + response_id: Some("resp_2".to_string()), + clean: true, + }, + ], + ] + } + + fn text_turn_script(reply: &str) -> Vec> { + vec![vec![ + AgentEvent::TextDone(reply.to_string()), + AgentEvent::ResponseDone { + response_id: Some("resp_text".to_string()), + clean: true, + }, + ]] + } + + fn test_options() -> StreamOptions { + StreamOptions { + model: String::new(), + system_prompt: None, + max_tokens: None, + temperature: None, + reset_chain: false, + } + } + + fn scripted_turn( + scripts: Vec>, + registry: ToolRegistry, + text: &str, + ) -> PreparedTurn { + let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(64); + let session = AgentSession::new( + Box::new(ScriptedProvider::new(scripts)), + Arc::new(registry), + ui_tx, + ); + PreparedTurn { + session, + text: text.to_string(), + attachments: Vec::new(), + options: test_options(), + ui_rx, + } + } + + async fn wait_until_set(flag: &AtomicBool, timeout: Duration) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + while tokio::time::Instant::now() < deadline { + if flag.load(Ordering::SeqCst) { + return true; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + flag.load(Ordering::SeqCst) + } + + #[tokio::test] + async fn provider_error_is_reported_once_via_throw_only() { + let listener = Arc::new(RecordingListener::default()); + let turns = Arc::new(TurnRegistry::default()); + let turn = scripted_turn( + vec![vec![AgentEvent::Error("upstream exploded".to_string())]], + ToolRegistry::new(), + "fail once", + ); + + let result = drive_turn( + turn, + Arc::clone(&listener) as Arc, + Arc::clone(&turns), + "thread-provider-error".to_string(), + ) + .await; + + let result_error_count = usize::from(result.is_err()); + let CsError::Agent { msg } = result.expect_err("provider error must throw") else { + panic!("provider error must surface as CsError::Agent"); + }; + assert!( + msg.contains("Provider stream error: upstream exploded"), + "provider failure reason should survive the thrown error: {msg}" + ); + assert_eq!( + listener.error_count.load(Ordering::SeqCst) + result_error_count, + 1, + "provider errors must not double-signal through on_error and throw" + ); + assert!( + !turns.cancel("thread-provider-error"), + "errored turn should deregister itself" + ); + } + + #[tokio::test] + async fn cancel_turn_aborts_in_flight_tool_before_its_side_effect() { + let side_effect = Arc::new(AtomicBool::new(false)); + let listener = Arc::new(RecordingListener::default()); + let turns = Arc::new(TurnRegistry::default()); + + let turn = scripted_turn( + tool_turn_script(), + slow_tool_registry(Arc::clone(&side_effect), Duration::from_millis(500)), + "cancel me", + ); + let driven = tokio::spawn(drive_turn( + turn, + Arc::clone(&listener) as Arc, + Arc::clone(&turns), + "thread-cancel".to_string(), + )); + + assert!( + wait_until_set(&listener.tool_started, Duration::from_secs(5)).await, + "tool should start executing before we cancel" + ); + assert!( + turns.cancel("thread-cancel"), + "an active turn should be cancellable" + ); + + let result = driven.await.expect("driving task must not panic"); + let CsError::Agent { msg } = result.expect_err("a cancelled turn must not report success") + else { + panic!("cancellation must surface as an agent error"); + }; + assert!( + msg.contains("cancelled"), + "cancel surfaces as a readable cancellation: {msg}" + ); + + // Wait well past the tool's own delay: the side effect must never fire + // because the tool future was dropped at the abort point. + tokio::time::sleep(Duration::from_millis(700)).await; + assert!( + !side_effect.load(Ordering::SeqCst), + "cancelled tool must not run its side effect" + ); + assert!( + !turns.cancel("thread-cancel"), + "the aborted turn must deregister itself" + ); + + // The same thread accepts the next turn after an abort: a fresh session + // (as run_stream builds per send) completes normally. + let next = scripted_turn(text_turn_script("recovered"), ToolRegistry::new(), "again"); + let (final_text, messages) = drive_turn( + next, + Arc::clone(&listener) as Arc, + Arc::clone(&turns), + "thread-cancel".to_string(), + ) + .await + .expect("the thread must keep working after a cancelled turn"); + assert_eq!(final_text, "recovered"); + assert!(messages.iter().any(|m| m.role == Role::Assistant)); + } + + #[tokio::test] + async fn dropping_the_turn_future_aborts_the_spawned_task() { + let side_effect = Arc::new(AtomicBool::new(false)); + let listener = Arc::new(RecordingListener::default()); + let turns = Arc::new(TurnRegistry::default()); + + let turn = scripted_turn( + tool_turn_script(), + slow_tool_registry(Arc::clone(&side_effect), Duration::from_millis(500)), + "drop me", + ); + let driven = tokio::spawn(drive_turn( + turn, + Arc::clone(&listener) as Arc, + Arc::clone(&turns), + "thread-drop".to_string(), + )); + + assert!( + wait_until_set(&listener.tool_started, Duration::from_secs(5)).await, + "tool should start executing before the future is dropped" + ); + + // Dropping the drive_turn future (what a cancelled UniFFI call does) + // must abort the inner turn task via the guard, not leave it detached. + driven.abort(); + let join_error = driven + .await + .expect_err("aborted future should not yield a value"); + assert!(join_error.is_cancelled()); + + tokio::time::sleep(Duration::from_millis(700)).await; + assert!( + !side_effect.load(Ordering::SeqCst), + "a dropped turn future must not leave the tool running detached" + ); + assert!( + !turns.cancel("thread-drop"), + "the guard must deregister the turn when the future is dropped" + ); + } + + #[test] + fn cancel_with_no_active_turn_is_a_noop() { + let turns = TurnRegistry::default(); + assert!(!turns.cancel("idle-thread")); + + // Same through the FFI surface object (no panic, returns false). + let agent = CodescribeAgent::default(); + assert!(!agent.cancel_turn("idle-thread".to_string())); + } + + #[tokio::test] + async fn completed_turn_is_not_broken_by_a_late_cancel() { + let listener = Arc::new(RecordingListener::default()); + let turns = Arc::new(TurnRegistry::default()); + + let turn = scripted_turn(text_turn_script("all done"), ToolRegistry::new(), "first"); + let (final_text, messages) = drive_turn( + turn, + Arc::clone(&listener) as Arc, + Arc::clone(&turns), + "thread-seq".to_string(), + ) + .await + .expect("uncancelled turn completes"); + assert_eq!(final_text, "all done"); + assert!(messages.iter().any(|m| m.role == Role::Assistant)); + + // A cancel arriving after completion finds nothing to abort — the + // finished result above stays intact (no retroactive corruption). + assert!(!turns.cancel("thread-seq")); + + // And the thread still accepts the next turn. + let next = scripted_turn(text_turn_script("all done"), ToolRegistry::new(), "second"); + let (final_text, _) = drive_turn( + next, + Arc::clone(&listener) as Arc, + turns, + "thread-seq".to_string(), + ) + .await + .expect("next turn on the same thread must work"); + assert_eq!(final_text, "all done"); + } +} diff --git a/bridge/src/agent_delivery.rs b/bridge/src/agent_delivery.rs new file mode 100644 index 00000000..19e034a3 --- /dev/null +++ b/bridge/src/agent_delivery.rs @@ -0,0 +1,119 @@ +//! Voice-assistive agent delivery — forwards the app-side delivery broadcast +//! (`codescribe::agent_delivery`) onto a Swift `CsAgentDeliveryListener`. +//! +//! This is the bridge half of the fix for the voice-assistive delivery gap: the +//! hotkey/voice reply used to die in `apply_agent_ui_event` (only persisted to +//! disk, never rendered live) after the legacy AppKit overlay sink was removed. +//! The app now publishes each reply event to a process-global broadcast; this +//! module subscribes and hops them across the UniFFI boundary to SwiftUI. +//! +//! It is the exact mirror of the transcription forwarder in `hotkeys.rs` +//! (`spawn_event_forwarder` / `forward_event_to_listener`), including the +//! `Lagged` (recoverable — keep forwarding) and `Closed` (sender dropped — end +//! the task) handling. Kept a separate listener + channel from +//! `CsTranscriptionListener` so agent-chat delivery never mixes with the +//! overlay/dictation stream. + +use std::sync::{Arc, OnceLock, RwLock}; + +use codescribe::agent_delivery::{AgentDeliveryEvent, subscribe_agent_delivery}; +use tokio::runtime::Handle; +use tokio::sync::broadcast::error::RecvError; + +/// Foreign callback trait — voice-assistive agent delivery events forwarded to +/// Swift. Symmetric to `CsAgentListener` (the composer path) plus an +/// `on_turn_started` opener carrying the correlation id + the user's transcript. +/// The Swift side must hop each callback onto the main actor (see +/// `VoiceDeliveryListener`). +#[uniffi::export(with_foreign)] +pub trait CsAgentDeliveryListener: Send + Sync { + /// A new voice turn began: open a You-bubble (`user_text`) + an assistant + /// placeholder. `thread_id` is the core runtime persistence id, used to + /// correlate the turn to a store thread. + fn on_turn_started(&self, thread_id: String, user_text: String); + fn on_text_delta(&self, delta: String); + fn on_text_done(&self, text: String); + fn on_reasoning_delta(&self, delta: String); + fn on_tool_executing(&self, name: String, id: String); + fn on_tool_result(&self, name: String, id: String, summary: String, is_error: bool); + fn on_done(&self); + fn on_error(&self, message: String); +} + +type SharedDeliveryListener = Arc>>>; + +fn shared_delivery_listener() -> SharedDeliveryListener { + static LISTENER: OnceLock = OnceLock::new(); + Arc::clone(LISTENER.get_or_init(|| Arc::new(RwLock::new(None)))) +} + +/// Register the Swift AgentChat delivery listener. Process-global (mirrors +/// `hotkeys::shared_listener`) so registration is independent of which +/// `CodescribeHotkeys` handle the forwarder was spawned from. +pub(crate) fn set_delivery_listener(listener: Arc) { + let store = shared_delivery_listener(); + let mut guard = store.write().unwrap_or_else(|e| e.into_inner()); + *guard = Some(listener); +} + +/// Spawn the app→bridge delivery forwarder on `handle`. +/// +/// Idempotent: guarded by a `OnceLock` so repeated `start()` calls (or a +/// listener re-registration) never stack duplicate forwarders that would each +/// deliver the same event. +pub(crate) fn spawn_delivery_forwarder(handle: Handle) { + static SPAWNED: OnceLock<()> = OnceLock::new(); + if SPAWNED.set(()).is_err() { + return; + } + let listener_store = shared_delivery_listener(); + let mut events = subscribe_agent_delivery(); + handle.spawn(async move { + loop { + let event = match events.recv().await { + Ok(event) => event, + // Lagged: a burst of token deltas overflowed the broadcast (cap + // 256) and dropped `skipped` events. Recoverable — keep + // forwarding subsequent events instead of tearing the bridge down. + Err(RecvError::Lagged(skipped)) => { + eprintln!("Agent delivery forwarder lagged; dropped {skipped} event(s)"); + continue; + } + // Closed: the app-side sender was dropped — nothing more arrives. + Err(RecvError::Closed) => break, + }; + let listener = listener_store + .read() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(Arc::clone); + let Some(listener) = listener else { + // No Swift listener registered yet (or chat surface not built): + // drop the event, the reply is still persisted to disk app-side. + continue; + }; + forward_delivery_event(event, listener); + } + }); +} + +fn forward_delivery_event(event: AgentDeliveryEvent, listener: Arc) { + match event { + AgentDeliveryEvent::TurnStarted { + thread_id, + user_text, + } => listener.on_turn_started(thread_id, user_text), + AgentDeliveryEvent::TextDelta(delta) => listener.on_text_delta(delta), + AgentDeliveryEvent::TextDone(text) => listener.on_text_done(text), + AgentDeliveryEvent::ReasoningDelta(delta) => listener.on_reasoning_delta(delta), + AgentDeliveryEvent::ToolExecuting { name, id } => listener.on_tool_executing(name, id), + AgentDeliveryEvent::ToolResult { + name, + id, + summary, + is_error, + } => listener.on_tool_result(name, id, summary, is_error), + AgentDeliveryEvent::Done => listener.on_done(), + AgentDeliveryEvent::Error(message) => listener.on_error(message), + } +} diff --git a/bridge/src/agent_status.rs b/bridge/src/agent_status.rs new file mode 100644 index 00000000..d525c52c --- /dev/null +++ b/bridge/src/agent_status.rs @@ -0,0 +1,192 @@ +//! Agent-status surface — read-only UniFFI wrapper over the codescribe agentic +//! readiness + MCP status probes (`app/agent/tools/mcp.rs`). Sync-only: every +//! call is cheap disk I/O (reads/parses `mcp.json`, merges the last runtime +//! discovery snapshot; no server spawning). Split as its own bridge slice so the +//! Settings Engine panel can render honest agent-substrate state instead of the +//! probes staying built-but-dead. +//! +//! Nothing here mutates config — MCP editing is a separate cut. This slice only +//! reports what the core already knows. + +use codescribe::agent::tools::mcp::{ + AgenticReadinessReport, McpRowTone, McpStatusReport, McpStatusRow, probe_agentic_readiness, + probe_mcp_status, +}; + +/// Visual tone for one status row, mirrored 1:1 from the core [`McpRowTone`] so +/// the Settings layer maps it to concrete colors without depending on agent +/// tooling. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsMcpRowTone { + Good, + Warn, + Bad, + Neutral, +} + +impl From for CsMcpRowTone { + fn from(tone: McpRowTone) -> Self { + match tone { + McpRowTone::Good => CsMcpRowTone::Good, + McpRowTone::Warn => CsMcpRowTone::Warn, + McpRowTone::Bad => CsMcpRowTone::Bad, + McpRowTone::Neutral => CsMcpRowTone::Neutral, + } + } +} + +/// One labelled status line (label + value + tone) for the Settings UI. +#[derive(uniffi::Record)] +pub struct CsMcpStatusRow { + pub label: String, + pub value: String, + pub tone: CsMcpRowTone, +} + +impl From<&McpStatusRow> for CsMcpStatusRow { + fn from(row: &McpStatusRow) -> Self { + Self { + label: row.label.clone(), + value: row.value.clone(), + tone: row.tone.into(), + } + } +} + +/// Honest MCP config + runtime snapshot for the Settings "MCP servers" section. +/// A missing `mcp.json` degrades to a single neutral "MCP off (optional)" row — +/// never an error. +#[derive(uniffi::Record)] +pub struct CsMcpStatusReport { + pub config_path_display: String, + /// `false` when there is no MCP config yet (missing `mcp.json` or a present + /// config with no servers). The onboarding readiness step uses this to choose + /// between the status card and the "set up MCP servers" prompt. + pub configured: bool, + pub rows: Vec, +} + +impl From for CsMcpStatusReport { + fn from(report: McpStatusReport) -> Self { + let configured = report.configured(); + let rows = report + .summary_rows() + .iter() + .map(CsMcpStatusRow::from) + .collect(); + Self { + config_path_display: report.config_path_display, + configured, + rows, + } + } +} + +/// Agentic-lane readiness verdict + rows. `ready` reflects the CORE capability +/// gate only (assistive provider configured + its API key set + native tools +/// available); the MCP rows (Vibecrafted + AICX + Loctree + PRView) are +/// informational context and never flip `ready`. See the core +/// `AgenticReadinessReport` for the C4 semantics decision. +#[derive(uniffi::Record)] +pub struct CsAgenticReadiness { + pub config_path_display: String, + pub ready: bool, + pub rows: Vec, +} + +impl From for CsAgenticReadiness { + fn from(report: AgenticReadinessReport) -> Self { + let ready = report.is_ready(); + let rows = report + .summary_rows() + .iter() + .map(CsMcpStatusRow::from) + .collect(); + Self { + config_path_display: report.config_path_display, + ready, + rows, + } + } +} + +/// Read-only handle over the codescribe agent-status probes. Stateless: every +/// call re-reads config truth so Swift always sees on-disk state. +#[derive(uniffi::Object, Default)] +pub struct CodescribeAgentStatus {} + +#[uniffi::export] +impl CodescribeAgentStatus { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self::default() + } + + /// Basic-lane MCP status: reads/parses `mcp.json` + merges the last runtime + /// discovery. Missing config → neutral optional row, never an error. + pub fn mcp_status(&self) -> CsMcpStatusReport { + probe_mcp_status().into() + } + + /// Agentic-lane readiness. `ready` is the core capability gate (assistive + /// provider + its API key + native tools); the MCP rows are informational. + /// Loads `Config` first so Keychain-backed keys are populated into env and the + /// core gate sees real key presence (mirrors `available_providers`). + pub fn agentic_readiness(&self) -> CsAgenticReadiness { + let _ = codescribe_core::config::Config::load(); + probe_agentic_readiness().into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codescribe::agent::tools::mcp::{McpRowTone, McpStatusRow}; + + #[test] + fn tone_maps_one_to_one() { + assert_eq!(CsMcpRowTone::from(McpRowTone::Good), CsMcpRowTone::Good); + assert_eq!(CsMcpRowTone::from(McpRowTone::Warn), CsMcpRowTone::Warn); + assert_eq!(CsMcpRowTone::from(McpRowTone::Bad), CsMcpRowTone::Bad); + assert_eq!( + CsMcpRowTone::from(McpRowTone::Neutral), + CsMcpRowTone::Neutral + ); + } + + #[test] + fn row_conversion_preserves_fields() { + let row = McpStatusRow { + label: "loctree-mcp:".to_string(), + value: "ready — 7 tool(s) live".to_string(), + tone: McpRowTone::Good, + }; + let cs = CsMcpStatusRow::from(&row); + assert_eq!(cs.label, "loctree-mcp:"); + assert_eq!(cs.value, "ready — 7 tool(s) live"); + assert_eq!(cs.tone, CsMcpRowTone::Good); + } + + // Degradation contract: the basic-lane probe always emits at least one row + // (a present config lists servers; a missing one yields a single neutral + // "MCP off" row). The FFI mapping must carry that row through with a + // non-empty config-path label and never collapse to an empty report. + #[test] + fn mcp_status_report_maps_at_least_one_row() { + let report = CodescribeAgentStatus::new().mcp_status(); + assert!(!report.rows.is_empty()); + assert!(!report.config_path_display.is_empty()); + } + + // The agentic readiness report always leads with a verdict row plus the + // per-prerequisite rows, so the FFI view must carry several rows and a + // boolean verdict without panicking on a bare environment. + #[test] + fn agentic_readiness_report_carries_verdict_and_rows() { + let report = CodescribeAgentStatus::new().agentic_readiness(); + assert!(!report.rows.is_empty()); + // `ready` is a plain bool either way; this asserts the field is wired. + let _ = report.ready; + } +} diff --git a/bridge/src/config.rs b/bridge/src/config.rs new file mode 100644 index 00000000..60958e60 --- /dev/null +++ b/bridge/src/config.rs @@ -0,0 +1,1095 @@ +//! Configuration surface — thin UniFFI wrapper over the live codescribe +//! `config` engine (settings.json / .env tiering, Keychain-backed API keys, +//! prompt files, onboarding state). Split out of `lib.rs` in W3 cut #0 so each +//! bridge slice owns a disjoint file. +//! +//! Sync-only (NOT tokio): every call here is cheap disk / Keychain / env I/O. +//! Secrets NEVER cross the FFI boundary — only `CsKeyStatus` booleans report +//! whether a key is present. + +use std::collections::HashMap; +use std::fs; +use std::str::FromStr; +use std::sync::{Mutex, OnceLock}; + +use codescribe_core::config::keychain::{KEYCHAIN_ACCOUNTS, delete_key, load_key, save_key}; +use codescribe_core::config::prompts::{get_assistive_prompt_path, get_formatting_prompt_path}; +use codescribe_core::config::{ + Config, DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, UserSettings, reset_to_defaults, +}; +use codescribe_core::llm::account_auth; +use codescribe_core::llm::key_liveness::{ + ApiKeyLivenessResult, ApiKeyLivenessStatus, probe_api_key_liveness, +}; +use codescribe_core::llm::model_discovery::{ + ModelDiscoveryStatus, discover_models as discover_provider_models, +}; +use codescribe_core::llm::provider::{ALL_PROVIDERS, ProviderKind}; + +use crate::{CsError, CsLanguage}; + +/// 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 +/// without relying on runtime process-env mutation. +/// +/// API keys are intentionally absent — they live only in `CsKeyStatus` as +/// booleans. Write back through `update_config` / `update_config_many` using the +/// router env keys (see `CodescribeConfig::update_config`). +#[derive(uniffi::Record)] +pub struct CsSettings { + // ── Hotkeys ── + pub hold_exclusive: bool, + pub hold_start_delay_ms: u64, + pub double_tap_interval_ms: u64, + pub toggle_silence_sec: f32, + // ── Language ── + pub whisper_language: CsLanguage, + // ── AI / formatting ── + pub ai_formatting_enabled: bool, + /// `TranscriptSendMode::as_str()` — `"end_of_utterance"` / `"streaming"`. + pub transcript_send_mode: String, + pub transcript_tagging_enabled: bool, + pub transcript_tag_template: String, + pub ai_max_tokens: i32, + pub ai_assistive_max_tokens: i32, + // ── UI ── + pub show_tray_glyph: bool, + pub show_dock_icon: bool, + pub transcription_overlay_enabled: bool, + pub hold_indicator: bool, + pub hold_badge_size: u32, + pub hold_badge_offset_x: i32, + pub hold_badge_offset_y: i32, + /// `OverlayPositionMode::as_str()` — `"snapped_top_right"` / `"custom"`. + pub overlay_position_mode: String, + pub overlay_custom_x: Option, + pub overlay_custom_y: Option, + // ── Sound ── + pub beep_on_start: bool, + pub sound_name: String, + pub sound_volume: f32, + // ── Audio ── + pub audio_input_device: Option, + // ── History / quick notes ── + pub history_enabled: bool, + pub quick_notes_enabled: bool, + pub quick_notes_save_only: bool, + // ── STT backend ── + pub use_local_stt: bool, + pub local_model: String, + pub stt_endpoint: Option, + /// STT engine selection (`CODESCRIBE_STT_ENGINE`): `"auto"` | `"apple"` | + /// `"whisper"`. `None` means the built-in auto policy. Written back via + /// `update_config` with the same key (promoted → settings.json). + pub stt_engine: Option, + // ── LLM backend (base) ── + pub llm_endpoint: Option, + // ── Clipboard ── + pub restore_clipboard: bool, + pub restore_clipboard_delay_ms: u64, + // ── System / agent ── + pub start_at_login: bool, + pub agent_enter_sends: bool, + pub dump_audio_logs: bool, + // ── Env-only knobs (not Config struct fields; read after load) ── + pub llm_model: Option, + pub llm_formatting_endpoint: Option, + pub llm_formatting_model: Option, + pub llm_assistive_endpoint: Option, + pub llm_assistive_model: Option, + /// Assistive/agent-lane provider identity (`LLM_ASSISTIVE_PROVIDER`): + /// `"openai-responses"` | `"anthropic-messages"`. Written back via + /// `update_config` with the same key; drives `create_default_provider`. + pub llm_assistive_provider: Option, + pub formatting_level: Option, + pub whisper_model: Option, + /// Layered incremental transcription phase (`CODESCRIBE_LAYERED_TRANSCRIPTION`): + /// `"phase1"` | `"off"` (anything non-phase means OFF). Written back via + /// `update_config` with the same key (promoted → settings.json). + pub layered_transcription: Option, + /// Workspace root directories the agent scans (`list_projects` tool) to + /// resolve project names to paths (`AGENT_WORKSPACE_ROOTS`, colon-joined on + /// the wire). Effective value: never empty here — defaults to `["~/Git"]` so + /// the Settings UI always shows the root the tool will actually scan. Written + /// back via `update_config` with the same key (env-managed, NOT promoted). + pub agent_workspace_roots: Vec, + pub buffer_delay_ms: Option, + pub typing_cps: Option, + pub emit_words_max: Option, + pub buffered_interim_sec: Option, + pub backend_max_upload_mb: Option, +} + +/// Presence-only view of the Keychain-backed API keys. Booleans only — the +/// secret values themselves never cross FFI. A key counts as "set" when its +/// account env var or Keychain account is present and non-empty. +#[derive(uniffi::Record)] +pub struct CsKeyStatus { + pub llm_api_key_set: bool, + pub stt_api_key_set: bool, + pub llm_formatting_api_key_set: bool, + pub llm_assistive_api_key_set: bool, + /// Anthropic assistive-lane key (`LLM_ANTHROPIC_API_KEY`) — separate from the + /// OpenAI assistive key so both providers can be configured at once. + pub llm_anthropic_api_key_set: bool, + pub github_token_set: bool, +} + +/// UI-safe API-key liveness bucket. No variant carries secret material. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsApiKeyProbeStatus { + Ok, + Invalid, + NoQuota, + Network, + Missing, + Unsupported, +} + +/// Result of one Settings "Test" action for a Keychain account. +#[derive(uniffi::Record)] +pub struct CsApiKeyProbeResult { + pub account: String, + pub status: CsApiKeyProbeStatus, + pub message: String, +} + +/// One selectable model for a provider (id sent on the wire + display label). +#[derive(uniffi::Record)] +pub struct CsModelOption { + pub id: String, + pub display_name: String, +} + +/// Live model discovery result for one provider. `status` is one of: +/// `"fresh"`, `"cached"`, `"no_key"`, `"error"`. Errors never carry secrets. +#[derive(uniffi::Record)] +pub struct CsModelDiscovery { + pub provider_id: String, + pub status: String, + pub message: Option, + pub models: Vec, +} + +/// One assistive/agent-lane provider option: canonical id, label, the Keychain +/// account holding its key (+ whether that key is present), and its model +/// catalog. Provider identity is static; models are discovered by +/// `discover_models` from the live provider API using the user's key. +#[derive(uniffi::Record)] +pub struct CsProviderOption { + /// `LLM_ASSISTIVE_PROVIDER` value: `"openai-responses"` | `"anthropic-messages"`. + pub id: String, + pub display_name: String, + /// Keychain account for this provider's assistive key. + pub api_key_account: String, + /// True when that key is present (mirrors `CsKeyStatus`, keyed per provider). + pub api_key_set: bool, + /// True when provider-account tokens are stored for this provider. + pub account_signed_in: bool, + /// True when the account-login flow can start. For OpenAI this requires + /// `CODESCRIBE_OPENAI_OAUTH_CLIENT_ID`; until then Settings renders the + /// disabled "Sign in with ChatGPT" affordance. + pub account_login_enabled: bool, + /// Human-readable account status ("signed in", "not signed in", or + /// "awaiting app registration"). Never contains secrets. + pub account_status_message: String, + /// Always empty for live Settings; retained for bridge compatibility with + /// older Swift bindings and preview seed objects. + pub models: Vec, +} + +/// Result of starting the provider-account login flow. `auth_url` is present +/// when the local callback server is listening and the UI should open a browser. +#[derive(uniffi::Record)] +pub struct CsAccountLoginResult { + pub provider_id: String, + pub status: String, + pub message: String, + pub auth_url: Option, + pub signed_in: bool, + pub client_id_configured: bool, +} + +/// One config key/value pair for `update_config_many` batch writes. `key` is a +/// router env key (e.g. `"WHISPER_LANGUAGE"`, `"USE_LOCAL_STT"`); `value` is the +/// string form the core parses (bool `"1"`/`"0"`, f32 `"1.00"`, etc.). +#[derive(uniffi::Record)] +pub struct CsConfigEntry { + pub key: String, + pub value: String, +} + +/// Prompt-free tray subset. Reads only settings.json/defaults and does not touch +/// Keychain-backed API keys. +#[derive(uniffi::Record)] +pub struct CsTrayToggles { + pub show_dock_icon: bool, + pub transcription_overlay_enabled: bool, + /// UI-initiated recording starts in the assistive lane when enabled. + pub start_assistive: bool, + /// Notes Mode: voice → daily note (no paste). Backed by the core + /// `quick_notes_enabled` + `quick_notes_save_only` pair, flipped together. + pub notes_mode_enabled: bool, +} + +/// Thin handle to the codescribe config engine. Stateless: each method reloads +/// or writes through the live `Config` / `UserSettings` / Keychain so Swift +/// always sees on-disk truth. +#[derive(uniffi::Object)] +pub struct CodescribeConfig {} + +#[uniffi::export] +impl CodescribeConfig { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self {} + } + + /// Full settings snapshot for the Settings UI. Reloads from disk so it + /// reflects any writes made since construction. + pub fn load_settings(&self) -> CsSettings { + let config = Config::load(); + let settings = UserSettings::load(); + let env_file = load_config_env_file(); + CsSettings { + hold_exclusive: config.hold_exclusive, + 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, + 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(), + transcript_tagging_enabled: config.transcript_tagging_enabled, + transcript_tag_template: config.transcript_tag_template.clone(), + ai_max_tokens: config.ai_max_tokens, + ai_assistive_max_tokens: config.ai_assistive_max_tokens, + show_tray_glyph: config.show_tray_glyph, + show_dock_icon: config.show_dock_icon, + transcription_overlay_enabled: config.transcription_overlay_enabled, + hold_indicator: config.hold_indicator, + hold_badge_size: config.hold_badge_size, + hold_badge_offset_x: config.hold_badge_offset_x, + hold_badge_offset_y: config.hold_badge_offset_y, + overlay_position_mode: config.overlay_position_mode.as_str().to_string(), + overlay_custom_x: config.overlay_custom_x, + overlay_custom_y: config.overlay_custom_y, + beep_on_start: config.beep_on_start, + sound_name: config.sound_name.clone(), + sound_volume: config.sound_volume, + audio_input_device: config.audio_input_device.clone(), + history_enabled: config.history_enabled, + quick_notes_enabled: config.quick_notes_enabled, + quick_notes_save_only: config.quick_notes_save_only, + use_local_stt: config.use_local_stt, + local_model: config.local_model.clone(), + stt_endpoint: config.stt_endpoint.clone(), + stt_engine: effective_env_string( + "CODESCRIBE_STT_ENGINE", + settings.stt_engine.clone(), + &env_file, + ), + llm_endpoint: config.llm_endpoint.clone(), + restore_clipboard: config.restore_clipboard, + restore_clipboard_delay_ms: config.restore_clipboard_delay_ms, + start_at_login: config.start_at_login, + agent_enter_sends: config.agent_enter_sends, + dump_audio_logs: config.dump_audio_logs, + // Env-only knobs: read the persisted stores first so a runtime UI + // write is visible without mutating the process environment. + llm_model: effective_settings_string( + "LLM_MODEL", + settings.llm_model.clone(), + &env_file, + ), + llm_formatting_endpoint: effective_settings_string( + "LLM_FORMATTING_ENDPOINT", + settings.llm_formatting_endpoint.clone(), + &env_file, + ), + llm_formatting_model: effective_settings_string( + "LLM_FORMATTING_MODEL", + settings.llm_formatting_model.clone(), + &env_file, + ), + llm_assistive_endpoint: effective_settings_string( + "LLM_ASSISTIVE_ENDPOINT", + settings.llm_assistive_endpoint.clone(), + &env_file, + ), + llm_assistive_model: effective_settings_string( + "LLM_ASSISTIVE_MODEL", + settings.llm_assistive_model.clone(), + &env_file, + ), + llm_assistive_provider: effective_file_env_string("LLM_ASSISTIVE_PROVIDER", &env_file), + formatting_level: effective_settings_string( + "FORMATTING_LEVEL", + settings.formatting_level.clone(), + &env_file, + ), + whisper_model: effective_settings_string( + "WHISPER_MODEL", + settings.whisper_model.clone(), + &env_file, + ), + layered_transcription: effective_env_string( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + settings.layered_transcription.clone(), + &env_file, + ), + agent_workspace_roots: effective_env_list( + "AGENT_WORKSPACE_ROOTS", + settings.agent_workspace_roots.clone(), + &env_file, + DEFAULT_AGENT_WORKSPACE_ROOT, + ), + buffer_delay_ms: effective_settings_parse( + "CODESCRIBE_BUFFER_DELAY_MS", + settings.buffer_delay_ms, + &env_file, + ), + typing_cps: effective_settings_parse( + "CODESCRIBE_TYPING_CPS", + settings.typing_cps, + &env_file, + ), + emit_words_max: effective_settings_parse( + "CODESCRIBE_EMIT_WORDS_MAX", + settings.emit_words_max, + &env_file, + ), + buffered_interim_sec: effective_settings_parse( + "CODESCRIBE_BUFFERED_INTERIM_SEC", + settings.buffered_interim_sec, + &env_file, + ), + backend_max_upload_mb: effective_settings_parse( + "BACKEND_MAX_UPLOAD_MB", + settings.backend_max_upload_mb, + &env_file, + ), + } + } + + /// Lightweight tray-only settings read. Unlike `load_settings`, this never + /// populates the Keychain, so it never prompts just because the user opened + /// the menu. It DOES honor the full tier stack (defaults < settings.json < + /// .env < process-env) so env overrides such as `SHOW_DOCK_ICON=0` take + /// effect — reading `UserSettings` + defaults alone silently dropped them. + pub fn tray_toggles(&self) -> CsTrayToggles { + let config = Config::load_without_keychain(); + CsTrayToggles { + show_dock_icon: config.show_dock_icon, + transcription_overlay_enabled: config.transcription_overlay_enabled, + start_assistive: config.tray_start_assistive, + // Notes Mode is "on" only when BOTH flags are set (dictation → note + // AND no paste). Reading just quick_notes_enabled could show the toggle + // ON while dictation still pastes (save_only=false) — an edge desync. + notes_mode_enabled: config.quick_notes_enabled && config.quick_notes_save_only, + } + } + + /// Persist one config value, auto-tiered by the core router + /// (`save_to_env`): API keys → Keychain, promoted keys → settings.json, + /// power-user keys → `.env`. Runtime readers reload persisted snapshots + /// instead of mutating the process env. + pub fn update_config(&self, key: String, value: String) -> Result<(), CsError> { + Config::load() + .save_to_env(&key, &value) + .map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + reload_hotkey_runtime(); + crate::hotkeys::refresh_live_controller_config(); + Ok(()) + } + + /// Toggle Notes Mode as one explicit two-key operation. Notes Mode means + /// "dictation → daily note AND no paste", so `QUICK_NOTES_ENABLED` and + /// `QUICK_NOTES_SAVE_ONLY` are written together in a single settings.json + /// update — never two independent writes that could half-succeed and desync. + pub fn set_notes_mode(&self, enabled: bool) -> Result<(), CsError> { + let value = if enabled { "1" } else { "0" }.to_string(); + self.update_config_many(vec![ + CsConfigEntry { + key: "QUICK_NOTES_ENABLED".to_string(), + value: value.clone(), + }, + CsConfigEntry { + key: "QUICK_NOTES_SAVE_ONLY".to_string(), + value, + }, + ]) + } + + /// Batch variant of `update_config` (`save_to_env_many`) — one settings.json + /// write and one `.env` rewrite for the whole batch. + pub fn update_config_many(&self, entries: Vec) -> Result<(), CsError> { + let pairs: Vec<(&str, &str)> = entries + .iter() + .map(|entry| (entry.key.as_str(), entry.value.as_str())) + .collect(); + Config::load() + .save_to_env_many(&pairs) + .map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + reload_hotkey_runtime(); + crate::hotkeys::refresh_live_controller_config(); + Ok(()) + } + + /// Absolute path to the config directory (`~/.codescribe`, or the + /// `CODESCRIBE_DATA_DIR` override). + pub fn config_dir(&self) -> String { + Config::config_dir().to_string_lossy().to_string() + } + + /// Presence booleans for every Keychain-backed API key. + pub fn key_status(&self) -> CsKeyStatus { + // This endpoint is explicitly about keys, so it may prompt. Construction + // of SettingsViewModel/TrayViewModel should remain prompt-free. + let _ = Config::load(); + CsKeyStatus { + llm_api_key_set: key_present("LLM_API_KEY"), + stt_api_key_set: key_present("STT_API_KEY"), + llm_formatting_api_key_set: key_present("LLM_FORMATTING_API_KEY"), + llm_assistive_api_key_set: key_present("LLM_ASSISTIVE_API_KEY"), + llm_anthropic_api_key_set: key_present("LLM_ANTHROPIC_API_KEY"), + github_token_set: key_present("GITHUB_TOKEN"), + } + } + + /// Probe one Keychain-backed API key account with a single cheap provider + /// request. Blocking by design: Swift calls this from a background queue. + /// The secret never crosses FFI; this method reads env/Keychain internally. + pub fn test_api_key(&self, account: String) -> Result { + ensure_known_account(&account)?; + Ok(probe_api_key_liveness(&account).into()) + } + + /// Assistive/agent-lane provider catalog with per-provider key presence. + /// Model lists are intentionally empty here: Settings must call + /// `discover_models` so dropdown options come from the provider's live API, + /// not a static fallback. + pub fn available_providers(&self) -> Vec { + let _ = Config::load(); + ALL_PROVIDERS + .iter() + .map(|kind| { + let account = kind.api_key_env_key().to_string(); + let account_status = account_auth::account_status(*kind); + CsProviderOption { + id: kind.as_str().to_string(), + display_name: kind.display_name().to_string(), + api_key_set: key_present(&account), + api_key_account: account, + account_signed_in: account_status.signed_in, + account_login_enabled: account_status.client_id_configured, + account_status_message: account_status.message, + models: Vec::new(), + } + }) + .collect() + } + + /// Start provider-account login for the selected provider. Today this is + /// only supported for OpenAI Responses and is gated by + /// `CODESCRIBE_OPENAI_OAUTH_CLIENT_ID`; absent client id returns a config + /// error whose message contains "awaiting app registration". + pub fn start_account_login( + &self, + provider_id: String, + ) -> Result { + let provider = ProviderKind::from_str(&provider_id).map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + let client_id = + account_auth::client_id_for_provider(provider).map_err(account_auth_to_cs)?; + let mut opts = account_auth::ServerOptions::new(client_id); + opts.issuer = account_auth::issuer_from_env(); + + let login = account_auth_runtime()? + .block_on(account_auth::run_login_server(opts)) + .map_err(account_auth_to_cs)?; + let auth_url = login.auth_url.clone(); + let mut guard = active_account_login().lock().map_err(|_| CsError::Config { + msg: "account login state lock poisoned".to_string(), + })?; + if let Some(previous) = guard.take() { + previous.cancel(); + } + *guard = Some(login); + + Ok(CsAccountLoginResult { + provider_id: provider.as_str().to_string(), + status: "started".to_string(), + message: "open the browser to finish sign-in".to_string(), + auth_url: Some(auth_url), + signed_in: false, + client_id_configured: true, + }) + } + + /// Discover model options from the selected provider using the live provider + /// `/models` API plus the existing config/Keychain/env key resolution path. + /// Missing key is returned as a typed status, not as a thrown bridge error, + /// so Settings can render "Add API key to discover models" inline. + pub fn discover_models(&self, provider_id: String) -> CsModelDiscovery { + let provider = match ProviderKind::from_str(&provider_id) { + Ok(provider) => provider, + Err(error) => { + return CsModelDiscovery { + provider_id, + status: "error".to_string(), + message: Some(error.to_string()), + models: Vec::new(), + }; + } + }; + + match discover_provider_models(provider) { + Ok(result) => { + let (status, message) = match result.status { + ModelDiscoveryStatus::Fresh => ("fresh".to_string(), None), + ModelDiscoveryStatus::Cached { reason } => ("cached".to_string(), Some(reason)), + }; + CsModelDiscovery { + provider_id: result.provider.as_str().to_string(), + status, + message, + models: result + .models + .into_iter() + .map(|model| CsModelOption { + id: model.id, + display_name: model.display_name, + }) + .collect(), + } + } + Err(error) => { + let status = if error.code() == "no_key" { + "no_key" + } else { + "error" + }; + CsModelDiscovery { + provider_id: error.provider().as_str().to_string(), + status: status.to_string(), + message: Some(error.message()), + models: Vec::new(), + } + } + } + } + + /// Canonical list of Keychain account names (`KEYCHAIN_ACCOUNTS`). + pub fn key_accounts(&self) -> Vec { + KEYCHAIN_ACCOUNTS.iter().map(|a| a.to_string()).collect() + } + + /// Store an API key in the Keychain. `account` must be a known + /// `KEYCHAIN_ACCOUNTS` entry. The secret is never echoed back. + pub fn set_api_key(&self, account: String, secret: String) -> Result<(), CsError> { + ensure_known_account(&account)?; + save_key(&account, &secret).map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + crate::hotkeys::refresh_live_controller_config(); + Ok(()) + } + + /// Delete an API key from the Keychain. `account` must be a known + /// `KEYCHAIN_ACCOUNTS` entry. + pub fn clear_api_key(&self, account: String) -> Result<(), CsError> { + ensure_known_account(&account)?; + delete_key(&account).map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + Ok(()) + } + + /// Current BASE formatting prompt (the editable `formatting.txt`, WITHOUT + /// the appended `*_tuning.txt`). Falls back to the built-in default when the + /// file does not exist yet. + pub fn get_formatting_prompt(&self) -> String { + read_prompt_or_default( + &get_formatting_prompt_path().to_string_lossy(), + DEFAULT_FORMATTING_PROMPT, + ) + } + + /// Current BASE assistive prompt (the editable `assistive.txt`, WITHOUT the + /// appended `*_tuning.txt`). Falls back to the built-in default when the file + /// does not exist yet. + pub fn get_assistive_prompt(&self) -> String { + read_prompt_or_default( + &get_assistive_prompt_path().to_string_lossy(), + DEFAULT_ASSISTIVE_PROMPT, + ) + } + + /// Overwrite the BASE formatting prompt file. The core has no setter, so we + /// write `formatting.txt` directly (creating the prompts dir if needed). + pub fn set_formatting_prompt(&self, content: String) -> Result<(), CsError> { + write_prompt(&get_formatting_prompt_path(), &content) + } + + /// Overwrite the BASE assistive prompt file (`assistive.txt`). + pub fn set_assistive_prompt(&self, content: String) -> Result<(), CsError> { + write_prompt(&get_assistive_prompt_path(), &content) + } + + /// Reset both BASE prompt files to their built-in defaults + /// (`reset_to_defaults`). Does not touch `*_tuning.txt`. + pub fn reset_prompts_to_defaults(&self) -> Result<(), CsError> { + reset_to_defaults().map_err(CsError::from) + } + + /// Built-in default formatting prompt (`DEFAULT_FORMATTING_PROMPT`). + pub fn default_formatting_prompt(&self) -> String { + DEFAULT_FORMATTING_PROMPT.to_string() + } + + /// Built-in default assistive prompt (`DEFAULT_ASSISTIVE_PROMPT`). + pub fn default_assistive_prompt(&self) -> String { + DEFAULT_ASSISTIVE_PROMPT.to_string() + } + + /// Whether the first-run onboarding wizard should be shown (mirrors the + /// live app gate; re-validates permission markers). + pub fn should_show_onboarding(&self) -> bool { + codescribe::should_show_onboarding() + } + + /// Persisted resume step for the first-run wizard (0-based index into the + /// canonical 12-step flow). Returns `0` (Welcome) when no progress marker + /// exists yet, so a fresh install starts at the top. + pub fn onboarding_progress(&self) -> u32 { + codescribe::load_onboarding_progress() as u32 + } + + /// Persist the wizard's current step so a relaunch resumes where the user + /// left off. Writer for the `onboarding_progress` marker that the live gate + /// (`should_show_onboarding`) already reconciles against permission state. + pub fn save_onboarding_progress(&self, step: u32) { + codescribe::save_onboarding_progress(step as usize); + } + + /// Mark onboarding complete: clears the resume marker and writes the + /// canonical `setup_done` sentinel so `should_show_onboarding()` returns + /// `false` on the next launch. + pub fn mark_onboarding_done(&self) { + codescribe::mark_onboarding_done(); + } + + /// First-run operating lane chosen during onboarding (`"basic"` / + /// `"agentic"`), or `None` when not yet chosen. + pub fn onboarding_mode(&self) -> Option { + UserSettings::load().onboarding_mode + } + + /// Persist the onboarding operating lane. Routes to settings.json via the + /// promoted `ONBOARDING_MODE` key. + pub fn set_onboarding_mode(&self, mode: String) -> Result<(), CsError> { + Config::load() + .save_to_env("ONBOARDING_MODE", &mode) + .map_err(|error| CsError::Config { + msg: error.to_string(), + }) + } + + /// Wipe all local codescribe data for a privacy "Reset app data" action. + /// + /// Removes the two app-owned data trees — the config / logs / transcription + /// directory (`~/.codescribe`) and the Application Support store + /// (`~/Library/Application Support/Codescribe`, i.e. settings.json + thread + /// history) — sourced from the same runtime path helpers the app reads, so a + /// `CODESCRIBE_DATA_DIR` override is honored and no `~` is ever hardcoded. + /// + /// When `include_keys` is true, also removes every Keychain-backed API key + /// (`KEYCHAIN_ACCOUNTS`). Deleting the data trees clears the `setup_done` sentinel, so the next launch replays the + /// first-run wizard. TCC / permission grants are deliberately NOT touched — + /// those are the user's to manage in System Settings. + /// + /// UserDefaults (window frames / SwiftUI scene restoration) is a CFPreferences + /// domain with no core helper; the Swift caller clears it before relaunch. + pub fn reset_app_data(&self, include_keys: bool) -> Result<(), CsError> { + for dir in app_data_dirs() { + remove_dir_all_tolerant(&dir).map_err(|error| CsError::Config { + msg: format!("failed to remove {}: {error}", dir.display()), + })?; + } + if include_keys { + for account in KEYCHAIN_ACCOUNTS { + delete_key(account).map_err(|error| CsError::Config { + msg: format!("failed to remove keychain key {account}: {error}"), + })?; + } + } + Ok(()) + } +} + +/// Re-seed the live hotkey detector atomics after a settings write so mode +/// binding / cadence changes take effect without an app restart. The CGEventTap +/// callback reads these atomics per-event (app/os/hotkeys/platform.rs), so a +/// fresh apply is a true live-reload. Idempotent and cheap (a few atomic stores); +/// applied unconditionally because every settings mutation funnels through +/// `update_config` / `update_config_many`, and re-applying unchanged values is a +/// no-op. +/// +/// Loads WITHOUT the Keychain: hotkey bindings and cadence live in +/// settings.json / .env / defaults, never in secrets, so this reload has no +/// reason to `populate_env_from_keychain()`. Using the plain `Config::load()` +/// here fired a Keychain read on every settings write (dock toggle, language, +/// overlay position, …), the same hot-path prompt class as the AX-probe defer. +/// Mirrors `hotkeys::reload_hotkey_runtime_after_write`. +fn reload_hotkey_runtime() { + codescribe::os::hotkeys::apply_hotkey_config(&Config::load_without_keychain()); +} + +/// App-owned data directories a full "Reset app data" wipes: the config / logs / +/// transcription tree first, then the Application Support store. Both come from +/// the runtime path helpers (honoring `CODESCRIBE_DATA_DIR`); when an override +/// collapses them onto one path the duplicate is dropped so we never remove twice. +fn app_data_dirs() -> Vec { + let config_dir = Config::config_dir(); + let settings_dir = codescribe_core::config::UserSettings::settings_dir(); + if settings_dir == config_dir { + vec![config_dir] + } else { + vec![config_dir, settings_dir] + } +} + +/// `remove_dir_all` that treats an already-absent directory as success: a reset +/// must not fail just because a tree (e.g. an Application Support store that was +/// never created) does not exist yet. +fn remove_dir_all_tolerant(dir: &std::path::Path) -> std::io::Result<()> { + match fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +/// Built-in default workspace root when `AGENT_WORKSPACE_ROOTS` is unset. Kept in +/// sync with the `list_projects` tool default (`app/agent/tools/workspace.rs`). +const DEFAULT_AGENT_WORKSPACE_ROOT: &str = "~/Git"; + +fn load_config_env_file() -> HashMap { + let path = Config::env_path(); + if path.exists() { + Config::parse_env_file(&path).unwrap_or_default() + } else { + HashMap::new() + } +} + +fn non_empty(value: String) -> Option { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn setting_string(value: Option) -> Option { + value.and_then(non_empty) +} + +fn file_env_string(key: &str, env_file: &HashMap) -> Option { + env_file.get(key).cloned().and_then(non_empty) +} + +/// Promoted settings are settings.json-owned; prefer that store over process +/// env so stale bootstrap-seeded env does not mask a fresh UI write. +fn effective_settings_string( + key: &str, + setting: Option, + env_file: &HashMap, +) -> Option { + setting_string(setting) + .or_else(|| file_env_string(key, env_file)) + .or_else(|| env_string(key)) +} + +/// Env-managed settings are persisted to .env when changed from the UI. Read +/// that file before process env so runtime writes are visible without set_var. +fn effective_env_string( + key: &str, + setting: Option, + env_file: &HashMap, +) -> Option { + file_env_string(key, env_file) + .or_else(|| setting_string(setting)) + .or_else(|| env_string(key)) +} + +fn effective_file_env_string(key: &str, env_file: &HashMap) -> Option { + file_env_string(key, env_file).or_else(|| env_string(key)) +} + +fn effective_settings_parse( + key: &str, + setting: Option, + env_file: &HashMap, +) -> Option +where + T: std::str::FromStr, +{ + setting + .or_else(|| file_env_string(key, env_file).and_then(|value| value.parse().ok())) + .or_else(|| env_parse(key)) +} + +fn parse_roots(value: &str) -> Vec { + value + .split(':') + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect() +} + +/// Colon-separated env var into a trimmed, non-empty `Vec`. Falls back to +/// a single-element `[default]` when the var is unset/empty, so the Settings UI +/// always renders the effective root the agent tool will scan. +fn effective_env_list( + key: &str, + setting: Option>, + env_file: &HashMap, + default: &str, +) -> Vec { + let roots = file_env_string(key, env_file) + .map(|value| parse_roots(&value)) + .or_else(|| { + setting.map(|roots| { + roots + .into_iter() + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect() + }) + }) + .or_else(|| std::env::var(key).ok().map(|value| parse_roots(&value))) + .unwrap_or_default(); + if roots.is_empty() { + vec![default.to_string()] + } else { + roots + } +} + +/// Non-empty env var as `Some(String)`, else `None`. +fn env_string(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// Parse a non-empty env var into `T`, else `None`. +fn env_parse(key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|value| value.trim().parse().ok()) +} + +/// True when the account env var or Keychain account is present and non-empty. +fn key_present(account: &str) -> bool { + env_string(account).is_some() || load_key(account).and_then(non_empty).is_some() +} + +fn account_auth_runtime() -> Result<&'static tokio::runtime::Runtime, CsError> { + static RUNTIME: OnceLock> = OnceLock::new(); + match RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("codescribe-account-auth") + .build() + .map_err(|error| format!("account auth runtime initialization failed: {error}")) + }) { + Ok(runtime) => Ok(runtime), + Err(msg) => Err(CsError::Config { msg: msg.clone() }), + } +} + +fn active_account_login() -> &'static Mutex> { + static ACTIVE: OnceLock>> = OnceLock::new(); + ACTIVE.get_or_init(|| Mutex::new(None)) +} + +fn account_auth_to_cs(error: account_auth::AccountAuthError) -> CsError { + CsError::Config { + msg: error.to_string(), + } +} + +impl From for CsApiKeyProbeStatus { + fn from(status: ApiKeyLivenessStatus) -> Self { + match status { + ApiKeyLivenessStatus::Ok => Self::Ok, + ApiKeyLivenessStatus::Invalid => Self::Invalid, + ApiKeyLivenessStatus::NoQuota => Self::NoQuota, + ApiKeyLivenessStatus::Network => Self::Network, + ApiKeyLivenessStatus::Missing => Self::Missing, + ApiKeyLivenessStatus::Unsupported => Self::Unsupported, + } + } +} + +impl From for CsApiKeyProbeResult { + fn from(result: ApiKeyLivenessResult) -> Self { + Self { + account: result.account, + status: result.status.into(), + message: result.message, + } + } +} + +/// Reject unknown Keychain accounts before touching the Keychain. +fn ensure_known_account(account: &str) -> Result<(), CsError> { + if KEYCHAIN_ACCOUNTS.contains(&account) { + Ok(()) + } else { + Err(CsError::Config { + msg: format!("unknown keychain account: {account}"), + }) + } +} + +/// Read a BASE prompt file, falling back to the supplied default when it is +/// missing or unreadable. +fn read_prompt_or_default(path: &str, default: &str) -> String { + fs::read_to_string(path).unwrap_or_else(|_| default.to_string()) +} + +/// Write a BASE prompt file, creating its parent directory first. +fn write_prompt(path: &std::path::Path, content: &str) -> Result<(), CsError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content)?; + Ok(()) +} + +#[cfg(test)] +mod reset_tests { + use super::{app_data_dirs, remove_dir_all_tolerant}; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Unique scratch directory under the OS temp dir (never the real home). + fn scratch(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("cs_reset_{}_{tag}_{nanos}", std::process::id())) + } + + /// The reset scope must follow the `CODESCRIBE_DATA_DIR` override and wipe + /// exactly that tree — never anything outside it — and a repeat wipe of an + /// already-gone tree must succeed (idempotent). `#[serial]`: mutates the global + /// `CODESCRIBE_DATA_DIR` env var read by the core path helpers. + #[test] + #[serial] + fn reset_scope_follows_data_dir_and_is_idempotent() { + let root = scratch("scope"); + std::fs::create_dir_all(root.join("transcriptions")).unwrap(); + std::fs::write(root.join("settings.json"), b"{}").unwrap(); + std::fs::write(root.join("transcriptions/a.txt"), b"hi").unwrap(); + let root_canon = root.canonicalize().unwrap(); + + let previous = std::env::var("CODESCRIBE_DATA_DIR").ok(); + // SAFETY: single-threaded test body, serialized via `#[serial]`. + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &root) }; + + let dirs = app_data_dirs(); + assert!(!dirs.is_empty(), "reset must target at least one dir"); + // Every target resolves to the override root — nothing escapes it. + for dir in &dirs { + let resolved = dir.canonicalize().unwrap_or_else(|_| dir.clone()); + assert_eq!( + resolved, root_canon, + "reset target escaped the data dir: {dir:?}" + ); + } + + for dir in &dirs { + remove_dir_all_tolerant(dir).unwrap(); + } + assert!(!root_canon.exists(), "data tree survived reset"); + + // Idempotent: wiping an absent tree is a no-op, not an error. + for dir in &dirs { + remove_dir_all_tolerant(dir).unwrap(); + } + + // SAFETY: restore prior env, same serialized single-thread context. + unsafe { + match previous { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + } + } +} + +#[cfg(test)] +mod settings_snapshot_tests { + use super::CodescribeConfig; + use codescribe_core::config::UserSettings; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn scratch(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "cs_settings_snapshot_{}_{tag}_{nanos}", + std::process::id() + )) + } + + #[test] + #[serial] + fn load_settings_prefers_persisted_model_over_stale_process_env() { + let root = scratch("llm_model"); + std::fs::create_dir_all(&root).unwrap(); + + let previous_data_dir = std::env::var("CODESCRIBE_DATA_DIR").ok(); + let previous_model = std::env::var("LLM_MODEL").ok(); + // SAFETY: serialized test body; no background workers are started. + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &root); + std::env::set_var("LLM_MODEL", "stale-bootstrap-model"); + } + + let settings = UserSettings { + llm_model: Some("fresh-runtime-model".to_string()), + ..Default::default() + }; + settings.save().unwrap(); + + let snapshot = CodescribeConfig::new().load_settings(); + assert_eq!(snapshot.llm_model.as_deref(), Some("fresh-runtime-model")); + + // SAFETY: restore prior env, same serialized single-thread context. + unsafe { + match previous_data_dir { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + match previous_model { + Some(value) => std::env::set_var("LLM_MODEL", value), + None => std::env::remove_var("LLM_MODEL"), + } + } + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/bridge/src/hotkeys.rs b/bridge/src/hotkeys.rs new file mode 100644 index 00000000..90bf04a1 --- /dev/null +++ b/bridge/src/hotkeys.rs @@ -0,0 +1,1301 @@ +//! Global hotkey runtime surface for the SwiftUI redesign. +//! +//! This does not reimplement hotkeys in Swift. It starts the same macOS +//! `CGEventTap` listener used by the legacy daemon and dispatches emitted +//! `HotkeyEvent`s into the existing `RecordingController` state machine. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; + +use codescribe::controller::{HotkeyAction, HotkeyInput, HotkeyType, RecordingController, State}; +use codescribe::os::hotkeys::{self, HoldAction, HoldMode, HotkeyEvent}; +use codescribe::os::permissions::{PermissionStatus, check_accessibility, check_input_monitoring}; +use codescribe::os::shortcut_registry::{detect_hotkey_conflicts, fn_tap_intercept_note}; +use codescribe::os::tray_status::{self, TrayStatus}; +use codescribe_core::config::{Config, ModeBinding, ShortcutBinding, UserSettings, WorkMode}; +use codescribe_core::ipc::{EngineEventWire, IpcEventPayload}; +use crossbeam_channel::unbounded; +use tokio::runtime::Handle; +use tokio::sync::broadcast::error::RecvError; + +use crate::agent_delivery::{ + CsAgentDeliveryListener, set_delivery_listener, spawn_delivery_forwarder, +}; +use crate::recording::{CsAnnotationKind, CsLayerSummary, CsTranscriptionListener}; +use crate::{CsError, CsLanguage}; + +type SharedController = Arc>>>; +type SharedListener = Arc>>>; + +fn shared_controller() -> SharedController { + static CONTROLLER: OnceLock = OnceLock::new(); + Arc::clone(CONTROLLER.get_or_init(|| Arc::new(Mutex::new(None)))) +} + +fn shared_listener() -> SharedListener { + static LISTENER: OnceLock = OnceLock::new(); + Arc::clone(LISTENER.get_or_init(|| Arc::new(RwLock::new(None)))) +} + +fn ensure_controller( + controller_store: &SharedController, + handle: Handle, +) -> Arc { + let mut guard = controller_store.lock().unwrap_or_else(|e| e.into_inner()); + Arc::clone(guard.get_or_insert_with(|| { + let controller = Arc::new(RecordingController::new_without_keychain()); + spawn_event_forwarder(Arc::clone(&controller), handle); + controller + })) +} + +fn current_controller(controller_store: &SharedController) -> Option> { + controller_store + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(Arc::clone) +} + +/// Process-global tokio runtime handle, captured when the hotkey listener +/// starts. Lets sync surfaces (e.g. the Settings config writer) schedule async +/// controller work on the runtime the shared controller already lives on. +fn shared_runtime_handle() -> &'static OnceLock { + static HANDLE: OnceLock = OnceLock::new(); + &HANDLE +} + +/// Push freshly-persisted settings into the live shared controller so a Settings +/// write takes effect without an app restart (language, AI formatting, hold +/// delays, …). No-op before the runtime/controller exist — a controller created +/// later already loads fresh config on construction. Runs `set_config` on the +/// hotkey runtime the controller lives on, mirroring how `start()` drives it. +pub(crate) fn refresh_live_controller_config() { + let Some(handle) = shared_runtime_handle().get() else { + return; + }; + let Some(controller) = current_controller(&shared_controller()) else { + return; + }; + handle.spawn(async move { + controller.set_config(Config::load_without_keychain()).await; + }); +} + +fn spawn_event_forwarder(controller: Arc, handle: Handle) { + let listener_store = shared_listener(); + let mut events = controller.subscribe_events(); + handle.spawn(async move { + loop { + let event = match events.recv().await { + Ok(event) => event, + // Lagged: the broadcast channel (cap 256) overflowed during a + // burst of dictation events and dropped `skipped` messages. That + // is recoverable — keep forwarding subsequent events instead of + // tearing the listener bridge down permanently. + Err(RecvError::Lagged(skipped)) => { + eprintln!( + "Hotkey event forwarder lagged; dropped {skipped} broadcast event(s)" + ); + continue; + } + // Closed: the controller (sender) was dropped — nothing more will + // ever arrive, so end the forwarder task. + Err(RecvError::Closed) => break, + }; + let listener = listener_store + .read() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(Arc::clone); + let Some(listener) = listener else { + continue; + }; + forward_event_to_listener(event.payload, listener); + } + }); +} + +fn forward_event_to_listener(payload: IpcEventPayload, listener: Arc) { + match payload { + IpcEventPayload::StateChange { to, .. } => match to.as_str() { + "rec_hold" | "rec_toggle" | "conversation" => { + // A real state transition resolves any pending optimistic + // "preparing" overlay, so the post-dispatch compensator must not + // also fire a terminal stop for it. + PREPARING_PENDING.store(false, Ordering::Release); + tray_status::update_tray_status(TrayStatus::Listening); + listener.on_recording_started(); + } + "busy" => { + // Capture ended; the controller is running the final transcription + // pass. Surface it as a distinct "finalising" beat BEFORE the + // terminal `idle`→stopped, so the native hold-release / toggle stop + // can show a "transcribing" phase instead of the still-pulsing + // live-capture UI. Does not touch PREPARING_PENDING — a real Rec + // state (rec_hold/rec_toggle) always precedes Busy and already + // cleared it. + tray_status::update_tray_status(TrayStatus::Thinking); + listener.on_recording_finalising(); + } + "idle" => { + PREPARING_PENDING.store(false, Ordering::Release); + tray_status::update_tray_status(TrayStatus::Idle); + listener.on_recording_stopped(); + } + _ => {} + }, + IpcEventPayload::FinalTranscript { text } => listener.on_final_transcript_ready(text), + IpcEventPayload::Engine(event) => match event { + EngineEventWire::VadStart { .. } => listener.on_vad_active(true), + EngineEventWire::VadEnd { .. } => listener.on_vad_active(false), + EngineEventWire::NoSpeech { reason } => listener.on_no_speech(reason), + EngineEventWire::Preview { text, .. } => listener.on_preview(text), + EngineEventWire::Correction { + text, + previous_text, + .. + } => listener.on_correction(text, previous_text), + EngineEventWire::UtteranceFinal { + utterance_id, text, .. + } => listener.on_final(utterance_id, text), + EngineEventWire::ReplaceRange { + utterance_id, + start, + end, + text, + source, + } => listener.on_replace_range( + utterance_id, + start as u64, + end as u64, + text, + source.into(), + ), + EngineEventWire::InsertAnnotation { + utterance_id, + position, + text, + kind, + } => listener.on_insert_annotation( + utterance_id, + position as u64, + text, + CsAnnotationKind::from(&kind), + ), + EngineEventWire::SessionFinalised { + session_id, + layer_summary, + } => listener.on_session_finalised(session_id, CsLayerSummary::from(&layer_summary)), + EngineEventWire::Warning { code, message } => { + tray_status::update_tray_status(TrayStatus::Error); + listener.on_error(format!("{code}: {message}")); + } + EngineEventWire::Drop { .. } | EngineEventWire::Stats { .. } => {} + }, + } +} + +fn current_listener() -> Option> { + shared_listener() + .read() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(Arc::clone) +} + +/// True while an optimistic "preparing" overlay has been shown but no terminal +/// event (`on_recording_started` / `on_recording_stopped`) has resolved it yet. +/// +/// The optimistic overlay (`optimistically_show_overlay`) is driven by a DIRECT +/// listener call, bypassing the controller's `StateChange` broadcast. The only +/// mechanism that later dismisses it is that broadcast — but +/// `set_state_with_broadcast` stays silent when the state does not change. Any +/// dispatch that shows "preparing" and returns to Idle WITHOUT a state +/// transition (quick hold-release cancel, start-failure reset, no-op re-check) +/// therefore orphans the overlay forever. This flag lets the post-dispatch +/// compensator emit exactly one terminal `on_recording_stopped` for those paths, +/// while the broadcast forwarder clears it so a genuine start/stop never +/// double-fires. +static PREPARING_PENDING: AtomicBool = AtomicBool::new(false); + +async fn optimistically_show_overlay(event: &HotkeyEvent) { + let starts_redesign_overlay = matches!( + event, + HotkeyEvent::ToggleNormal + | HotkeyEvent::ToggleRaw + | HotkeyEvent::ToggleAssistive + | HotkeyEvent::Hold { + action: HoldAction::Down, + .. + } + ); + if !starts_redesign_overlay { + return; + } + if let Some(existing) = current_controller(&shared_controller()) + && existing.current_state().await != State::Idle + { + return; + } + if let Some(listener) = current_listener() { + // Arm the compensator BEFORE the direct call so the terminal guarantee + // holds even if the dispatch that follows never transitions state. + PREPARING_PENDING.store(true, Ordering::Release); + tray_status::update_tray_status(TrayStatus::Starting); + listener.on_recording_preparing(); + } +} + +/// Guarantee the terminal half of the "preparing" contract after a dispatch. +/// +/// Run once after every `dispatch_hotkey_event` that may have shown an optimistic +/// overlay. If a "preparing" is still pending AND the controller did not end up +/// recording, the optimistic overlay was orphaned (no `StateChange` broadcast +/// will ever dismiss it) — emit the compensating terminal stop. If the controller +/// is recording (or finalising via `Busy`), the broadcast forwarder owns the +/// transition and we leave the flag for it to clear. The `swap` makes the stop +/// idempotent against a forwarder that already resolved the same "preparing". +async fn compensate_orphaned_preparing(controller: &Arc) { + if controller.current_state().await != State::Idle { + // Recording/finalising: the StateChange broadcast drives preparing→started + // and, later, →stopped. Nothing to compensate here. + return; + } + if PREPARING_PENDING.swap(false, Ordering::AcqRel) { + tray_status::update_tray_status(TrayStatus::Idle); + if let Some(listener) = current_listener() { + listener.on_recording_stopped(); + } + } +} + +/// Process-global hotkey runtime owner. +/// +/// `start()` installs the native listener but creates `RecordingController` +/// lazily on the first real hotkey event. That keeps app launch/menu-open free +/// of `Config::load()` side effects while still routing hotkeys through the +/// real controller once the user intentionally invokes a shortcut. +#[derive(uniffi::Object, Default)] +pub struct CodescribeHotkeys {} + +#[uniffi::export(async_runtime = "tokio")] +impl CodescribeHotkeys { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self::default() + } + + /// Start or replace the process-global hotkey listener. + pub async fn start(&self) -> Result<(), CsError> { + // Install the process-wide macOS thermal observer once at runtime + // bootstrap so STT duty-cycle throttling (core/stt/scheduler.rs) sees + // real thermal pressure. Without this the scheduler always reads + // ThermalLevel::Nominal and never backs off during hot/long sessions. + // Idempotent: install_thermal_probe guards its own observer singleton. + codescribe::os::thermal::install_thermal_probe(); + + // Seed the hotkey detector atomics from persisted config so the + // CGEventTap honours the user's saved mode bindings / cadence from + // launch. The atomics otherwise hold only compile-time defaults, so + // non-default bindings would never take effect. update_config re-applies + // this on every later settings change for live-reload without restart. + codescribe::os::hotkeys::apply_hotkey_config( + &codescribe_core::config::Config::load_without_keychain(), + ); + + let (tx, rx) = unbounded::(); + let handle = tokio::runtime::Handle::current(); + // Publish the runtime handle so sync config-write surfaces can push fresh + // settings into the live controller (refresh_live_controller_config). + let _ = shared_runtime_handle().set(handle.clone()); + // Bridge the app-side voice-assistive delivery broadcast onto the Swift + // AgentChat listener. Idempotent — a repeated start() does not stack a + // second forwarder. The listener itself is registered separately via + // `set_agent_delivery_listener` and may arrive before or after this. + spawn_delivery_forwarder(handle.clone()); + let controller_store = shared_controller(); + + // Spawn the event-dispatch thread BEFORE bringing up the tap. It drains + // `rx` for the lifetime of the retained sender, so it stays ready whether + // the CGEventTap comes up now (permissions already granted) or later via + // `rearm_after_permission_grant` after a first-run TCC grant. If it were + // spawned only after a successful `install_global_hotkey_manager`, a + // permission-less cold start would leave no consumer, and a later re-arm + // would build a live tap whose events pile up in the channel undispatched. + std::thread::spawn(move || { + for event in rx { + let spawn_handle = handle.clone(); + let controller_handle = handle.clone(); + let controller_store = Arc::clone(&controller_store); + spawn_handle.spawn(async move { + optimistically_show_overlay(&event).await; + let controller = ensure_controller(&controller_store, controller_handle); + let dispatch = dispatch_hotkey_event(event, Arc::clone(&controller)).await; + compensate_orphaned_preparing(&controller).await; + if let Err(error) = dispatch { + tray_status::update_tray_status(TrayStatus::Error); + eprintln!("Hotkey event error: {error}"); + } + }); + } + }); + + // Bring up the tap. On a permission-less first launch this returns an + // error, but the sender is retained inside the hotkey service so a later + // `rearm_after_permission_grant` can create the tap and feed the + // already-running dispatch thread — no app restart required. + hotkeys::install_global_hotkey_manager(tx.clone()) + .map_err(|msg| CsError::Recording { msg })?; + + Ok(()) + } + + /// Register the Swift overlay listener for the shared controller event stream. + pub fn set_listener(&self, listener: Arc) { + let listener_store = shared_listener(); + let mut guard = listener_store.write().unwrap_or_else(|e| e.into_inner()); + *guard = Some(listener); + } + + /// Register the Swift AgentChat listener that renders voice-assistive replies + /// live. Process-global, so it takes effect for the delivery forwarder spawned + /// in `start()` regardless of call order. Swift must keep a strong reference + /// to the listener (UniFFI otherwise releases the foreign callback). + pub fn set_agent_delivery_listener(&self, listener: Arc) { + set_delivery_listener(listener); + } + + /// Prompt-free warmup for the shared recording controller. + /// + /// This intentionally does not start recording. It front-loads the expensive + /// local recorder/model setup after app launch so the first user-triggered + /// dictation does not sit in the overlay's `starting` state for seconds. + pub async fn prewarm_recording(&self) -> Result<(), CsError> { + let _ = ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); + // Warm the ACTIVE engine the router will actually use (Apple SpeechAnalyzer + // on macOS 26+, Candle on fallback/older macOS) — not a hardcoded Candle + // singleton. `prewarm_active_engine` also runs a synthetic warmup inference, + // so the first user dictation pays neither model-load nor Metal + // kernel-compilation latency. Idempotent; safe to race the controller's own + // background prewarm. + tokio::task::spawn_blocking(codescribe::stt::prewarm_active_engine) + .await + .map_err(|error| CsError::Recording { + msg: format!("STT prewarm task failed: {error}"), + })? + .map_err(|error| CsError::Recording { + msg: format!("STT prewarm failed: {error}"), + })?; + Ok(()) + } + + /// Start the same toggle recording flow used by the default hotkey. + pub async fn start_recording(&self) -> Result<(), CsError> { + start_recording_with_event(HotkeyEvent::ToggleNormal).await + } + + /// Start the same toggle flow in the assistive lane for UI-initiated recording. + pub async fn start_assistive_recording(&self) -> Result<(), CsError> { + start_recording_with_event(HotkeyEvent::ToggleAssistive).await + } + + /// Stop the active legacy-controller recording flow, if one is live. + pub async fn stop_recording(&self) -> Result<(), CsError> { + let Some(controller) = current_controller(&shared_controller()) else { + return Ok(()); + }; + controller + .stop_recording_from_external_surface() + .await + .map_err(|error| CsError::Recording { + msg: error.to_string(), + }) + } + + /// 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 { + return false; + }; + matches!( + controller.current_state().await, + codescribe::controller::State::RecHold + | codescribe::controller::State::RecToggle + | codescribe::controller::State::Conversation + ) + } + + /// True when the configured formatting provider can handle a user-triggered + /// overlay format action. + pub fn is_formatting_available(&self) -> bool { + codescribe::ai_formatting::is_formatting_available() + } + + /// Format editable overlay text after recording stops. + pub async fn format_text( + &self, + text: String, + language: Option, + ) -> Result { + let language = language.map(|l| l.as_code().to_string()); + let result = codescribe::ai_formatting::format_text_with_status( + &text, + language.as_deref(), + false, + None, + ) + .await; + if result.text.trim().is_empty() { + Ok(text) + } else { + Ok(result.text) + } + } + + /// Stop the global hotkey listener if it is active. + pub fn stop(&self) { + hotkeys::shutdown_global_hotkey_manager(); + } + + /// True once the listener is installed and owned by this process. + pub fn is_active(&self) -> bool { + hotkeys::is_global_hotkey_manager_active() + } +} + +async fn start_recording_with_event(event: HotkeyEvent) -> Result<(), CsError> { + optimistically_show_overlay(&event).await; + let controller = ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); + let dispatch = dispatch_hotkey_event(event, Arc::clone(&controller)).await; + compensate_orphaned_preparing(&controller).await; + dispatch.map_err(|error| CsError::Recording { + msg: error.to_string(), + }) +} + +async fn dispatch_hotkey_event( + event: HotkeyEvent, + controller: Arc, +) -> anyhow::Result<()> { + match event { + HotkeyEvent::Hold { + action, + mode, + force_ai, + } => { + let mapped_action = match action { + HoldAction::Down => HotkeyAction::Down, + HoldAction::Up => HotkeyAction::Up, + }; + let input = HotkeyInput { + key_type: HotkeyType::Hold, + action: mapped_action, + assistive: !matches!(mode, HoldMode::Raw), + hold_mode: mode, + force_raw: matches!(mode, HoldMode::Raw) && !force_ai, + force_ai, + }; + controller.handle_hotkey_event(input).await?; + } + HotkeyEvent::HoldUpdate { mode, force_ai } => { + let input = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Press, + assistive: !matches!(mode, HoldMode::Raw), + hold_mode: mode, + force_raw: matches!(mode, HoldMode::Raw) && !force_ai, + force_ai, + }; + controller.handle_hotkey_event(input).await?; + } + HotkeyEvent::ToggleNormal => { + let input = HotkeyInput { + key_type: HotkeyType::Toggle, + action: HotkeyAction::Press, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: false, + force_ai: true, + }; + controller.handle_hotkey_event(input).await?; + } + HotkeyEvent::ToggleRaw => { + let input = HotkeyInput { + key_type: HotkeyType::Toggle, + action: HotkeyAction::Press, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + controller.handle_hotkey_event(input).await?; + } + HotkeyEvent::ToggleAssistive => { + let input = HotkeyInput { + key_type: HotkeyType::Toggle, + action: HotkeyAction::Press, + assistive: true, + hold_mode: HoldMode::Raw, + force_raw: false, + force_ai: false, + }; + controller.handle_hotkey_event(input).await?; + } + HotkeyEvent::DoubleTapBlocked { gesture, reason } => { + let body = format!( + "{} was detected, but {}.", + gesture.label(), + reason.message() + ); + eprintln!("Hotkey double-tap blocked: {body}"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod dispatch_tests { + use super::*; + use codescribe::os::hotkeys::{DoubleTapBlockReason, DoubleTapGesture}; + + #[tokio::test] + #[serial_test::serial] + async fn blocked_double_tap_does_not_publish_tray_conflict() { + tray_status::update_tray_status(TrayStatus::Idle); + + let controller = Arc::new(RecordingController::new_without_keychain()); + dispatch_hotkey_event( + HotkeyEvent::DoubleTapBlocked { + gesture: DoubleTapGesture::LeftOption, + reason: DoubleTapBlockReason::ModifierComboActive, + }, + controller, + ) + .await + .expect("blocked double-tap dispatch should not fail"); + + assert_eq!(tray_status::current_tray_status(), TrayStatus::Idle); + } +} + +// =========================================================================== +// Mode-binding configuration surface (B0) +// +// The hotkey ENGINE — mode-first bindings, seeded at launch and live-reloaded on +// every settings write — already exists after Wave A3. What was missing is a +// Settings editor: read the current per-mode bindings, propose a change, validate +// it for conflicts, and persist it so the running CGEventTap honours it WITHOUT +// an app restart. +// +// Writes go through the core's first-class `UserSettings::set_mode_binding` +// (mode bindings are NOT a `save_to_env` router key, so `update_config` can't +// carry them), then re-apply the hotkey atomics via the SAME `apply_hotkey_config` +// path `CodescribeConfig::update_config` uses — preserving A3 live-reload. +// Conflict validation reuses the revived `shortcut_registry` gem +// (`detect_hotkey_conflicts` + the informational `fn_tap_intercept_note`). +// =========================================================================== + +/// The three first-class work modes, mirrored from `codescribe_core::config::WorkMode`. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsWorkMode { + Dictation, + Formatting, + Assistive, +} + +impl From for CsWorkMode { + fn from(mode: WorkMode) -> Self { + match mode { + WorkMode::Dictation => CsWorkMode::Dictation, + WorkMode::Formatting => CsWorkMode::Formatting, + WorkMode::Assistive => CsWorkMode::Assistive, + } + } +} + +impl From for WorkMode { + fn from(mode: CsWorkMode) -> Self { + match mode { + CsWorkMode::Dictation => WorkMode::Dictation, + CsWorkMode::Formatting => WorkMode::Formatting, + CsWorkMode::Assistive => WorkMode::Assistive, + } + } +} + +/// A normalized gesture a work mode can bind to, mirrored from +/// `codescribe_core::config::ShortcutBinding`. This is a CLOSED set — the Settings +/// picker offers exactly these, matching `docs/HOTKEYS_CONTRACT.md`. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsShortcutBinding { + Disabled, + HoldFn, + HoldCtrl, + HoldCtrlAlt, + HoldCtrlShift, + HoldCtrlCmd, + DoubleCtrl, + DoubleLeftOption, + DoubleRightOption, +} + +impl From for CsShortcutBinding { + fn from(binding: ShortcutBinding) -> Self { + match binding { + ShortcutBinding::Disabled => CsShortcutBinding::Disabled, + ShortcutBinding::HoldFn => CsShortcutBinding::HoldFn, + ShortcutBinding::HoldCtrl => CsShortcutBinding::HoldCtrl, + ShortcutBinding::HoldCtrlAlt => CsShortcutBinding::HoldCtrlAlt, + ShortcutBinding::HoldCtrlShift => CsShortcutBinding::HoldCtrlShift, + ShortcutBinding::HoldCtrlCmd => CsShortcutBinding::HoldCtrlCmd, + ShortcutBinding::DoubleCtrl => CsShortcutBinding::DoubleCtrl, + ShortcutBinding::DoubleLeftOption => CsShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption => CsShortcutBinding::DoubleRightOption, + } + } +} + +impl From for ShortcutBinding { + fn from(binding: CsShortcutBinding) -> Self { + match binding { + CsShortcutBinding::Disabled => ShortcutBinding::Disabled, + CsShortcutBinding::HoldFn => ShortcutBinding::HoldFn, + CsShortcutBinding::HoldCtrl => ShortcutBinding::HoldCtrl, + CsShortcutBinding::HoldCtrlAlt => ShortcutBinding::HoldCtrlAlt, + CsShortcutBinding::HoldCtrlShift => ShortcutBinding::HoldCtrlShift, + CsShortcutBinding::HoldCtrlCmd => ShortcutBinding::HoldCtrlCmd, + CsShortcutBinding::DoubleCtrl => ShortcutBinding::DoubleCtrl, + CsShortcutBinding::DoubleLeftOption => ShortcutBinding::DoubleLeftOption, + CsShortcutBinding::DoubleRightOption => ShortcutBinding::DoubleRightOption, + } + } +} + +/// One work mode's current binding, with display labels sourced from the core so +/// the Settings UI never re-invents copy that lives in `HOTKEYS_CONTRACT`. +#[derive(uniffi::Record, Debug, Clone)] +pub struct CsModeBinding { + pub mode: CsWorkMode, + pub mode_label: String, + pub mode_description: String, + pub binding: CsShortcutBinding, + pub binding_label: String, +} + +/// One selectable gesture for the Settings picker (id + display label). +#[derive(uniffi::Record, Debug, Clone)] +pub struct CsBindingOption { + pub binding: CsShortcutBinding, + pub label: String, +} + +/// One detected conflict for a candidate binding set. `blocking` conflicts must be +/// resolved before a save is allowed; non-blocking entries are informational +/// (e.g. the macOS Fn-tap intercept note). +#[derive(uniffi::Record, Debug, Clone)] +pub struct CsHotkeyConflict { + pub gesture_label: String, + pub message: String, + pub blocking: bool, +} + +const ALL_WORK_MODES: [WorkMode; 3] = [ + WorkMode::Dictation, + WorkMode::Formatting, + WorkMode::Assistive, +]; + +const ALL_SHORTCUT_BINDINGS: [ShortcutBinding; 9] = [ + ShortcutBinding::Disabled, + ShortcutBinding::HoldFn, + ShortcutBinding::HoldCtrl, + ShortcutBinding::HoldCtrlAlt, + ShortcutBinding::HoldCtrlShift, + ShortcutBinding::HoldCtrlCmd, + ShortcutBinding::DoubleCtrl, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, +]; + +fn build_mode_binding(mode: WorkMode, binding: ShortcutBinding) -> CsModeBinding { + CsModeBinding { + mode: mode.into(), + mode_label: mode.label().to_string(), + mode_description: mode.description().to_string(), + binding: binding.into(), + binding_label: binding.label().to_string(), + } +} + +/// Re-seed the live hotkey detector atomics from persisted settings after a +/// binding write. Identical to `CodescribeConfig::update_config`'s reload step, so +/// mode-binding edits take effect on the running CGEventTap without a restart. +fn reload_hotkey_runtime_after_write() { + // Binding-only reload: never populate the Keychain (would prompt for a + // password on every mode-binding save even though bindings need none). + hotkeys::apply_hotkey_config(&Config::load_without_keychain()); +} + +/// Decide whether a permission-grant re-arm should rebuild the CGEventTap. +/// +/// Rebuild only when the tap is NOT already live (dedup: it is process-global and +/// survives TCC re-checks, so re-arming a running tap would needlessly tear it +/// down) AND both permissions that gate `CGEventTapCreate` are granted (otherwise +/// the rebuild would fail again and churn). Pure so it is unit-testable without a +/// live tap or real TCC grants. +fn should_rearm_hotkey_tap( + already_active: bool, + accessibility: PermissionStatus, + input_monitoring: PermissionStatus, +) -> bool { + !already_active + && accessibility == PermissionStatus::Granted + && input_monitoring == PermissionStatus::Granted +} + +#[uniffi::export] +impl CodescribeHotkeys { + /// Re-arm the global CGEventTap after a first-run permission grant, without + /// an app restart. The tap reads Accessibility / Input Monitoring only when + /// it is created, so a grant made in System Settings after launch otherwise + /// leaves every hotkey dead until the app is relaunched (the "TCC fresh-grant + /// dance"). + /// + /// Idempotent and safe to call on every permission Refresh: a no-op when the + /// tap is already live (dedup — CGEventTap survives TCC re-checks) or when the + /// two gating permissions are not both granted yet. Returns whether hotkeys + /// are live after the call. + pub fn rearm_after_permission_grant(&self) -> bool { + let already_active = hotkeys::is_global_hotkey_manager_active(); + if !should_rearm_hotkey_tap( + already_active, + check_accessibility(), + check_input_monitoring(), + ) { + return already_active; + } + match hotkeys::refresh_global_hotkey_manager() { + Ok(()) => true, + Err(error) => { + eprintln!("Hotkey re-arm after permission grant failed: {error}"); + false + } + } + } + + /// Current per-mode bindings (Dictation / Formatting / Assistive), normalized + /// against defaults so every mode is always present. Reads on-disk truth. + pub fn get_mode_bindings(&self) -> Vec { + let settings = UserSettings::load(); + ALL_WORK_MODES + .iter() + .map(|&mode| build_mode_binding(mode, settings.mode_binding_for(mode))) + .collect() + } + + /// The closed set of gestures a mode can bind to, with display labels. Drives + /// the Settings picker (no free-form key capture — the binding space is a + /// fixed enum, see `HOTKEYS_CONTRACT`). + pub fn available_bindings(&self) -> Vec { + ALL_SHORTCUT_BINDINGS + .iter() + .map(|&binding| CsBindingOption { + binding: binding.into(), + label: binding.label().to_string(), + }) + .collect() + } + + /// Persist one mode's binding through the core's canonical `set_mode_binding` + /// contract, then live-reload the detector atomics. + pub fn set_mode_binding( + &self, + mode: CsWorkMode, + binding: CsShortcutBinding, + ) -> Result<(), CsError> { + let mut settings = UserSettings::load(); + settings.set_mode_binding(mode.into(), binding.into()); + reload_hotkey_runtime_after_write(); + Ok(()) + } + + /// Clear all custom bindings back to the built-in defaults (Dictation=Hold Fn, + /// Formatting=Double Left Option, Assistive=Double Right Option) and reload. + pub fn reset_bindings_to_defaults(&self) -> Result<(), CsError> { + let mut settings = UserSettings::load(); + // `None` normalizes to `default_mode_bindings()` on the next read, so this + // is the canonical "reset" without hardcoding the default list twice. + settings.mode_bindings = None; + settings.save().map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + reload_hotkey_runtime_after_write(); + Ok(()) + } + + /// Validate a candidate binding set WITHOUT persisting it. Returns every + /// detected conflict via the revived `shortcut_registry` (internal reachability + /// collisions + macOS symbolic-hotkey collisions), plus the informational Fn + /// tap-intercept note when relevant. Callers gate "save" on zero `blocking` + /// entries. + pub fn validate_bindings(&self, candidate: Vec) -> Vec { + let mode_bindings: Vec = candidate + .iter() + .map(|entry| ModeBinding { + mode: entry.mode.into(), + binding: entry.binding.into(), + }) + .collect(); + let settings = UserSettings { + mode_bindings: Some(mode_bindings), + ..Default::default() + }; + + let mut conflicts: Vec = detect_hotkey_conflicts(&settings) + .into_iter() + .map(|conflict| CsHotkeyConflict { + gesture_label: conflict.gesture.label().to_string(), + message: conflict.message, + blocking: true, + }) + .collect(); + + if let Some(note) = fn_tap_intercept_note(&settings) { + conflicts.push(CsHotkeyConflict { + gesture_label: "Hold Fn/Globe".to_string(), + message: note.to_string(), + blocking: false, + }); + } + + conflicts + } +} + +#[cfg(test)] +mod mode_binding_tests { + use super::*; + use serial_test::serial; + use std::sync::Mutex; + + // Serializes the CODESCRIBE_DATA_DIR-mutating test below within this module. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn work_mode_ffi_round_trips() { + for mode in ALL_WORK_MODES { + let cs: CsWorkMode = mode.into(); + assert_eq!(WorkMode::from(cs), mode); + } + } + + #[test] + fn rearm_gate_rebuilds_only_when_inactive_and_fully_granted() { + use PermissionStatus::{Denied, Granted, NotDetermined}; + + // The one case that arms: tap not yet live, both gating perms granted. + assert!(should_rearm_hotkey_tap(false, Granted, Granted)); + + // Dedup: an already-live tap is never torn down, even fully granted. + assert!(!should_rearm_hotkey_tap(true, Granted, Granted)); + + // Missing either gating permission must not trigger a doomed rebuild. + assert!(!should_rearm_hotkey_tap(false, Denied, Granted)); + assert!(!should_rearm_hotkey_tap(false, Granted, Denied)); + assert!(!should_rearm_hotkey_tap( + false, + NotDetermined, + NotDetermined + )); + + // Already active + missing perms is still a no-op (both guards agree). + assert!(!should_rearm_hotkey_tap(true, Denied, Denied)); + } + + #[test] + fn shortcut_binding_ffi_round_trips() { + for binding in ALL_SHORTCUT_BINDINGS { + let cs: CsShortcutBinding = binding.into(); + assert_eq!(ShortcutBinding::from(cs), binding); + } + } + + #[test] + fn available_bindings_cover_the_closed_set() { + let options = CodescribeHotkeys::new().available_bindings(); + assert_eq!(options.len(), ALL_SHORTCUT_BINDINGS.len()); + for (option, expected) in options.iter().zip(ALL_SHORTCUT_BINDINGS.iter()) { + assert_eq!(ShortcutBinding::from(option.binding), *expected); + assert!(!option.label.is_empty()); + } + } + + fn candidate( + dictation: CsShortcutBinding, + formatting: CsShortcutBinding, + assistive: CsShortcutBinding, + ) -> Vec { + vec![ + build_mode_binding(WorkMode::Dictation, dictation.into()), + build_mode_binding(WorkMode::Formatting, formatting.into()), + build_mode_binding(WorkMode::Assistive, assistive.into()), + ] + } + + #[test] + fn validate_flags_internal_reachability_conflict_as_blocking() { + // Dictation=DoubleCtrl steals the toggle path from Formatting=DoubleLeftOption. + let conflicts = CodescribeHotkeys::new().validate_bindings(candidate( + CsShortcutBinding::DoubleCtrl, + CsShortcutBinding::DoubleLeftOption, + CsShortcutBinding::Disabled, + )); + assert!( + conflicts.iter().any(|c| c.blocking), + "a known reachability collision must surface a blocking conflict" + ); + } + + #[test] + fn validate_is_clean_for_a_safe_hold_only_profile() { + // HoldCtrl never collides with macOS symbolic hotkeys and Disabled toggles + // add nothing — a deterministic zero-conflict candidate on any machine. + let conflicts = CodescribeHotkeys::new().validate_bindings(candidate( + CsShortcutBinding::HoldCtrl, + CsShortcutBinding::Disabled, + CsShortcutBinding::Disabled, + )); + assert!( + conflicts.is_empty(), + "safe hold-only profile must validate clean, got {conflicts:?}" + ); + } + + #[test] + #[serial] + fn set_mode_binding_persists_and_reads_back_through_the_bridge() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let dir = std::env::temp_dir().join(format!("cs_bridge_hotkeys_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create isolated data dir"); + let previous = std::env::var("CODESCRIBE_DATA_DIR").ok(); + // SAFETY: serialized by ENV_LOCK; env is restored before the lock drops. + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &dir) }; + + let hotkeys = CodescribeHotkeys::new(); + hotkeys + .set_mode_binding(CsWorkMode::Dictation, CsShortcutBinding::HoldCtrlAlt) + .expect("set_mode_binding"); + + let bindings = hotkeys.get_mode_bindings(); + let dictation = bindings + .iter() + .find(|b| b.mode == CsWorkMode::Dictation) + .expect("dictation binding present"); + assert_eq!(dictation.binding, CsShortcutBinding::HoldCtrlAlt); + + // Reset restores defaults through the same path. + hotkeys + .reset_bindings_to_defaults() + .expect("reset_bindings_to_defaults"); + let after_reset = hotkeys.get_mode_bindings(); + let dictation_reset = after_reset + .iter() + .find(|b| b.mode == CsWorkMode::Dictation) + .expect("dictation binding present after reset"); + assert_eq!(dictation_reset.binding, CsShortcutBinding::HoldFn); + + // SAFETY: serialized by ENV_LOCK. + unsafe { + match previous { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + } + let _ = std::fs::remove_dir_all(&dir); + } +} + +// =========================================================================== +// Orphaned optimistic-overlay compensation (CUT P0a) +// +// Contract under test: any dispatch that shows the optimistic "preparing" +// overlay is guaranteed a terminal listener event. When the controller ends the +// dispatch back at Idle WITHOUT a StateChange broadcast — the shape produced by +// the quick hold-release cancel (`cancel_pending_hold_start`), the start-failure +// reset (`reset_session_after_start_failure` → `set_state(Idle)` at old==Idle), +// and the no-op re-check dispatch — `compensate_orphaned_preparing` emits exactly +// one compensating `on_recording_stopped`. When a real transition occurred the +// broadcast forwarder owns the terminal event and the compensator must NOT +// double-fire. +// =========================================================================== +#[cfg(test)] +mod preparing_compensation_tests { + use super::*; + use serial_test::serial; + use std::sync::atomic::AtomicUsize; + use tokio::sync::Mutex as AsyncMutex; + + // Serializes the process-global PREPARING_PENDING / shared_listener / + // shared_controller these tests mutate, so parallel runs don't interleave. + // Async-aware so the guard can be held across the `.await` points below. + static TEST_LOCK: AsyncMutex<()> = AsyncMutex::const_new(()); + + #[derive(Default)] + struct RecordingLifecycleListener { + preparing: AtomicUsize, + started: AtomicUsize, + stopped: AtomicUsize, + finalising: AtomicUsize, + } + + impl RecordingLifecycleListener { + fn preparing(&self) -> usize { + self.preparing.load(Ordering::SeqCst) + } + fn started(&self) -> usize { + self.started.load(Ordering::SeqCst) + } + fn stopped(&self) -> usize { + self.stopped.load(Ordering::SeqCst) + } + fn finalising(&self) -> usize { + self.finalising.load(Ordering::SeqCst) + } + } + + impl CsTranscriptionListener for RecordingLifecycleListener { + fn on_recording_preparing(&self) { + self.preparing.fetch_add(1, Ordering::SeqCst); + } + fn on_recording_started(&self) { + self.started.fetch_add(1, Ordering::SeqCst); + } + fn on_recording_stopped(&self) { + self.stopped.fetch_add(1, Ordering::SeqCst); + } + fn on_recording_finalising(&self) { + self.finalising.fetch_add(1, Ordering::SeqCst); + } + fn on_preview(&self, _text: String) {} + fn on_correction(&self, _text: String, _previous_text: String) {} + fn on_final(&self, _utterance_id: u64, _text: String) {} + fn on_replace_range( + &self, + _utterance_id: u64, + _start: u64, + _end: u64, + _text: String, + _source: crate::recording::CsLayerSource, + ) { + } + fn on_insert_annotation( + &self, + _utterance_id: u64, + _position: u64, + _text: String, + _kind: CsAnnotationKind, + ) { + } + fn on_session_finalised(&self, _session_id: String, _layer_summary: CsLayerSummary) {} + fn on_final_transcript_ready(&self, _text: String) {} + fn on_vad_active(&self, _active: bool) {} + fn on_no_speech(&self, _reason: String) {} + fn on_error(&self, _message: String) {} + } + + /// Install a fresh capturing listener + an Idle controller into the shared + /// process stores and clear the pending flag. Returns both so the test can + /// assert on the listener and pass the controller to the compensator. + fn install() -> (Arc, Arc) { + PREPARING_PENDING.store(false, Ordering::SeqCst); + let listener = Arc::new(RecordingLifecycleListener::default()); + *shared_listener().write().unwrap_or_else(|e| e.into_inner()) = + Some(Arc::clone(&listener) as Arc); + let controller = Arc::new(RecordingController::new_without_keychain()); + *shared_controller() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&controller)); + (listener, controller) + } + + fn teardown() { + *shared_listener().write().unwrap_or_else(|e| e.into_inner()) = None; + *shared_controller() + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + PREPARING_PENDING.store(false, Ordering::SeqCst); + } + + /// Paths 1 & 2 (quick hold-release cancel, start-failure reset): preparing was + /// shown, the controller ended the dispatch at Idle with no broadcast → the + /// compensator must emit exactly one terminal stop. + #[tokio::test] + #[serial] + async fn orphaned_preparing_at_idle_gets_a_compensating_stop() { + let _guard = TEST_LOCK.lock().await; + let (listener, controller) = install(); + + // The optimistic overlay is shown for a start gesture at Idle. + optimistically_show_overlay(&HotkeyEvent::ToggleNormal).await; + assert_eq!(listener.preparing(), 1, "preparing overlay must be shown"); + assert!(PREPARING_PENDING.load(Ordering::SeqCst), "flag armed"); + + // The dispatch left the controller at Idle without any StateChange + // (the shape of cancel_pending_hold_start / start-failure reset). + compensate_orphaned_preparing(&controller).await; + + assert_eq!( + listener.stopped(), + 1, + "orphaned preparing must receive one terminal stop" + ); + assert!(!PREPARING_PENDING.load(Ordering::SeqCst), "flag cleared"); + teardown(); + } + + /// The compensator is inert when no optimistic overlay was shown: an ordinary + /// stop dispatch (controller back at Idle, but flag never armed) must not have a + /// spurious extra stop synthesized on top of the broadcast one. + #[tokio::test] + #[serial] + async fn no_preparing_shown_means_no_compensating_stop() { + let _guard = TEST_LOCK.lock().await; + let (listener, controller) = install(); + + compensate_orphaned_preparing(&controller).await; + + assert_eq!(listener.preparing(), 0); + assert_eq!( + listener.stopped(), + 0, + "no preparing was pending, so nothing to compensate" + ); + teardown(); + } + + /// Idempotency: a second compensator pass (e.g. the FFI `start_recording` path + /// racing the hotkey spawn) must not emit a second stop for the same overlay. + #[tokio::test] + #[serial] + async fn compensation_is_idempotent_across_repeated_passes() { + let _guard = TEST_LOCK.lock().await; + let (listener, controller) = install(); + + optimistically_show_overlay(&HotkeyEvent::ToggleNormal).await; + compensate_orphaned_preparing(&controller).await; + compensate_orphaned_preparing(&controller).await; + + assert_eq!( + listener.stopped(), + 1, + "the compensating stop must fire at most once per preparing" + ); + teardown(); + } + + /// Path 3 (no-op dispatch) / genuine start: when a real transition's broadcast + /// already resolved the preparing (forwarder cleared the flag and emitted + /// started), the compensator must not double-fire a stop on top of it. + #[tokio::test] + #[serial] + async fn forwarder_resolved_preparing_is_not_double_stopped() { + let _guard = TEST_LOCK.lock().await; + let (listener, controller) = install(); + + optimistically_show_overlay(&HotkeyEvent::ToggleNormal).await; + assert!(PREPARING_PENDING.load(Ordering::SeqCst)); + + // Simulate the broadcast forwarder observing a real Idle→rec_toggle + // transition: it emits started and clears the pending flag. + forward_event_to_listener( + IpcEventPayload::StateChange { + from: "idle".to_string(), + to: "rec_toggle".to_string(), + }, + Arc::clone(&listener) as Arc, + ); + assert_eq!(listener.started(), 1, "forwarder emitted started"); + assert!( + !PREPARING_PENDING.load(Ordering::SeqCst), + "forwarder cleared flag" + ); + + // A late compensator pass (controller now back at Idle) must stay silent — + // the started already resolved the overlay. + compensate_orphaned_preparing(&controller).await; + assert_eq!( + listener.stopped(), + 0, + "a forwarder-resolved preparing must not be double-stopped" + ); + teardown(); + } + + /// The `Busy` StateChange (final transcription pass, after capture ends) routes + /// to `on_recording_finalising` — the native-path signal that lets the overlay + /// enter its "transcribing" phase — and NOT to started/stopped. The terminal + /// `idle` still maps to `on_recording_stopped`, so the sequence a real + /// hold-release / toggle stop produces (rec_hold → busy → idle) yields exactly + /// one finalising then one stopped. + #[tokio::test] + #[serial] + async fn busy_state_routes_to_finalising_then_idle_to_stopped() { + let _guard = TEST_LOCK.lock().await; + let (listener, _controller) = install(); + let dyn_listener = || Arc::clone(&listener) as Arc; + + forward_event_to_listener( + IpcEventPayload::StateChange { + from: "rec_hold".to_string(), + to: "busy".to_string(), + }, + dyn_listener(), + ); + assert_eq!(listener.finalising(), 1, "busy → finalising"); + assert_eq!(listener.stopped(), 0, "busy must not fire stopped"); + assert_eq!(listener.started(), 0, "busy must not fire started"); + + forward_event_to_listener( + IpcEventPayload::StateChange { + from: "busy".to_string(), + to: "idle".to_string(), + }, + dyn_listener(), + ); + assert_eq!(listener.stopped(), 1, "idle → stopped"); + assert_eq!(listener.finalising(), 1, "idle must not re-fire finalising"); + teardown(); + } + + /// A repeated `Busy` broadcast forwards a second `on_recording_finalising`; the + /// idempotency that matters (a no-op re-entry) lives in the Swift handler, so + /// the forwarder stays a thin, stateless router here. + #[tokio::test] + #[serial] + async fn repeated_busy_forwards_each_finalising() { + let _guard = TEST_LOCK.lock().await; + let (listener, _controller) = install(); + let dyn_listener = || Arc::clone(&listener) as Arc; + + for _ in 0..2 { + forward_event_to_listener( + IpcEventPayload::StateChange { + from: "rec_hold".to_string(), + to: "busy".to_string(), + }, + dyn_listener(), + ); + } + assert_eq!(listener.finalising(), 2, "each busy forwards a finalising"); + assert!( + !PREPARING_PENDING.load(Ordering::SeqCst), + "busy must not arm the preparing flag" + ); + teardown(); + } +} diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs new file mode 100644 index 00000000..67d6bedf --- /dev/null +++ b/bridge/src/lib.rs @@ -0,0 +1,116 @@ +//! UniFFI bridge over the LIVING codescribe engine. +//! +//! Strategy (Option B): do NOT re-port the engine. Wrap the real, already-working +//! `codescribe_core` + `codescribe` (provider/tools/config/stt) in a thin UniFFI +//! surface so the new SwiftUI app can drive real streaming agent replies, STT, and +//! config. Mirrors the UniFFI pattern proved in vista-kernel's `qube-ffi`. +//! +//! Layout (W3 cut #0 — split for conflict-free parallel work): +//! - `agent` — CodescribeAgent + CsAgentListener (streaming chat) [live] +//! - `agent_status` — CodescribeAgentStatus (read-only readiness + MCP status) [W-C1] +//! - `mcp_admin` — CodescribeMcpAdmin (add/update/remove/test MCP servers) [W-C4] +//! - `config` — CodescribeConfig (settings/prompts/keychain/onboarding) [W3 #1] +//! - `recording` — CodescribeDictation + CsTranscriptionListener (STT) [W3 #3] +//! - `threads` — CodescribeThreads (thread persistence + history) [W3 #5] +//! +//! Shared cross-slice types (`CsError`, `CsLanguage`) live here so each submodule +//! references one canonical definition. + +uniffi::setup_scaffolding!(); + +mod agent; +mod agent_delivery; +mod agent_status; +mod config; +mod hotkeys; +mod mcp_admin; +mod notes; +mod recording; +mod threads; +mod tray_status; + +pub use agent::{CodescribeAgent, CsAgentListener}; +pub use agent_delivery::CsAgentDeliveryListener; +pub use hotkeys::CodescribeHotkeys; +pub use tray_status::{ + CodescribeTrayStatus, CsTrayStatusKind, CsTrayStatusListener, CsTrayStatusPayload, + CsTrayStatusTone, +}; + +/// Error surfaced across the FFI boundary. One enum for every slice: +/// `Agent` (chat/provider), `Config` (settings/keychain/prompt I/O), +/// `Recording` (STT/audio). +#[derive(uniffi::Error, Debug)] +pub enum CsError { + Agent { msg: String }, + Config { msg: String }, + Recording { msg: String }, +} + +impl std::fmt::Display for CsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CsError::Agent { msg } | CsError::Config { msg } | CsError::Recording { msg } => { + write!(f, "{msg}") + } + } + } +} + +impl std::error::Error for CsError {} + +impl From for CsError { + fn from(error: anyhow::Error) -> Self { + CsError::Agent { + msg: error.to_string(), + } + } +} + +impl From for CsError { + fn from(error: std::io::Error) -> Self { + CsError::Config { + msg: error.to_string(), + } + } +} + +/// Language shared across the config (whisper language setting) and recording +/// (dictation language) surfaces. Maps 1:1 to `codescribe_core::config::Language`. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsLanguage { + Auto, + Polish, + English, +} + +impl From for CsLanguage { + fn from(language: codescribe_core::config::Language) -> Self { + match language { + codescribe_core::config::Language::Auto => CsLanguage::Auto, + codescribe_core::config::Language::Polish => CsLanguage::Polish, + codescribe_core::config::Language::English => CsLanguage::English, + } + } +} + +impl From for codescribe_core::config::Language { + fn from(language: CsLanguage) -> Self { + match language { + CsLanguage::Auto => codescribe_core::config::Language::Auto, + CsLanguage::Polish => codescribe_core::config::Language::Polish, + CsLanguage::English => codescribe_core::config::Language::English, + } + } +} + +impl CsLanguage { + /// Two-letter code (`"pl"` / `"en"`) as the core uses it. + pub fn as_code(&self) -> &'static str { + match self { + CsLanguage::Auto => "auto", + CsLanguage::Polish => "pl", + CsLanguage::English => "en", + } + } +} diff --git a/bridge/src/mcp_admin.rs b/bridge/src/mcp_admin.rs new file mode 100644 index 00000000..5409598b --- /dev/null +++ b/bridge/src/mcp_admin.rs @@ -0,0 +1,153 @@ +//! MCP management surface — read/write UniFFI wrapper over the core MCP config +//! store (`core/mcp/config_store.rs`). Distinct from the read-only +//! `agent_status` slice: this one MUTATES `~/.codescribe/mcp.json` (add / update +//! / remove) and can spawn a server to test it. +//! +//! Every mutation goes through the core store's atomic, unknown-field-preserving +//! writer, so a hand-edited config is never clobbered. Sync-only: the CRUD calls +//! are cheap disk I/O; `test_server` blocks on a one-shot discovery handshake +//! (bounded by a 10s timeout) run on the core's dedicated thread + runtime. + +use std::time::Duration; + +use codescribe_core::mcp::{ + McpServerSpec, McpServerSummary, add_server, list_servers, probe_server_blocking, + remove_server, update_server, +}; + +use crate::CsError; + +/// How long `test_server` waits for the full spawn + `initialize` + `tools/list` +/// handshake before giving up. +const TEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// One configured MCP server for the management list. Carries env var NAMES only +/// — secret values never cross the FFI boundary. +#[derive(uniffi::Record)] +pub struct CsMcpServer { + pub name: String, + pub command: String, + pub args: Vec, + pub env_keys: Vec, + pub enabled: bool, +} + +impl From for CsMcpServer { + fn from(summary: McpServerSummary) -> Self { + Self { + name: summary.name, + command: summary.command, + args: summary.args, + env_keys: summary.env_keys, + enabled: summary.enabled, + } + } +} + +/// Desired spawn shape from the add / edit form. Env is not edited here (secrets +/// stay file-side); an update preserves any existing `env` block. +#[derive(uniffi::Record)] +pub struct CsMcpServerInput { + pub name: String, + pub command: String, + pub args: Vec, + pub enabled: bool, +} + +impl From<&CsMcpServerInput> for McpServerSpec { + fn from(input: &CsMcpServerInput) -> Self { + Self { + name: input.name.clone(), + command: input.command.clone(), + args: input.args.clone(), + enabled: input.enabled, + } + } +} + +/// Result of testing one server: whether the handshake succeeded, the identity +/// it advertised in the `initialize` handshake (server name / version / protocol +/// — empty when the server omits them), the live tool count, and (on failure) a +/// concrete reason. Never throws — a failed test is a normal, displayable +/// outcome, not an FFI error. +#[derive(uniffi::Record)] +pub struct CsMcpTestResult { + pub ok: bool, + pub tool_count: u32, + pub server_name: String, + pub server_version: String, + pub protocol_version: String, + pub error: String, +} + +/// Read/write handle over the MCP config store. Stateless: every call re-reads +/// on-disk truth so Swift always sees current state. +#[derive(uniffi::Object, Default)] +pub struct CodescribeMcpAdmin {} + +#[uniffi::export] +impl CodescribeMcpAdmin { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self::default() + } + + /// List configured servers (sorted). A missing `mcp.json` is an empty list. + pub fn list_servers(&self) -> Result, CsError> { + let servers = list_servers().map_err(config_err)?; + Ok(servers.into_iter().map(CsMcpServer::from).collect()) + } + + /// Add a new server. Errors if the name already exists or is invalid. + pub fn add_server(&self, server: CsMcpServerInput) -> Result<(), CsError> { + add_server(&McpServerSpec::from(&server)).map_err(config_err) + } + + /// Update the named server's spawn shape, preserving its env + unknown fields. + pub fn update_server(&self, name: String, server: CsMcpServerInput) -> Result<(), CsError> { + update_server(&name, &McpServerSpec::from(&server)).map_err(config_err) + } + + /// Remove the named server. Errors if it does not exist. + pub fn remove_server(&self, name: String) -> Result<(), CsError> { + remove_server(&name).map_err(config_err) + } + + /// Spawn the named server, run the `initialize` + `tools/list` handshake, and + /// report its advertised identity + live tool count. Bounded by a 10s timeout. + /// A failed handshake is returned as `ok == false` with a reason, never as a + /// thrown error. + pub fn test_server(&self, name: String) -> CsMcpTestResult { + match probe_server_blocking(&name, TEST_TIMEOUT) { + Ok(summary) => CsMcpTestResult { + ok: true, + tool_count: summary.tool_count as u32, + server_name: summary.server_name.unwrap_or_default(), + server_version: summary.server_version.unwrap_or_default(), + protocol_version: summary.protocol_version.unwrap_or_default(), + error: String::new(), + }, + Err(error) => CsMcpTestResult::failure(error.root_cause().to_string()), + } + } +} + +impl CsMcpTestResult { + fn failure(error: String) -> Self { + Self { + ok: false, + tool_count: 0, + server_name: String::new(), + server_version: String::new(), + protocol_version: String::new(), + error, + } + } +} + +fn config_err(error: anyhow::Error) -> CsError { + CsError::Config { + msg: error.to_string(), + } +} diff --git a/bridge/src/notes.rs b/bridge/src/notes.rs new file mode 100644 index 00000000..21a75336 --- /dev/null +++ b/bridge/src/notes.rs @@ -0,0 +1,116 @@ +//! Daily-note surface — thin UniFFI wrapper over the live codescribe +//! `state::notes` store. Backs the tray's "Save last transcript" and "Save +//! selection" actions plus Notes Mode; every input funnels into the one raw +//! append sink (no paste — Notes is a brain-dump destination, not delivery). +//! +//! Sync-only (NOT tokio): every call here is cheap disk I/O or a one-shot +//! Accessibility selection read. Paths honour the same `CODESCRIBE_NOTES_DIR` / +//! `CODESCRIBE_DATA_DIR` overrides the core respects, so Swift always sees +//! on-disk truth. + +use chrono::Local; + +use codescribe_core::state::notes; + +use crate::CsError; + +/// Thin handle to the codescribe daily-notes store. Stateless: each call reads +/// or writes through the live on-disk notes dir. +#[derive(uniffi::Object)] +pub struct CodescribeNotes {} + +#[uniffi::export] +impl CodescribeNotes { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self {} + } + + /// Absolute path to the daily-notes directory (honours `CODESCRIBE_NOTES_DIR`), + /// creating it if needed. Wraps `state::notes::notes_dir`. + pub fn notes_dir(&self) -> String { + notes::notes_dir().to_string_lossy().into_owned() + } + + /// Absolute path to today's Markdown note file (may not exist yet). Wraps + /// `state::notes::today_note_path`. + pub fn today_note_path(&self) -> String { + notes::today_note_path(&Local::now()) + .to_string_lossy() + .into_owned() + } + + /// Append `text` to today's daily note and toast "Saved note". The one-shot + /// save behind the tray's "Save last transcript" action — no paste, because + /// Notes is a brain-dump destination, not delivery to the cursor. + pub fn save_text(&self, text: String) -> Result, CsError> { + if text.trim().is_empty() { + notify_toast("Nothing to save — no transcript"); + return Ok(None); + } + match notes::append_quick_note(&text, Local::now()) { + Ok(path) => { + notify_saved(&path); + Ok(Some(path.to_string_lossy().into_owned())) + } + Err(error) => { + notify_toast("Could not save note"); + Err(error.into()) + } + } + } + + /// Capture the user's current selection and append it to the daily note. + /// Prefers the real UI selection via Accessibility; only when there is none + /// does it fall back to the clipboard — which may be *stale* content, not a + /// guaranteed live selection. Returns the saved text, or `None` when there + /// was nothing to capture. + pub fn save_selection(&self) -> Result, CsError> { + const MAX_SELECTION_CHARS: usize = 500_000; + let text = codescribe::os::selection::get_selected_text(MAX_SELECTION_CHARS) + .filter(|selection| !selection.trim().is_empty()) + .or_else(|| { + codescribe::os::clipboard::get_clipboard() + .ok() + .filter(|clip| !clip.trim().is_empty()) + }); + + match text { + Some(text) => match notes::append_quick_note(&text, Local::now()) { + Ok(path) => { + notify_saved(&path); + Ok(Some(text)) + } + Err(error) => { + notify_toast("Could not save note"); + Err(error.into()) + } + }, + None => { + // Don't fail silently: tell the user there was nothing to capture. + notify_toast("Nothing to save — no selection"); + Ok(None) + } + } + } +} + +/// Best-effort "Saved note: " toast (macOS only). +#[cfg(target_os = "macos")] +fn notify_saved(path: &std::path::Path) { + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("note"); + notify_toast(&format!("Saved note: {name}")); +} + +#[cfg(not(target_os = "macos"))] +fn notify_saved(_path: &std::path::Path) {} + +/// Best-effort non-blocking toast (macOS only). +#[cfg(target_os = "macos")] +fn notify_toast(message: &str) { + codescribe::os::notifications::notify("Codescribe", message); +} + +#[cfg(not(target_os = "macos"))] +fn notify_toast(_message: &str) {} diff --git a/bridge/src/recording.rs b/bridge/src/recording.rs new file mode 100644 index 00000000..dd00dd0c --- /dev/null +++ b/bridge/src/recording.rs @@ -0,0 +1,813 @@ +//! Dictation / STT surface — thin UniFFI wrapper over the live codescribe +//! streaming recorder + Whisper singleton. Translates the engine's semantic +//! `EngineEvent` stream into a small foreign listener contract so the new +//! SwiftUI app can drive real microphone dictation and file transcription. +//! Filled by W3 cut #3 (sibling to `agent.rs`). Uses shared +//! `crate::{CsError, CsLanguage}`. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, RwLock}; +use std::time::{Duration, Instant}; + +use codescribe::os::tray_status::{self, TrayStatus}; +use codescribe_core::audio::load_audio_file; +use codescribe_core::audio::streaming_recorder::StreamingRecorder; +use codescribe_core::pipeline::contracts::{ + AnnotationKind, EngineEvent, EventSink, FileTranscriptionOptions, LayerSource, LayerSummary, +}; +use codescribe_core::stt::whisper; +use tokio::sync::Mutex; +use tracing::{info, warn}; + +use crate::{CsError, CsLanguage}; + +/// Result of a one-shot file transcription. +#[derive(uniffi::Record)] +pub struct CsTranscription { + /// Final post-processed transcript text. + pub text: String, + /// Detected (or requested) language code, e.g. `"pl"` / `"en"`. + pub language: String, +} + +/// Bridge-safe source for bounded transcript replacement events. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsLayerSource { + TailPatch, + Lexicon, + InlineLlm, + FinalBam, +} + +impl From for CsLayerSource { + fn from(source: LayerSource) -> Self { + match source { + LayerSource::TailPatch => Self::TailPatch, + LayerSource::Lexicon => Self::Lexicon, + LayerSource::InlineLlm => Self::InlineLlm, + LayerSource::FinalBam => Self::FinalBam, + } + } +} + +/// Bridge-safe annotation kind. `label` is set for paralingual annotations. +#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)] +pub struct CsAnnotationKind { + pub kind: String, + pub label: Option, +} + +impl From<&AnnotationKind> for CsAnnotationKind { + fn from(kind: &AnnotationKind) -> Self { + match kind { + AnnotationKind::HesitationPause => Self { + kind: "hesitation_pause".to_string(), + label: None, + }, + AnnotationKind::Paralingual { label } => Self { + kind: "paralingual".to_string(), + label: Some(label.clone()), + }, + } + } +} + +/// Session-end counters emitted with `SessionFinalised`. +#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)] +pub struct CsLayerSummary { + pub tail_patch_replacements: u64, + pub lexicon_replacements: u64, + pub inline_llm_replacements: u64, + pub final_bam_replacements: u64, + pub annotations_inserted: u64, +} + +impl From<&LayerSummary> for CsLayerSummary { + fn from(summary: &LayerSummary) -> Self { + Self { + tail_patch_replacements: summary.tail_patch_replacements, + lexicon_replacements: summary.lexicon_replacements, + inline_llm_replacements: summary.inline_llm_replacements, + final_bam_replacements: summary.final_bam_replacements, + annotations_inserted: summary.annotations_inserted, + } + } +} + +/// Foreign callback trait — dictation events forwarded to Swift. +/// +/// Distilled from the engine's richer `EngineEvent` stream: +/// - `on_preview` carries the latest interim/corrected utterance text +/// (replace-not-append semantics). +/// - `on_final` carries a completed (VAD-bounded) utterance together with its +/// `utterance_id`, so committed sinks can stamp the segment identity that +/// later `on_replace_range` / `on_insert_annotation` patches target. +/// - `on_vad_active` flips when speech starts/ends. +/// - `on_no_speech` fires when a session/utterance produced no usable speech. +/// - `on_error` carries recoverable engine warnings. +/// +/// The Swift side must hop these onto the main actor. +#[uniffi::export(with_foreign)] +pub trait CsTranscriptionListener: Send + Sync { + fn on_recording_preparing(&self); + fn on_recording_started(&self); + fn on_recording_stopped(&self); + /// Capture ended and the controller entered `Busy` (final transcription pass). + /// Fired BEFORE `on_recording_stopped` (which lands on the terminal Idle) so a + /// hotkey hold-release / toggle stop can show a distinct "transcribing" phase + /// instead of leaving the live-capture UI up while the final pass runs. The + /// Swift-driven Finish path enters that phase itself; this is the native-path + /// counterpart. Surfaces with no post-capture phase may leave it a no-op. + fn on_recording_finalising(&self); + fn on_preview(&self, text: String); + fn on_correction(&self, text: String, previous_text: String); + fn on_final(&self, utterance_id: u64, text: String); + fn on_replace_range( + &self, + utterance_id: u64, + start: u64, + end: u64, + text: String, + source: CsLayerSource, + ); + fn on_insert_annotation( + &self, + utterance_id: u64, + position: u64, + text: String, + kind: CsAnnotationKind, + ); + fn on_session_finalised(&self, session_id: String, layer_summary: CsLayerSummary); + /// Authoritative post-stop transcript (LocalFinalPass `final_formatted_text`): + /// the SAME clean text that is pasted/delivered and written to history. Surfaces + /// fire it once per dictation stop so the overlay FINAL matches delivery/Copy. + fn on_final_transcript_ready(&self, text: String); + fn on_vad_active(&self, active: bool); + fn on_no_speech(&self, reason: String); + fn on_error(&self, message: String); +} + +/// Accumulates finalized utterance text for the composer voice-note return, +/// mirroring core's crate-private `SessionTranscriptCollector` discipline +/// (skip empty, single-space join, trimmed). The same `CsEventSink` that +/// forwards engine events to Swift feeds each `UtteranceFinal` here, so +/// `stop_recording` can compose the return AFTER the streaming session's +/// completion signal fires — reusing existing finalization, not a new channel. +#[derive(Default)] +struct ComposerTranscript { + text: StdMutex, + utterances: AtomicU64, +} + +impl ComposerTranscript { + /// Append one finalized utterance (Layer 0 committed text). Empty/whitespace + /// finals are ignored so trailing silence never widens the transcript. + fn append_final(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let mut buf = self.text.lock().unwrap_or_else(|e| e.into_inner()); + if !buf.is_empty() { + buf.push(' '); + } + buf.push_str(trimmed); + self.utterances.fetch_add(1, Ordering::Relaxed); + } + + /// Current composed transcript and the number of utterances that fed it. + fn snapshot(&self) -> (String, u64) { + let text = self.text.lock().unwrap_or_else(|e| e.into_inner()).clone(); + (text, self.utterances.load(Ordering::Relaxed)) + } +} + +/// Wait budget for `stop_recording` to compose its return: it covers BOTH the +/// streaming drain AND the delivery-grade final pass over the saved WAV. +/// Proportional to recording length (STT work scales with audio) but clamped so +/// the composer UI never hangs indefinitely if the scheduler stalls (e.g. +/// thermal throttling): the floor covers a cold commit + short final pass, the +/// cap bounds the worst case. On exhaustion the streaming splice is returned as +/// a fallback, so overrun degrades quality, never correctness. +fn compose_stop_timeout(elapsed: Duration) -> Duration { + const FLOOR: Duration = Duration::from_secs(8); + const CAP: Duration = Duration::from_secs(30); + elapsed.mul_f32(0.6).clamp(FLOOR, CAP) +} + +/// Which transcript `stop_recording` returned, for the stop breadcrumb. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ComposerTranscriptSource { + /// Delivery-grade whole-WAV final pass (matches the hotkey/overlay quality). + FinalPass, + /// Spliced streaming `UtteranceFinal` chunks (final pass unavailable/empty). + StreamingFallback, +} + +impl ComposerTranscriptSource { + fn label(self) -> &'static str { + match self { + Self::FinalPass => "final_pass", + Self::StreamingFallback => "streaming_fallback", + } + } +} + +/// Pick the composer return: the whole-WAV final pass wins whenever it produced +/// non-empty text (it decodes the recording as one continuous utterance, so it +/// avoids the mid-word cut artifacts of the streaming splice); otherwise the +/// streaming accumulation is the fallback authority. Both inputs are trimmed. +fn select_composer_transcript( + final_pass: Option<&str>, + streaming: &str, +) -> (String, ComposerTranscriptSource) { + if let Some(text) = final_pass { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return (trimmed.to_string(), ComposerTranscriptSource::FinalPass); + } + } + ( + streaming.trim().to_string(), + ComposerTranscriptSource::StreamingFallback, + ) +} + +/// Run the delivery-grade final pass over the saved WAV, mirroring the +/// controller's toggle-stop adjudicator (`transcribe_file_verdict` with default +/// options). Blocking Whisper work runs off the async runtime and is bounded by +/// the shared `deadline`; any failure/timeout/absent-WAV yields `None` so the +/// caller falls back to the streaming splice. +async fn run_final_pass( + audio_path: Option, + language: Option, + deadline: tokio::time::Instant, +) -> Option { + let path = audio_path?; + let job = tokio::task::spawn_blocking(move || { + whisper::transcribe_file_verdict( + &path, + language.as_deref(), + FileTranscriptionOptions::default(), + ) + }); + match tokio::time::timeout_at(deadline, job).await { + Ok(Ok(Ok(verdict))) => Some(verdict.text), + Ok(Ok(Err(e))) => { + warn!(target: "composer-dictation", error = %e, "final pass transcription failed"); + None + } + Ok(Err(e)) => { + warn!(target: "composer-dictation", error = %e, "final pass task join failed"); + None + } + Err(_elapsed) => { + warn!(target: "composer-dictation", "final pass timed out; using streaming fallback"); + None + } + } +} + +/// Internal `EventSink` adapter (NOT exposed across FFI). Lives between the +/// core streaming pipeline and the foreign `CsTranscriptionListener`, +/// translating every `EngineEvent` variant into the appropriate listener call. +struct CsEventSink { + listener: Arc, + /// Composer-side accumulator: `stop_recording` reads its snapshot for the + /// return value (the Swift `on_final` callback is a no-op on this path). + transcript: Arc, +} + +impl EventSink for CsEventSink { + fn on_event(&self, event: &EngineEvent) { + match event { + EngineEvent::VadStart { .. } => self.listener.on_vad_active(true), + EngineEvent::VadEnd { .. } => self.listener.on_vad_active(false), + EngineEvent::NoSpeech { reason } => self.listener.on_no_speech(reason.clone()), + EngineEvent::Preview { text, .. } => self.listener.on_preview(text.clone()), + EngineEvent::Correction { + text, + previous_text, + .. + } => self + .listener + .on_correction(text.clone(), previous_text.clone()), + EngineEvent::UtteranceFinal { + utterance_id, text, .. + } => { + // Compose the composer return here: the streaming recorder's own + // transcript buffer is never filled on this path. + self.transcript.append_final(text); + self.listener.on_final(*utterance_id, text.clone()); + } + EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + source, + } => self.listener.on_replace_range( + *utterance_id, + *start as u64, + *end as u64, + text.clone(), + (*source).into(), + ), + EngineEvent::InsertAnnotation { + utterance_id, + position, + text, + kind, + } => self.listener.on_insert_annotation( + *utterance_id, + *position as u64, + text.clone(), + kind.into(), + ), + EngineEvent::SessionFinalised { + session_id, + layer_summary, + } => self + .listener + .on_session_finalised(session_id.clone(), layer_summary.into()), + // Recoverable engine warning — surface as a non-fatal error string. + EngineEvent::Warning { code, message } => { + tray_status::update_tray_status(TrayStatus::Error); + self.listener.on_error(format!("{code}: {message}")) + } + // Engine-internal bookkeeping (dropped content, session stats) has no + // listener surface; intentionally ignored. + EngineEvent::Drop { .. } | EngineEvent::Stats { .. } => {} + } + } +} + +/// Resolve the Whisper language hint for a manual voice-note session. +/// +/// An explicit caller choice wins; `None` falls back to the persisted +/// `WHISPER_LANGUAGE` setting (mirroring the hotkey path in +/// `RecordingController`) rather than forcing blind auto-detect — the latter +/// mis-guessed `en`/`ru` on short manual notes. `Auto` collapses to `None` +/// (genuine auto-detect) via `whisper_hint`, never the literal `"auto"` code. +/// Uses `load_without_keychain` so opening the composer mic never triggers a +/// Keychain prompt. +fn resolve_language_hint(language: Option) -> Option { + match language { + Some(lang) => codescribe_core::config::Language::from(lang).whisper_hint(), + None => codescribe_core::config::Config::load_without_keychain() + .whisper_language + .whisper_hint(), + } + .map(str::to_string) +} + +/// One live composer voice-note session: the streaming recorder plus the +/// finalized-text accumulator its event sink feeds, the wall-clock start used to +/// size the stop timeout, and the resolved Whisper language hint reused for the +/// stop-time final pass (kept so it honours the persisted setting exactly like +/// the start-time streaming session). +struct ActiveSession { + recorder: StreamingRecorder, + transcript: Arc, + started_at: Instant, + language_hint: Option, +} + +/// Thin handle to the codescribe dictation engine (streaming recorder + +/// Whisper). Holds the active session behind an async mutex and the current +/// foreign listener behind an `RwLock`. +#[derive(uniffi::Object)] +pub struct CodescribeDictation { + recorder: Mutex>, + listener: RwLock>>, +} + +#[uniffi::export(async_runtime = "tokio")] +impl CodescribeDictation { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self { + recorder: Mutex::new(None), + listener: RwLock::new(None), + } + } + + /// Register (or replace) the foreign listener that receives dictation + /// events. Must be called before `start_recording`. + pub fn set_listener(&self, listener: Arc) { + if let Ok(mut guard) = self.listener.write() { + *guard = Some(listener); + } + } + + /// Load the Whisper engine (idempotent). Runs on a blocking thread because + /// model load touches the GPU and can take seconds. + /// Wraps `whisper::init` (stt/whisper/singleton.rs:199). + pub async fn init_model(&self) -> Result<(), CsError> { + tokio::task::spawn_blocking(whisper::init) + .await + .map_err(|e| CsError::Recording { + msg: format!("init_model task join error: {e}"), + })? + .map_err(|e| CsError::Recording { msg: e.to_string() }) + } + + /// True when the Whisper engine is currently loaded. May flip back to + /// `false` after idle-unload; the next transcription reloads transparently. + /// Wraps `whisper::is_initialized` (stt/whisper/singleton.rs:207). + pub fn is_model_loaded(&self) -> bool { + whisper::is_initialized() + } + + /// Start microphone dictation. Builds a `CsEventSink` from the registered + /// listener, wires it into a fresh `StreamingRecorder`, and starts the + /// event-based transcription session. + /// + /// Wraps `StreamingRecorder::new` (audio/streaming_recorder.rs:25), + /// `set_event_sink` (:74) and `start_event_session` (:87). Errors if no + /// listener was set (the core pipeline requires an event sink). + pub async fn start_recording(&self, language: Option) -> Result<(), CsError> { + let listener = self + .listener + .read() + .map_err(|_| CsError::Recording { + msg: "listener lock poisoned".to_string(), + })? + .clone() + .ok_or_else(|| CsError::Recording { + msg: "set_listener(...) must be called before start_recording".to_string(), + })?; + + let transcript = Arc::new(ComposerTranscript::default()); + let sink: Arc = Arc::new(CsEventSink { + listener: Arc::clone(&listener), + transcript: Arc::clone(&transcript), + }); + let mut recorder = + StreamingRecorder::new().map_err(|e| CsError::Recording { msg: e.to_string() })?; + recorder.set_event_sink(Some(sink)); + + // Manual voice-note: the composer's Stop click is the source of truth, + // exactly like the hotkey hold's key-up (see `RecordingController` + // hold-start, which also sets `auto_silence = false`). The legacy + // `RecorderConfig` defaults to `auto_silence = true`, which auto-stops the + // stream after ~0.3s of silence and chops a single spoken note into + // fragments the commit-VAD then rejects as "no speech". Disable it so the + // user — not the VAD — ends the recording. + recorder.recorder.config.auto_silence = false; + + let language_code = resolve_language_hint(language); + recorder + .start_event_session(language_code.clone()) + .await + .map_err(|e| CsError::Recording { msg: e.to_string() })?; + + *self.recorder.lock().await = Some(ActiveSession { + recorder, + transcript, + started_at: Instant::now(), + language_hint: language_code, + }); + tray_status::update_tray_status(TrayStatus::Listening); + listener.on_recording_started(); + Ok(()) + } + + /// Stop the active dictation session and return the composed transcript. + /// + /// Two-phase, within one shared budget (`compose_stop_timeout`): + /// + /// 1. `StreamingRecorder::stop` is the completion signal — it stops the + /// audio stream, joins the transcription task (which only finishes AFTER + /// every `UtteranceFinal` has been emitted synchronously into our + /// accumulator), and saves the WAV. So the streaming splice is complete + /// once stop returns cleanly. + /// 2. A delivery-grade final pass re-transcribes the whole saved WAV, the + /// same `transcribe_file_verdict` adjudicator the hotkey/overlay + /// toggle-stop uses. Decoding the recording as one continuous utterance + /// avoids the mid-word cut artifacts of the spliced streaming chunks, so + /// its text is the quality the overlay delivers. + /// + /// The final pass wins whenever it yields non-empty text; the streaming + /// splice is the fallback for a failed/timed-out/empty final pass (or a + /// drain timeout, where no WAV is composed). Either way the UI never hangs: + /// the shared budget bounds both phases and overrun degrades quality, not + /// correctness. The streaming recorder's own transcript buffer is ignored — + /// it stays empty on this path. + pub async fn stop_recording(&self) -> Result { + let mut session = { + let mut guard = self.recorder.lock().await; + guard.take().ok_or_else(|| CsError::Recording { + msg: "no active recording to stop".to_string(), + })? + }; + + let budget = compose_stop_timeout(session.started_at.elapsed()); + let deadline = tokio::time::Instant::now() + budget; + let transcript = Arc::clone(&session.transcript); + let language_hint = session.language_hint.clone(); + self.notify_recording_finalising(); + + // Phase 1: drain the streaming session and recover the saved WAV path. + let audio_path = match tokio::time::timeout_at(deadline, session.recorder.stop()).await { + Ok(Ok((_streaming_buf, audio_path))) => audio_path, + Ok(Err(e)) => { + tray_status::update_tray_status(TrayStatus::Error); + return Err(CsError::Recording { msg: e.to_string() }); + } + Err(_elapsed) => { + // Drain overran the budget — no WAV to adjudicate; return the + // streaming finals accumulated so far. + let (streaming_text, utterances) = transcript.snapshot(); + let text = streaming_text.trim().to_string(); + warn!( + target: "composer-dictation", + source = ComposerTranscriptSource::StreamingFallback.label(), + utterances, + streaming_chars = text.chars().count(), + budget_ms = budget.as_millis() as u64, + "composer voice-note stop drain timed out; returning streaming fallback" + ); + self.notify_recording_stopped(); + return Ok(text); + } + }; + + // Phase 2: delivery-grade final pass over the whole WAV; the streaming + // splice remains the fallback authority. + let (streaming_text, _utterances) = transcript.snapshot(); + let final_pass_text = run_final_pass(audio_path, language_hint, deadline).await; + + let final_pass_chars = final_pass_text + .as_deref() + .map(|t| t.trim().chars().count()) + .unwrap_or(0); + let (text, source) = + select_composer_transcript(final_pass_text.as_deref(), &streaming_text); + + info!( + target: "composer-dictation", + source = source.label(), + final_pass_chars, + streaming_chars = streaming_text.trim().chars().count(), + "composer voice-note stop composed transcript" + ); + + self.notify_recording_stopped(); + Ok(text) + } + + /// Fire the foreign `on_recording_stopped` callback if a listener is set. + fn notify_recording_stopped(&self) { + tray_status::update_tray_status(TrayStatus::Idle); + if let Ok(guard) = self.listener.read() + && let Some(listener) = guard.as_ref() + { + listener.on_recording_stopped(); + } + } + + /// Fire the foreign `on_recording_finalising` callback and publish processing. + fn notify_recording_finalising(&self) { + tray_status::update_tray_status(TrayStatus::Thinking); + if let Ok(guard) = self.listener.read() + && let Some(listener) = guard.as_ref() + { + listener.on_recording_finalising(); + } + } + + /// True while a dictation session is active. + /// Wraps `StreamingRecorder::is_recording` (audio/streaming_recorder.rs:79). + pub async fn is_recording(&self) -> bool { + self.recorder + .lock() + .await + .as_ref() + .map(|session| session.recorder.is_recording()) + .unwrap_or(false) + } + + /// Transcribe an existing audio file. Loads + decodes the file, detects the + /// language, then runs Whisper. All blocking work runs off the async runtime. + /// + /// Wraps `audio::load_audio_file` (audio/loader.rs:10), + /// `whisper::detect_language` (stt/whisper/singleton.rs:249) and + /// `whisper::transcribe` (stt/whisper/singleton.rs:214). + pub async fn transcribe_file(&self, path: String) -> Result { + tokio::task::spawn_blocking(move || -> Result { + let path = std::path::PathBuf::from(path); + let (samples, sample_rate) = + load_audio_file(&path).map_err(|e| CsError::Recording { msg: e.to_string() })?; + let language = whisper::detect_language(&samples, sample_rate) + .map_err(|e| CsError::Recording { msg: e.to_string() })?; + let text = whisper::transcribe(&samples, sample_rate, Some(language.as_str())) + .map_err(|e| CsError::Recording { msg: e.to_string() })?; + Ok(CsTranscription { text, language }) + }) + .await + .map_err(|e| CsError::Recording { + msg: format!("transcribe_file task join error: {e}"), + })? + } +} + +/// True when microphone permission is already granted. +/// Wraps `os::permissions::check_microphone` (app/os/permissions.rs:135). +#[uniffi::export] +pub fn mic_permission_granted() -> bool { + codescribe::os::permissions::check_microphone() + == codescribe::os::permissions::PermissionStatus::Granted +} + +/// Request microphone permission (shows the system dialog when undetermined), +/// returning whether access is granted. +/// Wraps `os::permissions::request_microphone` (app/os/permissions.rs:301). +#[uniffi::export] +pub fn request_mic_permission() -> bool { + codescribe::os::permissions::request_microphone() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + /// Captures the payload of the single listener call we assert on. + #[derive(Default)] + struct CapturingListener { + final_calls: StdMutex>, + } + + impl CsTranscriptionListener for CapturingListener { + fn on_recording_preparing(&self) {} + fn on_recording_started(&self) {} + fn on_recording_stopped(&self) {} + fn on_recording_finalising(&self) {} + fn on_preview(&self, _text: String) {} + fn on_correction(&self, _text: String, _previous_text: String) {} + fn on_final(&self, utterance_id: u64, text: String) { + self.final_calls.lock().unwrap().push((utterance_id, text)); + } + fn on_replace_range( + &self, + _utterance_id: u64, + _start: u64, + _end: u64, + _text: String, + _source: CsLayerSource, + ) { + } + fn on_insert_annotation( + &self, + _utterance_id: u64, + _position: u64, + _text: String, + _kind: CsAnnotationKind, + ) { + } + fn on_session_finalised(&self, _session_id: String, _layer_summary: CsLayerSummary) {} + fn on_final_transcript_ready(&self, _text: String) {} + fn on_vad_active(&self, _active: bool) {} + fn on_no_speech(&self, _reason: String) {} + fn on_error(&self, _message: String) {} + } + + /// Build a minimal `UtteranceFinal` event with the given identity/text. + fn utterance_final(utterance_id: u64, text: &str) -> EngineEvent { + EngineEvent::UtteranceFinal { + utterance_id, + text: text.to_string(), + raw_text: text.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(), + } + } + + /// 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). + #[test] + fn utterance_final_forwards_utterance_id() { + let listener = Arc::new(CapturingListener::default()); + let sink = CsEventSink { + listener: listener.clone(), + transcript: Arc::new(ComposerTranscript::default()), + }; + + sink.on_event(&utterance_final(7, "ala ma kota")); + + let calls = listener.final_calls.lock().unwrap(); + assert_eq!( + calls.as_slice(), + &[(7, "ala ma kota".to_string())], + "on_final must receive the utterance_id from UtteranceFinal" + ); + } + + /// The composer return is composed from the finalized utterance stream: the + /// sink must accumulate each `UtteranceFinal` (space-joined, empties skipped) + /// so `stop_recording` never returns an empty transcript after real speech. + /// Regression guard for the "audio + STT work but final is empty" bug. + #[test] + fn cs_event_sink_accumulates_final_transcript() { + let listener = Arc::new(CapturingListener::default()); + let transcript = Arc::new(ComposerTranscript::default()); + let sink = CsEventSink { + listener: listener.clone(), + transcript: Arc::clone(&transcript), + }; + + sink.on_event(&utterance_final(1, " no to ")); + sink.on_event(&utterance_final(2, "")); // empty final must not widen text + sink.on_event(&utterance_final(3, "dobra teraz")); + + let (text, utterances) = transcript.snapshot(); + assert_eq!(text, "no to dobra teraz"); + assert_eq!( + utterances, 2, + "empty final must not count toward utterances" + ); + } + + /// The stop-drain budget scales with recording length but is clamped so the + /// composer UI can never hang indefinitely on a stalled scheduler. + #[test] + fn compose_stop_timeout_scales_and_clamps() { + fn assert_duration_close(actual: Duration, expected: Duration) { + let drift = actual.abs_diff(expected); + assert!( + drift <= Duration::from_micros(1), + "duration drift {drift:?} exceeded tolerance: actual={actual:?}, expected={expected:?}" + ); + } + + // Short note: floored so a cold commit + tail patch still fits. + assert_eq!( + compose_stop_timeout(Duration::from_secs(3)), + Duration::from_secs(8) + ); + // Mid-length: proportional (20s * 0.6 = 12s) inside the band. + assert_duration_close( + compose_stop_timeout(Duration::from_secs(20)), + Duration::from_secs(12), + ); + // Long note: capped so the UI never waits unboundedly. + assert_eq!( + compose_stop_timeout(Duration::from_secs(300)), + Duration::from_secs(30) + ); + } + + /// A non-empty final pass is the delivery-grade winner over the streaming + /// splice; both sides are trimmed on the way out. + #[test] + fn select_composer_transcript_prefers_final_pass() { + let (text, source) = select_composer_transcript(Some(" raz dwa trzy "), "raz dwa tszy"); + assert_eq!(text, "raz dwa trzy"); + assert_eq!(source, ComposerTranscriptSource::FinalPass); + } + + /// An absent or empty/whitespace final pass falls back to the streaming + /// splice so a failed adjudication never blanks a real transcript. + #[test] + fn select_composer_transcript_falls_back_to_streaming() { + let (none_text, none_source) = select_composer_transcript(None, " raz dwa "); + assert_eq!(none_text, "raz dwa"); + assert_eq!(none_source, ComposerTranscriptSource::StreamingFallback); + + let (empty_text, empty_source) = select_composer_transcript(Some(" \n "), "raz dwa"); + assert_eq!(empty_text, "raz dwa"); + assert_eq!(empty_source, ComposerTranscriptSource::StreamingFallback); + } + + /// An explicit caller language must map to its two-letter Whisper hint, and + /// `Auto` must collapse to genuine auto-detect (`None`) — never the literal + /// `"auto"` code, which Whisper cannot honour. Guards the manual voice-note + /// language path so the composer respects the persisted language like the + /// hotkey path instead of blind-guessing `en`/`ru`. + #[test] + fn resolve_language_hint_maps_explicit_choices() { + assert_eq!( + resolve_language_hint(Some(CsLanguage::Polish)), + Some("pl".to_string()) + ); + assert_eq!( + resolve_language_hint(Some(CsLanguage::English)), + Some("en".to_string()) + ); + assert_eq!( + resolve_language_hint(Some(CsLanguage::Auto)), + None, + "Auto must be genuine auto-detect (None), never the literal \"auto\" code" + ); + } +} diff --git a/bridge/src/threads.rs b/bridge/src/threads.rs new file mode 100644 index 00000000..161fcfe0 --- /dev/null +++ b/bridge/src/threads.rs @@ -0,0 +1,512 @@ +//! Thread persistence + transcript history surface — a thin, synchronous UniFFI +//! wrapper over the live codescribe `ThreadStore` / `ThreadIndex` (saved agent +//! conversations) and `state::history` (on-disk transcript artifacts). Split out +//! of `lib.rs` in W3 cut #5 so each bridge slice owns a disjoint file. +//! +//! All cross-FFI types are UniFFI-able: `DateTime` / `DateTime` are +//! flattened to `i64` epoch-millis, `serde_json::Value` to a `raw_json` String, +//! `PathBuf` to String, and `usize` to `u64`. No secret values cross the boundary. + +use std::fs; + +use chrono::Local; +use codescribe_core::agent::thread_export::thread_to_markdown; +use codescribe_core::agent::thread_index::{ThreadFilter, ThreadIndex, ThreadSummary}; +use codescribe_core::agent::thread_store::{ + Thread, ThreadMessage, ThreadNote, ThreadStore, TokenUsage, +}; +use codescribe_core::state::history::{self, HistoryEntry, TranscriptKind}; +use serde_json::Value; + +use crate::CsError; + +/// Cumulative token accounting for a thread. Mirrors `TokenUsage` +/// (`thread_store.rs:105`). +#[derive(uniffi::Record)] +pub struct CsTokenUsage { + pub input: u64, + pub output: u64, +} + +impl From<&TokenUsage> for CsTokenUsage { + fn from(usage: &TokenUsage) -> Self { + Self { + input: usage.input, + output: usage.output, + } + } +} + +/// Lightweight thread index entry used to render the thread list/search. +/// Mirrors `ThreadSummary` (`thread_index.rs:30`); the internal `search_text` +/// field is intentionally omitted (index-only, not display-facing). +#[derive(uniffi::Record)] +pub struct CsThreadSummary { + pub id: String, + pub title: String, + pub created_at_ms: i64, + pub updated_at_ms: i64, + pub message_count: u64, + pub mode: String, + pub tags: Vec, + pub summary: Option, + pub has_notes: bool, + pub latest_message: Option, + pub latest_note: Option, + pub is_favorite: bool, +} + +impl From<&ThreadSummary> for CsThreadSummary { + fn from(summary: &ThreadSummary) -> Self { + Self { + id: summary.id.clone(), + title: summary.title.clone(), + created_at_ms: summary.created_at.timestamp_millis(), + updated_at_ms: summary.updated_at.timestamp_millis(), + message_count: summary.message_count as u64, + mode: summary.mode.clone(), + tags: summary.tags.clone(), + summary: summary.summary.clone(), + has_notes: summary.has_notes, + latest_message: summary.latest_message.clone(), + latest_note: summary.latest_note.clone(), + is_favorite: summary.is_favorite, + } + } +} + +/// One message inside a thread. `text` is the flattened, human-readable content +/// (replicating the private preview logic at `thread_index.rs:334/348`, without +/// the search-side lowercasing). `raw_json` carries the full structured content +/// array so callers can recover tool calls / images. Mirrors `ThreadMessage` +/// (`thread_store.rs:54`). +#[derive(uniffi::Record)] +pub struct CsThreadMessage { + pub role: String, + pub text: String, + pub raw_json: String, + pub timestamp_ms: i64, +} + +impl From<&ThreadMessage> for CsThreadMessage { + fn from(message: &ThreadMessage) -> Self { + Self { + role: message.role.clone(), + text: flatten_message_text(&message.content), + raw_json: serde_json::to_string(&message.content).unwrap_or_default(), + timestamp_ms: message.timestamp.timestamp_millis(), + } + } +} + +/// A pinned note attached to a thread. Mirrors `ThreadNote` +/// (`thread_store.rs:96`). +#[derive(uniffi::Record)] +pub struct CsThreadNote { + pub id: String, + pub created_at_ms: i64, + pub text: String, + pub anchored_to_message: Option, +} + +impl From<&ThreadNote> for CsThreadNote { + fn from(note: &ThreadNote) -> Self { + Self { + id: note.id.clone(), + created_at_ms: note.created_at.timestamp_millis(), + text: note.text.clone(), + anchored_to_message: note.anchored_to_message.map(|index| index as u64), + } + } +} + +/// A fully loaded conversation thread. Mirrors `Thread` (`thread_store.rs:20`). +#[derive(uniffi::Record)] +pub struct CsThread { + pub id: String, + pub created_at_ms: i64, + pub updated_at_ms: i64, + pub title: String, + pub mode: String, + pub tags: Vec, + pub notes: Vec, + pub messages: Vec, + pub summary: Option, + pub total_tokens: Option, + pub provider: String, + pub model: String, +} + +impl From<&Thread> for CsThread { + fn from(thread: &Thread) -> Self { + Self { + id: thread.id.clone(), + created_at_ms: thread.created_at.timestamp_millis(), + updated_at_ms: thread.updated_at.timestamp_millis(), + title: thread.title.clone(), + mode: thread.mode.clone(), + tags: thread.tags.clone(), + notes: thread.notes.iter().map(CsThreadNote::from).collect(), + messages: thread.messages.iter().map(CsThreadMessage::from).collect(), + summary: thread.summary.clone(), + total_tokens: thread.total_tokens.as_ref().map(CsTokenUsage::from), + provider: thread.provider.clone(), + model: thread.model.clone(), + } + } +} + +/// Filter applied to `list_threads`. Mirrors `ThreadFilter` +/// (`thread_index.rs:111`). +#[derive(uniffi::Record)] +pub struct CsThreadFilter { + pub mode: Option, + pub favorites_only: bool, + pub has_notes: bool, + pub tag: Option, +} + +impl From for ThreadFilter { + fn from(filter: CsThreadFilter) -> Self { + Self { + mode: filter.mode, + favorites_only: filter.favorites_only, + has_notes: filter.has_notes, + tag: filter.tag, + } + } +} + +/// What kind of transcript artifact a history entry holds. Mirrors +/// `TranscriptKind` (`state/history.rs:27`). +#[derive(uniffi::Enum)] +pub enum CsTranscriptKind { + Raw, + Cloud, + FormattedTranscript, + AssistantInterpretation, + FormattingFailed, + Failed, +} + +impl From for CsTranscriptKind { + fn from(kind: TranscriptKind) -> Self { + match kind { + TranscriptKind::Raw => CsTranscriptKind::Raw, + TranscriptKind::Cloud => CsTranscriptKind::Cloud, + TranscriptKind::FormattedTranscript => CsTranscriptKind::FormattedTranscript, + TranscriptKind::AssistantInterpretation => CsTranscriptKind::AssistantInterpretation, + TranscriptKind::FormattingFailed => CsTranscriptKind::FormattingFailed, + TranscriptKind::Failed => CsTranscriptKind::Failed, + } + } +} + +/// One on-disk transcript artifact. Mirrors `HistoryEntry` +/// (`state/history.rs:20`); `path` is the absolute file path as a String and +/// `timestamp_ms` is epoch-millis from the entry's local timestamp. +#[derive(uniffi::Record)] +pub struct CsHistoryEntry { + pub path: String, + pub timestamp_ms: i64, + pub preview: String, + pub kind: CsTranscriptKind, +} + +impl From for CsHistoryEntry { + fn from(entry: HistoryEntry) -> Self { + Self { + path: entry.path.to_string_lossy().into_owned(), + timestamp_ms: entry.timestamp.timestamp_millis(), + preview: entry.preview, + kind: entry.kind.into(), + } + } +} + +/// Thin handle to the codescribe thread store + transcript history. +/// +/// Stateless: every call constructs a fresh `ThreadStore` / `ThreadIndex` over +/// the live on-disk data dir, so reads always reflect what the engine wrote. +#[derive(uniffi::Object)] +pub struct CodescribeThreads {} + +#[uniffi::export] +impl CodescribeThreads { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + Self {} + } + + /// List indexed thread summaries, newest first, optionally filtered. + /// Wraps `ThreadIndex::list` (`thread_index.rs:195`). + pub fn list_threads( + &self, + filter: Option, + ) -> Result, CsError> { + let index = open_index()?; + let core_filter = filter.map(ThreadFilter::from); + Ok(index + .list(core_filter.as_ref()) + .into_iter() + .map(CsThreadSummary::from) + .collect()) + } + + /// Full-text search over indexed threads (all query words must match), + /// newest first. Wraps `ThreadIndex::search` (`thread_index.rs:206`). + pub fn search_threads(&self, query: String) -> Result, CsError> { + let index = open_index()?; + Ok(index + .search(&query) + .into_iter() + .map(CsThreadSummary::from) + .collect()) + } + + /// Load a full thread by id. Wraps `ThreadStore::load_thread` + /// (`thread_store.rs:149`). + pub fn load_thread(&self, id: String) -> Result { + let store = ThreadStore::new()?; + let thread = store.load_thread(&id)?; + Ok(CsThread::from(&thread)) + } + + /// Delete a thread (file + index entry) by id. Wraps + /// `ThreadStore::delete_thread` (`thread_store.rs:159`). + pub fn delete_thread(&self, id: String) -> Result<(), CsError> { + let store = ThreadStore::new()?; + store.delete_thread(&id)?; + Ok(()) + } + + /// Set a thread's favorite flag; returns `false` when no such thread exists. + /// Wraps `ThreadStore::set_thread_favorite` (`thread_store.rs:173`). + pub fn set_thread_favorite(&self, id: String, is_favorite: bool) -> Result { + let store = ThreadStore::new()?; + Ok(store.set_thread_favorite(&id, is_favorite)?) + } + + /// Rename a thread. Marks the title as user-custom so auto-titling won't + /// overwrite it on the next turn; returns `false` when no such thread exists. + /// Wraps `ThreadStore::set_thread_title` (`thread_store.rs`). + pub fn rename_thread(&self, id: String, title: String) -> Result { + let store = ThreadStore::new()?; + Ok(store.set_thread_title(&id, &title)?) + } + + /// Generate a fresh, collision-resistant thread id. Wraps + /// `ThreadStore::generate_id` (`thread_store.rs:191`). + pub fn generate_thread_id(&self) -> String { + ThreadStore::generate_id() + } + + /// Export a thread as a Markdown transcript saved under + /// `~/.codescribe/transcriptions/YYYY-MM-DD/`. Returns the absolute path of + /// the written file. `assistant_only = true` keeps only assistant turns. + /// Formatting lives in `codescribe_core::agent::thread_export` (unit-tested); + /// this wrapper owns the on-disk placement + collision-avoidance, mirroring + /// the legacy `save_chat_markdown_to_history` (removed in 37efe51). + pub fn export_thread_markdown( + &self, + id: String, + assistant_only: bool, + ) -> Result { + let store = ThreadStore::new()?; + let thread = store.load_thread(&id)?; + let markdown = thread_to_markdown(&thread, assistant_only); + + let now = Local::now(); + let dir = history::transcriptions_dir(&now); + let time_base = now.format("%H%M%S").to_string(); + let kind = if assistant_only { + "chat-assistant" + } else { + "chat" + }; + + let mut candidate = dir.join(format!("{time_base}_{kind}.md")); + for i in 1..=10_000 { + if !candidate.exists() { + break; + } + candidate = dir.join(format!("{time_base}_{kind}_{i}.md")); + } + + fs::write(&candidate, markdown)?; + Ok(candidate.to_string_lossy().into_owned()) + } + + /// Recent transcript history entries, newest first, capped at `limit`. + /// Wraps `history::recent_entries` (`state/history.rs:404`). + pub fn recent_history(&self, limit: u32) -> Vec { + history::recent_entries(limit as usize) + .into_iter() + .map(CsHistoryEntry::from) + .collect() + } + + /// Read the full text of a transcript artifact at `path`. Wraps + /// `std::fs::read_to_string`. + pub fn read_history_text(&self, path: String) -> Result { + Ok(fs::read_to_string(&path)?) + } +} + +/// Open the live thread index over the default on-disk data dir. +fn open_index() -> Result { + let store = ThreadStore::new()?; + let index = ThreadIndex::load_or_create(store.threads_dir())?; + Ok(index) +} + +/// Flatten a message's structured content into a display string, mirroring the +/// private `collect_message_text` walk (`thread_index.rs:348`) but for the +/// *display* surface rather than the search index. Unlike the search preview we +/// must NOT collapse interior whitespace: the live stream hands the renderer raw +/// markdown, so a restored message has to keep its newlines or the block parser +/// sees one giant paragraph. We therefore preserve intra-block newlines and only +/// trim each block's outer edges, separating distinct content blocks with a +/// blank line so their markdown structure stays intact. +fn flatten_message_text(content: &[Value]) -> String { + let mut chunks = Vec::new(); + for value in content { + collect_message_text(value, &mut chunks); + } + chunks + .iter() + .map(|chunk| chunk.trim()) + .filter(|chunk| !chunk.is_empty()) + .collect::>() + .join("\n\n") +} + +/// Recursively collect human-readable text from a content `Value`, keyed on the +/// canonical content-block `type` (like `core::agent::thread_export::collect_text`) +/// rather than a blind key walk. A blind walk recurses into structural fields, so +/// a restored `image` block leaks its `media_type` ("image/png") and a `tool_use` +/// block leaks its `id`/`name` into the displayed transcript. A type-aware +/// whitelist emits only real prose: canonical `text` blocks, legacy +/// `input_text` / `output_text` aliases, plus recursed `tool_result` content. +/// Interior newlines are preserved (unlike the search-index twin) so restored +/// markdown keeps its structure. +fn collect_message_text(value: &Value, out: &mut Vec) { + match value { + Value::String(text) if !text.trim().is_empty() => { + out.push(text.to_string()); + } + Value::Array(items) => { + // Skip binary-like arrays (e.g., raw image bytes). + if items.iter().all(Value::is_number) { + return; + } + for item in items { + collect_message_text(item, out); + } + } + Value::Object(map) => match map.get("type").and_then(Value::as_str) { + // A text block (or an untyped object treated as one) contributes its + // `text`; structural fields (media_type, id, name, …) are ignored. + Some("text") | Some("input_text") | Some("output_text") | None => { + if let Some(text) = map.get("text").and_then(Value::as_str) + && !text.trim().is_empty() + { + out.push(text.to_string()); + } + } + // Tool results carry nested display prose; recurse into it. + Some("tool_result") => { + if let Some(nested) = map.get("content") { + collect_message_text(nested, out); + } + } + // tool_use / image / image_asset / anything else: no display prose. + Some(_) => {} + }, + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Regression: a message that streams as rich markdown must survive the + /// structured-content -> display-text flatten with its newlines intact. + /// Before the fix `split_whitespace().join(" ")` collapsed every `\n` into a + /// space, so a restored thread rendered one giant paragraph. + #[test] + fn flatten_preserves_newlines_within_a_text_block() { + let raw = "# Nagłówek H1\n\n## Nagłówek H2\n\n- punkt 1\n- punkt 2"; + let content = vec![json!({ "type": "text", "text": raw })]; + assert_eq!(flatten_message_text(&content), raw); + } + + #[test] + fn flatten_separates_distinct_text_blocks_with_a_blank_line() { + let content = vec![ + json!({ "type": "text", "text": "Pierwszy akapit." }), + json!({ "type": "text", "text": "Drugi akapit\nz nową linią." }), + ]; + assert_eq!( + flatten_message_text(&content), + "Pierwszy akapit.\n\nDrugi akapit\nz nową linią." + ); + } + + #[test] + fn flatten_surfaces_openai_text_alias_blocks() { + let content = vec![ + json!({ "type": "input_text", "text": "legacy prompt" }), + json!({ "type": "output_text", "text": "legacy reply" }), + ]; + assert_eq!( + flatten_message_text(&content), + "legacy prompt\n\nlegacy reply" + ); + } + + #[test] + fn flatten_skips_binary_arrays_and_blank_blocks() { + let content = vec![ + json!({ "type": "text", "text": "widoczny" }), + json!([1, 2, 3, 4]), + json!({ "type": "text", "text": " " }), + ]; + assert_eq!(flatten_message_text(&content), "widoczny"); + } + + /// Regression: a restored `image` block must not leak its `media_type` + /// ("image/png") and a `tool_use` block must not leak its `id`/`name` into + /// the displayed transcript. The type-aware whitelist emits only real prose. + #[test] + fn flatten_skips_restored_image_and_tool_use_structural_fields() { + let content = vec![ + json!({ "type": "text", "text": "opis zdjęcia" }), + json!({ + "type": "image", + "source": { "type": "base64", "media_type": "image/png", "data": "AAAA" } + }), + json!({ + "type": "tool_use", + "id": "toolu_42", + "name": "grep", + "input": { "pattern": "x" } + }), + ]; + assert_eq!(flatten_message_text(&content), "opis zdjęcia"); + } + + /// Nested `tool_result` prose still surfaces on the display surface. + #[test] + fn flatten_surfaces_tool_result_prose() { + let content = vec![json!({ + "type": "tool_result", + "tool_use_id": "toolu_42", + "content": [ { "type": "text", "text": "wynik grep" } ] + })]; + assert_eq!(flatten_message_text(&content), "wynik grep"); + } +} diff --git a/bridge/src/tray_status.rs b/bridge/src/tray_status.rs new file mode 100644 index 00000000..586c22b7 --- /dev/null +++ b/bridge/src/tray_status.rs @@ -0,0 +1,294 @@ +//! Live tray-status bridge. +//! +//! The core app crate owns `TrayStatus` and the producer-facing +//! `update_tray_status` API. This UniFFI slice registers a plain callback sink +//! into that core API, converts status changes into bridge-safe payloads, and +//! pushes them to the Swift menu-bar listener. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; + +use codescribe::os::tray_status::{self, TrayStatus, TrayStatusSnapshot}; +use tracing::trace; + +/// Bridge-safe tray status kind. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsTrayStatusKind { + Starting, + Idle, + Listening, + Processing, + Success, + Error, + Thermal, + HotkeyConflict, +} + +/// Coarse display tone for the Swift menu row. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CsTrayStatusTone { + Neutral, + Active, + Success, + Warning, + Critical, +} + +/// One menu-bar status update. +#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)] +pub struct CsTrayStatusPayload { + pub kind: CsTrayStatusKind, + pub tone: CsTrayStatusTone, + pub assistive: bool, + pub tooltip: String, + pub menu_label: String, + pub generation: u64, +} + +#[uniffi::export(with_foreign)] +pub trait CsTrayStatusListener: Send + Sync { + fn on_tray_status(&self, status: CsTrayStatusPayload); +} + +#[derive(uniffi::Object, Default)] +pub struct CodescribeTrayStatus {} + +#[uniffi::export] +impl CodescribeTrayStatus { + #[uniffi::constructor] + pub fn new() -> Self { + codescribe::logging::init_logging(); + install_sink(); + Self::default() + } + + /// Register or replace the Swift listener, then immediately seed it with the + /// latest core-side status so the menu never starts stale. + pub fn set_listener(&self, listener: Arc) { + install_sink(); + { + let listener_store = shared_listener(); + let mut guard = listener_store + .write() + .unwrap_or_else(|error| error.into_inner()); + *guard = Some(Arc::clone(&listener)); + } + listener.on_tray_status(current_payload()); + } + + /// Current status snapshot for Swift surfaces that need an initial value. + pub fn current_status(&self) -> CsTrayStatusPayload { + install_sink(); + current_payload() + } +} + +type SharedListener = RwLock>>; + +fn shared_listener() -> &'static SharedListener { + static LISTENER: OnceLock = OnceLock::new(); + LISTENER.get_or_init(|| RwLock::new(None)) +} + +fn last_forwarded_status() -> &'static Mutex> { + static LAST_FORWARDED: OnceLock>> = OnceLock::new(); + LAST_FORWARDED.get_or_init(|| Mutex::new(None)) +} + +fn generation_counter() -> &'static AtomicU64 { + static GENERATION: AtomicU64 = AtomicU64::new(0); + &GENERATION +} + +fn install_sink() { + tray_status::set_tray_status_sink(Some(Arc::new(publish_tray_status))); +} + +fn current_payload() -> CsTrayStatusPayload { + payload_from_status( + tray_status::current_tray_status_snapshot(), + generation_counter().load(Ordering::SeqCst), + ) +} + +fn payload_from_status(snapshot: TrayStatusSnapshot, generation: u64) -> CsTrayStatusPayload { + let status = snapshot.status; + let (kind, tone) = match status { + TrayStatus::Starting => (CsTrayStatusKind::Starting, CsTrayStatusTone::Neutral), + TrayStatus::Idle => (CsTrayStatusKind::Idle, CsTrayStatusTone::Neutral), + TrayStatus::Listening => (CsTrayStatusKind::Listening, CsTrayStatusTone::Active), + TrayStatus::Thinking => (CsTrayStatusKind::Processing, CsTrayStatusTone::Active), + TrayStatus::Success => (CsTrayStatusKind::Success, CsTrayStatusTone::Success), + TrayStatus::Error => (CsTrayStatusKind::Error, CsTrayStatusTone::Critical), + TrayStatus::Thermal => (CsTrayStatusKind::Thermal, CsTrayStatusTone::Warning), + TrayStatus::HotkeyConflict => (CsTrayStatusKind::HotkeyConflict, CsTrayStatusTone::Warning), + }; + CsTrayStatusPayload { + kind, + tone, + assistive: snapshot.is_assistive_visible(), + tooltip: snapshot.tooltip(), + menu_label: snapshot.menu_label().to_string(), + generation, + } +} + +fn publish_tray_status(snapshot: TrayStatusSnapshot) { + let changed = { + let mut last = last_forwarded_status() + .lock() + .unwrap_or_else(|error| error.into_inner()); + if *last == Some(snapshot) { + false + } else { + *last = Some(snapshot); + true + } + }; + if !changed { + trace!( + status = ?snapshot.status, + assistive = snapshot.assistive, + "coalesced duplicate tray status" + ); + return; + } + + let generation = generation_counter().fetch_add(1, Ordering::SeqCst) + 1; + let payload = payload_from_status(snapshot, generation); + let listener_store = shared_listener(); + let listener = listener_store + .read() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .map(Arc::clone); + + if let Some(listener) = listener { + listener.on_tray_status(payload); + } else { + trace!( + status = ?snapshot.status, + assistive = snapshot.assistive, + "tray status changed without Swift listener" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + static TEST_LOCK: StdMutex<()> = StdMutex::new(()); + + struct CapturingTrayStatusListener { + calls: Arc>>, + } + + impl CsTrayStatusListener for CapturingTrayStatusListener { + fn on_tray_status(&self, status: CsTrayStatusPayload) { + self.calls + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(status); + } + } + + fn reset_for_test() { + tray_status::set_tray_status_sink(None); + tray_status::set_tray_assistive_session(false); + tray_status::update_tray_status(TrayStatus::Idle); + let listener_store = shared_listener(); + *listener_store + .write() + .unwrap_or_else(|error| error.into_inner()) = None; + *last_forwarded_status() + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; + generation_counter().store(0, Ordering::SeqCst); + } + + #[test] + fn maps_core_status_to_bridge_payload() { + let payload = payload_from_status(TrayStatusSnapshot::new(TrayStatus::Thinking, false), 42); + + assert_eq!(payload.kind, CsTrayStatusKind::Processing); + assert_eq!(payload.tone, CsTrayStatusTone::Active); + assert!(!payload.assistive); + assert_eq!(payload.tooltip, "Codescribe - Processing..."); + assert_eq!(payload.menu_label, "Status: Processing..."); + assert_eq!(payload.generation, 42); + } + + #[test] + fn maps_assistive_status_to_agent_payload_copy() { + let payload = payload_from_status(TrayStatusSnapshot::new(TrayStatus::Listening, true), 43); + + assert_eq!(payload.kind, CsTrayStatusKind::Listening); + assert_eq!(payload.tone, CsTrayStatusTone::Active); + assert!(payload.assistive); + assert_eq!(payload.tooltip, "Codescribe - Agent listening..."); + assert_eq!(payload.menu_label, "Status: Agent listening..."); + assert_eq!(payload.generation, 43); + } + + #[test] + #[serial_test::serial] + fn listener_receives_changes_and_coalesces_duplicates() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + reset_for_test(); + + let tray_status_bridge = CodescribeTrayStatus::new(); + let calls = Arc::new(StdMutex::new(Vec::new())); + let listener = Arc::new(CapturingTrayStatusListener { + calls: Arc::clone(&calls), + }); + tray_status_bridge.set_listener(listener); + calls + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + + tray_status::update_tray_status(TrayStatus::Thermal); + tray_status::update_tray_status(TrayStatus::Thermal); + tray_status::update_tray_status(TrayStatus::HotkeyConflict); + + let calls = calls.lock().unwrap_or_else(|error| error.into_inner()); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].kind, CsTrayStatusKind::Thermal); + assert_eq!(calls[0].tone, CsTrayStatusTone::Warning); + assert_eq!(calls[1].kind, CsTrayStatusKind::HotkeyConflict); + assert!(calls[1].generation > calls[0].generation); + } + + #[test] + #[serial_test::serial] + fn listener_refires_when_only_assistive_lane_changes() { + let _guard = TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + reset_for_test(); + + let tray_status_bridge = CodescribeTrayStatus::new(); + let calls = Arc::new(StdMutex::new(Vec::new())); + let listener = Arc::new(CapturingTrayStatusListener { + calls: Arc::clone(&calls), + }); + tray_status_bridge.set_listener(listener); + calls + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + + tray_status::update_tray_status(TrayStatus::Listening); + tray_status::update_tray_status(TrayStatus::Listening); + tray_status::set_tray_assistive_session(true); + tray_status::set_tray_assistive_session(true); + + let calls = calls.lock().unwrap_or_else(|error| error.into_inner()); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].kind, CsTrayStatusKind::Listening); + assert!(!calls[0].assistive); + assert_eq!(calls[1].kind, CsTrayStatusKind::Listening); + assert!(calls[1].assistive); + assert!(calls[1].generation > calls[0].generation); + } +} diff --git a/bridge/uniffi-bindgen.rs b/bridge/uniffi-bindgen.rs new file mode 100644 index 00000000..5da7ebca --- /dev/null +++ b/bridge/uniffi-bindgen.rs @@ -0,0 +1,6 @@ +// Generates foreign-language bindings from the built dylib: +// cargo run -p codescribe-ffi --bin uniffi-bindgen -- \ +// generate --library target/debug/libcodescribe_ffi.dylib --language swift --out-dir +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/core/Cargo.toml b/core/Cargo.toml index 7ab5e612..214022c0 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -2,12 +2,12 @@ name = "codescribe-core" version.workspace = true edition.workspace = true -authors = ["VetCoders "] +authors = ["Vetcoders "] license = "FSL-1.1-ALv2" -description = "Core library for CodeScribe (audio, STT, quality pipeline)" -repository = "https://github.com/VetCoders/CodeScribe" -homepage = "https://vetcoders.github.io/CodeScribe/" -documentation = "https://vetcoders.github.io/CodeScribe/quickstart/" +description = "Core library for Codescribe (audio, STT, quality pipeline)" +repository = "https://github.com/vetcoders/codescribe" +homepage = "https://vetcoders.github.io/codescribe/" +documentation = "https://vetcoders.github.io/codescribe/quickstart/" keywords = ["speech-to-text", "whisper", "audio", "transcription", "stt"] categories = ["multimedia::audio", "multimedia::encoding"] @@ -20,7 +20,7 @@ offline_eval = [] [dependencies] # Async runtime -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "test-util"] } # Logging & errors tracing = "0.1" @@ -50,6 +50,8 @@ ndarray-npy = { version = "0.10", default-features = false } tokio-tungstenite = { version = "0.28", features = ["native-tls"] } futures-util = "0.3" base64 = "0.22" +sha2 = "0.10" +tiny_http = "0.12" # Audio cpal = "0.17" diff --git a/core/agent/assets.rs b/core/agent/assets.rs index 74f5a7b3..e2bfa4e8 100644 --- a/core/agent/assets.rs +++ b/core/agent/assets.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use chrono::Utc; use rand::distributions::Alphanumeric; use rand::{Rng, thread_rng}; +use sha2::{Digest, Sha256}; use super::types::ImageAsset; @@ -37,6 +38,37 @@ impl AgentAssetStore { }) } + /// Persist an inline (composer-attached) image as a disk-backed asset. + /// + /// Unlike `save_image`, the file name is derived from the content hash: + /// the whole thread is re-saved every turn, so the same bytes must map to + /// the same asset file instead of minting a new copy per save. + pub fn save_inline_image(data: &[u8], media_type: &str) -> Result { + let dir = Self::assets_dir(); + std::fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create agent assets dir: {}", dir.display()))?; + + let hash = Sha256::digest(data) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let asset_id = format!("inline_{hash}"); + let path = dir.join(format!( + "{asset_id}.{}", + extension_for_media_type(media_type) + )); + if !path.exists() { + atomic_write(&path, data)?; + } + + Ok(ImageAsset { + asset_id, + path, + media_type: normalize_media_type(media_type), + size_bytes: u64::try_from(data.len()).expect("usize length should fit in u64"), + }) + } + /// Read an asset previously stored by `save_image`. Asset paths travel /// through conversation state, so the requested path is never handed to /// the filesystem: only its file name is matched against the actual diff --git a/core/agent/event.rs b/core/agent/event.rs index b8473163..cf8e6a63 100644 --- a/core/agent/event.rs +++ b/core/agent/event.rs @@ -47,6 +47,10 @@ pub enum AgentUiEvent { name: String, id: String, summary: String, + /// True when the tool dispatch returned an error result. Carried so the + /// UI can render a failed tool compactly (`· failed · `) without + /// string-sniffing the summary. The reason already lives in `summary`. + is_error: bool, }, Done, Error(String), diff --git a/core/agent/mod.rs b/core/agent/mod.rs index 6a6dac0b..6d87471b 100644 --- a/core/agent/mod.rs +++ b/core/agent/mod.rs @@ -3,6 +3,7 @@ pub mod event; pub mod provider; pub mod registry; pub mod session; +pub mod thread_export; pub mod thread_index; pub mod thread_store; pub mod types; diff --git a/core/agent/session.rs b/core/agent/session.rs index b0e08e08..9280bc07 100644 --- a/core/agent/session.rs +++ b/core/agent/session.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use chrono::Utc; @@ -273,7 +273,11 @@ impl AgentSession { self.provider.name(), message ); - send_ui_event(&self.ui_tx, AgentUiEvent::Error(message.clone())).await; + // Fatal agent failures use the Result channel only. + // Swift already turns the thrown bridge error into the + // visible failed assistant bubble; also emitting + // AgentUiEvent::Error would double-signal the same + // failure through listener.on_error + throw. return Err(anyhow::anyhow!("Provider stream error: {message}")); } } @@ -318,14 +322,6 @@ impl AgentSession { match serde_json::from_str::(buffered) { Ok(arguments) => ready_calls.push((call.id, call.name, arguments)), Err(error) => { - send_ui_event( - &self.ui_tx, - AgentUiEvent::Error(format!( - "Failed to parse tool arguments for '{}': {}", - call.name, error - )), - ) - .await; return Err(anyhow::anyhow!( "Failed to parse tool arguments for '{}': {}", call.name, @@ -364,6 +360,7 @@ impl AgentSession { ) .await; + let dispatch_started = Instant::now(); let tool_outputs = match self.tools.dispatch(&tool_name, arguments).await { Ok(outputs) => outputs, Err(error) => { @@ -380,6 +377,24 @@ impl AgentSession { .iter() .any(|output| matches!(output, ToolResultContent::Error(_))); + // Per-call observability: one INFO line per agent tool call (native + // or MCP) so failures are diagnosable from codescribe.log after the + // fact. Never logs arguments or full payloads — only the tool name, + // duration, outcome, and (on error) the error's first line. For MCP + // tools the server segment of the public name is surfaced. + let dispatch_ms = dispatch_started.elapsed().as_millis(); + let server_label = mcp_server_name(&tool_name) + .map(|server| format!(" (mcp server `{server}`)")) + .unwrap_or_default(); + if is_error { + info!( + "agent tool `{tool_name}`{server_label} failed in {dispatch_ms}ms: {}", + first_error_line(&tool_outputs) + ); + } else { + info!("agent tool `{tool_name}`{server_label} ok in {dispatch_ms}ms"); + } + let mut content_blocks = Vec::new(); for output in tool_outputs { match output { @@ -414,6 +429,7 @@ impl AgentSession { name: tool_name, id: call_id, summary, + is_error, }, ) .await; @@ -424,7 +440,6 @@ impl AgentSession { "Agent loop exceeded max iterations ({})", self.max_iterations ); - send_ui_event(&self.ui_tx, AgentUiEvent::Error(message.clone())).await; Err(anyhow::anyhow!(message)) } } @@ -461,10 +476,36 @@ fn is_transient_stream_start_error(error: &anyhow::Error) -> bool { .any(|pattern| message.contains(pattern)) } +/// Extract the MCP server segment from a public tool name shaped +/// `mcp____`. Native tools return `None`. +fn mcp_server_name(tool_name: &str) -> Option<&str> { + tool_name + .strip_prefix("mcp__") + .and_then(|rest| rest.split_once("__")) + .map(|(server, _)| server) +} + +/// First line of the first error result, trimmed and truncated for a single log +/// line. Never includes tool arguments — only the message the tool produced. +fn first_error_line(outputs: &[ToolResultContent]) -> String { + outputs + .iter() + .find_map(|output| match output { + ToolResultContent::Error(message) => Some(message), + _ => None, + }) + .map(|message| { + let line = message.lines().next().unwrap_or("").trim(); + truncate_summary(line, 200) + }) + .unwrap_or_default() +} + fn summarize_tool_result(outputs: &[ToolResultContent]) -> String { const SUMMARY_MAX_CHARS: usize = 120; let mut first_text: Option = None; + let mut first_error: Option = None; let mut image_count = 0usize; let mut error_count = 0usize; @@ -476,7 +517,12 @@ fn summarize_tool_result(outputs: &[ToolResultContent]) -> String { } } ToolResultContent::Image { .. } | ToolResultContent::ImageAsset(_) => image_count += 1, - ToolResultContent::Error(_) => error_count += 1, + ToolResultContent::Error(message) => { + error_count += 1; + if first_error.is_none() { + first_error = Some(message.trim().to_string()); + } + } } } @@ -491,8 +537,14 @@ fn summarize_tool_result(outputs: &[ToolResultContent]) -> String { return format!("{image_count} image result(s)"); } - if error_count > 0 { - return format!("{error_count} error result(s)"); + // Surface the real failure reason (e.g. "empty index") so the grouped Tool + // Activity block can show it compactly. Fall back to a count when the error + // carries no message. The full payload still goes to the debug log. + if let Some(error) = first_error { + if error.is_empty() { + return format!("{error_count} error result(s)"); + } + return truncate_summary(&error, SUMMARY_MAX_CHARS); } "No tool output".to_string() @@ -1006,13 +1058,28 @@ mod tests { }), "expected ToolExecuting event, got {ui_events:?}" ); + let tool_result_event = ui_events + .iter() + .find_map(|event| match event { + AgentUiEvent::ToolResult { + name, + id, + summary, + is_error, + } => Some((name.clone(), id.clone(), summary.clone(), *is_error)), + _ => None, + }) + .expect("expected a ToolResult UI event"); + assert_eq!(tool_result_event.0, "missing_tool"); + assert_eq!(tool_result_event.1, "call_missing"); assert!( - ui_events.contains(&AgentUiEvent::ToolResult { - name: "missing_tool".to_string(), - id: "call_missing".to_string(), - summary: "1 error result(s)".to_string(), - }), - "expected ToolResult fallback summary, got {ui_events:?}" + tool_result_event.3, + "missing tool dispatch must flag the UI event as an error, got {ui_events:?}" + ); + assert!( + tool_result_event.2.contains("not registered"), + "expected the failure reason to reach the UI summary, got {:?}", + tool_result_event.2 ); assert!( ui_events @@ -1143,11 +1210,10 @@ mod tests { ui_events.push(event); } assert!( - ui_events.iter().any(|event| matches!( - event, - AgentUiEvent::Error(message) if message.contains("server_error") - )), - "expected provider Error surfaced to UI, got {ui_events:?}" + ui_events + .iter() + .all(|event| !matches!(event, AgentUiEvent::Error(_))), + "fatal provider errors are surfaced through the Result channel only: {ui_events:?}" ); } @@ -1161,7 +1227,7 @@ mod tests { "Agent response was cancelled before completion".to_string(), )]]); - let (ui_tx, mut _ui_rx) = mpsc::channel(16); + let (ui_tx, mut ui_rx) = mpsc::channel(16); let mut session = AgentSession::new(Box::new(provider), Arc::new(ToolRegistry::new()), ui_tx); session.thread_id = Some("resp_poisoned".to_string()); @@ -1187,6 +1253,16 @@ mod tests { None, "a bare Error must still clear the chain (belt-and-suspenders)" ); + let mut ui_events = Vec::new(); + while let Ok(event) = ui_rx.try_recv() { + ui_events.push(event); + } + assert!( + ui_events + .iter() + .all(|event| !matches!(event, AgentUiEvent::Error(_))), + "fatal provider errors are surfaced through the Result channel only: {ui_events:?}" + ); } #[tokio::test] diff --git a/core/agent/thread_export.rs b/core/agent/thread_export.rs new file mode 100644 index 00000000..7437281f --- /dev/null +++ b/core/agent/thread_export.rs @@ -0,0 +1,508 @@ +//! Render a persisted agent `Thread` to a Markdown transcript. +//! +//! Pure formatting — no filesystem. The bridge (`bridge/src/threads.rs`) owns +//! saving the returned string under `~/.codescribe/transcriptions/YYYY-MM-DD/`. +//! The shape mirrors the legacy voice-chat `chat_markdown_from_messages` +//! (`app/ui/voice_chat/api/export.rs`, removed in 37efe51): a thread heading, +//! export metadata, then one `## Role · timestamp` section per turn. +//! +//! Tool results are the exception. In the Anthropic/OpenAI protocol a tool +//! result is a `tool_result` content block carried inside a **user-role** +//! message; naively keying the section header off `message.role` mis-attributes +//! every tool payload (health JSON, MCP errors, search output) as if the user +//! had typed it. So tool-result blocks are lifted out of the role-based prose +//! flow and rendered under their own `## Tool · · timestamp` sections +//! (`## Tool result · timestamp` when the invoking tool's name can't be +//! resolved). Genuine user prose still renders as `## User`. + +use std::collections::HashMap; + +use chrono::{DateTime, Local, Utc}; +use serde_json::Value; + +use super::thread_store::{Thread, ThreadMessage}; + +/// A tool-result content block lifted out of its carrier message for rendering. +struct ToolResultEntry { + /// The `tool_use` id this result answers (used to resolve the tool name). + tool_use_id: String, + /// Flattened, human-readable body of the result. + body: String, + is_error: bool, +} + +/// Build a Markdown transcript for `thread`. +/// +/// `assistant_only = true` keeps only assistant turns (mirrors the legacy +/// "assistant replies only" export variant); otherwise every turn carrying +/// visible text is included. Turns whose content flattens to nothing (e.g. +/// pure tool-use / image payloads) are skipped either way. Tool results are +/// attributed to the tool, not the user, regardless of the carrier role. +pub fn thread_to_markdown(thread: &Thread, assistant_only: bool) -> String { + let exported_at = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let title = thread.title.trim(); + let heading = if title.is_empty() { + "Untitled thread" + } else { + title + }; + + let mut out = String::new(); + out.push_str(&format!("# {heading}\n\n")); + out.push_str(&format!("- exported_at: {exported_at}\n")); + out.push_str(&format!( + "- scope: {}\n\n", + if assistant_only { + "assistant_only" + } else { + "all" + } + )); + + // tool_use_id -> tool name, harvested from the assistant `tool_use` blocks + // so a `tool_result` (which only carries the id) can name its invoker. + let tool_names = build_tool_name_index(&thread.messages); + + for message in &thread.messages { + if assistant_only && !is_assistant(&message.role) { + continue; + } + + // Prose (real text the role authored) keeps the role-based header. This + // deliberately does not dive into `tool_result` blocks, so a user turn + // that merely relays tool output produces no phantom `## User` section. + let prose = flatten_prose(&message.content); + if !prose.trim().is_empty() { + out.push_str(&format!( + "## {} · {}\n\n", + role_label(&message.role), + local_timestamp(message.timestamp) + )); + out.push_str(prose.trim_end()); + out.push_str("\n\n"); + } + + // Tool results are attributed to the tool, not the carrier role. + for entry in collect_tool_results(&message.content) { + if entry.body.trim().is_empty() { + continue; + } + let mut header = match tool_names.get(&entry.tool_use_id) { + Some(name) => format!("Tool · {name}"), + None => "Tool result".to_string(), + }; + if entry.is_error { + header.push_str(" (error)"); + } + out.push_str(&format!( + "## {} · {}\n\n", + header, + local_timestamp(message.timestamp) + )); + out.push_str(entry.body.trim_end()); + out.push_str("\n\n"); + } + } + + out.trim_end().to_string() + "\n" +} + +fn is_assistant(role: &str) -> bool { + role.eq_ignore_ascii_case("assistant") +} + +/// Human-readable section label for a message role. Known roles get a fixed +/// capitalization; anything else is capitalized best-effort. +fn role_label(role: &str) -> String { + match role.to_ascii_lowercase().as_str() { + "user" => "User".to_string(), + "assistant" => "Assistant".to_string(), + "system" => "System".to_string(), + "tool" => "Tool".to_string(), + "" => "Message".to_string(), + other => { + let mut chars = other.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => other.to_string(), + } + } + } +} + +fn local_timestamp(ts: DateTime) -> String { + ts.with_timezone(&Local) + .format("%Y-%m-%d %H:%M") + .to_string() +} + +/// Map every `tool_use` block's id to its tool name across the whole thread. +/// The name lives on the assistant's `tool_use` block; a `tool_result` only +/// stores the referencing `tool_use_id`, so this index lets us name the tool +/// without persisting anything new alongside the result. +fn build_tool_name_index(messages: &[ThreadMessage]) -> HashMap { + let mut index = HashMap::new(); + for message in messages { + for value in &message.content { + gather_tool_names(value, &mut index); + } + } + index +} + +fn gather_tool_names(value: &Value, index: &mut HashMap) { + match value { + Value::Array(items) => { + for item in items { + gather_tool_names(item, index); + } + } + Value::Object(map) if map.get("type").and_then(Value::as_str) == Some("tool_use") => { + if let (Some(id), Some(name)) = ( + map.get("id").and_then(Value::as_str), + map.get("name").and_then(Value::as_str), + ) { + index.insert(id.to_string(), name.to_string()); + } + } + _ => {} + } +} + +/// Lift every `tool_result` block out of a message's content, flattening each +/// result's nested body for standalone rendering. +fn collect_tool_results(content: &[Value]) -> Vec { + let mut entries = Vec::new(); + for value in content { + gather_tool_results(value, &mut entries); + } + entries +} + +fn gather_tool_results(value: &Value, entries: &mut Vec) { + match value { + Value::Array(items) => { + for item in items { + gather_tool_results(item, entries); + } + } + Value::Object(map) if map.get("type").and_then(Value::as_str) == Some("tool_result") => { + let body = map + .get("content") + .map(flatten_tool_body) + .unwrap_or_default(); + entries.push(ToolResultEntry { + tool_use_id: map + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + body, + is_error: map + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + }); + } + _ => {} + } +} + +/// Flatten a message's prose content into a display string, skipping +/// `tool_result` blocks (rendered separately) so tool output never leaks into a +/// role-attributed section. See `collect_text` for block handling. +fn flatten_prose(content: &[Value]) -> String { + let mut chunks = Vec::new(); + for value in content { + collect_text(value, &mut chunks, false); + } + join_chunks(chunks) +} + +/// Flatten the nested content of a single `tool_result` block into its body, +/// recursing through the wrapped `text` blocks. +fn flatten_tool_body(content: &Value) -> String { + let mut chunks = Vec::new(); + collect_text(content, &mut chunks, true); + join_chunks(chunks) +} + +fn join_chunks(chunks: Vec) -> String { + chunks + .iter() + .map(|chunk| chunk.trim()) + .filter(|chunk| !chunk.is_empty()) + .collect::>() + .join("\n\n") +} + +/// Collect human-readable prose from a stored content `Value`, keyed on the +/// canonical `content_block_to_value` shapes (`thread_store.rs`): `text` blocks +/// contribute their text, with legacy `input_text` / `output_text` aliases read +/// the same way for already-persisted OpenAI-shaped files. `tool_use` / `image` +/// / `image_asset` blocks carry no display prose (skipped). `tool_result` blocks +/// are only descended into when `dive_tool_result` is set (i.e. when rendering a +/// result body via `flatten_tool_body`); prose flattening leaves them for +/// `collect_tool_results` so tool output is never attributed to the carrier +/// role. A field-only extractor (as opposed to a blind key walk) avoids emitting +/// structural values like the literal `"text"` / `"tool_use"` type tags. +/// Interior whitespace — newlines, fenced code blocks, list breaks — is +/// preserved; only each block's outer edges are trimmed. +fn collect_text(value: &Value, out: &mut Vec, dive_tool_result: bool) { + match value { + Value::String(text) if !text.trim().is_empty() => { + out.push(text.trim().to_string()); + } + Value::Array(items) => { + for item in items { + collect_text(item, out, dive_tool_result); + } + } + Value::Object(map) => match map.get("type").and_then(Value::as_str) { + Some("text") | Some("input_text") | Some("output_text") | None => { + if let Some(text) = map.get("text").and_then(Value::as_str) + && !text.trim().is_empty() + { + out.push(text.trim().to_string()); + } + } + Some("tool_result") => { + if dive_tool_result && let Some(nested) = map.get("content") { + collect_text(nested, out, dive_tool_result); + } + } + Some(_) => {} + }, + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use serde_json::json; + + fn message(role: &str, text: &str) -> super::super::thread_store::ThreadMessage { + super::super::thread_store::ThreadMessage { + role: role.to_string(), + content: vec![json!({ "type": "text", "text": text })], + timestamp: Utc.with_ymd_and_hms(2026, 7, 2, 9, 30, 0).unwrap(), + metadata: None, + } + } + + fn thread(messages: Vec) -> Thread { + Thread { + id: "t_test".to_string(), + created_at: Utc.with_ymd_and_hms(2026, 7, 2, 9, 0, 0).unwrap(), + updated_at: Utc.with_ymd_and_hms(2026, 7, 2, 9, 30, 0).unwrap(), + title: "auth-refactor".to_string(), + title_is_custom: true, + mode: "assistive".to_string(), + tags: vec![], + notes: vec![], + messages, + summary: None, + total_tokens: None, + provider: "anthropic".to_string(), + model: "claude".to_string(), + } + } + + #[test] + fn renders_heading_scope_roles_and_body() { + let md = thread_to_markdown( + &thread(vec![ + message("user", "where do we double-dispatch events?"), + message("assistant", "Two spots: bus.ts and store.ts."), + ]), + false, + ); + assert!( + md.starts_with("# auth-refactor\n"), + "heading is the thread title:\n{md}" + ); + assert!(md.contains("- scope: all\n")); + assert!(md.contains("## User · ")); + assert!(md.contains("## Assistant · ")); + assert!(md.contains("where do we double-dispatch events?")); + assert!(md.contains("Two spots: bus.ts and store.ts.")); + assert!(md.ends_with('\n')); + } + + #[test] + fn renders_legacy_openai_text_alias_blocks() { + let timestamp = Utc.with_ymd_and_hms(2026, 7, 2, 9, 31, 0).unwrap(); + let md = thread_to_markdown( + &thread(vec![ + super::super::thread_store::ThreadMessage { + role: "user".to_string(), + content: vec![json!({ "type": "input_text", "text": "legacy prompt" })], + timestamp, + metadata: None, + }, + super::super::thread_store::ThreadMessage { + role: "assistant".to_string(), + content: vec![json!({ "type": "output_text", "text": "legacy reply" })], + timestamp, + metadata: None, + }, + ]), + false, + ); + + assert!(md.contains("legacy prompt"), "\n{md}"); + assert!(md.contains("legacy reply"), "\n{md}"); + assert!(!md.contains("input_text"), "\n{md}"); + assert!(!md.contains("output_text"), "\n{md}"); + } + + #[test] + fn assistant_only_drops_non_assistant_turns() { + let md = thread_to_markdown( + &thread(vec![ + message("user", "please summarize"), + message("assistant", "Done — here is the summary."), + ]), + true, + ); + assert!(md.contains("- scope: assistant_only\n")); + assert!(!md.contains("## User"), "user turn must be excluded:\n{md}"); + assert!(!md.contains("please summarize")); + assert!(md.contains("## Assistant · ")); + assert!(md.contains("Done — here is the summary.")); + } + + #[test] + fn preserves_code_fences_and_line_breaks_in_export() { + // Regression: the .md export must keep fenced code blocks and list line + // breaks intact. `split_whitespace().join(" ")` collapsed them into a + // single paragraph; the fix in threads.rs never reached this exporter. + let raw = "Here:\n\n```rust\nfn main() {}\n```\n\n- a\n- b"; + let md = thread_to_markdown(&thread(vec![message("assistant", raw)]), false); + assert!( + md.contains("```rust\nfn main() {}\n```"), + "fenced code block must survive export:\n{md}" + ); + assert!( + md.contains("- a\n- b"), + "list line breaks must survive export:\n{md}" + ); + } + + #[test] + fn skips_turns_with_no_visible_text() { + // A tool-use-only turn (no plain text) should not emit a section. + let mut msgs = vec![message("assistant", "Result ready.")]; + msgs.insert( + 0, + super::super::thread_store::ThreadMessage { + role: "assistant".to_string(), + content: vec![ + json!({ "type": "tool_use", "id": "abc", "name": "grep", "input": {} }), + ], + timestamp: Utc.with_ymd_and_hms(2026, 7, 2, 9, 29, 0).unwrap(), + metadata: None, + }, + ); + let md = thread_to_markdown(&thread(msgs), false); + // Only one Assistant section (the tool-use-only turn is skipped). + assert_eq!(md.matches("## Assistant").count(), 1, "\n{md}"); + assert!(md.contains("Result ready.")); + } + + #[test] + fn tool_result_is_attributed_to_tool_not_user() { + // A user-role message that merely carries a tool_result must render + // under a Tool section, never `## User`. The tool name is resolved from + // the matching assistant `tool_use` block. + let tool_use = super::super::thread_store::ThreadMessage { + role: "assistant".to_string(), + content: vec![json!({ + "type": "tool_use", + "id": "call_1", + "name": "health", + "input": {}, + })], + timestamp: Utc.with_ymd_and_hms(2026, 7, 2, 9, 28, 0).unwrap(), + metadata: None, + }; + let tool_result = super::super::thread_store::ThreadMessage { + role: "user".to_string(), + content: vec![json!({ + "type": "tool_result", + "tool_use_id": "call_1", + "content": [{ "type": "text", "text": "{\"status\":\"ok\"}" }], + "is_error": false, + })], + timestamp: Utc.with_ymd_and_hms(2026, 7, 2, 9, 29, 0).unwrap(), + metadata: None, + }; + let md = thread_to_markdown( + &thread(vec![tool_use, tool_result, message("user", "thanks!")]), + false, + ); + assert!( + md.contains("## Tool · health · "), + "tool result must render under a named Tool section:\n{md}" + ); + assert!(md.contains("{\"status\":\"ok\"}"), "\n{md}"); + // Exactly one User section — the genuine prose turn, not the tool relay. + assert_eq!( + md.matches("## User").count(), + 1, + "only the real user prose is a User section:\n{md}" + ); + assert!(md.contains("thanks!")); + } + + #[test] + fn tool_result_without_matching_tool_use_falls_back_and_flags_errors() { + // No assistant tool_use to resolve the name -> "Tool result"; is_error + // is surfaced in the header. + let tool_result = super::super::thread_store::ThreadMessage { + role: "user".to_string(), + content: vec![json!({ + "type": "tool_result", + "tool_use_id": "orphan", + "content": [{ "type": "text", "text": "connection refused" }], + "is_error": true, + })], + timestamp: Utc.with_ymd_and_hms(2026, 7, 2, 9, 29, 0).unwrap(), + metadata: None, + }; + let md = thread_to_markdown(&thread(vec![tool_result]), false); + assert!( + md.contains("## Tool result (error) · "), + "unresolved erroring tool result must be flagged:\n{md}" + ); + assert!(md.contains("connection refused"), "\n{md}"); + assert!(!md.contains("## User"), "no user attribution:\n{md}"); + } + + #[test] + fn assistant_only_drops_tool_results_carried_by_user() { + // Tool results live in user-role messages, so assistant_only export + // (legacy "assistant replies only") must not include them. + let tool_result = super::super::thread_store::ThreadMessage { + role: "user".to_string(), + content: vec![json!({ + "type": "tool_result", + "tool_use_id": "call_1", + "content": [{ "type": "text", "text": "secret tool payload" }], + "is_error": false, + })], + timestamp: Utc.with_ymd_and_hms(2026, 7, 2, 9, 29, 0).unwrap(), + metadata: None, + }; + let md = thread_to_markdown( + &thread(vec![tool_result, message("assistant", "All set.")]), + true, + ); + assert!(!md.contains("## Tool"), "\n{md}"); + assert!(!md.contains("secret tool payload"), "\n{md}"); + assert!(md.contains("## Assistant · ")); + assert!(md.contains("All set.")); + } +} diff --git a/core/agent/thread_index.rs b/core/agent/thread_index.rs index c3761669..b14850b4 100644 --- a/core/agent/thread_index.rs +++ b/core/agent/thread_index.rs @@ -1,8 +1,10 @@ use std::borrow::Cow; +use std::collections::HashMap; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use unicode_segmentation::UnicodeSegmentation; @@ -10,7 +12,7 @@ use unicode_segmentation::UnicodeSegmentation; use super::thread_store::{Thread, ThreadMessage}; const INDEX_FILE_NAME: &str = "index.json"; -const INDEX_VERSION: u32 = 1; +const INDEX_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ThreadIndexData { @@ -131,12 +133,27 @@ impl ThreadIndex { ) })?; + // `path` joins a compile-time constant filename (`INDEX_FILE_NAME`) onto + // the store-owned `threads_dir`. No caller-supplied component reaches it, + // so there is no path-traversal source to taint. let path = threads_dir.join(INDEX_FILE_NAME); if path.exists() { - let raw = fs::read_to_string(&path) // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + let path = canonical_existing_child(threads_dir, &path)?; + let mut raw = String::new(); + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- path is canonicalized and checked to stay under threads_dir immediately above. + fs::File::open(&path) + .with_context(|| format!("Failed to open thread index: {}", path.display()))? + .read_to_string(&mut raw) .with_context(|| format!("Failed to read thread index: {}", path.display()))?; - let data = serde_json::from_str::(&raw) + let mut data = serde_json::from_str::(&raw) .with_context(|| format!("Failed to parse thread index: {}", path.display()))?; + if data.version < INDEX_VERSION { + let rebuild_dir = path.parent().unwrap_or(threads_dir); + data = rebuild_index_from_threads(rebuild_dir, &data)?; + let index = Self { path, data }; + index.save()?; + return Ok(index); + } return Ok(Self { path, data }); } @@ -288,6 +305,62 @@ fn build_search_text( out } +fn rebuild_index_from_threads( + threads_dir: &Path, + existing: &ThreadIndexData, +) -> Result { + let favorites_by_id = existing + .threads + .iter() + .map(|summary| (summary.id.clone(), summary.is_favorite)) + .collect::>(); + let mut threads = Vec::new(); + + // `threads_dir` is store-owned: the only caller passes `path.parent()` of the + // index file, which itself is `threads_dir.join(INDEX_FILE_NAME)`. No + // caller-supplied component reaches it, so there is no path-traversal source to taint. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- threads_dir is store-owned (derived from the store-owned index path), not caller-supplied. + for entry in fs::read_dir(threads_dir).with_context(|| { + format!( + "Failed to read threads directory: {}", + threads_dir.display() + ) + })? { + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + if !is_thread_json_file(&path) { + continue; + } + + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + let Ok(thread) = serde_json::from_str::(&raw) else { + continue; + }; + let is_favorite = favorites_by_id.get(&thread.id).copied().unwrap_or(false); + threads.push(ThreadSummary::from_thread(&thread, is_favorite)); + } + + sort_by_updated_desc(&mut threads); + Ok(ThreadIndexData { + version: INDEX_VERSION, + threads, + }) +} + +fn is_thread_json_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("t_")) + && path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) +} + fn append_search_chunk(out: &mut String, value: &str, max_len: usize) { if value.is_empty() || out.len() >= max_len { return; @@ -345,6 +418,10 @@ fn thread_message_preview_text(message: &ThreadMessage) -> Option { } } +/// Collect preview/search prose from stored thread content. Only text-like +/// blocks (`text`, plus legacy `input_text` / `output_text`) contribute their +/// `text`; `tool_result` recurses into nested content, and structural fields are +/// never walked blindly. fn collect_message_text(value: &serde_json::Value, out: &mut Vec) { match value { serde_json::Value::String(text) if !text.trim().is_empty() => { @@ -359,25 +436,22 @@ fn collect_message_text(value: &serde_json::Value, out: &mut Vec) { collect_message_text(item, out); } } - serde_json::Value::Object(map) => { - if let Some(text) = map.get("text").and_then(serde_json::Value::as_str) - && !text.trim().is_empty() - { - out.push(text.to_string()); - } - if let Some(content) = map.get("content") { - collect_message_text(content, out); - } - if let Some(input) = map.get("input") { - collect_message_text(input, out); + serde_json::Value::Object(map) => match map.get("type").and_then(serde_json::Value::as_str) + { + Some("text") | Some("input_text") | Some("output_text") | None => { + if let Some(text) = map.get("text").and_then(serde_json::Value::as_str) + && !text.trim().is_empty() + { + out.push(text.to_string()); + } } - for (key, nested) in map { - if matches!(key.as_str(), "text" | "content" | "input" | "data") { - continue; + Some("tool_result") => { + if let Some(content) = map.get("content") { + collect_message_text(content, out); } - collect_message_text(nested, out); } - } + Some(_) => {} + }, _ => {} } } @@ -428,6 +502,23 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { Ok(()) } +fn canonical_existing_child(base: &Path, path: &Path) -> Result { + let base = base + .canonicalize() + .with_context(|| format!("Failed to canonicalize base dir: {}", base.display()))?; + let path = path + .canonicalize() + .with_context(|| format!("Failed to canonicalize file path: {}", path.display()))?; + if !path.starts_with(&base) { + bail!( + "Thread index path escaped threads dir: {} outside {}", + path.display(), + base.display() + ); + } + Ok(path) +} + #[cfg(test)] mod tests { use anyhow::Result; @@ -451,6 +542,7 @@ mod tests { created_at: updated_at - Duration::minutes(5), updated_at, title: title.to_string(), + title_is_custom: false, mode: mode.to_string(), tags: vec!["vet".to_string(), "urgent".to_string()], notes: Vec::new(), @@ -586,6 +678,134 @@ mod tests { Ok(()) } + #[test] + fn legacy_openai_text_aliases_feed_preview_and_search_without_type_leak() -> Result<()> { + let tmp = TempDir::new()?; + let mut index = ThreadIndex::load_or_create(tmp.path())?; + + let mut thread = sample_thread( + "t_2026-07-05_legacy_aliases", + "Legacy aliases", + None, + "assistive", + 1, + ); + thread.messages = vec![ + ThreadMessage { + role: "user".to_string(), + content: vec![json!({"type":"input_text","text":"Owner asked about appetite"})], + timestamp: Utc::now(), + metadata: None, + }, + ThreadMessage { + role: "assistant".to_string(), + content: vec![ + json!({"type":"tool_use","id":"toolu_1","name":"search_threads","input":{"query":"output_text"}}), + json!({"type":"output_text","text":"Appetite improved overnight"}), + ], + timestamp: Utc::now(), + metadata: None, + }, + ]; + index.add(&thread)?; + + let summary = index + .list(None) + .into_iter() + .find(|summary| summary.id == "t_2026-07-05_legacy_aliases") + .expect("thread summary should be indexed"); + assert_eq!( + summary.latest_message.as_deref(), + Some("appetite improved overnight") + ); + assert!(summary.search_text.contains("owner asked about appetite")); + assert!(summary.search_text.contains("appetite improved overnight")); + assert!(!summary.search_text.contains("input_text")); + assert!(!summary.search_text.contains("output_text")); + + let results = index.search("appetite improved"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "t_2026-07-05_legacy_aliases"); + + Ok(()) + } + + #[test] + fn load_migrates_legacy_index_summaries_from_thread_files() -> Result<()> { + let tmp = TempDir::new()?; + let mut thread = sample_thread( + "t_2026-07-05_legacy_rebuild", + "Legacy rebuild", + None, + "assistive", + 1, + ); + thread.messages = vec![ + ThreadMessage { + role: "user".to_string(), + content: vec![json!({"type":"input_text","text":"Owner reports appetite"})], + timestamp: Utc::now(), + metadata: None, + }, + ThreadMessage { + role: "assistant".to_string(), + content: vec![json!({"type":"output_text","text":"Appetite improved overnight"})], + timestamp: Utc::now(), + metadata: None, + }, + ]; + fs::write( + tmp.path().join(format!("{}.json", thread.id)), + serde_json::to_vec_pretty(&thread)?, + )?; + fs::write(tmp.path().join("t_2026-07-05_broken.json"), "{not-json")?; + + let stale_index = ThreadIndexData { + version: 1, + threads: vec![ThreadSummary { + id: thread.id.clone(), + title: thread.title.clone(), + created_at: thread.created_at, + updated_at: thread.updated_at, + message_count: thread.messages.len(), + mode: thread.mode.clone(), + tags: thread.tags.clone(), + summary: None, + has_notes: false, + latest_message: Some("ai failed output_text".to_string()), + latest_note: None, + search_text: "ai failed output_text".to_string(), + is_favorite: true, + }], + }; + fs::write( + tmp.path().join(INDEX_FILE_NAME), + serde_json::to_vec_pretty(&stale_index)?, + )?; + + let index = ThreadIndex::load_or_create(tmp.path())?; + assert_eq!(index.data().version, INDEX_VERSION); + assert_eq!(index.data().threads.len(), 1); + let summary = &index.data().threads[0]; + assert_eq!(summary.id, thread.id); + assert!(summary.is_favorite); + assert_eq!( + summary.latest_message.as_deref(), + Some("appetite improved overnight") + ); + assert!(summary.search_text.contains("owner reports appetite")); + assert!(summary.search_text.contains("appetite improved overnight")); + assert!(!summary.search_text.contains("input_text")); + assert!(!summary.search_text.contains("output_text")); + assert!(!summary.search_text.contains("ai failed output_text")); + + let reloaded = ThreadIndex::load_or_create(tmp.path())?; + assert_eq!(reloaded.data().version, INDEX_VERSION); + assert!(reloaded.data().threads[0].is_favorite); + + Ok(()) + } + #[test] fn append_search_chunk_respects_grapheme_boundary_at_limit() { let mut out = "x".repeat(16_382); diff --git a/core/agent/thread_store.rs b/core/agent/thread_store.rs index 5c03d565..7d24e1fe 100644 --- a/core/agent/thread_store.rs +++ b/core/agent/thread_store.rs @@ -8,7 +8,7 @@ use rand::distributions::Alphanumeric; use rand::{Rng, thread_rng}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use tracing::debug; +use tracing::{debug, warn}; use super::thread_index::ThreadIndex; use super::types::{ContentBlock, Message, Role}; @@ -23,6 +23,12 @@ pub struct Thread { pub created_at: DateTime, pub updated_at: DateTime, pub title: String, + /// True when the user renamed the thread by hand. Auto-titling (deriving a + /// title from the first message) must never overwrite a custom title. + /// `#[serde(default)]` keeps threads saved before this field deserializable + /// (they default to auto-titled). + #[serde(default)] + pub title_is_custom: bool, pub mode: String, pub tags: Vec, pub notes: Vec, @@ -136,8 +142,7 @@ impl ThreadStore { } pub fn save_thread(&self, thread: &Thread) -> Result<()> { - validate_thread_id(&thread.id)?; - let path = self.thread_path(&thread.id); + let path = self.thread_path(&thread.id)?; let json = serde_json::to_vec_pretty(thread).context("Failed to serialize thread JSON")?; atomic_write(&path, &json)?; @@ -147,9 +152,9 @@ impl ThreadStore { } pub fn load_thread(&self, id: &str) -> Result { - validate_thread_id(id)?; - let path = self.thread_path(id); - let raw = fs::read_to_string(&path) // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + let path = self.thread_path(id)?; + let path = canonical_existing_child(&self.threads_dir, &path)?; + let raw = fs::read_to_string(&path) .with_context(|| format!("Failed to read thread file: {}", path.display()))?; let thread = serde_json::from_str::(&raw) .with_context(|| format!("Failed to parse thread file: {}", path.display()))?; @@ -157,8 +162,7 @@ impl ThreadStore { } pub fn delete_thread(&self, id: &str) -> Result<()> { - validate_thread_id(id)?; - let path = self.thread_path(id); + let path = self.thread_path(id)?; if path.exists() { fs::remove_file(&path) .with_context(|| format!("Failed to remove thread file: {}", path.display()))?; @@ -176,9 +180,27 @@ impl ThreadStore { index.set_favorite(id, is_favorite) } + /// Rename a thread and mark the title as user-custom so later auto-titling + /// won't clobber it. Returns `false` when no such thread exists on disk. + /// `updated_at` is left untouched so a rename does not reorder the rail. + pub fn set_thread_title(&self, id: &str, title: &str) -> Result { + let trimmed = title.trim(); + if trimmed.is_empty() { + bail!("Thread title cannot be empty"); + } + let path = self.thread_path(id)?; + if !path.exists() { + return Ok(false); + } + let mut thread = self.load_thread(id)?; + thread.title = trimmed.to_string(); + thread.title_is_custom = true; + self.save_thread(&thread)?; + Ok(true) + } + pub fn thread_file_path(&self, id: &str) -> Result { - validate_thread_id(id)?; - Ok(self.thread_path(id)) + self.thread_path(id) } pub fn save_blob(&self, data: &[u8], name: &str) -> Result { @@ -200,8 +222,16 @@ impl ThreadStore { &self.blobs_dir } - fn thread_path(&self, id: &str) -> PathBuf { - self.threads_dir.join(format!("{id}.{THREAD_FILE_EXT}")) + /// Build the on-disk path for a thread id. + /// + /// Validation lives here — at path *construction* — so every caller + /// (current or future) that turns an id into a filesystem path is forced + /// through `validate_thread_id`. This keeps the path-traversal guard + /// adjacent to the join that produces the path, instead of relying on each + /// API entry point to remember to validate first. + fn thread_path(&self, id: &str) -> Result { + validate_thread_id(id)?; + Ok(self.threads_dir.join(format!("{id}.{THREAD_FILE_EXT}"))) } fn unique_blob_path(&self, file_name: &str) -> PathBuf { @@ -242,10 +272,10 @@ fn app_data_dir() -> PathBuf { } BaseDirs::new() - .map(|dirs| dirs.data_dir().join("CodeScribe")) + .map(|dirs| dirs.data_dir().join("Codescribe")) .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); - PathBuf::from(home).join("Library/Application Support/CodeScribe") + PathBuf::from(home).join("Library/Application Support/Codescribe") }) } @@ -287,6 +317,23 @@ fn validate_thread_id(id: &str) -> Result<()> { Ok(()) } +fn canonical_existing_child(base: &Path, path: &Path) -> Result { + let base = base + .canonicalize() + .with_context(|| format!("Failed to canonicalize base dir: {}", base.display()))?; + let path = path + .canonicalize() + .with_context(|| format!("Failed to canonicalize file path: {}", path.display()))?; + if !path.starts_with(&base) { + bail!( + "Thread path escaped threads dir: {} outside {}", + path.display(), + base.display() + ); + } + Ok(path) +} + fn role_to_string(role: Role) -> &'static str { match role { Role::User => "user", @@ -309,12 +356,37 @@ fn content_block_to_value(block: &ContentBlock) -> Value { "type": "text", "text": text, }), - ContentBlock::Image { data, media_type } => json!({ - "type": "image", - "media_type": media_type, - "size_bytes": data.len(), - "data_omitted": true, - }), + // Inline images (composer attachments) are spilled to the shared + // agent asset store so they survive the persist/restore roundtrip — + // the JSON thread file itself never carries image bytes. Empty blocks + // (restored from pre-asset thread files) and failed spills keep the + // explicit `data_omitted` marker instead of minting an empty asset. + ContentBlock::Image { data, media_type } => { + let data_omitted = || { + json!({ + "type": "image", + "media_type": media_type, + "size_bytes": data.len(), + "data_omitted": true, + }) + }; + if data.is_empty() { + return data_omitted(); + } + match crate::agent::AgentAssetStore::save_inline_image(data, media_type) { + Ok(asset) => json!({ + "type": "image_asset", + "asset_id": asset.asset_id, + "path": asset.path, + "media_type": asset.media_type, + "size_bytes": asset.size_bytes, + }), + Err(error) => { + warn!("Failed to persist inline image as disk-backed asset: {error}"); + data_omitted() + } + } + } ContentBlock::ImageAsset(asset) => json!({ "type": "image_asset", "asset_id": asset.asset_id, @@ -347,7 +419,7 @@ fn value_to_content_block(value: &Value) -> ContentBlock { }; match value_type { - "text" => ContentBlock::Text( + "text" | "input_text" | "output_text" => ContentBlock::Text( value .get("text") .and_then(Value::as_str) @@ -473,6 +545,7 @@ mod tests { created_at: updated_at - Duration::minutes(10), updated_at, title: "Parvo patient follow-up".to_string(), + title_is_custom: false, mode: "assistive".to_string(), tags: vec!["urgent".to_string(), "canine".to_string()], notes: vec![ThreadNote { @@ -522,6 +595,30 @@ mod tests { Ok(()) } + #[test] + fn legacy_openai_text_aliases_restore_as_plain_text() { + let message = ThreadMessage { + role: "assistant".to_string(), + content: vec![ + json!({"type":"input_text","text":"Owner asked about appetite"}), + json!({"type":"output_text","text":"Appetite improved overnight"}), + ], + timestamp: Utc::now(), + metadata: None, + } + .to_message(); + + assert_eq!(message.content.len(), 2); + match &message.content[0] { + ContentBlock::Text(text) => assert_eq!(text, "Owner asked about appetite"), + other => panic!("input_text restored as unexpected block: {other:?}"), + } + match &message.content[1] { + ContentBlock::Text(text) => assert_eq!(text, "Appetite improved overnight"), + other => panic!("output_text restored as unexpected block: {other:?}"), + } + } + #[test] fn delete_removes_thread_and_index_entry() -> Result<()> { let tmp = TempDir::new()?; @@ -531,7 +628,7 @@ mod tests { store.save_thread(&thread)?; store.delete_thread(&thread.id)?; - let path = store.thread_path(&thread.id); + let path = store.thread_path(&thread.id)?; assert!(!path.exists()); let index = ThreadIndex::load_or_create(store.threads_dir())?; @@ -628,6 +725,153 @@ mod tests { Ok(()) } + #[test] + fn set_thread_title_marks_custom_and_persists() -> Result<()> { + let tmp = TempDir::new()?; + let store = ThreadStore::new_in(tmp.path().join("threads"))?; + let thread = sample_thread(ThreadStore::generate_id(), Utc::now()); + let id = thread.id.clone(); + store.save_thread(&thread)?; + + let renamed = store.set_thread_title(&id, " Custom name ")?; + assert!(renamed); + + let loaded = store.load_thread(&id)?; + assert_eq!(loaded.title, "Custom name"); + assert!( + loaded.title_is_custom, + "rename marks the title as user-custom" + ); + + let index = ThreadIndex::load_or_create(store.threads_dir())?; + let summary = index + .data() + .threads + .iter() + .find(|value| value.id == id) + .expect("summary should exist"); + assert_eq!(summary.title, "Custom name", "index reflects renamed title"); + + assert!( + store.set_thread_title(&id, " ").is_err(), + "empty title is rejected" + ); + assert!( + !store.set_thread_title("t_2026-01-01_missing", "x")?, + "renaming an absent thread returns false" + ); + Ok(()) + } + + #[test] + fn inline_image_roundtrips_through_disk_backed_asset() -> Result<()> { + let tmp = TempDir::new()?; + let store = ThreadStore::new_in(tmp.path().join("threads"))?; + let image_bytes = b"w5a-inline-roundtrip-bytes".to_vec(); + + let message = Message { + role: Role::User, + content: vec![ + ContentBlock::Text("look at this".to_string()), + ContentBlock::Image { + data: image_bytes.clone(), + media_type: "image/png".to_string(), + }, + ], + timestamp: Some(Utc::now()), + }; + + let mut thread = sample_thread(ThreadStore::generate_id(), Utc::now()); + thread.messages = vec![ThreadMessage::from(&message)]; + store.save_thread(&thread)?; + + let restored = store.load_thread(&thread.id)?.messages[0].to_message(); + let ContentBlock::ImageAsset(asset) = &restored.content[1] else { + panic!( + "inline image should restore as a disk-backed asset, got: {:?}", + restored.content[1] + ); + }; + let data = crate::agent::AgentAssetStore::read_image(&asset.path)?; + assert_eq!( + data, image_bytes, + "restored asset bytes must match the original image" + ); + assert_eq!(asset.size_bytes, image_bytes.len() as u64); + assert_eq!(asset.media_type, "image/png"); + + // The persisted thread JSON must not carry raw image bytes. + let raw = fs::read_to_string(store.thread_file_path(&thread.id)?)?; + assert!(raw.contains("image_asset")); + assert!(!raw.contains("data_omitted")); + + fs::remove_file(&asset.path).ok(); + Ok(()) + } + + #[test] + fn inline_image_asset_is_written_once_across_saves() -> Result<()> { + let block = ContentBlock::Image { + data: b"w5a-dedup-bytes".to_vec(), + media_type: "image/png".to_string(), + }; + + let first = content_block_to_value(&block); + let path = PathBuf::from( + first + .get("path") + .and_then(Value::as_str) + .expect("persisted inline image should carry an asset path"), + ); + assert!(path.exists(), "first persist must write the asset file"); + + // Simulate a pre-existing asset: if the second persist rewrote the + // file, the sentinel would be clobbered. + fs::write(&path, b"sentinel")?; + let second = content_block_to_value(&block); + assert_eq!( + first, second, + "same bytes must map to the same asset across saves" + ); + assert_eq!( + fs::read(&path)?, + b"sentinel", + "existing asset must be referenced, not rewritten" + ); + + fs::remove_file(&path).ok(); + Ok(()) + } + + #[test] + fn legacy_data_omitted_image_restores_without_bytes_and_repersists_safely() { + let legacy = json!({ + "type": "image", + "media_type": "image/png", + "size_bytes": 123, + "data_omitted": true, + }); + + let block = value_to_content_block(&legacy); + let ContentBlock::Image { data, media_type } = &block else { + panic!("legacy image value should restore as an image block: {block:?}"); + }; + assert!(data.is_empty(), "legacy blocks carry no bytes by design"); + assert_eq!(media_type, "image/png"); + + // Re-persisting a byteless block keeps the explicit degraded marker + // instead of minting an empty asset. + let repersisted = content_block_to_value(&block); + assert_eq!( + repersisted.get("data_omitted").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + repersisted.get("type").and_then(Value::as_str), + Some("image") + ); + } + #[test] fn thread_file_path_validates_id() -> Result<()> { let tmp = TempDir::new()?; diff --git a/core/attachment.rs b/core/attachment.rs index 72dd6b07..3dd9376b 100644 --- a/core/attachment.rs +++ b/core/attachment.rs @@ -1,4 +1,4 @@ -//! Attachment model and on-disk store for CodeScribe. +//! Attachment model and on-disk store for Codescribe. //! //! Provides a thin wrapper around file paths with metadata (kind, source, //! display name) used by the Agent chat UI and LLM context pipeline. @@ -194,6 +194,125 @@ fn kind_from_extension(path: &Path) -> AttachmentKind { } } +// ═══════════════════════════════════════════════════════════ +// Vision attachment parsing (shared by agent + legacy send paths) +// ═══════════════════════════════════════════════════════════ + +/// Marker line emitted by `build_attachments_block` that introduces the list of +/// image file paths appended to a chat payload as text. +pub const IMAGE_PATHS_MARKER: &str = "ATTACHMENTS (image paths)"; + +/// Default per-image byte cap honored when loading images for vision input. +pub const MAX_VISION_IMAGE_BYTES: u64 = 8 * 1024 * 1024; + +/// MIME media type for a vision-supported image, inferred from extension. +/// +/// Returns `None` for extensions the model APIs do not accept as image input +/// (e.g. `svg`, `heic`, `raw`), even though [`AttachmentKind::Image`] classifies +/// them as images for UI purposes. +pub fn image_media_type(path: &Path) -> Option<&'static str> { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "webp" => Some("image/webp"), + "gif" => Some("image/gif"), + "bmp" => Some("image/bmp"), + "tif" | "tiff" => Some("image/tiff"), + _ => None, + } +} + +/// Split a chat payload into its visible text and the image paths listed under +/// the `ATTACHMENTS (image paths)` marker appended by `build_attachments_block`. +/// +/// The marker block (and a dangling `---`/`—` separator directly above it) is +/// removed from the returned text so the model never sees raw file paths where a +/// real vision input belongs. The original payload is returned verbatim in +/// `.0` when no marker is present, so non-attachment messages pass through +/// unchanged. +pub fn parse_image_attachment_block(text: &str) -> (String, Vec) { + let mut out_lines: Vec = Vec::new(); + let mut image_paths: Vec = Vec::new(); + let mut in_block = false; + + for line in text.lines() { + let trimmed = line.trim(); + + if trimmed == IMAGE_PATHS_MARKER { + // Drop a preceding separator if present to avoid leaving a dangling "---". + if out_lines + .last() + .is_some_and(|l| l.trim() == "---" || l.trim() == "—") + { + out_lines.pop(); + } + in_block = true; + continue; + } + + if in_block { + if trimmed.is_empty() { + in_block = false; + continue; + } + if let Some(rest) = trimmed.strip_prefix("- ") { + let p = rest.trim(); + if !p.is_empty() { + image_paths.push(PathBuf::from(p)); + } + continue; + } + // Unexpected line → end block, keep the line. + in_block = false; + out_lines.push(line.to_string()); + continue; + } + + out_lines.push(line.to_string()); + } + + (out_lines.join("\n"), image_paths) +} + +/// Load an image file as `(bytes, media_type)` for vision input. +/// +/// Returns `None` (with a warning) when the extension is not a vision-supported +/// image, the file is unreadable, or it exceeds `max_bytes`. +pub fn load_image_for_vision(path: &Path, max_bytes: u64) -> Option<(Vec, String)> { + let media_type = image_media_type(path)?; + + let meta = std::fs::metadata(path).ok()?; + if meta.len() > max_bytes { + warn!( + "Skipping image attachment (too large, {} bytes > {} max): {}", + meta.len(), + max_bytes, + path.display() + ); + return None; + } + + match std::fs::read(path) { + Ok(bytes) if bytes.is_empty() => { + // An empty/zero-byte file would encode to an empty base64 string, + // which providers reject ("empty base64-encoded bytes") and which + // fails the whole request. Drop it instead. + warn!("Skipping image attachment (empty file): {}", path.display()); + None + } + Ok(bytes) => Some((bytes, media_type.to_string())), + Err(e) => { + warn!("Failed to read image attachment {}: {}", path.display(), e); + None + } + } +} + // ═══════════════════════════════════════════════════════════ // Attachment Store // ═══════════════════════════════════════════════════════════ @@ -449,6 +568,82 @@ mod tests { assert_eq!(sanitize_filename(&many_dots), "abc.txt"); } + #[test] + fn test_image_media_type() { + assert_eq!(image_media_type(Path::new("a.png")), Some("image/png")); + assert_eq!(image_media_type(Path::new("a.JPG")), Some("image/jpeg")); + assert_eq!(image_media_type(Path::new("a.jpeg")), Some("image/jpeg")); + assert_eq!(image_media_type(Path::new("a.webp")), Some("image/webp")); + assert_eq!(image_media_type(Path::new("a.tiff")), Some("image/tiff")); + // Not accepted as vision input despite being "images" for the UI. + assert_eq!(image_media_type(Path::new("a.svg")), None); + assert_eq!(image_media_type(Path::new("a.heic")), None); + assert_eq!(image_media_type(Path::new("a.txt")), None); + assert_eq!(image_media_type(Path::new("noext")), None); + } + + #[test] + fn test_parse_image_attachment_block_passthrough() { + // No marker → text returned unchanged, no paths. + let text = "just a normal message\nwith two lines"; + let (cleaned, paths) = parse_image_attachment_block(text); + assert_eq!(cleaned, text); + assert!(paths.is_empty()); + } + + #[test] + fn test_parse_image_attachment_block_extracts_paths() { + let text = "Look at these\n\n---\nATTACHMENTS (image paths)\n- /tmp/a.png\n- /tmp/b.jpg\n"; + let (cleaned, paths) = parse_image_attachment_block(text); + assert_eq!( + paths, + vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.jpg")] + ); + // The marker block and the dangling separator are stripped. + assert!(!cleaned.contains(IMAGE_PATHS_MARKER)); + assert!(!cleaned.contains("/tmp/a.png")); + assert!(cleaned.contains("Look at these")); + assert!(!cleaned.trim_end().ends_with("---")); + } + + #[test] + fn test_parse_image_attachment_block_stops_at_blank_line() { + let text = "msg\nATTACHMENTS (image paths)\n- /tmp/a.png\n\ntrailing text kept"; + let (cleaned, paths) = parse_image_attachment_block(text); + assert_eq!(paths, vec![PathBuf::from("/tmp/a.png")]); + assert!(cleaned.contains("trailing text kept")); + } + + #[test] + fn test_load_image_for_vision_rejects_oversize_and_nonimage() { + let dir = std::env::temp_dir().join(format!("cs_vision_test_{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + + let png = dir.join("small.png"); + std::fs::write(&png, b"\x89PNG\r\n\x1a\nfake").unwrap(); + let loaded = load_image_for_vision(&png, MAX_VISION_IMAGE_BYTES); + assert!(loaded.is_some()); + let (bytes, mt) = loaded.unwrap(); + assert_eq!(mt, "image/png"); + assert!(!bytes.is_empty()); + + // Oversize → rejected. + assert!(load_image_for_vision(&png, 2).is_none()); + + // Non-vision extension → rejected even if file exists. + let txt = dir.join("note.txt"); + std::fs::write(&txt, b"hello").unwrap(); + assert!(load_image_for_vision(&txt, MAX_VISION_IMAGE_BYTES).is_none()); + + // Empty (0-byte) image → rejected: an empty base64 payload would make + // the provider reject the whole request. + let empty = dir.join("empty.png"); + std::fs::write(&empty, b"").unwrap(); + assert!(load_image_for_vision(&empty, MAX_VISION_IMAGE_BYTES).is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn test_paths_extraction() { let attachments = vec![ diff --git a/core/audio/chunker.rs b/core/audio/chunker.rs index 7586c563..e09373ff 100644 --- a/core/audio/chunker.rs +++ b/core/audio/chunker.rs @@ -28,6 +28,7 @@ use crate::vad; // ═══════════════════════════════════════════════════════════ const SILERO_GATE_MODE: VadGateMode = VadGateMode::Supervisor; +pub(crate) const DEFAULT_BUFFERED_SILENCE_SEC: f32 = 1.2; // ═══════════════════════════════════════════════════════════ // Public types @@ -515,9 +516,15 @@ impl SpeechSession { self.last_boundary_prob = speech_prob; } - // Speech-time integrity: count only Silero-positive VAD frames while - // a Supervisor segment is active. - if self.segment_start.is_some() && speech_prob >= self.threshold { + // Speech-time integrity: count Silero-positive VAD frames while a + // Supervisor segment is active. Use `neg_threshold` (the same + // segment-open hysteresis used by `gate_with_prob`), NOT the higher + // onset `threshold`: speech hovering in the 0.35–0.50 band keeps the + // segment open, so counting it at >= threshold under-reports real + // speech and makes the downstream silence-drop ratio gate + // (`should_drop_silence_chunk`) wrongly classify present speech as + // silence. + if self.segment_start.is_some() && speech_prob >= self.neg_threshold { self.pending_event_speech_vad_samples = self .pending_event_speech_vad_samples .saturating_add(vad::CHUNK_SIZE as u64); @@ -960,10 +967,18 @@ impl SpeechSession { } /// Override VAD threshold (test-only). Set impossibly high to prevent - /// VadIterState from firing `Start`. + /// VadIterState from firing `Start`, or below zero to force a deterministic + /// open segment when the Silero model is unavailable. + /// + /// Forces `neg_threshold` to the same value: the speech-sample accounting + /// (see `feed`) now counts frames at `neg_threshold`, not `threshold`, so a + /// test that drives the onset threshold below the silence floor must move + /// the hysteresis bound with it — otherwise forced-open segments would + /// report zero accounted speech. #[cfg(test)] pub fn set_vad_threshold_for_test(&mut self, threshold: f32) { self.threshold = threshold; + self.neg_threshold = threshold; if let Some(iter_state) = self.iter_state.as_mut() { iter_state.params.threshold = threshold; } @@ -1126,13 +1141,13 @@ pub(crate) fn hardcoded_gate_config() -> GateConfig { } pub(crate) fn hardcoded_utterance_gate_config() -> GateConfig { - let mut vad_cfg = vad::VadConfig::default(); - - // Optional per-utterance override (buffered mode). This is separate from global VAD silence - // and exists for buffered mode only. - if let Some(sec) = utterance_silence_sec_override() { - vad_cfg.max_silence_duration_sec = sec; - } + // Buffered mode feeds final utterances through the serialized Commit lane. + // Keep the base stream VAD at 0.3s, but avoid over-fragmenting buffered speech. + let vad_cfg = vad::VadConfig { + max_silence_duration_sec: utterance_silence_sec_override() + .unwrap_or(DEFAULT_BUFFERED_SILENCE_SEC), + ..vad::VadConfig::default() + }; let pre_roll = vad_cfg.pre_roll_sec; let speech_pad = pre_roll; @@ -1445,7 +1460,7 @@ mod tests { #[test] #[serial] - fn utterance_default_silence_is_not_forced_to_stream_default() { + fn utterance_default_silence_uses_buffered_cadence_default() { let _g = EnvGuard::unset("CODESCRIBE_BUFFERED_SILENCE_SEC"); let sr = 16000u32; @@ -1459,9 +1474,8 @@ mod tests { .max(1.0) as usize; assert_eq!(stream.min_silence_samples(), stream_expected); - let utter_expected = (base.max_silence_duration_sec * vad::VAD_SAMPLE_RATE as f32) - .round() - .max(1.0) as usize; + let utter_expected = + (DEFAULT_BUFFERED_SILENCE_SEC * vad::VAD_SAMPLE_RATE as f32).round() as usize; assert_eq!(utterance.min_silence_samples(), utter_expected); } diff --git a/core/audio/recorder.rs b/core/audio/recorder.rs index 3496008d..5db5d302 100644 --- a/core/audio/recorder.rs +++ b/core/audio/recorder.rs @@ -36,7 +36,7 @@ // recorder.start().await?; // println!("Recording... speak now!"); // -// // Record for 3 seconds (or until silence detected) +// // Record for 3 seconds (or until silence detected when enabled) // tokio::time::sleep(Duration::from_secs(3)).await; // // // Stop and save to WAV file @@ -54,15 +54,16 @@ // configuration via constants in `core/vad/config.rs`: // - speech probability threshold (Silero default: 0.5) // - silence duration before auto-stop (Silero default profile) -// - AUTO_SILENCE: enable/disable silence detection (default: true) +// - AUTO_SILENCE: enable/disable silence detection (default: false) use crate::vad; use anyhow::{Context, Result}; 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::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -143,10 +144,14 @@ const CHANNELS: u16 = 1; /// Below this is considered silence. Default: 0.5 /// Silence duration threshold (seconds) /// Recording stops automatically after this duration of continuous silence. -/// Synced with VadConfig::max_silence_duration_sec default (1.2s) +/// Synced with `vad::config::SILERO_DEFAULT_MAX_SILENCE_SEC`. /// Size of audio chunks to read from stream (samples) const BLOCK_SIZE: usize = 1024; +/// Retain at most this much mono i16 audio when a streaming callback consumes +/// the live samples. Non-streaming recorder usage still keeps the full take. +const STREAMING_BUFFER_CAP_SECONDS: usize = 300; + // --- configuration --- #[derive(Debug, Clone)] @@ -176,9 +181,9 @@ impl Default for RecorderConfig { channels: CHANNELS, speech_threshold: vad_cfg.threshold.clamp(0.1, 0.9), hang_sec: vad_cfg.max_silence_duration_sec.clamp(0.1, 10.0), - auto_silence: std::env::var("AUTO_SILENCE") - .map(|v| !matches!(v.to_lowercase().as_str(), "0" | "false" | "no" | "off")) - .unwrap_or(true), + auto_silence: auto_silence_from_env_value( + std::env::var("AUTO_SILENCE").ok().as_deref(), + ), block_size: BLOCK_SIZE, } } @@ -212,7 +217,7 @@ pub struct SnapshotResult { // --- audio buffer --- -type AudioBuffer = Arc>>; +type AudioBuffer = Arc>>; pub type AudioCallback = Box; @@ -221,6 +226,7 @@ pub type AudioCallback = Box; pub struct Recorder { pub config: RecorderConfig, buffer: AudioBuffer, + buffer_start_offset: Arc, stream: Option, device: Option, is_recording: Arc, @@ -237,7 +243,7 @@ pub struct Recorder { } // Safety: Recorder can be sent between threads because: -// - AudioBuffer (Arc>>) is Send +// - AudioBuffer (Arc>>) is Send // - Stream operations are thread-safe (internally uses Arc) // - All other fields are Send unsafe impl Send for Recorder {} @@ -264,7 +270,8 @@ impl Recorder { Ok(Self { config: config.clone(), - buffer: Arc::new(Mutex::new(Vec::new())), + buffer: Arc::new(Mutex::new(VecDeque::new())), + buffer_start_offset: Arc::new(AtomicUsize::new(0)), stream: None, device: None, is_recording: Arc::new(AtomicBool::new(false)), @@ -283,6 +290,14 @@ impl Recorder { self.on_data = Some(callback); } + fn warn_inert_vad_stop_callback(&self, context: &str) { + if self.on_vad_stop.is_some() && !self.config.auto_silence { + warn!( + "{context}: VAD stop callback is registered but auto_silence is disabled; callback will remain inert until auto_silence is enabled" + ); + } + } + /// Set a callback invoked when VAD (silence detection) stops recording pub fn set_on_vad_stop(&mut self, callback: F) where @@ -335,7 +350,9 @@ impl Recorder { .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); + self.buffer_start_offset.store(0, Ordering::SeqCst); self.diagnostics = RecorderDiagnostics::default(); + self.warn_inert_vad_stop_callback("Recorder start"); // Select input device let host = cpal::default_host(); @@ -384,17 +401,19 @@ impl Recorder { // Use the device's native sample rate for compatibility // (backend will handle resampling if needed) let native_sample_rate = supported_config.sample_rate(); + let native_channels = supported_config.channels().max(1); - // Build stream config using native sample rate + // Build stream config using native sample rate/channel count. The + // callback downmixes interleaved native channels to mono for downstream. let stream_config = StreamConfig { - channels: self.config.channels, + channels: native_channels, sample_rate: native_sample_rate, buffer_size: cpal::BufferSize::Default, // Let system choose buffer size }; info!( - "Audio stream config: {:?} (native rate: {}Hz)", - stream_config, native_sample_rate + "Audio stream config: {:?} (native rate: {}Hz, native channels: {})", + stream_config, native_sample_rate, native_channels ); // Store actual sample rate for WAV file and duration calculations @@ -416,17 +435,22 @@ impl Recorder { // Setup stream callback let buffer = Arc::clone(&self.buffer); + let buffer_start_offset = Arc::clone(&self.buffer_start_offset); let is_recording_data = Arc::clone(&self.is_recording); let is_recording_error = Arc::clone(&self.is_recording); let speech_threshold = self.config.speech_threshold; let hang_sec = self.config.hang_sec; let auto_silence = self.config.auto_silence; + let native_channels = usize::from(native_channels); let silent_frames = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let silent_frames_clone = Arc::clone(&silent_frames); let seen_speech = Arc::new(std::sync::atomic::AtomicBool::new(false)); let seen_speech_clone = Arc::clone(&seen_speech); + let has_streaming_callback = self.on_data.is_some(); let on_data = self.on_data.take(); + let buffer_cap_samples = + has_streaming_callback.then(|| streaming_buffer_cap_samples(native_sample_rate)); // Share local VAD with audio callback (RecorderVad is Send via channel+atomic) let vad_prob = self @@ -442,24 +466,26 @@ impl Recorder { .build_input_stream( &stream_config, move |data: &[f32], _: &cpal::InputCallbackInfo| { + let mono_samples = downmix_to_mono(data, native_channels); + // Send data to callback if present if let Some(ref cb) = on_data { - cb(data); + cb(&mono_samples); } - // Convert f32 samples to i16 and append to buffer + // Convert mono f32 samples to i16 and append to buffer. if let Ok(mut buf) = buffer.lock() { - for &sample in data { - // Clamp and convert f32 [-1.0, 1.0] to i16 - let clamped = sample.clamp(-1.0, 1.0); - let i16_sample = (clamped * i16::MAX as f32) as i16; - buf.push(i16_sample); - } + append_mono_i16_samples( + &mut buf, + &buffer_start_offset, + &mono_samples, + buffer_cap_samples, + ); } // Feed audio to local VAD (non-blocking) if let Some(ref tx) = vad_tx { - let _ = tx.try_send(data.to_vec()); + let _ = tx.try_send(mono_samples.clone()); } // Read latest speech probability from local VAD @@ -482,7 +508,7 @@ impl Recorder { // Check for silence using VAD (only after speech has been observed) if seen_speech_clone.load(Ordering::SeqCst) { if is_silence { - silent_frames_clone.fetch_add(data.len(), Ordering::SeqCst); + silent_frames_clone.fetch_add(mono_samples.len(), Ordering::SeqCst); } else { silent_frames_clone.store(0, Ordering::SeqCst); } @@ -533,10 +559,16 @@ impl Recorder { _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => { if !is_recording_clone.load(Ordering::SeqCst) { debug!("Recording stopped by silence detection"); - // Invoke VAD callback if set - if let Some(ref callback) = on_vad_stop_clone { - info!("VAD triggered - invoking callback"); - callback(); + let callback_registered = on_vad_stop_clone.is_some(); + if should_invoke_vad_stop_callback(auto_silence, callback_registered) { + if let Some(ref callback) = on_vad_stop_clone { + info!("VAD triggered - invoking callback"); + callback(); + } + } else if callback_registered && !auto_silence { + debug!( + "Recording stopped while auto_silence is disabled; VAD callback remains inert" + ); } if let Some(tx) = stop_tx_clone.as_ref() { let _ = tx.send(()).await; @@ -589,9 +621,10 @@ impl Recorder { if buf.is_empty() { warn!("No audio data captured"); self.last_duration = 0.0; + self.buffer_start_offset.store(0, Ordering::SeqCst); return Ok(None); } - buf.clone() + buf.iter().copied().collect::>() }; let num_frames = wav_data.len(); @@ -614,12 +647,7 @@ impl Recorder { info!("Saving audio to: {:?}", temp_path); // Write WAV file using actual sample rate - write_wav_file( - &temp_path, - &wav_data, - self.actual_sample_rate, - self.config.channels, - )?; + write_wav_file(&temp_path, &wav_data, self.actual_sample_rate, CHANNELS)?; info!("Audio successfully saved to WAV file"); @@ -628,6 +656,7 @@ impl Recorder { .lock() .unwrap_or_else(|e| e.into_inner()) .clear(); + self.buffer_start_offset.store(0, Ordering::SeqCst); Ok(Some(temp_path)) } @@ -646,11 +675,20 @@ impl Recorder { // Read slice under lock (cheap memcpy), drop lock before WAV write. let (slice, end_offset) = { let buf = self.buffer.lock().unwrap_or_else(|e| e.into_inner()); - let total = buf.len(); + let buffer_start = self.buffer_start_offset.load(Ordering::SeqCst); + let total = buffer_start.saturating_add(buf.len()); if from_offset >= total { return Ok(None); } - let slice: Vec = buf[from_offset..total].to_vec(); + let start_offset = from_offset.max(buffer_start); + if from_offset < buffer_start { + warn!( + "snapshot_wav requested from_offset={} older than retained buffer start {}; using retained start", + from_offset, buffer_start + ); + } + let start = start_offset - buffer_start; + let slice: Vec = buf.iter().skip(start).copied().collect(); (slice, total) }; @@ -662,12 +700,7 @@ impl Recorder { chrono::Utc::now().timestamp_millis() )); - write_wav_file( - &temp_path, - &slice, - self.actual_sample_rate, - self.config.channels, - )?; + write_wav_file(&temp_path, &slice, self.actual_sample_rate, CHANNELS)?; info!( "Segment snapshot: {} samples ({:.2}s) saved to {:?}", @@ -688,7 +721,10 @@ impl Recorder { /// start a fresh segment without saving anything yet. Returns 0 on poisoned /// lock (recoverable). pub fn current_sample_offset(&self) -> usize { - self.buffer.lock().unwrap_or_else(|e| e.into_inner()).len() + let buf = self.buffer.lock().unwrap_or_else(|e| e.into_inner()); + self.buffer_start_offset + .load(Ordering::SeqCst) + .saturating_add(buf.len()) } } @@ -713,6 +749,54 @@ impl Drop for Recorder { // Note: RMS-based silence detection replaced with Silero VAD (see vad module) +fn streaming_buffer_cap_samples(sample_rate: u32) -> usize { + (sample_rate as usize).saturating_mul(STREAMING_BUFFER_CAP_SECONDS) +} + +fn auto_silence_from_env_value(value: Option<&str>) -> bool { + value + .map(|v| !matches!(v.to_lowercase().as_str(), "0" | "false" | "no" | "off")) + .unwrap_or(false) +} + +fn should_invoke_vad_stop_callback(auto_silence: bool, callback_registered: bool) -> bool { + auto_silence && callback_registered +} + +fn downmix_to_mono(samples: &[f32], channels: usize) -> Vec { + let channels = channels.max(1); + if channels == 1 { + return samples.to_vec(); + } + + samples + .chunks(channels) + .map(|frame| frame.iter().copied().sum::() / frame.len() as f32) + .collect() +} + +fn append_mono_i16_samples( + buffer: &mut VecDeque, + buffer_start_offset: &AtomicUsize, + samples: &[f32], + cap_samples: Option, +) { + buffer.extend(samples.iter().map(|sample| { + let clamped = sample.clamp(-1.0, 1.0); + (clamped * i16::MAX as f32) as i16 + })); + + if let Some(cap_samples) = cap_samples + && buffer.len() > cap_samples + { + let drop_count = buffer.len() - cap_samples; + for _ in 0..drop_count { + buffer.pop_front(); + } + buffer_start_offset.fetch_add(drop_count, Ordering::SeqCst); + } +} + /// Write audio samples to a WAV file. fn write_wav_file(path: &PathBuf, samples: &[i16], sample_rate: u32, channels: u16) -> Result<()> { let spec = WavSpec { @@ -744,10 +828,127 @@ mod tests { #[test] fn test_recorder_config_default() { - // Note: This test checks hardcoded defaults, not env-dependent behavior - // to avoid race conditions with parallel tests + let config = RecorderConfig::default(); + assert_eq!(SAMPLE_RATE, 16000); assert_eq!(CHANNELS, 1); + assert!( + (config.hang_sec - vad::config::SILERO_DEFAULT_MAX_SILENCE_SEC).abs() < f32::EPSILON, + "RecorderConfig hang_sec should follow vad::config::SILERO_DEFAULT_MAX_SILENCE_SEC" + ); + } + + #[test] + fn auto_silence_env_parser_defaults_off_and_honors_opt_in() { + assert!( + !auto_silence_from_env_value(None), + "AUTO_SILENCE absent means manual stop unless explicitly enabled" + ); + for disabled in ["0", "false", "no", "off", "OFF"] { + assert!( + !auto_silence_from_env_value(Some(disabled)), + "{disabled} should disable auto-silence" + ); + } + for enabled in ["1", "true", "yes", "on", "anything-else"] { + assert!( + auto_silence_from_env_value(Some(enabled)), + "{enabled} should enable auto-silence" + ); + } + } + + #[test] + fn vad_stop_callback_is_retained_but_inert_when_auto_silence_disabled() { + let config = RecorderConfig { + auto_silence: false, + ..Default::default() + }; + let mut recorder = Recorder::with_config(config).expect("recorder should initialize"); + + recorder.set_on_vad_stop(|| {}); + + assert!( + recorder.on_vad_stop.is_some(), + "disabled auto_silence should retain registration while emission remains gated" + ); + } + + #[test] + fn vad_stop_callback_is_armed_when_auto_silence_enabled() { + let config = RecorderConfig { + auto_silence: true, + ..Default::default() + }; + let mut recorder = Recorder::with_config(config).expect("recorder should initialize"); + + recorder.set_on_vad_stop(|| {}); + + assert!( + recorder.on_vad_stop.is_some(), + "enabled auto_silence should arm the VAD-stop callback" + ); + } + + #[test] + fn vad_stop_callback_emission_is_gated_by_auto_silence() { + assert!( + !should_invoke_vad_stop_callback(false, true), + "registered callbacks must stay inert while auto_silence is disabled" + ); + assert!( + !should_invoke_vad_stop_callback(true, false), + "auto_silence alone is not enough without a registered callback" + ); + assert!( + should_invoke_vad_stop_callback(true, true), + "auto_silence can invoke a registered VAD-stop callback" + ); + } + + #[test] + fn streaming_i16_buffer_is_capped_and_tracks_absolute_offset() { + let mut buf = VecDeque::new(); + let start_offset = std::sync::atomic::AtomicUsize::new(0); + + append_mono_i16_samples(&mut buf, &start_offset, &[0.10, 0.20, 0.30, 0.40], Some(6)); + append_mono_i16_samples(&mut buf, &start_offset, &[0.50, 0.60, 0.70, 0.80], Some(6)); + + assert_eq!(buf.len(), 6, "streaming buffer must not grow past cap"); + let retained: Vec = buf.iter().copied().collect(); + assert_eq!( + retained, + vec![ + (0.30 * i16::MAX as f32) as i16, + (0.40 * i16::MAX as f32) as i16, + (0.50 * i16::MAX as f32) as i16, + (0.60 * i16::MAX as f32) as i16, + (0.70 * i16::MAX as f32) as i16, + (0.80 * i16::MAX as f32) as i16, + ], + "streaming buffer must retain the newest samples after cap eviction" + ); + assert_eq!( + start_offset.load(Ordering::SeqCst), + 2, + "absolute sample offset must advance by trimmed samples" + ); + } + + #[test] + fn downmix_to_mono_averages_interleaved_native_channels() { + let stereo = [1.0, -1.0, 0.25, 0.75, -0.50, -0.25]; + + let mono = downmix_to_mono(&stereo, 2); + + assert_eq!(mono, vec![0.0, 0.5, -0.375]); + + let three_channel_with_tail = [1.0, 0.0, -1.0, 0.75, 0.0, 0.75, 0.25]; + let mono = downmix_to_mono(&three_channel_with_tail, 3); + assert_eq!(mono, vec![0.0, 0.5, 0.25]); + + let already_mono = [-0.25, 0.5, 0.75]; + assert_eq!(downmix_to_mono(&already_mono, 1), already_mono); } #[tokio::test] diff --git a/core/audio/streaming_recorder.rs b/core/audio/streaming_recorder.rs index 29b291d5..d8340d15 100644 --- a/core/audio/streaming_recorder.rs +++ b/core/audio/streaming_recorder.rs @@ -8,6 +8,10 @@ use tokio::sync::{Mutex, mpsc}; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; +// Keep enough raw audio queued to survive a cold Whisper load without dropping +// the user's first words. The STT session drains this backlog once the model is ready. +const AUDIO_BACKLOG_CHUNKS: usize = 2048; + pub struct StreamingRecorder { pub recorder: Recorder, transcript_buffer: Arc>, @@ -95,8 +99,9 @@ impl StreamingRecorder { *self.transcript_buffer.lock().await = String::new(); self.dropped_chunks.store(0, Ordering::Relaxed); - // Create channel for audio chunks - let (tx, rx) = mpsc::channel::>(500); + // Create channel for audio chunks. This is intentionally larger than a + // normal live queue: cold STT initialization happens behind this buffer. + let (tx, rx) = mpsc::channel::>(AUDIO_BACKLOG_CHUNKS); // Setup callback to send audio data let dropped = Arc::clone(&self.dropped_chunks); @@ -471,6 +476,27 @@ mod tests { crate::config::Config::config_dir().join("transcriptions") } + fn is_terminal_no_speech_artifact(file_name: &str) -> bool { + file_name.contains("no-speech") || file_name.ends_with("_failed.wav") + } + + #[test] + fn terminal_no_speech_artifact_filter_is_name_bounded() { + assert!(is_terminal_no_speech_artifact( + "20260709_120000_no-speech_raw.wav" + )); + assert!(is_terminal_no_speech_artifact( + "20260709_120001_dictation_failed.wav" + )); + assert!(!is_terminal_no_speech_artifact( + "20260709_120002_failed-but-recovered_raw.wav" + )); + assert!(!is_terminal_no_speech_artifact( + "03_algorytm-ma-zlozonosc.wav" + )); + assert!(!is_terminal_no_speech_artifact("dictation_failed.m4a")); + } + fn collect_pairs( root: &Path, date_filter: Option<&str>, @@ -779,6 +805,11 @@ mod tests { for entry in entries.flatten() { let p = entry.path(); if p.extension().and_then(|s| s.to_str()) == Some("wav") { + let fname = p.file_name().unwrap_or_default().to_string_lossy(); + // Terminal failed/no-speech artifacts should not be scored as VAD segmentation misses. + if is_terminal_no_speech_artifact(&fname) { + continue; + } wavs.push(p); if wavs.len() >= 5 { break; diff --git a/core/build.rs b/core/build.rs index 952c27dd..a3c15b1f 100644 --- a/core/build.rs +++ b/core/build.rs @@ -1,5 +1,5 @@ //! -//! Build script for CodeScribe +//! Build script for Codescribe //! Exports embedded model data and configuration. //! Generates embedded_tts_data.rs / embedded_embedder_data.rs / embedded_vad_data.rs in OUT_DIR. //! diff --git a/core/config/default_env.txt b/core/config/default_env.txt index f24cc09d..39153a55 100644 --- a/core/config/default_env.txt +++ b/core/config/default_env.txt @@ -1,4 +1,4 @@ -# CodeScribe Configuration +# Codescribe Configuration # Generated automatically - edit with care # === Secrets === @@ -10,7 +10,7 @@ LLM_FORMATTING_API_KEY= # === Core mode === USE_LOCAL_STT=1 LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 -WHISPER_LANGUAGE=pl +WHISPER_LANGUAGE=auto # === Hotkeys === HOLD_EXCLUSIVE=1 @@ -39,16 +39,21 @@ CODESCRIBE_BUFFER_DELAY_MS=280 CODESCRIBE_TYPING_CPS=90 CODESCRIBE_EMIT_WORDS_MAX=2 CODESCRIBE_BUFFERED_INTERIM_SEC=1.2 +CODESCRIBE_BUFFERED_SILENCE_SEC=1.2 CODESCRIBE_MAX_INFERENCE_CONCURRENCY=1 CODESCRIBE_BUFFERED_CORRECTION_PREFIX=0.50 CODESCRIBE_OVERLAY_STABLE_PREVIEW=0 # STT thermal governor (core portable; app supplies macOS thermal level). CODESCRIBE_STT_THERMAL_GOVERNOR_ENABLED=1 -CODESCRIBE_STT_MIN_INFER_INTERVAL_MS=180 -CODESCRIBE_STT_COMMIT_MIN_INTERVAL_MS=350 +CODESCRIBE_STT_MIN_INFER_INTERVAL_MS=90 +CODESCRIBE_STT_COMMIT_MIN_INTERVAL_MS=180 +CODESCRIBE_STT_REFINE_MIN_INTERVAL_MS=240 +CODESCRIBE_STT_THERMAL_NOMINAL_MULT=0.0 CODESCRIBE_STT_THERMAL_FAIR_MULT=1.0 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 # VAD config is hardcoded in core/vad/config.rs (Silero defaults, no env knobs). CODESCRIBE_STREAM_SIMILARITY=0.90 CODESCRIBE_STREAM_NOVELTY=0.20 @@ -86,8 +91,8 @@ DUMP_AUDIO_LOGS=1 QUICK_NOTES_ENABLED=0 # If enabled, Quick Notes becomes "save-only" (no auto-paste). QUICK_NOTES_SAVE_ONLY=0 -# Optional override for notes directory (Vista-friendly): -# CODESCRIBE_NOTES_DIR=~/Git/vista/notes +# Optional override for notes directory: +# CODESCRIBE_NOTES_DIR=~/Documents/notes # === Clipboard === RESTORE_CLIPBOARD=1 diff --git a/core/config/defaults.rs b/core/config/defaults.rs index 93284c6d..4f786660 100644 --- a/core/config/defaults.rs +++ b/core/config/defaults.rs @@ -1,4 +1,4 @@ -//! Default value functions for CodeScribe configuration. +//! Default value functions for Codescribe configuration. //! //! These are used by serde for deserialization defaults. @@ -7,6 +7,11 @@ pub const DEFAULT_LLM_MODEL: &str = DEFAULT_FORMATTING_MODEL; pub const DEFAULT_FORMATTING_MODEL: &str = "gpt-4.1"; pub const DEFAULT_ASSISTIVE_MODEL: &str = "gpt-5.5"; +/// Default LLM provider identity for both lanes — OpenAI Responses. This is the +/// protected default: neither lane routes to another provider unless explicitly +/// configured. Mirrors [`crate::llm::provider::ProviderKind::default`]. +pub const DEFAULT_LLM_PROVIDER: &str = "openai-responses"; + pub fn default_llm_endpoint() -> String { DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string() } @@ -27,6 +32,14 @@ pub fn default_assistive_model() -> String { DEFAULT_ASSISTIVE_MODEL.to_string() } +pub fn default_formatting_provider() -> String { + DEFAULT_LLM_PROVIDER.to_string() +} + +pub fn default_assistive_provider() -> String { + DEFAULT_LLM_PROVIDER.to_string() +} + pub fn default_hold_start_delay_ms() -> u64 { 800 } @@ -114,3 +127,7 @@ pub fn default_dump_audio_logs() -> bool { pub fn default_local_model() -> String { super::models::DEFAULT_MODEL.to_string() } + +pub fn default_stt_initial_prompt_enabled() -> bool { + false +} diff --git a/core/config/keychain.rs b/core/config/keychain.rs index 2c7d1734..225dbeae 100644 --- a/core/config/keychain.rs +++ b/core/config/keychain.rs @@ -21,6 +21,7 @@ pub const KEYCHAIN_ACCOUNTS: &[&str] = &[ "STT_API_KEY", "LLM_FORMATTING_API_KEY", "LLM_ASSISTIVE_API_KEY", + "LLM_ANTHROPIC_API_KEY", "GITHUB_TOKEN", ]; @@ -113,10 +114,12 @@ fn save_bundle(bundle: &KeychainBundle) -> Result<()> { /// Returns true when running inside a test harness or when Keychain is explicitly disabled. /// -/// NOTE: `cfg!(test)` only works for **unit tests** within this crate. -/// Integration tests (`tests/`, `core/tests/`) compile the library normally, -/// so `cfg!(test)` returns false. Those tests MUST set `CODESCRIBE_DISABLE_KEYCHAIN=1` -/// or `CODESCRIBE_DATA_DIR` to skip Keychain. The Makefile `TEST_SETUP` sets the former. +/// NOTE: `cfg!(test)` only works for **unit tests** within this crate. Integration tests +/// (`tests/`, `core/tests/`) and tests in other crates (e.g. `app/*`, `bridge/*`) compile +/// this library normally, so `cfg!(test)` is false there. Such tests are still detected via +/// the harness signals below (the `target/**/deps/` exe path and `RUST_TEST_THREADS`), and +/// may additionally set `CODESCRIBE_DISABLE_KEYCHAIN=1` — which the Makefile `TEST_SETUP` does. +/// `CODESCRIBE_DATA_DIR` does NOT skip Keychain: it is a production-valid data-dir override. fn is_test_env() -> bool { if cfg!(test) { return true; @@ -137,16 +140,27 @@ fn is_test_env() -> bool { if std::env::var_os("RUST_TEST_THREADS").is_some() { return true; } - if std::env::var_os("CODESCRIBE_DISABLE_KEYCHAIN").is_some() { - return true; - } - if std::env::var_os("CI").is_some() { - return true; - } - std::env::var("CODESCRIBE_DATA_DIR").is_ok() + keychain_disabled_by_signals( + std::env::var_os("CODESCRIBE_DISABLE_KEYCHAIN").is_some(), + std::env::var_os("CODESCRIBE_DATA_DIR").is_some(), + std::env::var_os("CI").is_some(), + ) +} + +/// Pure Keychain-skip policy over explicit environment signals. +/// +/// `CODESCRIBE_DISABLE_KEYCHAIN` is the ONLY user-facing kill switch. `CODESCRIBE_DATA_DIR` +/// (a documented data-directory override) and `CI` are accepted here purely so the policy is +/// explicit and regression-tested — they MUST NOT disable Keychain. Silently dropping +/// persisted API keys / OAuth tokens when a user merely relocates the data dir (or runs under +/// a CI flag) was the bug this function pins shut. Kept side-effect-free so the policy is +/// unit-testable without mutating process-global env vars (which race across the suite). +fn keychain_disabled_by_signals(disable_keychain: bool, data_dir_set: bool, ci_set: bool) -> bool { + let _ = (data_dir_set, ci_set); + disable_keychain } -/// Saves a secret to the macOS Keychain under the CodeScribe service. +/// Saves a secret to the macOS Keychain under the Codescribe service. /// In test environments, sets the env var directly instead of touching Keychain. pub fn save_key(account: &str, secret: &str) -> Result<()> { if is_test_env() { @@ -239,3 +253,25 @@ pub fn populate_env_from_keychain() { } }); } + +#[cfg(test)] +mod tests { + use super::keychain_disabled_by_signals; + + #[test] + fn disable_keychain_flag_is_honored() { + // Explicit user opt-out disables Keychain regardless of other signals. + assert!(keychain_disabled_by_signals(true, false, false)); + assert!(keychain_disabled_by_signals(true, true, true)); + } + + #[test] + fn data_dir_and_ci_do_not_disable_keychain() { + // Regression: setting CODESCRIBE_DATA_DIR (a documented data-dir override) or CI + // must NOT silently disable Keychain persistence of API keys / OAuth tokens. + assert!(!keychain_disabled_by_signals(false, true, false)); + assert!(!keychain_disabled_by_signals(false, false, true)); + assert!(!keychain_disabled_by_signals(false, true, true)); + assert!(!keychain_disabled_by_signals(false, false, false)); + } +} diff --git a/core/config/loader.rs b/core/config/loader.rs index 67798c4a..c7c3865f 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -9,16 +9,24 @@ //! - explicit process env can still override for tests and developer runs. use directories::BaseDirs; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::env::VarError; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; use tracing::{info, warn}; use super::defaults::{ - default_assistive_model, default_formatting_model, default_llm_endpoint, default_llm_model, + default_assistive_model, default_assistive_provider, default_formatting_model, + default_formatting_provider, default_llm_endpoint, default_llm_model, }; use super::types::{Config, Language, OverlayPositionMode, TranscriptSendMode}; +static CONFIG_ENV_BOOTSTRAPPED: AtomicBool = AtomicBool::new(false); +static CONFIG_ENV_BOOTSTRAP_LOCK: OnceLock> = OnceLock::new(); +static CONFIG_SEEDED_ENV_KEYS: OnceLock>> = OnceLock::new(); + impl Config { /// Load configuration from disk or environment. /// @@ -31,6 +39,20 @@ impl Config { /// If the .env file doesn't exist or is malformed, returns default configuration /// without raising an error. pub fn load() -> Self { + Self::load_with_keychain_population(true) + } + + /// Load runtime configuration without reading Keychain. + /// + /// This is for UI/runtime surfaces that must not trigger a macOS Keychain + /// password prompt as a side effect of starting local dictation. + pub fn load_without_keychain() -> Self { + Self::load_with_keychain_population(false) + } + + fn load_with_keychain_population(populate_keychain: bool) -> Self { + let _bootstrap_guard = Self::config_env_bootstrap_guard(); + let seed_process_env = Self::can_seed_process_env(); let env_path = Self::env_path(); let mut file_env_vars: Option> = None; @@ -56,8 +78,10 @@ impl Config { Self::inject_file_env_for_runtime(vars); } - // Load API keys from Keychain (only if not already set by .env) - super::keychain::populate_env_from_keychain(); + // Load API keys from Keychain (only if not already set by .env). + if populate_keychain && seed_process_env { + super::keychain::populate_env_from_keychain(); + } // Load user settings from JSON let user_settings = super::settings::UserSettings::load(); @@ -71,9 +95,63 @@ impl Config { config.load_from_env(); config.apply_default_llm_runtime_env(); config.sanitize(); + Self::mark_process_env_bootstrapped(seed_process_env); config } + fn config_env_bootstrap_guard() -> Option> { + if cfg!(test) { + None + } else { + Some( + CONFIG_ENV_BOOTSTRAP_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("config env bootstrap lock poisoned"), + ) + } + } + + fn can_seed_process_env() -> bool { + cfg!(test) || !CONFIG_ENV_BOOTSTRAPPED.load(Ordering::SeqCst) + } + + fn mark_process_env_bootstrapped(seed_process_env: bool) { + if seed_process_env && !cfg!(test) { + CONFIG_ENV_BOOTSTRAPPED.store(true, Ordering::SeqCst); + } + } + + fn seeded_env_keys() -> &'static Mutex> { + CONFIG_SEEDED_ENV_KEYS.get_or_init(|| Mutex::new(HashSet::new())) + } + + fn remember_seeded_env_key(key: &str) { + if cfg!(test) { + return; + } + if let Ok(mut keys) = Self::seeded_env_keys().lock() { + keys.insert(key.to_string()); + } + } + + fn was_seeded_env_key(key: &str) -> bool { + if cfg!(test) { + return false; + } + Self::seeded_env_keys() + .lock() + .map(|keys| keys.contains(key)) + .unwrap_or(false) + } + + fn config_runtime_env_var(key: &str) -> Result { + if !Self::can_seed_process_env() && Self::was_seeded_env_key(key) { + return Err(VarError::NotPresent); + } + std::env::var(key) + } + /// Inject optional .env values into the process environment without allowing /// legacy file overrides to shadow promoted settings.json-backed keys. fn inject_file_env_for_runtime(file_env: &HashMap) { @@ -92,7 +170,7 @@ impl Config { } fn env_missing_or_empty(key: &str) -> bool { - std::env::var(key) + Self::config_runtime_env_var(key) .ok() .is_none_or(|value| value.trim().is_empty()) } @@ -117,131 +195,142 @@ impl Config { Self::config_init_set_env_if_missing("LLM_MODEL", default_llm_model()); Self::config_init_set_env_if_missing("LLM_FORMATTING_ENDPOINT", &endpoint); Self::config_init_set_env_if_missing("LLM_FORMATTING_MODEL", default_formatting_model()); + Self::config_init_set_env_if_missing( + "LLM_FORMATTING_PROVIDER", + default_formatting_provider(), + ); Self::config_init_set_env_if_missing("LLM_ASSISTIVE_ENDPOINT", &endpoint); Self::config_init_set_env_if_missing("LLM_ASSISTIVE_MODEL", default_assistive_model()); + Self::config_init_set_env_if_missing( + "LLM_ASSISTIVE_PROVIDER", + default_assistive_provider(), + ); } /// Load configuration values from environment variables. pub fn load_from_env(&mut self) { // Hotkeys - if let Ok(val) = std::env::var("HOLD_EXCLUSIVE") { + if let Ok(val) = Self::config_runtime_env_var("HOLD_EXCLUSIVE") { self.hold_exclusive = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("HOLD_START_DELAY_MS") + if let Ok(val) = Self::config_runtime_env_var("HOLD_START_DELAY_MS") && let Ok(ms) = val.parse() { self.hold_start_delay_ms = ms; } - if let Ok(val) = std::env::var("DOUBLE_TAP_INTERVAL_MS") + if let Ok(val) = Self::config_runtime_env_var("DOUBLE_TAP_INTERVAL_MS") && let Ok(ms) = val.parse() { self.double_tap_interval_ms = ms; } - if let Ok(val) = std::env::var("TOGGLE_SILENCE_SEC") + if let Ok(val) = Self::config_runtime_env_var("TOGGLE_SILENCE_SEC") && let Ok(sec) = val.parse() { self.toggle_silence_sec = sec; } // Language - if let Ok(val) = std::env::var("WHISPER_LANGUAGE") + if let Ok(val) = Self::config_runtime_env_var("WHISPER_LANGUAGE") && let Ok(lang) = val.parse::() { self.whisper_language = lang; } // AI Formatting - if let Ok(val) = std::env::var("AI_FORMATTING_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("AI_FORMATTING_ENABLED") { self.ai_formatting_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on" | "enabled"); } - if let Ok(val) = std::env::var("TRANSCRIPT_SEND_MODE") + if let Ok(val) = Self::config_runtime_env_var("TRANSCRIPT_SEND_MODE") && let Ok(mode) = val.parse::() { self.transcript_send_mode = mode; } - if let Ok(val) = std::env::var("CODESCRIBE_TRANSCRIPT_TAGGING") { + if let Ok(val) = Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAGGING") { self.transcript_tagging_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on" | "enabled"); } - if let Ok(val) = std::env::var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE") { + if let Ok(val) = Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE") { self.transcript_tag_template = val; } - if let Ok(val) = std::env::var("AI_MAX_TOKENS") + if let Ok(val) = Self::config_runtime_env_var("AI_MAX_TOKENS") && let Ok(tokens) = val.parse() { self.ai_max_tokens = tokens; } - if let Ok(val) = std::env::var("AI_ASSISTIVE_MAX_TOKENS") + if let Ok(val) = Self::config_runtime_env_var("AI_ASSISTIVE_MAX_TOKENS") && let Ok(tokens) = val.parse() { self.ai_assistive_max_tokens = tokens; } // UI - if let Ok(val) = std::env::var("SHOW_TRAY_GLYPH") { + if let Ok(val) = Self::config_runtime_env_var("SHOW_TRAY_GLYPH") { self.show_tray_glyph = val.parse().unwrap_or(true); } - if let Ok(val) = std::env::var("SHOW_DOCK_ICON") { + if let Ok(val) = Self::config_runtime_env_var("SHOW_DOCK_ICON") { self.show_dock_icon = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("TRANSCRIPTION_OVERLAY_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("TRANSCRIPTION_OVERLAY_ENABLED") { self.transcription_overlay_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("HOLD_INDICATOR") { + if let Ok(val) = Self::config_runtime_env_var("TRAY_START_ASSISTIVE") { + self.tray_start_assistive = matches!(val.as_str(), "1" | "true" | "yes" | "on"); + } + if let Ok(val) = Self::config_runtime_env_var("HOLD_INDICATOR") { self.hold_indicator = val.parse().unwrap_or(true); } - if let Ok(val) = std::env::var("HOLD_BADGE_SIZE") + if let Ok(val) = Self::config_runtime_env_var("HOLD_BADGE_SIZE") && let Ok(size) = val.parse() { self.hold_badge_size = size; } - if let Ok(val) = std::env::var("HOLD_BADGE_OFFSET_X") + if let Ok(val) = Self::config_runtime_env_var("HOLD_BADGE_OFFSET_X") && let Ok(offset) = val.parse() { self.hold_badge_offset_x = offset; } - if let Ok(val) = std::env::var("HOLD_BADGE_OFFSET_Y") + if let Ok(val) = Self::config_runtime_env_var("HOLD_BADGE_OFFSET_Y") && let Ok(offset) = val.parse() { self.hold_badge_offset_y = offset; } - if let Ok(val) = std::env::var("OVERLAY_POSITION_MODE") + if let Ok(val) = Self::config_runtime_env_var("OVERLAY_POSITION_MODE") && let Ok(mode) = val.parse::() { self.overlay_position_mode = mode; } - if let Ok(val) = std::env::var("OVERLAY_CUSTOM_X") + if let Ok(val) = Self::config_runtime_env_var("OVERLAY_CUSTOM_X") && let Ok(x) = val.parse() { self.overlay_custom_x = Some(x); } - if let Ok(val) = std::env::var("OVERLAY_CUSTOM_Y") + if let Ok(val) = Self::config_runtime_env_var("OVERLAY_CUSTOM_Y") && let Ok(y) = val.parse() { self.overlay_custom_y = Some(y); } // Sound - if let Ok(val) = std::env::var("BEEP_ON_START") { + if let Ok(val) = Self::config_runtime_env_var("BEEP_ON_START") { self.beep_on_start = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("AGENT_ENTER_SENDS") { + if let Ok(val) = Self::config_runtime_env_var("AGENT_ENTER_SENDS") { self.agent_enter_sends = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("SOUND_NAME") { + if let Ok(val) = Self::config_runtime_env_var("SOUND_NAME") { self.sound_name = val; } - if let Ok(val) = std::env::var("SOUND_VOLUME") + if let Ok(val) = Self::config_runtime_env_var("SOUND_VOLUME") && let Ok(volume) = val.parse() { self.sound_volume = volume; } // Audio - if let Ok(val) = std::env::var("AUDIO_INPUT_DEVICE") { + if let Ok(val) = Self::config_runtime_env_var("AUDIO_INPUT_DEVICE") { self.audio_input_device = (!val.trim().is_empty()).then_some(val); } // VAD config lives in `core/vad/config.rs` with hardcoded defaults and @@ -250,61 +339,65 @@ impl Config { // No legacy SILENCE_* variables - single source of truth. // History (default: on to avoid data loss) - if let Ok(val) = std::env::var("HISTORY_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("HISTORY_ENABLED") { self.history_enabled = val.parse().unwrap_or(true); } // Quick Notes (default: off) - if let Ok(val) = std::env::var("QUICK_NOTES_ENABLED") { + if let Ok(val) = Self::config_runtime_env_var("QUICK_NOTES_ENABLED") { self.quick_notes_enabled = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("QUICK_NOTES_SAVE_ONLY") { + if let Ok(val) = Self::config_runtime_env_var("QUICK_NOTES_SAVE_ONLY") { self.quick_notes_save_only = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } // Backends - LLM // LLM_API_KEY for cloud providers - if let Ok(val) = std::env::var("LLM_API_KEY") { + if let Ok(val) = Self::config_runtime_env_var("LLM_API_KEY") { self.llm_api_key = Some(val); } - if let Ok(val) = std::env::var("LLM_ENDPOINT") { + if let Ok(val) = Self::config_runtime_env_var("LLM_ENDPOINT") { self.llm_endpoint = Some(val); } // Backends - STT - if let Ok(val) = std::env::var("STT_ENDPOINT") { + if let Ok(val) = Self::config_runtime_env_var("STT_ENDPOINT") { self.stt_endpoint = Some(val); } + if let Ok(val) = Self::config_runtime_env_var("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED") { + self.stt_initial_prompt_enabled = + matches!(val.as_str(), "1" | "true" | "yes" | "on" | "enabled"); + } // STT_API_KEY for cloud STT - if let Ok(val) = std::env::var("STT_API_KEY") { + if let Ok(val) = Self::config_runtime_env_var("STT_API_KEY") { self.stt_api_key = Some(val); } // Local STT (Pure Rust Whisper) - if let Ok(val) = std::env::var("USE_LOCAL_STT") { + if let Ok(val) = Self::config_runtime_env_var("USE_LOCAL_STT") { self.use_local_stt = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } - if let Ok(val) = std::env::var("LOCAL_MODEL") { + if let Ok(val) = Self::config_runtime_env_var("LOCAL_MODEL") { self.local_model = val; } // Clipboard - if let Ok(val) = std::env::var("RESTORE_CLIPBOARD") { + if let Ok(val) = Self::config_runtime_env_var("RESTORE_CLIPBOARD") { self.restore_clipboard = val.parse().unwrap_or(true); } - if let Ok(val) = std::env::var("RESTORE_CLIPBOARD_DELAY_MS") + if let Ok(val) = Self::config_runtime_env_var("RESTORE_CLIPBOARD_DELAY_MS") && let Ok(delay) = val.parse() { self.restore_clipboard_delay_ms = delay; } // System - if let Ok(val) = std::env::var("START_AT_LOGIN") { + if let Ok(val) = Self::config_runtime_env_var("START_AT_LOGIN") { self.start_at_login = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } // Debugging (default: on to keep paired .wav with transcripts) - if let Ok(val) = std::env::var("DUMP_AUDIO_LOGS") { + if let Ok(val) = Self::config_runtime_env_var("DUMP_AUDIO_LOGS") { self.dump_audio_logs = matches!(val.as_str(), "1" | "true" | "yes" | "on"); } } @@ -323,15 +416,13 @@ impl Config { } fn config_init_set_env(key: &str, value: impl AsRef) { - // SAFETY: config init happens before background workers consume configuration, - // so process-env mutation is confined to a single writer during bootstrap. + if !Self::can_seed_process_env() { + return; + } + // SAFETY: a process-wide bootstrap lock confines config env mutation to + // the one pre-runtime writer; later loads read settings snapshots instead. unsafe { std::env::set_var(key, value.as_ref()) }; - } - - fn ui_thread_set_env(key: &str, value: &str) { - // SAFETY: settings writes originate from the main UI thread; runtime readers - // consume refreshed Config snapshots rather than racing direct env access. - unsafe { std::env::set_var(key, value) }; + Self::remember_seeded_env_key(key); } /// Apply user settings from JSON (lower priority than .env). @@ -340,7 +431,7 @@ impl Config { // Helper: only apply if the env var is NOT set macro_rules! apply_parsed_if_no_env { ($env_key:expr, $field:expr, $val:expr) => { - if std::env::var($env_key).is_err() { + if Self::config_runtime_env_var($env_key).is_err() { if let Some(ref v) = $val { if let Ok(parsed) = v.parse() { $field = parsed; @@ -357,77 +448,87 @@ impl Config { settings.whisper_language ); // Hotkeys - if std::env::var("HOLD_START_DELAY_MS").is_err() + if Self::config_runtime_env_var("HOLD_START_DELAY_MS").is_err() && let Some(v) = settings.hold_start_delay_ms { self.hold_start_delay_ms = v; } - if std::env::var("DOUBLE_TAP_INTERVAL_MS").is_err() + if Self::config_runtime_env_var("DOUBLE_TAP_INTERVAL_MS").is_err() && let Some(v) = settings.double_tap_interval_ms { self.double_tap_interval_ms = v; } - if std::env::var("TOGGLE_SILENCE_SEC").is_err() + if Self::config_runtime_env_var("TOGGLE_SILENCE_SEC").is_err() && let Some(v) = settings.toggle_silence_sec { self.toggle_silence_sec = v; } - if std::env::var("HOLD_EXCLUSIVE").is_err() + if Self::config_runtime_env_var("HOLD_EXCLUSIVE").is_err() && let Some(v) = settings.hold_exclusive { self.hold_exclusive = v; } // AI - if std::env::var("AI_FORMATTING_ENABLED").is_err() + if Self::config_runtime_env_var("AI_FORMATTING_ENABLED").is_err() && let Some(v) = settings.ai_formatting_enabled { self.ai_formatting_enabled = v; } - if std::env::var("CODESCRIBE_TRANSCRIPT_TAGGING").is_err() + if Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAGGING").is_err() && let Some(v) = settings.transcript_tagging_enabled { self.transcript_tagging_enabled = v; } - if std::env::var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE").is_err() + if Self::config_runtime_env_var("CODESCRIBE_TRANSCRIPT_TAG_TEMPLATE").is_err() && let Some(ref v) = settings.transcript_tag_template { self.transcript_tag_template = v.clone(); } - if std::env::var("FORMATTING_LEVEL").is_err() + if Self::config_runtime_env_var("FORMATTING_LEVEL").is_err() && let Some(ref v) = settings.formatting_level { // FORMATTING_LEVEL is read from env at runtime (not a Config field). Self::safe_set_env("FORMATTING_LEVEL", v); } // Sound - if std::env::var("BEEP_ON_START").is_err() + if Self::config_runtime_env_var("BEEP_ON_START").is_err() && let Some(v) = settings.beep_on_start { self.beep_on_start = v; } - if std::env::var("SHOW_DOCK_ICON").is_err() + if Self::config_runtime_env_var("SHOW_DOCK_ICON").is_err() && let Some(v) = settings.show_dock_icon { self.show_dock_icon = v; } - if std::env::var("TRANSCRIPTION_OVERLAY_ENABLED").is_err() + if Self::config_runtime_env_var("TRANSCRIPTION_OVERLAY_ENABLED").is_err() && let Some(v) = settings.transcription_overlay_enabled { self.transcription_overlay_enabled = v; Self::safe_set_env("TRANSCRIPTION_OVERLAY_ENABLED", if v { "1" } else { "0" }); } - if std::env::var("SOUND_VOLUME").is_err() + if Self::config_runtime_env_var("TRAY_START_ASSISTIVE").is_err() + && let Some(v) = settings.tray_start_assistive + { + // `tray_start_assistive` is a Config struct field; downstream reads it + // directly (e.g. `tray_toggles`). Persistence lives in settings.json, + // so no runtime env mutation is needed here - and `load_without_keychain` + // runs on UI actions (tray/composer mic), where `set_var` would race + // background threads. + self.tray_start_assistive = v; + } + if Self::config_runtime_env_var("SOUND_VOLUME").is_err() && let Some(v) = settings.sound_volume { self.sound_volume = v; } // LLM endpoints (from JSON, lower priority than .env) - if std::env::var("LLM_ENDPOINT").is_err() + if Self::config_runtime_env_var("LLM_ENDPOINT").is_err() && let Some(ref v) = settings.llm_endpoint { self.llm_endpoint = Some(v.clone()); } - if std::env::var("LLM_MODEL").is_err() + if Self::config_runtime_env_var("LLM_MODEL").is_err() && let Some(ref v) = settings.llm_model { // LLM_MODEL is not in Config struct but read from env at runtime @@ -435,12 +536,12 @@ impl Config { Self::safe_set_env("LLM_MODEL", v); } // Assistive LLM (not in Config struct, read from env at runtime) - if std::env::var("LLM_ASSISTIVE_ENDPOINT").is_err() + if Self::config_runtime_env_var("LLM_ASSISTIVE_ENDPOINT").is_err() && let Some(ref v) = settings.llm_assistive_endpoint { Self::safe_set_env("LLM_ASSISTIVE_ENDPOINT", v); } - if std::env::var("LLM_ASSISTIVE_MODEL").is_err() + if Self::config_runtime_env_var("LLM_ASSISTIVE_MODEL").is_err() && let Some(ref v) = settings.llm_assistive_model { Self::safe_set_env("LLM_ASSISTIVE_MODEL", v); @@ -448,32 +549,32 @@ impl Config { // ── Promoted fields (previously .env only) ── // LLM formatting (not in Config struct, read from env at runtime) - if std::env::var("LLM_FORMATTING_ENDPOINT").is_err() + if Self::config_runtime_env_var("LLM_FORMATTING_ENDPOINT").is_err() && let Some(ref v) = settings.llm_formatting_endpoint { Self::safe_set_env("LLM_FORMATTING_ENDPOINT", v); } - if std::env::var("LLM_FORMATTING_MODEL").is_err() + if Self::config_runtime_env_var("LLM_FORMATTING_MODEL").is_err() && let Some(ref v) = settings.llm_formatting_model { Self::safe_set_env("LLM_FORMATTING_MODEL", v); } // Local STT - if std::env::var("USE_LOCAL_STT").is_err() + if Self::config_runtime_env_var("USE_LOCAL_STT").is_err() && let Some(v) = settings.use_local_stt { self.use_local_stt = v; Self::config_init_set_env("USE_LOCAL_STT", if v { "1" } else { "0" }); } - if std::env::var("LOCAL_MODEL").is_err() + if Self::config_runtime_env_var("LOCAL_MODEL").is_err() && let Some(ref v) = settings.local_model { self.local_model = v.clone(); } // STT endpoint - if std::env::var("STT_ENDPOINT").is_err() + if Self::config_runtime_env_var("STT_ENDPOINT").is_err() && let Some(ref v) = settings.stt_endpoint { self.stt_endpoint = Some(v.clone()); @@ -487,98 +588,132 @@ impl Config { ); // Audio input device - if std::env::var("AUDIO_INPUT_DEVICE").is_err() + if Self::config_runtime_env_var("AUDIO_INPUT_DEVICE").is_err() && let Some(ref v) = settings.audio_input_device { self.audio_input_device = Some(v.clone()); } // Sound name - if std::env::var("SOUND_NAME").is_err() + if Self::config_runtime_env_var("SOUND_NAME").is_err() && let Some(ref v) = settings.sound_name { self.sound_name = v.clone(); } // History - if std::env::var("HISTORY_ENABLED").is_err() + if Self::config_runtime_env_var("HISTORY_ENABLED").is_err() && let Some(v) = settings.history_enabled { self.history_enabled = v; } // Quick Notes - if std::env::var("QUICK_NOTES_ENABLED").is_err() + if Self::config_runtime_env_var("QUICK_NOTES_ENABLED").is_err() && let Some(v) = settings.quick_notes_enabled { self.quick_notes_enabled = v; } - if std::env::var("QUICK_NOTES_SAVE_ONLY").is_err() + if Self::config_runtime_env_var("QUICK_NOTES_SAVE_ONLY").is_err() && let Some(v) = settings.quick_notes_save_only { self.quick_notes_save_only = v; } // System - if std::env::var("START_AT_LOGIN").is_err() + if Self::config_runtime_env_var("START_AT_LOGIN").is_err() && let Some(v) = settings.start_at_login { self.start_at_login = v; } - if std::env::var("QUBE_DAEMON_AUTOSTART").is_err() + if Self::config_runtime_env_var("QUBE_DAEMON_AUTOSTART").is_err() && let Some(v) = settings.qube_daemon_autostart { Self::config_init_set_env("QUBE_DAEMON_AUTOSTART", if v { "1" } else { "0" }); } - if std::env::var("AGENT_ENTER_SENDS").is_err() + if Self::config_runtime_env_var("AGENT_ENTER_SENDS").is_err() && let Some(v) = settings.agent_enter_sends { self.agent_enter_sends = v; } // ── Voice Lab survivors (runtime env vars, not Config struct fields) ── - if std::env::var("CODESCRIBE_BUFFER_DELAY_MS").is_err() + if Self::config_runtime_env_var("CODESCRIBE_BUFFER_DELAY_MS").is_err() && let Some(v) = settings.buffer_delay_ms { Self::config_init_set_env("CODESCRIBE_BUFFER_DELAY_MS", v.to_string()); } - if std::env::var("CODESCRIBE_TYPING_CPS").is_err() + if Self::config_runtime_env_var("CODESCRIBE_TYPING_CPS").is_err() && let Some(v) = settings.typing_cps { Self::config_init_set_env("CODESCRIBE_TYPING_CPS", v.to_string()); } - if std::env::var("CODESCRIBE_EMIT_WORDS_MAX").is_err() + if Self::config_runtime_env_var("CODESCRIBE_EMIT_WORDS_MAX").is_err() && let Some(v) = settings.emit_words_max { Self::config_init_set_env("CODESCRIBE_EMIT_WORDS_MAX", v.to_string()); } - if std::env::var("CODESCRIBE_BUFFERED_INTERIM_SEC").is_err() + if Self::config_runtime_env_var("CODESCRIBE_BUFFERED_INTERIM_SEC").is_err() && let Some(v) = settings.buffered_interim_sec { Self::config_init_set_env("CODESCRIBE_BUFFERED_INTERIM_SEC", format!("{v:.1}")); } - if std::env::var("WHISPER_MODEL").is_err() + if Self::config_runtime_env_var("WHISPER_MODEL").is_err() && let Some(ref v) = settings.whisper_model { Self::safe_set_env("WHISPER_MODEL", v); } - if std::env::var("BACKEND_MAX_UPLOAD_MB").is_err() + if Self::config_runtime_env_var("BACKEND_MAX_UPLOAD_MB").is_err() && let Some(v) = settings.backend_max_upload_mb { Self::config_init_set_env("BACKEND_MAX_UPLOAD_MB", v.to_string()); } + + // ── STT engine / layered transcription (F1) ── + // Explicit process env wins; settings.json seeds the env-only knobs that + // core/stt reads per-call (selected_engine / layered_phase). + if Self::config_runtime_env_var("CODESCRIBE_STT_ENGINE").is_err() + && let Some(ref v) = settings.stt_engine + { + Self::safe_set_env("CODESCRIBE_STT_ENGINE", v); + } + if Self::config_runtime_env_var("CODESCRIBE_LAYERED_TRANSCRIPTION").is_err() + && let Some(ref v) = settings.layered_transcription + { + Self::safe_set_env("CODESCRIBE_LAYERED_TRANSCRIPTION", v); + } + if Self::config_runtime_env_var("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED").is_err() + && let Some(v) = settings.stt_initial_prompt_enabled + { + self.stt_initial_prompt_enabled = v; + Self::config_init_set_env( + "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", + if v { "1" } else { "0" }, + ); + } + + // ── Agent workspace roots ── + // Colon-joined (PATH-style); the `list_projects` tool reads and splits it. + // Explicit process env / .env wins; settings.json only seeds when absent. + if Self::config_runtime_env_var("AGENT_WORKSPACE_ROOTS").is_err() + && let Some(ref roots) = settings.agent_workspace_roots + && !roots.is_empty() + { + Self::safe_set_env("AGENT_WORKSPACE_ROOTS", &roots.join(":")); + } } /// Save a configuration value, routing to the appropriate tier: /// - API keys → Keychain /// - Regular-user fields → settings.json /// - Everything else → .env + /// + /// 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<()> { // API keys → Keychain if super::keychain::KEYCHAIN_ACCOUNTS.contains(&key) { super::keychain::save_key(key, value)?; - // Also update runtime env var. - Self::ui_thread_set_env(key, value); return Ok(()); } @@ -611,6 +746,7 @@ impl Config { | "BEEP_ON_START" | "SHOW_DOCK_ICON" | "TRANSCRIPTION_OVERLAY_ENABLED" + | "TRAY_START_ASSISTIVE" | "HOLD_EXCLUSIVE" | "USE_LOCAL_STT" | "HISTORY_ENABLED" @@ -618,7 +754,8 @@ impl Config { | "QUICK_NOTES_SAVE_ONLY" | "START_AT_LOGIN" | "QUBE_DAEMON_AUTOSTART" - | "AGENT_ENTER_SENDS" => { + | "AGENT_ENTER_SENDS" + | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { let bool_val = matches!(value, "1" | "true" | "yes" | "on"); settings.set_bool(key, bool_val); } @@ -626,8 +763,6 @@ impl Config { settings.set_string(key, value); } } - // Also update runtime env var. - Self::ui_thread_set_env(key, value); return Ok(()); } @@ -643,7 +778,6 @@ impl Config { }; env_vars.insert(key.to_string(), value.to_string()); Self::write_env_file(&env_path, &env_vars)?; - Self::ui_thread_set_env(key, value); Ok(()) } @@ -664,7 +798,6 @@ impl Config { // API keys → Keychain if super::keychain::KEYCHAIN_ACCOUNTS.contains(key) { super::keychain::save_key(key, value)?; - Self::ui_thread_set_env(key, value); continue; } @@ -761,6 +894,7 @@ impl Config { | "BEEP_ON_START" | "SHOW_DOCK_ICON" | "TRANSCRIPTION_OVERLAY_ENABLED" + | "TRAY_START_ASSISTIVE" | "HOLD_EXCLUSIVE" | "USE_LOCAL_STT" | "HISTORY_ENABLED" @@ -768,7 +902,8 @@ impl Config { | "QUICK_NOTES_SAVE_ONLY" | "START_AT_LOGIN" | "QUBE_DAEMON_AUTOSTART" - | "AGENT_ENTER_SENDS" => { + | "AGENT_ENTER_SENDS" + | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { let bv = matches!(*value, "1" | "true" | "yes" | "on"); match *key { "AI_FORMATTING_ENABLED" => { @@ -779,6 +914,7 @@ impl Config { "TRANSCRIPTION_OVERLAY_ENABLED" => { settings_ref.transcription_overlay_enabled = Some(bv) } + "TRAY_START_ASSISTIVE" => settings_ref.tray_start_assistive = Some(bv), "HOLD_EXCLUSIVE" => settings_ref.hold_exclusive = Some(bv), "USE_LOCAL_STT" => settings_ref.use_local_stt = Some(bv), "HISTORY_ENABLED" => settings_ref.history_enabled = Some(bv), @@ -791,12 +927,14 @@ impl Config { settings_ref.qube_daemon_autostart = Some(bv) } "AGENT_ENTER_SENDS" => settings_ref.agent_enter_sends = Some(bv), + "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { + settings_ref.stt_initial_prompt_enabled = Some(bv) + } _ => {} } } _ => {} } - Self::ui_thread_set_env(key, value); continue; } @@ -810,7 +948,6 @@ impl Config { } }); vars_ref.insert((*key).to_string(), (*value).to_string()); - Self::ui_thread_set_env(key, value); } if let Some(settings) = settings @@ -829,10 +966,13 @@ impl Config { } /// Parse .env file into HashMap. - pub fn parse_env_file(path: &PathBuf) -> anyhow::Result> { - // Path comes from Config::env_path() which is hardcoded to ~/.codescribe/.env - // nosemgrep: tainted-path - let contents = fs::read_to_string(path)?; + pub fn parse_env_file(path: &Path) -> anyhow::Result> { + // `path` is always internally derived from `Config::env_path()` + // (config_dir()/.env, or the `CODESCRIBE_ENV_PATH` override used by tests + // and power users) — never raw request or end-user input. No external + // path-traversal source reaches this read. + let path = canonical_existing_file(path)?; + let contents = fs::read_to_string(&path)?; let mut vars = HashMap::new(); for line in contents.lines() { @@ -914,7 +1054,7 @@ impl Config { { output_lines.push(String::new()); // blank line before new section } - output_lines.push("# Added by CodeScribe".to_string()); + output_lines.push("# Added by Codescribe".to_string()); let mut keys: Vec<_> = remaining_vars.keys().collect(); keys.sort(); @@ -1048,6 +1188,14 @@ impl Config { } } +fn canonical_existing_file(path: &Path) -> anyhow::Result { + let path = path.canonicalize()?; + if !path.is_file() { + anyhow::bail!("Config env path is not a file: {}", path.display()); + } + Ok(path) +} + #[cfg(test)] mod tests { use super::*; @@ -1097,10 +1245,56 @@ mod tests { fn setup_isolated_data_dir() -> TempDir { let tmp = TempDir::new().expect("tempdir"); set_env_for_test("CODESCRIBE_DATA_DIR", tmp.path()); + remove_env_for_test("CODESCRIBE_ENV_PATH"); remove_env_for_test("USE_LOCAL_STT"); + remove_env_for_test("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED"); tmp } + #[test] + #[serial] + fn save_to_env_persists_promoted_setting_without_process_env_mutation() { + let _tmp = setup_isolated_data_dir(); + let _model = TestEnvGuard::unset("LLM_MODEL"); + + Config::default() + .save_to_env("LLM_MODEL", "runtime-model") + .expect("save setting"); + + assert!(std::env::var("LLM_MODEL").is_err()); + assert_eq!( + UserSettings::load().llm_model.as_deref(), + Some("runtime-model") + ); + } + + #[test] + #[serial] + fn save_to_env_many_persists_batch_without_process_env_mutation() { + let _tmp = setup_isolated_data_dir(); + let _model = TestEnvGuard::unset("LLM_MODEL"); + let _workspace_roots = TestEnvGuard::unset("AGENT_WORKSPACE_ROOTS"); + + Config::default() + .save_to_env_many(&[ + ("LLM_MODEL", "batch-model"), + ("AGENT_WORKSPACE_ROOTS", "/tmp/a:/tmp/b"), + ]) + .expect("save settings batch"); + + assert!(std::env::var("LLM_MODEL").is_err()); + assert!(std::env::var("AGENT_WORKSPACE_ROOTS").is_err()); + assert_eq!( + UserSettings::load().llm_model.as_deref(), + Some("batch-model") + ); + let env_vars = Config::parse_env_file(&Config::env_path()).expect("parse .env"); + assert_eq!( + env_vars.get("AGENT_WORKSPACE_ROOTS").map(String::as_str), + Some("/tmp/a:/tmp/b") + ); + } + #[test] #[serial] fn load_injects_openai_responses_defaults_without_api_key() { @@ -1201,6 +1395,52 @@ mod tests { ); } + #[test] + #[serial] + fn test_stt_initial_prompt_defaults_off_and_requires_opt_in() { + let _tmp = setup_isolated_data_dir(); + let _prompt_env = TestEnvGuard::unset("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED"); + + let default_config = Config::load(); + assert!( + !default_config.stt_initial_prompt_enabled, + "fresh config must not enable Whisper initial_prompt" + ); + + let mut settings = UserSettings::load(); + settings.stt_initial_prompt_enabled = Some(true); + settings.save().expect("save settings"); + + let config = Config::load(); + assert!( + config.stt_initial_prompt_enabled, + "settings.json seed should be able to opt into Whisper initial_prompt" + ); + assert_eq!( + std::env::var("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED").as_deref(), + Ok("1"), + "settings seed should publish the env-managed STT prompt knob" + ); + } + + #[test] + #[serial] + fn test_runtime_env_can_force_stt_initial_prompt_off_over_settings() { + let _tmp = setup_isolated_data_dir(); + let _prompt_env = TestEnvGuard::unset("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED"); + + let mut settings = UserSettings::load(); + settings.stt_initial_prompt_enabled = Some(true); + settings.save().expect("save settings"); + + set_env_for_test("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", "0"); + let config = Config::load(); + assert!( + !config.stt_initial_prompt_enabled, + "explicit env must be able to keep Whisper initial_prompt disabled" + ); + } + #[test] #[serial] fn test_load_respects_transcription_overlay_enabled_from_settings_json() { @@ -1218,6 +1458,29 @@ mod tests { ); } + #[test] + #[serial] + fn test_load_respects_tray_start_assistive_from_settings_json() { + let _tmp = setup_isolated_data_dir(); + let _tray_start_env = TestEnvGuard::unset("TRAY_START_ASSISTIVE"); + + let default_config = Config::load(); + assert!( + !default_config.tray_start_assistive, + "UI-initiated recording should default to dictation" + ); + + let mut settings = UserSettings::load(); + settings.tray_start_assistive = Some(true); + settings.save().expect("save settings"); + + let config = Config::load(); + assert!( + config.tray_start_assistive, + "settings.json should be able to switch UI-initiated recording to assistive" + ); + } + #[test] #[serial] fn test_load_migrates_use_local_stt_from_env_file_before_settings_json_exists() { diff --git a/core/config/migrate.rs b/core/config/migrate.rs index c2fac161..b88706a2 100644 --- a/core/config/migrate.rs +++ b/core/config/migrate.rs @@ -156,22 +156,27 @@ pub fn migrate_if_needed(file_env: Option<&HashMap>) { settings.backend_max_upload_mb = Some(n); } - // Save settings.json - if let Err(e) = settings.save() { - tracing::warn!("Migration: failed to save settings.json: {e}"); - return; - } - - // Migrate API keys to Keychain + // Migrate API keys to Keychain before writing settings.json. The existence + // of settings.json is the migration-complete sentinel, so a failed secret + // write must leave the migration retryable on the next launch. for &account in keychain::KEYCHAIN_ACCOUNTS { if let Some(secret) = migrated_value(file_env, account) && !secret.is_empty() - && let Err(e) = keychain::save_key(account, &secret) + && let Err(e) = save_migrated_key(account, &secret) { - tracing::warn!("Migration: failed to save {account} to Keychain: {e}"); + tracing::warn!( + "Migration: failed to save {account} to Keychain; will retry on next launch: {e}" + ); + return; } } + // Save settings.json last because its presence marks the one-time import as complete. + if let Err(e) = settings.save() { + tracing::warn!("Migration: failed to save settings.json: {e}"); + return; + } + info!("Migrated config to settings.json + Keychain"); } @@ -179,6 +184,38 @@ fn migrated_value(file_env: Option<&HashMap>, key: &str) -> Opti file_env.and_then(|vars| vars.get(key).cloned()) } +fn save_migrated_key(account: &str, secret: &str) -> anyhow::Result<()> { + #[cfg(test)] + if test_save_key_failure_account(account) { + anyhow::bail!("injected save_key failure for {account}"); + } + + keychain::save_key(account, secret) +} + +#[cfg(test)] +static TEST_SAVE_KEY_FAILURE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +fn test_save_key_failure_account(account: &str) -> bool { + TEST_SAVE_KEY_FAILURE + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + .map(|guard| guard.as_deref() == Some(account)) + .unwrap_or(false) +} + +#[cfg(test)] +fn set_test_save_key_failure(account: Option<&str>) { + if let Ok(mut guard) = TEST_SAVE_KEY_FAILURE + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + { + *guard = account.map(str::to_owned); + } +} + #[cfg(test)] mod tests { use super::*; @@ -260,4 +297,82 @@ mod tests { remove_env_for_test("CODESCRIBE_DATA_DIR"); } + + #[test] + #[serial] + fn migrate_retries_when_keychain_save_fails() { + let _tmp = setup_isolated_data_dir(); + let mut file_env = HashMap::new(); + file_env.insert("WHISPER_LANGUAGE".to_string(), "en".to_string()); + file_env.insert("LLM_API_KEY".to_string(), "retry-secret".to_string()); + + set_test_save_key_failure(Some("LLM_API_KEY")); + remove_env_for_test("LLM_API_KEY"); + + migrate_if_needed(Some(&file_env)); + + assert!( + !UserSettings::settings_path().exists(), + "failed keychain save must not mark migration complete" + ); + assert!( + std::env::var("LLM_API_KEY").is_err(), + "injected failure happens before test key persistence" + ); + + set_test_save_key_failure(None); + migrate_if_needed(Some(&file_env)); + + assert!( + UserSettings::settings_path().exists(), + "retry after keychain recovery should complete migration" + ); + assert_eq!( + std::env::var("LLM_API_KEY").as_deref(), + Ok("retry-secret"), + "retry writes the migrated secret" + ); + + remove_env_for_test("LLM_API_KEY"); + remove_env_for_test("CODESCRIBE_DATA_DIR"); + } + + #[test] + #[serial] + fn successful_migration_marks_complete_once() { + let _tmp = setup_isolated_data_dir(); + let mut first_env = HashMap::new(); + first_env.insert("WHISPER_LANGUAGE".to_string(), "en".to_string()); + first_env.insert("LLM_API_KEY".to_string(), "first-secret".to_string()); + + migrate_if_needed(Some(&first_env)); + + let path = UserSettings::settings_path(); + assert!( + path.exists(), + "successful migration writes completion sentinel" + ); + assert_eq!(std::env::var("LLM_API_KEY").as_deref(), Ok("first-secret")); + + let mut second_env = HashMap::new(); + second_env.insert("WHISPER_LANGUAGE".to_string(), "pl".to_string()); + second_env.insert("LLM_API_KEY".to_string(), "second-secret".to_string()); + + migrate_if_needed(Some(&second_env)); + + let persisted = UserSettings::load(); + assert_eq!( + persisted.whisper_language.as_deref(), + Some("en"), + "existing settings.json skips re-migration" + ); + assert_eq!( + std::env::var("LLM_API_KEY").as_deref(), + Ok("first-secret"), + "existing completion sentinel skips duplicate key migration" + ); + + remove_env_for_test("LLM_API_KEY"); + remove_env_for_test("CODESCRIBE_DATA_DIR"); + } } diff --git a/core/config/mod.rs b/core/config/mod.rs index 76714357..823bf587 100644 --- a/core/config/mod.rs +++ b/core/config/mod.rs @@ -1,4 +1,4 @@ -//! Configuration module for CodeScribe Rust app. +//! Configuration module for Codescribe Rust app. //! //! Manages persistent settings with a tiered truth model: //! 1. Code defaults define zero-state runtime behaviour @@ -6,7 +6,7 @@ //! 3. `.env` is optional and only used for env-managed / developer overrides //! //! Runtime/user settings are stored under: -//! - `~/Library/Application Support/CodeScribe/settings.json` on macOS +//! - `~/Library/Application Support/Codescribe/settings.json` on macOS //! - `~/.codescribe/.env` only when an optional power-user env file exists //! //! ## Module Structure @@ -54,7 +54,7 @@ mod tests { #[test] fn test_default_config() { let config = Config::default(); - assert_eq!(config.whisper_language, Language::Polish); // Polish is default + assert_eq!(config.whisper_language, Language::Auto); assert_eq!(config.ai_max_tokens, 0); // 0 = no limit (API decides) assert!(!config.ai_formatting_enabled); assert!(!config.transcript_tagging_enabled); @@ -111,10 +111,11 @@ mod tests { #[test] fn test_language_parsing() { + assert_eq!("auto".parse::(), Ok(Language::Auto)); + assert_eq!("detect".parse::(), Ok(Language::Auto)); + assert_eq!("multilingual".parse::(), Ok(Language::Auto)); assert_eq!("pl".parse::(), Ok(Language::Polish)); assert_eq!("en".parse::(), Ok(Language::English)); - // "auto" maps to Polish (legacy compatibility) - assert_eq!("auto".parse::(), Ok(Language::Polish)); assert!("invalid".parse::().is_err()); } diff --git a/core/config/models.rs b/core/config/models.rs index a27033c3..e6c13794 100644 --- a/core/config/models.rs +++ b/core/config/models.rs @@ -365,7 +365,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let hf_cache = temp_dir.path().join("hf-cache"); let snapshot = hf_cache - .join("models--VetCoders--custom-whisper") + .join("models--Vetcoders--custom-whisper") .join("snapshots") .join("abc123"); @@ -379,7 +379,7 @@ mod tests { let _hf_cache = EnvGuard::set("CODESCRIBE_HF_CACHE", &hf_cache); let resolved = - resolve_runtime_whisper_model_path(Some("VetCoders/custom-whisper")).unwrap(); + resolve_runtime_whisper_model_path(Some("Vetcoders/custom-whisper")).unwrap(); assert_eq!(resolved, snapshot); } diff --git a/core/config/prompts.rs b/core/config/prompts.rs index 33f0abb9..9ed02ff1 100644 --- a/core/config/prompts.rs +++ b/core/config/prompts.rs @@ -36,7 +36,7 @@ Examples: → "Najpierw zrób to, potem tamto, a na końcu jeszcze coś." "#; -pub const DEFAULT_ASSISTIVE_PROMPT: &str = r#"You are a text assistant running inside CodeScribe. +pub const DEFAULT_ASSISTIVE_PROMPT: &str = r#"You are a text assistant running inside Codescribe. ASSISTIVE TEXT EDITING BEHAVIOR Act as a voice-native intent editor: speech -> intent -> location -> patch -> style. diff --git a/core/config/settings.rs b/core/config/settings.rs index d1eae505..6cfe1c7f 100644 --- a/core/config/settings.rs +++ b/core/config/settings.rs @@ -52,6 +52,8 @@ pub struct UserSettings { pub show_dock_icon: Option, #[serde(skip_serializing_if = "Option::is_none")] pub transcription_overlay_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tray_start_assistive: Option, // ── Promoted from .env (settings.json is now source of truth) ── #[serde(skip_serializing_if = "Option::is_none")] @@ -82,6 +84,10 @@ pub struct UserSettings { pub qube_daemon_autostart: Option, #[serde(skip_serializing_if = "Option::is_none")] pub agent_enter_sends: Option, + /// First-run operating lane chosen during onboarding ("basic" | "agentic"). + /// `None` means "not yet chosen" — callers treat that as the safe Basic lane. + #[serde(skip_serializing_if = "Option::is_none")] + pub onboarding_mode: Option, // ── Voice Lab survivors (user-facing UX knobs) ── #[serde(skip_serializing_if = "Option::is_none")] @@ -96,6 +102,31 @@ pub struct UserSettings { pub whisper_model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub backend_max_upload_mb: Option, + + // ── STT engine / layered transcription (F1) ── + /// STT engine selection ("auto" | "apple" | "whisper"). + /// Seeds `CODESCRIBE_STT_ENGINE`; string on purpose (1:1 env mapping, like + /// `onboarding_mode`). `None`/absent means the built-in auto policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub stt_engine: 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. + #[serde(skip_serializing_if = "Option::is_none")] + pub layered_transcription: Option, + /// Opt-in Whisper `initial_prompt` vocabulary hint. + /// Seeds `CODESCRIBE_STT_INITIAL_PROMPT_ENABLED`; absent means default OFF. + #[serde(skip_serializing_if = "Option::is_none")] + pub stt_initial_prompt_enabled: Option, + + // ── Agent workspace ── + /// Workspace root directories the agent scans (`list_projects`) to resolve a + /// project name to an absolute path. Seeds `AGENT_WORKSPACE_ROOTS` + /// (colon-joined); `None`/absent means the built-in default (`~/Git`). + /// Env-managed (NOT promoted), same rationale as the F1 STT knobs: a manual + /// `~/.codescribe/.env` line keeps winning. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_workspace_roots: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -187,6 +218,13 @@ struct SpeechEngineV2 { // De-ghosted (2026-05-30): Whisper model id (distinct from local_model_id path). #[serde(skip_serializing_if = "Option::is_none")] whisper_model: Option, + // F1 layered transcription: engine selector + phase flag (string, 1:1 env). + #[serde(skip_serializing_if = "Option::is_none")] + stt_engine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + layered_transcription: Option, + #[serde(skip_serializing_if = "Option::is_none")] + initial_prompt_enabled: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -257,6 +295,8 @@ struct UiV2 { show_dock_icon: Option, #[serde(skip_serializing_if = "Option::is_none")] transcription_overlay_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tray_start_assistive: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -278,6 +318,12 @@ struct SystemV2 { start_at_login: Option, #[serde(skip_serializing_if = "Option::is_none")] qube_daemon_autostart: Option, + #[serde(skip_serializing_if = "Option::is_none")] + onboarding_mode: Option, + // Agent workspace roots (colon-joined into AGENT_WORKSPACE_ROOTS). List on + // purpose — mirrors mode_bindings' Vec round-trip through the V2 schema. + #[serde(skip_serializing_if = "Option::is_none")] + agent_workspace_roots: Option>, } /// Canonical list of env keys that route to `settings.json` (not `.env`). @@ -306,6 +352,7 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ // App visibility "SHOW_DOCK_ICON", "TRANSCRIPTION_OVERLAY_ENABLED", + "TRAY_START_ASSISTIVE", // LLM endpoints "LLM_ENDPOINT", "LLM_MODEL", @@ -325,6 +372,7 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ "START_AT_LOGIN", "QUBE_DAEMON_AUTOSTART", "AGENT_ENTER_SENDS", + "ONBOARDING_MODE", // Voice Lab survivors "CODESCRIBE_BUFFER_DELAY_MS", "CODESCRIBE_TYPING_CPS", @@ -332,6 +380,10 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ "CODESCRIBE_BUFFERED_INTERIM_SEC", "WHISPER_MODEL", "BACKEND_MAX_UPLOAD_MB", + // NOTE (F1/W2-G): CODESCRIBE_STT_ENGINE / CODESCRIBE_LAYERED_TRANSCRIPTION / + // CODESCRIBE_STT_INITIAL_PROMPT_ENABLED are deliberately NOT promoted — they + // stay env-managed so an existing ~/.codescribe/.env line keeps winning. + // settings.json only seeds them when the env var is absent (loader.rs). ]; /// Check if a key is a promoted (settings.json) setting. @@ -367,6 +419,9 @@ impl UserSettings { cloud_transcription_endpoint: self.stt_endpoint.clone(), cloud_max_upload_mb: self.backend_max_upload_mb, whisper_model: self.whisper_model.clone(), + stt_engine: self.stt_engine.clone(), + layered_transcription: self.layered_transcription.clone(), + initial_prompt_enabled: self.stt_initial_prompt_enabled, }), formatting: Some(FormattingV2 { enabled: self.ai_formatting_enabled, @@ -401,6 +456,7 @@ impl UserSettings { chat_zoom: self.chat_zoom, show_dock_icon: self.show_dock_icon, transcription_overlay_enabled: self.transcription_overlay_enabled, + tray_start_assistive: self.tray_start_assistive, }), features: Some(FeaturesV2 { history_enabled: self.history_enabled, @@ -410,6 +466,8 @@ impl UserSettings { system: Some(SystemV2 { start_at_login: self.start_at_login, qube_daemon_autostart: self.qube_daemon_autostart, + onboarding_mode: self.onboarding_mode.clone(), + agent_workspace_roots: self.agent_workspace_roots.clone(), }), } } @@ -489,6 +547,7 @@ impl UserSettings { .ui .as_ref() .and_then(|ui| ui.transcription_overlay_enabled), + tray_start_assistive: v2.ui.as_ref().and_then(|ui| ui.tray_start_assistive), llm_formatting_endpoint: v2 .speech .as_ref() @@ -527,6 +586,11 @@ impl UserSettings { quick_notes_save_only: v2.features.as_ref().and_then(|f| f.quick_notes_save_only), start_at_login: v2.system.as_ref().and_then(|s| s.start_at_login), qube_daemon_autostart: v2.system.as_ref().and_then(|s| s.qube_daemon_autostart), + onboarding_mode: v2.system.as_ref().and_then(|s| s.onboarding_mode.clone()), + agent_workspace_roots: v2 + .system + .as_ref() + .and_then(|s| s.agent_workspace_roots.clone()), agent_enter_sends: v2.interaction.as_ref().and_then(|i| i.agent_enter_sends), buffer_delay_ms: v2 .speech @@ -558,6 +622,21 @@ impl UserSettings { .as_ref() .and_then(|s| s.engine.as_ref()) .and_then(|e| e.cloud_max_upload_mb), + stt_engine: v2 + .speech + .as_ref() + .and_then(|s| s.engine.as_ref()) + .and_then(|e| e.stt_engine.clone()), + layered_transcription: v2 + .speech + .as_ref() + .and_then(|s| s.engine.as_ref()) + .and_then(|e| e.layered_transcription.clone()), + stt_initial_prompt_enabled: v2 + .speech + .as_ref() + .and_then(|s| s.engine.as_ref()) + .and_then(|e| e.initial_prompt_enabled), } } @@ -583,16 +662,16 @@ impl UserSettings { /// Returns the settings directory. /// /// Respects `CODESCRIBE_DATA_DIR` for test isolation; otherwise uses - /// `~/Library/Application Support/CodeScribe/`. + /// `~/Library/Application Support/Codescribe/`. pub fn settings_dir() -> PathBuf { let dir = if let Ok(test_dir) = std::env::var("CODESCRIBE_DATA_DIR") { PathBuf::from(test_dir) } else { BaseDirs::new() - .map(|b| b.data_dir().join("CodeScribe")) + .map(|b| b.data_dir().join("Codescribe")) .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); - PathBuf::from(home).join("Library/Application Support/CodeScribe") + PathBuf::from(home).join("Library/Application Support/Codescribe") }) }; @@ -793,6 +872,22 @@ impl UserSettings { "AUDIO_INPUT_DEVICE" => self.audio_input_device = Some(value.to_owned()), "SOUND_NAME" => self.sound_name = Some(value.to_owned()), "WHISPER_MODEL" => self.whisper_model = Some(value.to_owned()), + "ONBOARDING_MODE" => self.onboarding_mode = Some(value.to_owned()), + "CODESCRIBE_STT_ENGINE" => self.stt_engine = Some(value.to_owned()), + "CODESCRIBE_LAYERED_TRANSCRIPTION" => { + self.layered_transcription = Some(value.to_owned()) + } + "AGENT_WORKSPACE_ROOTS" => { + // Colon-separated on the wire (PATH-style); empty → None so the + // core falls back to the built-in default (`~/Git`). + let roots: Vec = value + .split(':') + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .map(str::to_owned) + .collect(); + self.agent_workspace_roots = (!roots.is_empty()).then_some(roots); + } other => { warn!("Unknown string setting key: {other}"); return; @@ -810,6 +905,7 @@ impl UserSettings { "BEEP_ON_START" => self.beep_on_start = Some(value), "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_EXCLUSIVE" => self.hold_exclusive = Some(value), "USE_LOCAL_STT" => self.use_local_stt = Some(value), "HISTORY_ENABLED" => self.history_enabled = Some(value), @@ -818,6 +914,9 @@ impl UserSettings { "START_AT_LOGIN" => self.start_at_login = Some(value), "QUBE_DAEMON_AUTOSTART" => self.qube_daemon_autostart = Some(value), "AGENT_ENTER_SENDS" => self.agent_enter_sends = Some(value), + "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { + self.stt_initial_prompt_enabled = Some(value) + } other => { warn!("Unknown bool setting key: {other}"); return; @@ -997,6 +1096,83 @@ mod tests { assert_eq!(loaded.llm_model.as_deref(), Some("gpt-4.1")); } + #[test] + #[serial] + fn test_stt_engine_and_layered_transcription_survive_roundtrip() { + // F1 layered transcription: both env-managed keys must round-trip through + // the V2 speech.engine section, or save→load silently drops the seed value. + let _tmp = setup_isolated_data_dir(); + let settings = UserSettings { + stt_engine: Some("apple".to_string()), + layered_transcription: Some("phase1".to_string()), + stt_initial_prompt_enabled: Some(true), + ..Default::default() + }; + settings.save().expect("save settings"); + + let loaded = UserSettings::load(); + assert_eq!(loaded.stt_engine.as_deref(), Some("apple")); + assert_eq!(loaded.layered_transcription.as_deref(), Some("phase1")); + assert_eq!(loaded.stt_initial_prompt_enabled, Some(true)); + + // Setters route all three keys (settings.json stays a valid seed source). + let mut mutated = loaded; + mutated.set_string("CODESCRIBE_STT_ENGINE", "whisper"); + mutated.set_string("CODESCRIBE_LAYERED_TRANSCRIPTION", "off"); + mutated.set_bool("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", false); + let reloaded = UserSettings::load(); + assert_eq!(reloaded.stt_engine.as_deref(), Some("whisper")); + assert_eq!(reloaded.layered_transcription.as_deref(), Some("off")); + assert_eq!(reloaded.stt_initial_prompt_enabled, Some(false)); + } + + #[test] + #[serial] + fn test_agent_workspace_roots_survive_v2_system_roundtrip() { + // Workspace roots must round-trip through the V2 `system` section (Vec, + // like mode_bindings) or save→load silently drops the AGENT_WORKSPACE_ROOTS + // seed the list_projects tool depends on. + let _tmp = setup_isolated_data_dir(); + let settings = UserSettings { + agent_workspace_roots: Some(vec!["~/Git".to_string(), "~/dev".to_string()]), + ..Default::default() + }; + settings.save().expect("save settings"); + + let loaded = UserSettings::load(); + assert_eq!( + loaded.agent_workspace_roots.as_deref(), + Some(["~/Git".to_string(), "~/dev".to_string()].as_slice()) + ); + + // Land under the V2 `system` section (not a stray top-level key). + let path = UserSettings::settings_path(); + let persisted: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read persisted settings")) + .expect("parse persisted settings"); + assert_eq!( + persisted + .get("system") + .and_then(|v| v.get("agent_workspace_roots")) + .and_then(|v| v.as_array()) + .map(Vec::len), + Some(2) + ); + + // set_string parses the colon-joined wire form; empty clears back to None. + let mut mutated = loaded; + mutated.set_string("AGENT_WORKSPACE_ROOTS", "~/code : ~/work"); + let reloaded = UserSettings::load(); + assert_eq!( + reloaded.agent_workspace_roots.as_deref(), + Some(["~/code".to_string(), "~/work".to_string()].as_slice()) + ); + + let mut cleared = reloaded; + cleared.set_string("AGENT_WORKSPACE_ROOTS", ""); + assert_eq!(cleared.agent_workspace_roots, None); + } + #[test] #[serial] fn test_transcription_overlay_enabled_persists_in_v2_ui_section() { @@ -1020,6 +1196,29 @@ mod tests { ); } + #[test] + #[serial] + fn test_tray_start_assistive_persists_in_v2_ui_section() { + let _tmp = setup_isolated_data_dir(); + let mut settings = UserSettings::default(); + settings.set_bool("TRAY_START_ASSISTIVE", true); + + let loaded = UserSettings::load(); + assert_eq!(loaded.tray_start_assistive, Some(true)); + + let path = UserSettings::settings_path(); + let persisted: serde_json::Value = + serde_json::from_str(&fs::read_to_string(path).expect("read persisted settings")) + .expect("parse persisted settings"); + assert_eq!( + persisted + .get("ui") + .and_then(|v| v.get("tray_start_assistive")) + .and_then(|v| v.as_bool()), + Some(true) + ); + } + #[test] #[serial] fn test_qube_daemon_autostart_persists_in_v2_system_section() { @@ -1043,6 +1242,39 @@ mod tests { ); } + #[test] + #[serial] + fn test_onboarding_mode_persists_in_v2_system_section() { + // Ghosting guard (W1-C1): onboarding_mode must survive the flat -> + // SettingsV2 -> flat round-trip and land in the V2 `system` section. + let _tmp = setup_isolated_data_dir(); + let mut settings = UserSettings::default(); + settings.set_string("ONBOARDING_MODE", "agentic"); + + let loaded = UserSettings::load(); + assert_eq!(loaded.onboarding_mode.as_deref(), Some("agentic")); + + let path = UserSettings::settings_path(); + let persisted: serde_json::Value = + serde_json::from_str(&fs::read_to_string(path).expect("read persisted settings")) + .expect("parse persisted settings"); + assert_eq!( + persisted + .get("system") + .and_then(|v| v.get("onboarding_mode")) + .and_then(|v| v.as_str()), + Some("agentic") + ); + } + + #[test] + #[serial] + fn test_onboarding_mode_defaults_to_none_when_unset() { + let _tmp = setup_isolated_data_dir(); + let settings = UserSettings::default(); + assert_eq!(settings.onboarding_mode, None); + } + #[test] #[serial] fn test_mode_binding_updates_canonical_contract_only() { diff --git a/core/config/types.rs b/core/config/types.rs index 0ecea96b..8c3cebdd 100644 --- a/core/config/types.rs +++ b/core/config/types.rs @@ -1,4 +1,4 @@ -//! Type definitions for CodeScribe configuration. +//! Type definitions for Codescribe configuration. //! //! Contains all enums and the main Config struct. @@ -35,9 +35,9 @@ impl WorkMode { pub fn description(&self) -> &'static str { match self { - Self::Dictation => "Fast transcript / auto-paste mode.", - Self::Formatting => "AI formatting pass for dictation text.", - Self::Assistive => "AI assistive conversation mode.", + Self::Dictation => "Transcribes your voice and pastes the text.", + Self::Formatting => "Records dictation, then formats it before pasting.", + Self::Assistive => "Sends your voice to the agent instead of pasting.", } } @@ -154,12 +154,16 @@ pub fn default_mode_bindings() -> Vec { ] } -/// Language options for Whisper transcription -/// NOTE: No "Auto" - Whisper requires explicit language for reliable transcription +/// Language options for Whisper transcription. +/// +/// `Auto` leaves language detection to Whisper. Use `whisper_hint()` when +/// calling STT/formatting paths: forcing `Some("pl")`/`Some("en")` is only for +/// explicit single-language sessions. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Language { #[default] + Auto, Polish, English, } @@ -167,10 +171,27 @@ pub enum Language { impl Language { pub fn as_str(&self) -> &'static str { match self { + Self::Auto => "auto", Self::Polish => "pl", Self::English => "en", } } + + pub fn label(&self) -> &'static str { + match self { + Self::Auto => "Auto-detect / multilingual", + Self::Polish => "Polish (pl)", + Self::English => "English (en)", + } + } + + pub fn whisper_hint(&self) -> Option<&'static str> { + match self { + Self::Auto => None, + Self::Polish => Some("pl"), + Self::English => Some("en"), + } + } } impl FromStr for Language { @@ -178,10 +199,9 @@ impl FromStr for Language { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { + "auto" | "" | "detect" | "multilingual" | "any" => Ok(Self::Auto), "pl" | "polish" => Ok(Self::Polish), "en" | "english" => Ok(Self::English), - // Legacy "auto" maps to Polish (default) - "auto" | "" => Ok(Self::Polish), _ => Err(format!("Unknown Language: {}", s)), } } @@ -247,7 +267,7 @@ impl FromStr for OverlayPositionMode { } } -/// CodeScribe configuration structure. +/// Codescribe configuration structure. /// /// This struct contains all configuration options for the app. /// Values are loaded from .env file (single source of truth). @@ -316,6 +336,10 @@ pub struct Config { #[serde(default = "default_transcription_overlay_enabled")] pub transcription_overlay_enabled: bool, + /// Whether recording started from UI surfaces uses the assistive lane. + #[serde(default)] + pub tray_start_assistive: bool, + /// Whether to show hold indicator badge #[serde(default = "default_hold_indicator")] pub hold_indicator: bool, @@ -395,6 +419,14 @@ pub struct Config { /// Cloud STT endpoint used when cloud is selected as the committed verdict path. pub stt_endpoint: Option, + /// Opt-in Whisper domain-vocabulary initial prompt. + /// + /// Default OFF after W2-F measured the active runtime lexicon prompt as a + /// 100% WER regression. Runtime env/settings can still enable the feature + /// for diagnosis and future retuning. + #[serde(default = "default_stt_initial_prompt_enabled")] + pub stt_initial_prompt_enabled: bool, + /// Full LLM endpoint URL (default: https://api.openai.com/v1/responses) #[serde(default = "default_llm_endpoint_option")] pub llm_endpoint: Option, @@ -447,6 +479,7 @@ impl Default for Config { show_tray_glyph: default_show_tray_glyph(), show_dock_icon: default_show_dock_icon(), transcription_overlay_enabled: default_transcription_overlay_enabled(), + tray_start_assistive: false, hold_indicator: default_hold_indicator(), hold_badge_size: default_hold_badge_size(), hold_badge_offset_x: default_hold_badge_offset_x(), @@ -464,6 +497,7 @@ impl Default for Config { use_local_stt: true, local_model: default_local_model(), stt_endpoint: None, + stt_initial_prompt_enabled: default_stt_initial_prompt_enabled(), llm_endpoint: Some(default_llm_endpoint()), llm_api_key: None, stt_api_key: None, @@ -531,4 +565,12 @@ mod tests { Some(DEFAULT_OPENAI_RESPONSES_ENDPOINT) ); } + + #[test] + fn default_config_disables_stt_initial_prompt() { + assert!( + !Config::default().stt_initial_prompt_enabled, + "Whisper initial_prompt must stay opt-in after W2-F WER collapse" + ); + } } diff --git a/core/connectors/github.rs b/core/connectors/github.rs index 76e6a19d..978ee1ae 100644 --- a/core/connectors/github.rs +++ b/core/connectors/github.rs @@ -103,12 +103,11 @@ fn parse_github_spec(spec: &str) -> Option { let git_ref = &after_at[..colon_pos]; let path = &after_at[colon_pos + 1..]; (repo, git_ref, path) - } else if let Some(colon_pos) = rest.find(':') { + } else { + let colon_pos = rest.find(':')?; let repo = &rest[..colon_pos]; let path = &rest[colon_pos + 1..]; (repo, "main", path) - } else { - return None; }; if repo.is_empty() || path.is_empty() { @@ -251,12 +250,15 @@ mod tests { #[test] fn test_parse_github_url_blob() { - let gh = parse_github_ref("https://github.com/VetCoders/CodeScribe/blob/main/src/lib.rs"); + let gh = parse_github_ref("https://github.com/vetcoders/codescribe/blob/main/src/lib.rs"); assert_eq!( gh, Some(GitHubRef { - owner: "VetCoders".into(), - repo: "CodeScribe".into(), + // A ref parser preserves the case present in the URL path; + // GitHub `contents`/raw paths are case-sensitive, so brand + // Title-casing here would break fetches of real repositories. + owner: "vetcoders".into(), + repo: "codescribe".into(), git_ref: "main".into(), path: "src/lib.rs".into(), }) @@ -281,12 +283,12 @@ mod tests { #[test] fn test_parse_github_spec_full() { - let gh = parse_github_ref("VetCoders/CodeScribe@fix/multiple-fixes:core/lib.rs"); + let gh = parse_github_ref("Vetcoders/Codescribe@fix/multiple-fixes:core/lib.rs"); assert_eq!( gh, Some(GitHubRef { - owner: "VetCoders".into(), - repo: "CodeScribe".into(), + owner: "Vetcoders".into(), + repo: "Codescribe".into(), git_ref: "fix/multiple-fixes".into(), path: "core/lib.rs".into(), }) @@ -295,12 +297,12 @@ mod tests { #[test] fn test_parse_github_spec_default_ref() { - let gh = parse_github_ref("VetCoders/CodeScribe:core/lib.rs"); + let gh = parse_github_ref("Vetcoders/Codescribe:core/lib.rs"); assert_eq!( gh, Some(GitHubRef { - owner: "VetCoders".into(), - repo: "CodeScribe".into(), + owner: "Vetcoders".into(), + repo: "Codescribe".into(), git_ref: "main".into(), path: "core/lib.rs".into(), }) @@ -338,7 +340,7 @@ mod tests { #[test] fn test_percent_encode_param_safe() { - assert_eq!(percent_encode_param("VetCoders"), "VetCoders"); + assert_eq!(percent_encode_param("Vetcoders"), "Vetcoders"); assert_eq!(percent_encode_param("my-repo_v2"), "my-repo_v2"); } diff --git a/core/connectors/mod.rs b/core/connectors/mod.rs index 0c1ec020..a1f4de28 100644 --- a/core/connectors/mod.rs +++ b/core/connectors/mod.rs @@ -1,4 +1,4 @@ -//! External content connectors for CodeScribe attachments. +//! External content connectors for Codescribe attachments. //! //! Each connector fetches content from an external source and produces //! files on disk that become regular `Attachment` objects. @@ -16,7 +16,7 @@ pub(crate) fn shared_client() -> &'static reqwest::Client { reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::limited(3)) - .user_agent("CodeScribe/1.0 (speech-to-text assistant)") + .user_agent("Codescribe/1.0 (speech-to-text assistant)") .build() .expect("Failed to build shared HTTP client") }) diff --git a/core/connectors/web.rs b/core/connectors/web.rs index a8e8ac1e..75ff68f1 100644 --- a/core/connectors/web.rs +++ b/core/connectors/web.rs @@ -147,7 +147,7 @@ fn ssrf_safe_client() -> reqwest::Client { attempt.follow() } })) - .user_agent("CodeScribe/1.0 (speech-to-text assistant)") + .user_agent("Codescribe/1.0 (speech-to-text assistant)") .build() .expect("Failed to build SSRF-safe HTTP client") }) diff --git a/core/embedded/mod.rs b/core/embedded/mod.rs index 29c696e6..f5b9fb3b 100644 --- a/core/embedded/mod.rs +++ b/core/embedded/mod.rs @@ -1,6 +1,6 @@ //! Embedded asset registry: shared abstraction over compiled-in model blobs. //! -//! CodeScribe ships up to four independent model assets that may be embedded +//! Codescribe ships up to four independent model assets that may be embedded //! into the binary at build time: Whisper STT, MiniLM text embedder, Silero //! VAD, and CSM-1B TTS. Each asset has its own payload shape (Whisper carries //! mel filters; TTS carries Mimi codec + voice tokens; VAD is a single diff --git a/core/embedder/singleton.rs b/core/embedder/singleton.rs index ff2e0b51..2f18a1f1 100644 --- a/core/embedder/singleton.rs +++ b/core/embedder/singleton.rs @@ -1,105 +1,176 @@ //! Singleton pattern for embedder - easy global access. //! -//! Provides a global embedder instance that's initialized once and reused. -//! Thread-safe via OnceLock + Mutex pattern. +//! Provides a global embedder instance that is loaded on demand and reused. +//! Thread-safe via a single `Mutex` guarding a resettable slot. +//! +//! ## Idle unload +//! +//! Like Whisper, the MiniLM embedder lives on the GPU (Metal, candle BertModel) +//! and its multilingual tokenizer is a large host-side structure — together a +//! few hundred MB held for the whole process. The engine is therefore held in a +//! *resettable* slot: after a configurable idle period with no embedding a +//! background reaper drops it, returning GPU/host memory to the system, and the +//! next call transparently reloads it. Set +//! `CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS=0` to disable. use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; use anyhow::Result; -use tracing::info; +use tracing::{info, warn}; use super::engine::{EmbedderConfig, EmbedderEngine}; -/// Global embedder instance -static EMBEDDER_INSTANCE: OnceLock> = OnceLock::new(); +/// Default idle period after which the embedder is unloaded to free GPU memory. +/// Disabled by default (0): like the Whisper engine, idle-unload recreates the +/// Metal device on reload and leaks IOAccelerator ports/threads per cycle. +/// Re-enable per machine via `CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS=`. +const DEFAULT_IDLE_UNLOAD_SECS: u64 = 0; -/// Initialize the embedder with default config -pub fn init() -> Result<()> { - init_with_config(EmbedderConfig::default()) +/// How often the reaper wakes to check for idleness. +const REAPER_TICK: Duration = Duration::from_secs(30); + +/// Resettable engine slot: `None` when unloaded, plus the last-use timestamp. +struct EmbedderSlot { + engine: Option, + last_used: Instant, } -/// Initialize with custom configuration -pub fn init_with_config(config: EmbedderConfig) -> Result<()> { - if EMBEDDER_INSTANCE.get().is_some() { - info!("Embedder already initialized"); - return Ok(()); - } +static SLOT: OnceLock> = OnceLock::new(); - let engine = EmbedderEngine::with_config(config)?; +/// Config used to (re)load the engine. First value wins (default unless +/// `init_with_config` set one before the first load). +static CONFIG: OnceLock = OnceLock::new(); - EMBEDDER_INSTANCE - .set(Mutex::new(engine)) - .map_err(|_| anyhow::anyhow!("Embedder already initialized"))?; +/// Guard so the idle reaper thread is spawned at most once. +static REAPER_STARTED: OnceLock<()> = OnceLock::new(); - info!("Embedder singleton initialized"); - Ok(()) +fn slot() -> &'static Mutex { + SLOT.get_or_init(|| { + Mutex::new(EmbedderSlot { + engine: None, + last_used: Instant::now(), + }) + }) } -/// Check if embedder is initialized -pub fn is_initialized() -> bool { - EMBEDDER_INSTANCE.get().is_some() +fn config() -> EmbedderConfig { + CONFIG.get_or_init(EmbedderConfig::default).clone() } -/// Embed a single text (query) -/// -/// Auto-initializes with default config if not already done. -pub fn embed(text: &str) -> Result> { - ensure_initialized()?; +/// Resolve the configured idle-unload period, or `None` when disabled (0). +fn idle_unload_after() -> Option { + let secs = std::env::var("CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(DEFAULT_IDLE_UNLOAD_SECS); + (secs > 0).then(|| Duration::from_secs(secs)) +} - let embedder = EMBEDDER_INSTANCE - .get() - .ok_or_else(|| anyhow::anyhow!("Embedder not initialized"))?; +fn load_engine() -> Result { + let engine = EmbedderEngine::with_config(config())?; + info!("Embedder engine loaded"); + Ok(engine) +} + +/// Spawn the idle reaper once (only when idle-unload is enabled). +fn ensure_reaper() { + if idle_unload_after().is_none() { + return; + } + REAPER_STARTED.get_or_init(|| { + let spawned = std::thread::Builder::new() + .name("embedder-idle-reaper".into()) + .spawn(reaper_loop); + if let Err(e) = spawned { + warn!("Failed to spawn embedder idle reaper: {e}"); + } + }); +} - let mut guard = embedder +/// Background loop: drops the engine after it has been idle long enough. +fn reaper_loop() { + loop { + std::thread::sleep(REAPER_TICK); + let Some(threshold) = idle_unload_after() else { + continue; + }; + let mut guard = match slot().lock() { + Ok(g) => g, + Err(_) => continue, + }; + if guard.engine.is_some() && guard.last_used.elapsed() >= threshold { + guard.engine = None; + drop(guard); + info!( + "Embedder engine unloaded after {}s idle; releasing GPU/host memory", + threshold.as_secs() + ); + crate::memory::release_freed_heap(); + } + } +} + +/// Run `f` with the engine, loading it on demand and refreshing the idle clock. +fn with_embedder(f: impl FnOnce(&mut EmbedderEngine) -> Result) -> Result { + let mut guard = slot() .lock() .map_err(|e| anyhow::anyhow!("Embedder lock poisoned: {}", e))?; + if guard.engine.is_none() { + guard.engine = Some(load_engine()?); + ensure_reaper(); + } + guard.last_used = Instant::now(); + let engine = guard + .engine + .as_mut() + .ok_or_else(|| anyhow::anyhow!("Embedder not initialized"))?; + f(engine) +} - guard.embed(text) +/// Initialize the embedder with default config. +pub fn init() -> Result<()> { + with_embedder(|_| Ok(())) } -/// Embed a passage (document) for indexing -pub fn embed_passage(text: &str) -> Result> { - ensure_initialized()?; +/// Initialize with custom configuration. +/// +/// The config is captured for (re)loads; the first config wins. Idempotent. +pub fn init_with_config(config: EmbedderConfig) -> Result<()> { + let _ = CONFIG.set(config); + with_embedder(|_| Ok(())) +} - let embedder = EMBEDDER_INSTANCE - .get() - .ok_or_else(|| anyhow::anyhow!("Embedder not initialized"))?; +/// Check if the embedder is currently loaded. +/// +/// Note: with idle-unload enabled this can become `false` again after a period +/// of inactivity; the next call reloads transparently. +pub fn is_initialized() -> bool { + SLOT.get() + .and_then(|m| m.lock().ok().map(|g| g.engine.is_some())) + .unwrap_or(false) +} - let mut guard = embedder - .lock() - .map_err(|e| anyhow::anyhow!("Embedder lock poisoned: {}", e))?; +/// Embed a single text (query) +/// +/// Auto-initializes with default config if not already done. +pub fn embed(text: &str) -> Result> { + with_embedder(|engine| engine.embed(text)) +} - guard.embed_passage(text) +/// Embed a passage (document) for indexing +pub fn embed_passage(text: &str) -> Result> { + with_embedder(|engine| engine.embed_passage(text)) } /// Embed multiple texts at once pub fn embed_batch(texts: &[&str]) -> Result>> { - ensure_initialized()?; - - let embedder = EMBEDDER_INSTANCE - .get() - .ok_or_else(|| anyhow::anyhow!("Embedder not initialized"))?; - - let mut guard = embedder - .lock() - .map_err(|e| anyhow::anyhow!("Embedder lock poisoned: {}", e))?; - - guard.embed_batch(texts) + with_embedder(|engine| engine.embed_batch(texts)) } /// Embed multiple passages at once pub fn embed_passages(texts: &[&str]) -> Result>> { - ensure_initialized()?; - - let embedder = EMBEDDER_INSTANCE - .get() - .ok_or_else(|| anyhow::anyhow!("Embedder not initialized"))?; - - let mut guard = embedder - .lock() - .map_err(|e| anyhow::anyhow!("Embedder lock poisoned: {}", e))?; - - guard.embed_passages(texts) + with_embedder(|engine| engine.embed_passages(texts)) } /// Calculate cosine similarity between two embeddings @@ -109,25 +180,7 @@ pub fn similarity(a: &[f32], b: &[f32]) -> f32 { /// Get embedding dimension for the current model pub fn dimension() -> Result { - ensure_initialized()?; - - let embedder = EMBEDDER_INSTANCE - .get() - .ok_or_else(|| anyhow::anyhow!("Embedder not initialized"))?; - - let guard = embedder - .lock() - .map_err(|e| anyhow::anyhow!("Embedder lock poisoned: {}", e))?; - - Ok(guard.dimension()) -} - -/// Ensure embedder is initialized (auto-init with defaults if not) -fn ensure_initialized() -> Result<()> { - if !is_initialized() { - init()?; - } - Ok(()) + with_embedder(|engine| Ok(engine.dimension())) } #[cfg(test)] @@ -142,5 +195,18 @@ mod tests { assert!((sim - 1.0).abs() < 0.001); } + #[test] + fn idle_unload_disabled_when_zero() { + // SAFETY: single-threaded test mutating a process env var it owns. + unsafe { std::env::set_var("CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS", "0") }; + assert!(idle_unload_after().is_none()); + unsafe { std::env::set_var("CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS", "90") }; + assert_eq!(idle_unload_after(), Some(Duration::from_secs(90))); + unsafe { std::env::remove_var("CODESCRIBE_EMBEDDER_IDLE_UNLOAD_SECS") }; + // DEFAULT_IDLE_UNLOAD_SECS is now 0 (idle-unload disabled by default), + // so with no override the reaper is off. + assert!(idle_unload_after().is_none()); + } + // Note: Full embedding tests require model download and are in integration tests } diff --git a/core/hf_cache.rs b/core/hf_cache.rs index b2cdc4c3..d480952e 100644 --- a/core/hf_cache.rs +++ b/core/hf_cache.rs @@ -48,7 +48,7 @@ fn cache_bases() -> Vec { .join("huggingface") .join("hub"), ); - // Support local cache used by CodeScribe tools (hf download into ~/.codescribe/embeddings) + // Support local cache used by Codescribe tools (hf download into ~/.codescribe/embeddings) out.push(dirs.home_dir().join(".codescribe").join("embeddings")); out.push( dirs.home_dir() diff --git a/core/ipc/client.rs b/core/ipc/client.rs deleted file mode 100644 index cef9d483..00000000 --- a/core/ipc/client.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow, bail}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixStream; -use tokio::time::timeout; - -use super::{IpcCommand, IpcResponse, socket_path}; - -const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(20 * 60); - -#[derive(Debug, Clone)] -pub struct IpcClient { - socket_path: PathBuf, - response_timeout: Duration, -} - -impl Default for IpcClient { - fn default() -> Self { - Self::new(socket_path()) - } -} - -impl IpcClient { - pub fn new(socket_path: PathBuf) -> Self { - Self { - socket_path, - response_timeout: DEFAULT_RESPONSE_TIMEOUT, - } - } - - pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self { - self.response_timeout = response_timeout; - self - } - - pub async fn transcribe_file(&self, path: &Path) -> Result { - let response = self - .send_command(IpcCommand::TranscribeFile { - path: path.to_string_lossy().into_owned(), - }) - .await?; - - match response { - IpcResponse::Message(text) => Ok(text), - IpcResponse::Error(message) => bail!("CodeScribe IPC transcription failed: {message}"), - other => bail!("Unexpected CodeScribe IPC response: {other:?}"), - } - } - - async fn send_command(&self, command: IpcCommand) -> Result { - let mut stream = UnixStream::connect(&self.socket_path) - .await - .with_context(|| { - format!( - "CodeScribe IPC unavailable at {}", - self.socket_path.display() - ) - })?; - - let payload = - serde_json::to_string(&command).context("Failed to encode CodeScribe IPC command")?; - stream - .write_all(payload.as_bytes()) - .await - .context("Failed to write CodeScribe IPC command")?; - stream - .write_all(b"\n") - .await - .context("Failed to terminate CodeScribe IPC command")?; - stream - .flush() - .await - .context("Failed to flush CodeScribe IPC command")?; - - let mut reader = BufReader::new(stream); - let mut line = String::new(); - let bytes_read = timeout(self.response_timeout, reader.read_line(&mut line)) - .await - .map_err(|_| anyhow!("CodeScribe IPC response timed out"))? - .context("Failed to read CodeScribe IPC response")?; - - if bytes_read == 0 { - bail!("CodeScribe IPC closed before sending a response"); - } - - serde_json::from_str::(&line).with_context(|| { - format!( - "Malformed CodeScribe IPC response from {}", - self.socket_path.display() - ) - }) - } -} - -pub async fn transcribe_file(path: &Path) -> Result { - IpcClient::default().transcribe_file(path).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn missing_socket_returns_unavailable_error() { - let temp = tempfile::tempdir().expect("create temp dir for ipc client test"); - let socket = temp.path().join("missing-codescribe.sock"); - let audio = temp.path().join("audio.wav"); - let client = IpcClient::new(socket); - - let err = client - .transcribe_file(&audio) - .await - .expect_err("missing socket should fail without local STT fallback"); - let message = err.to_string(); - - assert!( - message.contains("CodeScribe IPC unavailable"), - "expected unavailable error, got: {message}" - ); - } -} diff --git a/core/ipc/mod.rs b/core/ipc/mod.rs index 8e541191..258fad5a 100644 --- a/core/ipc/mod.rs +++ b/core/ipc/mod.rs @@ -1,11 +1,2 @@ -pub mod client; pub mod types; -pub use client::*; pub use types::*; - -use crate::config::Config; -use std::path::PathBuf; - -pub fn socket_path() -> PathBuf { - Config::config_dir().join("ipc").join("codescribe.sock") -} diff --git a/core/ipc/types.rs b/core/ipc/types.rs index 6d276a31..1fe94da2 100644 --- a/core/ipc/types.rs +++ b/core/ipc/types.rs @@ -1,75 +1,10 @@ use serde::{Deserialize, Serialize}; use crate::pipeline::contracts::{ - DropKind, EngineEvent, TranscriptSegment, TranscriptionConfidenceFlag, + AnnotationKind, DropKind, EngineEvent, LayerSource, LayerSummary, TranscriptSegment, + TranscriptionConfidenceFlag, }; -#[derive(Debug, Serialize, Deserialize)] -pub enum IpcCommand { - // Config - GetConfig, - SaveConfig { - config: Box, - }, - ReloadRuntimeConfig, - - // Prompts - GetPrompt { - prompt_type: String, - }, - SavePrompt { - prompt_type: String, - content: String, - }, - ResetPrompt { - prompt_type: String, - }, - - // AI / Chat - SendMessage { - message: String, - }, - ResetContext, - FormatTranscript { - text: String, - language: Option, - assistive: bool, - }, - TranscribeFile { - path: String, - }, - - // Status - GetStatus, - - // Recording - StartRecording { - assistive: bool, - }, - StopRecording, - - // Event stream - Subscribe, - Unsubscribe, -} - -#[derive(Debug, Serialize, Deserialize)] -pub enum IpcResponse { - Config(Box), - Prompt(String), - Message(String), - Status(AppStatus), - Ok, - Error(String), - Event(IpcEvent), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppStatus { - pub state: String, // "idle", "recording", "busy" - pub ai_formatting: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IpcEvent { pub timestamp: String, // RFC3339 UTC @@ -84,6 +19,12 @@ pub enum IpcEventPayload { Engine(EngineEventWire), #[serde(rename = "state_change")] StateChange { from: String, to: String }, + /// Authoritative post-stop transcript (the LocalFinalPass `final_formatted_text` + /// that is pasted and written to history). Emitted once per dictation stop so + /// external surfaces (the SwiftUI overlay) can show the SAME clean text as the + /// delivery/Copy paths instead of the raw per-utterance streaming hypotheses. + #[serde(rename = "final_transcript")] + FinalTranscript { text: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -121,6 +62,23 @@ pub enum EngineEventWire { quality_gate_dropped: bool, confidence_flags: Vec, }, + ReplaceRange { + utterance_id: u64, + start: usize, + end: usize, + text: String, + source: LayerSource, + }, + InsertAnnotation { + utterance_id: u64, + position: usize, + text: String, + kind: AnnotationKind, + }, + SessionFinalised { + session_id: String, + layer_summary: LayerSummary, + }, Drop { kind: String, text: String, @@ -232,6 +190,37 @@ impl From<&EngineEvent> for EngineEventWire { partial_coalesced_count: *partial_coalesced_count, partial_dropped_count: *partial_dropped_count, }, + EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + source, + } => Self::ReplaceRange { + utterance_id: *utterance_id, + start: *start, + end: *end, + text: text.clone(), + source: *source, + }, + EngineEvent::InsertAnnotation { + utterance_id, + position, + text, + kind, + } => Self::InsertAnnotation { + utterance_id: *utterance_id, + position: *position, + text: text.clone(), + kind: kind.clone(), + }, + EngineEvent::SessionFinalised { + session_id, + layer_summary, + } => Self::SessionFinalised { + session_id: session_id.clone(), + layer_summary: layer_summary.clone(), + }, EngineEvent::Warning { code, message } => Self::Warning { code: code.clone(), message: message.clone(), @@ -378,6 +367,101 @@ mod tests { ); } + #[test] + fn replace_range_event_serializes_typed_wire_payload() { + let event = EngineEvent::ReplaceRange { + utterance_id: 7, + start: 2, + end: 5, + text: "kot".to_string(), + source: LayerSource::TailPatch, + }; + + let wire = EngineEventWire::from(&event); + let json = serde_json::to_value(&wire).expect("serialize replace_range"); + let obj = must_object(json); + + assert_eq!( + obj.get("type").and_then(Value::as_str), + Some("replace_range") + ); + assert_eq!(obj.get("utterance_id").and_then(Value::as_u64), Some(7)); + assert_eq!(obj.get("start").and_then(Value::as_u64), Some(2)); + assert_eq!(obj.get("end").and_then(Value::as_u64), Some(5)); + assert_eq!(obj.get("text").and_then(Value::as_str), Some("kot")); + assert_eq!( + obj.get("source").and_then(Value::as_str), + Some("tail_patch") + ); + } + + #[test] + fn insert_annotation_event_serializes_typed_wire_payload() { + let event = EngineEvent::InsertAnnotation { + utterance_id: 9, + position: 4, + text: "...".to_string(), + kind: AnnotationKind::HesitationPause, + }; + + let wire = EngineEventWire::from(&event); + let json = serde_json::to_value(&wire).expect("serialize insert_annotation"); + let obj = must_object(json); + + assert_eq!( + obj.get("type").and_then(Value::as_str), + Some("insert_annotation") + ); + assert_eq!(obj.get("utterance_id").and_then(Value::as_u64), Some(9)); + assert_eq!(obj.get("position").and_then(Value::as_u64), Some(4)); + assert_eq!(obj.get("text").and_then(Value::as_str), Some("...")); + assert_eq!( + obj.get("kind").and_then(Value::as_str), + Some("hesitation_pause") + ); + } + + #[test] + fn session_finalised_event_serializes_typed_wire_payload() { + let event = EngineEvent::SessionFinalised { + session_id: "session-1".to_string(), + layer_summary: LayerSummary { + tail_patch_replacements: 1, + lexicon_replacements: 2, + inline_llm_replacements: 3, + final_bam_replacements: 4, + annotations_inserted: 5, + }, + }; + + let wire = EngineEventWire::from(&event); + let json = serde_json::to_value(&wire).expect("serialize session_finalised"); + let obj = must_object(json); + + assert_eq!( + obj.get("type").and_then(Value::as_str), + Some("session_finalised") + ); + assert_eq!( + obj.get("session_id").and_then(Value::as_str), + Some("session-1") + ); + let summary = obj + .get("layer_summary") + .and_then(Value::as_object) + .expect("layer_summary object"); + assert_eq!( + summary + .get("inline_llm_replacements") + .and_then(Value::as_u64), + Some(3) + ); + assert_eq!( + summary.get("annotations_inserted").and_then(Value::as_u64), + Some(5) + ); + } + #[test] fn legacy_vad_fallback_wire_is_rejected() { let legacy_json = serde_json::json!({ diff --git a/core/lib.rs b/core/lib.rs index 89c5559f..64c33567 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -1,4 +1,4 @@ -//! CodeScribe Core - speech, transcription, and assistive runtime primitives. +//! Codescribe Core - speech, transcription, and assistive runtime primitives. //! //! ## Quick Start //! @@ -48,6 +48,7 @@ mod hf_cache; pub mod ipc; pub mod llm; pub mod mcp; +pub mod memory; pub mod pipeline; pub mod quality; pub mod state; diff --git a/core/llm/account_auth/device_code.rs b/core/llm/account_auth/device_code.rs new file mode 100644 index 00000000..14454bd4 --- /dev/null +++ b/core/llm/account_auth/device_code.rs @@ -0,0 +1,278 @@ +// Portions derived from openai/codex (Apache-2.0). + +use std::time::{Duration, Instant}; + +use reqwest::StatusCode; +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::llm::account_auth::pkce::PkceCodes; +use crate::llm::account_auth::server::exchange_code_for_tokens; +use crate::llm::account_auth::{AccountAuthError, DEFAULT_ISSUER, store_account_tokens}; +use crate::llm::provider::ProviderKind; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceAuthConfig { + pub issuer: String, + pub client_id: String, + pub max_wait: Duration, +} + +impl DeviceAuthConfig { + pub fn new(client_id: String) -> Self { + Self { + issuer: DEFAULT_ISSUER.to_string(), + client_id, + max_wait: Duration::from_secs(15 * 60), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceCode { + pub verification_url: String, + pub user_code: String, + pub device_auth_id: String, + pub interval: u64, +} + +#[derive(Debug, Deserialize)] +struct UserCodeResp { + device_auth_id: String, + #[serde(alias = "user_code", alias = "usercode")] + user_code: String, + #[serde(default, deserialize_with = "deserialize_interval")] + interval: u64, +} + +#[derive(Debug, Serialize)] +struct UserCodeReq { + client_id: String, +} + +#[derive(Debug, Serialize)] +struct TokenPollReq { + device_auth_id: String, + user_code: String, +} + +#[derive(Debug, Deserialize)] +struct CodeSuccessResp { + authorization_code: String, + code_challenge: String, + code_verifier: String, +} + +fn deserialize_interval<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrU64 { + String(String), + U64(u64), + } + + match StringOrU64::deserialize(deserializer)? { + StringOrU64::String(value) => value + .trim() + .parse::() + .map_err(|error| de::Error::custom(format!("invalid u64 string: {error}"))), + StringOrU64::U64(value) => Ok(value), + } +} + +pub async fn request_device_code( + config: &DeviceAuthConfig, +) -> Result { + let client = reqwest::Client::new(); + request_device_code_with_client(&client, config).await +} + +async fn request_device_code_with_client( + client: &reqwest::Client, + config: &DeviceAuthConfig, +) -> Result { + let base_url = api_accounts_base(&config.issuer); + let response = client + .post(format!("{base_url}/deviceauth/usercode")) + .json(&UserCodeReq { + client_id: config.client_id.clone(), + }) + .send() + .await + .map_err(|error| AccountAuthError::Http(error.to_string()))?; + + if !response.status().is_success() { + return Err(AccountAuthError::OAuth(format!( + "device code request failed with status {}", + response.status() + ))); + } + + let body: UserCodeResp = response + .json() + .await + .map_err(|error| AccountAuthError::OAuth(error.to_string()))?; + Ok(DeviceCode { + verification_url: format!("{}/codex/device", config.issuer.trim_end_matches('/')), + user_code: body.user_code, + device_auth_id: body.device_auth_id, + interval: body.interval, + }) +} + +pub async fn complete_device_code_login( + config: &DeviceAuthConfig, + device_code: &DeviceCode, +) -> Result<(), AccountAuthError> { + let client = reqwest::Client::new(); + let code_resp = poll_for_authorization_code( + &client, + &api_accounts_base(&config.issuer), + &device_code.device_auth_id, + &device_code.user_code, + device_code.interval, + config.max_wait, + ) + .await?; + let pkce = PkceCodes { + code_verifier: code_resp.code_verifier, + code_challenge: code_resp.code_challenge, + }; + let redirect_uri = format!( + "{}/deviceauth/callback", + config.issuer.trim_end_matches('/') + ); + let tokens = exchange_code_for_tokens( + &config.issuer, + &config.client_id, + &redirect_uri, + &pkce, + &code_resp.authorization_code, + ) + .await?; + store_account_tokens(ProviderKind::OpenAiResponses, &tokens) +} + +async fn poll_for_authorization_code( + client: &reqwest::Client, + api_base_url: &str, + device_auth_id: &str, + user_code: &str, + interval: u64, + max_wait: Duration, +) -> Result { + let url = format!("{api_base_url}/deviceauth/token"); + let started = Instant::now(); + + loop { + let response = client + .post(&url) + .json(&TokenPollReq { + device_auth_id: device_auth_id.to_string(), + user_code: user_code.to_string(), + }) + .send() + .await + .map_err(|error| AccountAuthError::Http(error.to_string()))?; + let status = response.status(); + + if status.is_success() { + return response + .json() + .await + .map_err(|error| AccountAuthError::OAuth(error.to_string())); + } + + if status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND { + if started.elapsed() >= max_wait { + return Err(AccountAuthError::OAuth( + "device auth timed out after 15 minutes".to_string(), + )); + } + let remaining = max_wait.saturating_sub(started.elapsed()); + tokio::time::sleep(Duration::from_secs(interval).min(remaining)).await; + continue; + } + + return Err(AccountAuthError::OAuth(format!( + "device auth failed with status {status}" + ))); + } +} + +fn api_accounts_base(issuer: &str) -> String { + format!("{}/api/accounts", issuer.trim_end_matches('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Matcher; + + #[tokio::test] + async fn device_code_polling_waits_through_pending_then_returns_code() { + let mut server = mockito::Server::new_async().await; + let _pending = server + .mock("POST", "/api/accounts/deviceauth/token") + .match_body(Matcher::JsonString( + r#"{"device_auth_id":"dev-1","user_code":"USER-1"}"#.to_string(), + )) + .with_status(403) + .expect(1) + .create_async() + .await; + let _ok = server + .mock("POST", "/api/accounts/deviceauth/token") + .match_body(Matcher::JsonString( + r#"{"device_auth_id":"dev-1","user_code":"USER-1"}"#.to_string(), + )) + .with_status(200) + .with_body( + r#"{"authorization_code":"auth-code","code_challenge":"challenge","code_verifier":"verifier"}"#, + ) + .expect(1) + .create_async() + .await; + + let client = reqwest::Client::new(); + let response = poll_for_authorization_code( + &client, + &format!("{}/api/accounts", server.url()), + "dev-1", + "USER-1", + 0, + Duration::from_secs(2), + ) + .await + .unwrap(); + + assert_eq!(response.authorization_code, "auth-code"); + assert_eq!(response.code_verifier, "verifier"); + } + + #[tokio::test] + async fn request_device_code_maps_usercode_response() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("POST", "/api/accounts/deviceauth/usercode") + .with_status(200) + .with_body(r#"{"device_auth_id":"dev-1","user_code":"USER-1","interval":"0"}"#) + .expect(1) + .create_async() + .await; + let config = DeviceAuthConfig { + issuer: server.url(), + client_id: "client".to_string(), + max_wait: Duration::from_secs(1), + }; + + let code = request_device_code(&config).await.unwrap(); + + assert_eq!(code.device_auth_id, "dev-1"); + assert_eq!(code.user_code, "USER-1"); + assert_eq!(code.interval, 0); + assert!(code.verification_url.ends_with("/codex/device")); + } +} diff --git a/core/llm/account_auth/mod.rs b/core/llm/account_auth/mod.rs new file mode 100644 index 00000000..7bf0003d --- /dev/null +++ b/core/llm/account_auth/mod.rs @@ -0,0 +1,339 @@ +//! Provider-account authentication foundation for future "Sign in with ChatGPT". +//! +//! Tokens are stored as serialized JSON in the existing CodeScribe Keychain +//! bundle under a provider-specific account. No `auth.json` file is written. + +use std::fmt; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::config::keychain::{delete_key, load_key, save_key}; +use crate::llm::provider::ProviderKind; + +pub mod device_code; +pub mod pkce; +pub mod server; + +pub use device_code::{ + DeviceAuthConfig, DeviceCode, complete_device_code_login, request_device_code, +}; +pub use pkce::{PkceCodes, challenge_for_verifier, generate_pkce}; +pub use server::{LoginServer, ServerOptions, exchange_code_for_tokens, run_login_server}; + +pub const OPENAI_ACCOUNT_TOKENS_ACCOUNT: &str = "LLM_OPENAI_ACCOUNT_TOKENS"; +pub const OPENAI_CLIENT_ID_ENV: &str = "CODESCRIBE_OPENAI_OAUTH_CLIENT_ID"; +pub const OPENAI_ISSUER_ENV: &str = "CODESCRIBE_OPENAI_OAUTH_ISSUER"; +pub const DEFAULT_ISSUER: &str = "https://auth.openai.com"; +pub const NO_CLIENT_ID_MESSAGE: &str = "awaiting app registration"; + +const REFRESH_SKEW: Duration = Duration::from_secs(60); + +#[derive(Debug)] +pub enum AccountAuthError { + NoClientId, + UnsupportedProvider(String), + NotSignedIn(String), + Storage(String), + Http(String), + OAuth(String), + Io(std::io::Error), +} + +impl fmt::Display for AccountAuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AccountAuthError::NoClientId => write!( + f, + "{NO_CLIENT_ID_MESSAGE}; set {OPENAI_CLIENT_ID_ENV} after app registration" + ), + AccountAuthError::UnsupportedProvider(provider) => { + write!(f, "provider account auth is not available for {provider}") + } + AccountAuthError::NotSignedIn(provider) => { + write!(f, "no provider account tokens stored for {provider}") + } + AccountAuthError::Storage(message) => { + write!(f, "account token storage failed: {message}") + } + AccountAuthError::Http(message) => write!(f, "account auth HTTP failed: {message}"), + AccountAuthError::OAuth(message) => write!(f, "account auth failed: {message}"), + AccountAuthError::Io(error) => error.fmt(f), + } + } +} + +impl std::error::Error for AccountAuthError {} + +impl From for AccountAuthError { + fn from(error: std::io::Error) -> Self { + AccountAuthError::Io(error) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AccountTokens { + pub provider: String, + pub access_token: String, + pub refresh_token: Option, + pub id_token: Option, + pub token_type: String, + pub expires_at_unix: Option, +} + +impl AccountTokens { + pub fn new( + provider: ProviderKind, + access_token: String, + refresh_token: Option, + id_token: Option, + token_type: Option, + expires_in: Option, + ) -> Self { + let expires_at_unix = expires_in.and_then(|seconds| now_unix().checked_add(seconds as i64)); + Self { + provider: provider.as_str().to_string(), + access_token, + refresh_token, + id_token, + token_type: token_type.unwrap_or_else(|| "Bearer".to_string()), + expires_at_unix, + } + } + + pub fn expires_within(&self, skew: Duration) -> bool { + let Some(expires_at) = self.expires_at_unix else { + return false; + }; + let now = now_unix(); + expires_at <= now.saturating_add(skew.as_secs() as i64) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountAuthStatus { + pub provider: ProviderKind, + pub signed_in: bool, + pub client_id_configured: bool, + pub message: String, +} + +pub fn account_status(provider: ProviderKind) -> AccountAuthStatus { + let client_id_configured = client_id_for_provider(provider).is_ok(); + let signed_in = load_account_tokens(provider).is_ok(); + let message = if !client_id_configured { + NO_CLIENT_ID_MESSAGE.to_string() + } else if signed_in { + "signed in".to_string() + } else { + "not signed in".to_string() + }; + AccountAuthStatus { + provider, + signed_in, + client_id_configured, + message, + } +} + +pub fn client_id_for_provider(provider: ProviderKind) -> Result { + ensure_provider_supported(provider)?; + std::env::var(OPENAI_CLIENT_ID_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or(AccountAuthError::NoClientId) +} + +pub fn issuer_from_env() -> String { + std::env::var(OPENAI_ISSUER_ENV) + .ok() + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_ISSUER.to_string()) +} + +pub fn store_account_tokens( + provider: ProviderKind, + tokens: &AccountTokens, +) -> Result<(), AccountAuthError> { + ensure_provider_supported(provider)?; + let account = token_account(provider)?; + let payload = serde_json::to_string(tokens) + .map_err(|error| AccountAuthError::Storage(error.to_string()))?; + save_key(account, &payload).map_err(|error| AccountAuthError::Storage(error.to_string())) +} + +pub fn load_account_tokens(provider: ProviderKind) -> Result { + ensure_provider_supported(provider)?; + let account = token_account(provider)?; + let payload = std::env::var(account) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| load_key(account)) + .ok_or_else(|| AccountAuthError::NotSignedIn(provider.as_str().to_string()))?; + serde_json::from_str(&payload).map_err(|error| AccountAuthError::Storage(error.to_string())) +} + +pub fn clear_account_tokens(provider: ProviderKind) -> Result<(), AccountAuthError> { + ensure_provider_supported(provider)?; + let account = token_account(provider)?; + delete_key(account).map_err(|error| AccountAuthError::Storage(error.to_string()))?; + unsafe { std::env::remove_var(account) }; + Ok(()) +} + +pub async fn authorization_header(provider: ProviderKind) -> Result { + let mut tokens = load_account_tokens(provider)?; + if tokens.expires_within(REFRESH_SKEW) { + tokens = refresh_tokens(provider, tokens).await?; + } + Ok(format!("Bearer {}", tokens.access_token)) +} + +pub async fn refresh_tokens( + provider: ProviderKind, + tokens: AccountTokens, +) -> Result { + ensure_provider_supported(provider)?; + let refresh_token = tokens.refresh_token.ok_or_else(|| { + AccountAuthError::OAuth("stored account has no refresh token".to_string()) + })?; + let client_id = client_id_for_provider(provider)?; + let issuer = issuer_from_env(); + let refreshed = refresh_openai_tokens(&issuer, &client_id, &refresh_token).await?; + store_account_tokens(provider, &refreshed)?; + Ok(refreshed) +} + +async fn refresh_openai_tokens( + issuer: &str, + client_id: &str, + refresh_token: &str, +) -> Result { + #[derive(Deserialize)] + struct RefreshResponse { + access_token: String, + refresh_token: Option, + id_token: Option, + token_type: Option, + expires_in: Option, + } + + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/oauth/token", issuer.trim_end_matches('/'))) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", refresh_token), + ]) + .send() + .await + .map_err(|error| AccountAuthError::Http(error.to_string()))?; + + if !response.status().is_success() { + return Err(AccountAuthError::OAuth(format!( + "refresh endpoint returned status {}", + response.status() + ))); + } + + let body: RefreshResponse = response + .json() + .await + .map_err(|error| AccountAuthError::OAuth(error.to_string()))?; + Ok(AccountTokens::new( + ProviderKind::OpenAiResponses, + body.access_token, + body.refresh_token.or(Some(refresh_token.to_string())), + body.id_token, + body.token_type, + body.expires_in, + )) +} + +fn token_account(provider: ProviderKind) -> Result<&'static str, AccountAuthError> { + match provider { + ProviderKind::OpenAiResponses => Ok(OPENAI_ACCOUNT_TOKENS_ACCOUNT), + ProviderKind::AnthropicMessages => Err(AccountAuthError::UnsupportedProvider( + provider.as_str().to_string(), + )), + } +} + +fn ensure_provider_supported(provider: ProviderKind) -> Result<(), AccountAuthError> { + token_account(provider).map(|_| ()) +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn no_client_id_reports_registration_gate() { + let _guard = EnvGuard::unset(OPENAI_CLIENT_ID_ENV); + let err = client_id_for_provider(ProviderKind::OpenAiResponses).unwrap_err(); + assert!(matches!(err, AccountAuthError::NoClientId)); + assert!(err.to_string().contains(NO_CLIENT_ID_MESSAGE)); + } + + #[test] + #[serial] + fn keychain_mock_round_trips_serialized_account_tokens() { + let _disable = EnvGuard::set("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + let _tokens = EnvGuard::unset(OPENAI_ACCOUNT_TOKENS_ACCOUNT); + let tokens = AccountTokens::new( + ProviderKind::OpenAiResponses, + "access".to_string(), + Some("refresh".to_string()), + Some("id".to_string()), + None, + Some(3600), + ); + + store_account_tokens(ProviderKind::OpenAiResponses, &tokens).unwrap(); + + let loaded = load_account_tokens(ProviderKind::OpenAiResponses).unwrap(); + assert_eq!(loaded.access_token, "access"); + assert_eq!(loaded.refresh_token.as_deref(), Some("refresh")); + } + + #[derive(Debug)] + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn unset(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvGuard { + 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) }, + } + } + } +} diff --git a/core/llm/account_auth/pkce.rs b/core/llm/account_auth/pkce.rs new file mode 100644 index 00000000..78f0f129 --- /dev/null +++ b/core/llm/account_auth/pkce.rs @@ -0,0 +1,51 @@ +// Portions derived from openai/codex (Apache-2.0). + +use base64::Engine; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PkceCodes { + pub code_verifier: String, + pub code_challenge: String, +} + +pub fn generate_pkce() -> PkceCodes { + let mut bytes = [0u8; 64]; + rand::thread_rng().fill_bytes(&mut bytes); + let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let code_challenge = challenge_for_verifier(&code_verifier); + PkceCodes { + code_verifier, + code_challenge, + } +} + +pub fn challenge_for_verifier(code_verifier: &str) -> String { + let digest = Sha256::digest(code_verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_challenge_matches_rfc7636_vector() { + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + challenge_for_verifier(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn generated_verifier_uses_valid_pkce_length() { + let codes = generate_pkce(); + assert!((43..=128).contains(&codes.code_verifier.len())); + assert_eq!( + challenge_for_verifier(&codes.code_verifier), + codes.code_challenge + ); + } +} diff --git a/core/llm/account_auth/server.rs b/core/llm/account_auth/server.rs new file mode 100644 index 00000000..93475e27 --- /dev/null +++ b/core/llm/account_auth/server.rs @@ -0,0 +1,447 @@ +// Portions derived from openai/codex (Apache-2.0). + +use std::io::{self, Cursor, Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use rand::RngCore; +use tiny_http::{Header, Request, Response, Server, StatusCode}; + +use crate::llm::account_auth::pkce::{PkceCodes, generate_pkce}; +use crate::llm::account_auth::{ + AccountAuthError, AccountTokens, DEFAULT_ISSUER, store_account_tokens, +}; +use crate::llm::provider::ProviderKind; + +use base64::Engine; + +const DEFAULT_PORT: u16 = 1455; +const SUCCESS_HTML: &str = r#" + +CodeScribe signed in +

CodeScribe signed in

You can close this window.

+"#; + +#[derive(Debug, Clone)] +pub struct ServerOptions { + pub client_id: String, + pub issuer: String, + pub port: u16, + pub force_state: Option, +} + +impl ServerOptions { + pub fn new(client_id: String) -> Self { + Self { + client_id, + issuer: DEFAULT_ISSUER.to_string(), + port: DEFAULT_PORT, + force_state: None, + } + } +} + +pub struct LoginServer { + pub auth_url: String, + pub actual_port: u16, + server_handle: tokio::task::JoinHandle>, + shutdown_handle: ShutdownHandle, +} + +impl LoginServer { + pub async fn block_until_done(self) -> Result<(), AccountAuthError> { + self.server_handle + .await + .map_err(|error| AccountAuthError::Io(io::Error::other(error.to_string())))? + } + + pub fn cancel(&self) { + self.shutdown_handle.shutdown(); + } + + pub fn cancel_handle(&self) -> ShutdownHandle { + self.shutdown_handle.clone() + } +} + +#[derive(Clone, Debug)] +pub struct ShutdownHandle { + shutdown_notify: Arc, +} + +impl ShutdownHandle { + pub fn shutdown(&self) { + self.shutdown_notify.notify_waiters(); + } +} + +pub async fn run_login_server(opts: ServerOptions) -> Result { + let pkce = generate_pkce(); + let state = opts.force_state.clone().unwrap_or_else(generate_state); + let server = bind_server(opts.port)?; + let actual_port = server + .server_addr() + .to_ip() + .map(|addr| addr.port()) + .ok_or_else(|| AccountAuthError::Io(io::Error::other("unable to determine server port")))?; + let server = Arc::new(server); + let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); + let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state); + + let (tx, mut rx) = tokio::sync::mpsc::channel::(16); + let request_server = server.clone(); + let _request_thread = thread::spawn(move || { + while let Ok(request) = request_server.recv() { + if tx.blocking_send(request).is_err() { + break; + } + } + }); + + let shutdown_notify = Arc::new(tokio::sync::Notify::new()); + let server_handle = { + let shutdown_notify = shutdown_notify.clone(); + let server = server.clone(); + tokio::spawn(async move { + let result = loop { + tokio::select! { + _ = shutdown_notify.notified() => { + break Err(AccountAuthError::OAuth("login was not completed".to_string())); + } + maybe_request = rx.recv() => { + let Some(request) = maybe_request else { + break Err(AccountAuthError::OAuth("login was not completed".to_string())); + }; + let url_raw = request.url().to_string(); + let handled = process_request( + &url_raw, + &opts, + &redirect_uri, + &pkce, + actual_port, + &state, + ) + .await; + let exit = respond(request, handled).await; + if let Some(result) = exit { + break result; + } + } + } + }; + server.unblock(); + result + }) + }; + + Ok(LoginServer { + auth_url, + actual_port, + server_handle, + shutdown_handle: ShutdownHandle { shutdown_notify }, + }) +} + +enum HandledRequest { + Response(Response>>), + Redirect(Header), + ResponseAndExit { + headers: Vec
, + body: Vec, + result: Result<(), AccountAuthError>, + }, +} + +async fn respond( + request: Request, + handled: HandledRequest, +) -> Option> { + match handled { + HandledRequest::Response(response) => { + let _ = tokio::task::spawn_blocking(move || request.respond(response)).await; + None + } + HandledRequest::Redirect(header) => { + let response = Response::empty(302).with_header(header); + let _ = tokio::task::spawn_blocking(move || request.respond(response)).await; + None + } + HandledRequest::ResponseAndExit { + headers, + body, + result, + } => { + let _ = tokio::task::spawn_blocking(move || { + send_response_with_disconnect(request, headers, body) + }) + .await; + Some(result) + } + } +} + +async fn process_request( + url_raw: &str, + opts: &ServerOptions, + redirect_uri: &str, + pkce: &PkceCodes, + actual_port: u16, + state: &str, +) -> HandledRequest { + let parsed_url = match reqwest::Url::parse(&format!("http://localhost{url_raw}")) { + Ok(url) => url, + Err(error) => { + return HandledRequest::Response( + Response::from_string(format!("Bad Request: {error}")).with_status_code(400), + ); + } + }; + + match parsed_url.path() { + "/auth/callback" => { + let params: std::collections::HashMap = + parsed_url.query_pairs().into_owned().collect(); + if params.get("state").map(String::as_str) != Some(state) { + return HandledRequest::Response( + Response::from_string("State mismatch").with_status_code(400), + ); + } + let Some(code) = params.get("code").filter(|value| !value.is_empty()) else { + return HandledRequest::Response( + Response::from_string("Missing authorization code").with_status_code(400), + ); + }; + + match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, code) + .await + { + Ok(tokens) => { + if let Err(error) = store_account_tokens(ProviderKind::OpenAiResponses, &tokens) + { + return HandledRequest::Response( + Response::from_string(format!( + "Unable to persist account tokens: {error}" + )) + .with_status_code(500), + ); + } + let success_url = format!("http://localhost:{actual_port}/success"); + match Header::from_bytes(&b"Location"[..], success_url.as_bytes()) { + Ok(header) => HandledRequest::Redirect(header), + Err(_) => HandledRequest::Response( + Response::from_string("Internal Server Error").with_status_code(500), + ), + } + } + Err(error) => HandledRequest::Response( + Response::from_string(format!("Token exchange failed: {error}")) + .with_status_code(500), + ), + } + } + "/success" => { + let headers = + match Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { + Ok(header) => vec![header], + Err(_) => Vec::new(), + }; + HandledRequest::ResponseAndExit { + headers, + body: SUCCESS_HTML.as_bytes().to_vec(), + result: Ok(()), + } + } + "/cancel" => HandledRequest::ResponseAndExit { + headers: Vec::new(), + body: b"Login cancelled".to_vec(), + result: Err(AccountAuthError::OAuth("login cancelled".to_string())), + }, + _ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)), + } +} + +pub async fn exchange_code_for_tokens( + issuer: &str, + client_id: &str, + redirect_uri: &str, + pkce: &PkceCodes, + code: &str, +) -> Result { + #[derive(serde::Deserialize)] + struct TokenResponse { + access_token: String, + refresh_token: Option, + id_token: Option, + token_type: Option, + expires_in: Option, + } + + let response = reqwest::Client::new() + .post(format!("{}/oauth/token", issuer.trim_end_matches('/'))) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", client_id), + ("code_verifier", &pkce.code_verifier), + ]) + .send() + .await + .map_err(|error| AccountAuthError::Http(error.to_string()))?; + + if !response.status().is_success() { + return Err(AccountAuthError::OAuth(format!( + "token endpoint returned status {}", + response.status() + ))); + } + + let tokens: TokenResponse = response + .json() + .await + .map_err(|error| AccountAuthError::OAuth(error.to_string()))?; + Ok(AccountTokens::new( + ProviderKind::OpenAiResponses, + tokens.access_token, + tokens.refresh_token, + tokens.id_token, + tokens.token_type, + tokens.expires_in, + )) +} + +fn build_authorize_url( + issuer: &str, + client_id: &str, + redirect_uri: &str, + pkce: &PkceCodes, + state: &str, +) -> String { + let mut url = reqwest::Url::parse(&format!("{}/oauth/authorize", issuer.trim_end_matches('/'))) + .expect("default issuer must parse"); + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", "openid profile email offline_access") + .append_pair("code_challenge", &pkce.code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("id_token_add_organizations", "true") + .append_pair("codescribe_account_flow", "true") + .append_pair("state", state); + url.to_string() +} + +fn generate_state() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn bind_server(port: u16) -> Result { + let bind_address = format!("127.0.0.1:{port}"); + match Server::http(&bind_address) { + Ok(server) => Ok(server), + Err(error) => { + let message = error.to_string(); + if message.contains("Address already in use") { + let _ = send_cancel_request(port); + } + Server::http(&bind_address).map_err(|retry_error| { + AccountAuthError::Io(io::Error::other(retry_error.to_string())) + }) + } + } +} + +fn send_cancel_request(port: u16) -> io::Result<()> { + let addr: SocketAddr = format!("127.0.0.1:{port}") + .parse() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(2))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + stream.write_all(b"GET /cancel HTTP/1.1\r\n")?; + stream.write_all(format!("Host: 127.0.0.1:{port}\r\n").as_bytes())?; + stream.write_all(b"Connection: close\r\n\r\n")?; + let mut buf = [0u8; 64]; + let _ = stream.read(&mut buf); + Ok(()) +} + +fn send_response_with_disconnect( + request: Request, + mut headers: Vec
, + body: Vec, +) -> io::Result<()> { + let status = StatusCode(200); + let mut writer = request.into_writer(); + let reason = status.default_reason_phrase(); + write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?; + headers.retain(|header| !header.field.equiv("Connection")); + if let Ok(header) = Header::from_bytes(&b"Connection"[..], &b"close"[..]) { + headers.push(header); + } + if let Ok(header) = + Header::from_bytes(&b"Content-Length"[..], body.len().to_string().as_bytes()) + { + headers.push(header); + } + for header in headers { + write!( + writer, + "{}: {}\r\n", + header.field.as_str(), + header.value.as_str() + )?; + } + writer.write_all(b"\r\n")?; + writer.write_all(&body)?; + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn exchange_code_posts_authorization_grant_and_maps_tokens() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("POST", "/oauth/token") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded( + "grant_type".to_string(), + "authorization_code".to_string(), + ), + mockito::Matcher::UrlEncoded("code".to_string(), "auth-code".to_string()), + mockito::Matcher::UrlEncoded("client_id".to_string(), "client".to_string()), + ])) + .with_status(200) + .with_body( + r#"{"access_token":"access","refresh_token":"refresh","id_token":"id","expires_in":3600}"#, + ) + .expect(1) + .create_async() + .await; + + let tokens = exchange_code_for_tokens( + &server.url(), + "client", + "http://localhost:1455/auth/callback", + &PkceCodes { + code_verifier: "verifier".to_string(), + code_challenge: "challenge".to_string(), + }, + "auth-code", + ) + .await + .unwrap(); + + assert_eq!(tokens.access_token, "access"); + assert_eq!(tokens.refresh_token.as_deref(), Some("refresh")); + assert_eq!(tokens.id_token.as_deref(), Some("id")); + } +} diff --git a/core/llm/ai_formatting.rs b/core/llm/ai_formatting.rs index 8d107570..b723aeef 100644 --- a/core/llm/ai_formatting.rs +++ b/core/llm/ai_formatting.rs @@ -21,12 +21,13 @@ use anyhow::{Context, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::env; -use std::path::PathBuf; use std::sync::{Arc, OnceLock, RwLock}; use std::time::Duration; use tracing::{debug, info, trace, warn}; +use super::provider::{LlmMode, ProviderKind, capability_policy, resolve_provider}; use super::responses_streaming_manager::{ResponsesStreamingManager, StreamCallbacks}; /// HTTP client for AI providers @@ -103,6 +104,9 @@ const DEFAULT_AI_CLIENT_TIMEOUT_MS: u64 = 90_000; const DEFAULT_AI_CONNECT_TIMEOUT_MS: u64 = 5_000; const DEFAULT_AI_POOL_IDLE_TIMEOUT_MS: u64 = 90_000; const DEFAULT_AI_TCP_KEEPALIVE_MS: u64 = 30_000; +const ANTHROPIC_API_KEY_ENV: &str = "LLM_ANTHROPIC_API_KEY"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; +const DEFAULT_ANTHROPIC_MAX_TOKENS: u32 = 8192; #[derive(Debug, Clone, Copy)] struct RetryPolicy { @@ -159,6 +163,9 @@ fn should_retry_provider_error(error: &anyhow::Error) -> bool { let message = error.to_string(); !(message.contains("No text content in SSE stream") || message.contains("No text content in response") + || message.contains("No text content in Anthropic response") + || message.contains("Anthropic refusal stop") + || message.contains("Anthropic response truncated") || message.contains("SSE error internal_error") || message.contains("SSE error bad_request")) } @@ -271,6 +278,27 @@ fn get_assistive_api_key() -> Result { get_mode_config("LLM_ASSISTIVE_API_KEY", "LLM API key (assistive)") } +fn get_anthropic_api_key() -> Result { + if let Ok(value) = env::var(ANTHROPIC_API_KEY_ENV) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + + if let Some(value) = crate::config::keychain::load_key(ANTHROPIC_API_KEY_ENV) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + + anyhow::bail!( + "Anthropic API key is required. Set {}.", + ANTHROPIC_API_KEY_ENV + ) +} + /// Get temperature from env var. Returns None if empty/unset (skip parameter). /// Supports mode-specific: LLM_FORMATTING_TEMPERATURE, LLM_ASSISTIVE_TEMPERATURE /// Falls back to LLM_TEMPERATURE, then to default (0.1 formatting, 0.3 assistive) @@ -310,14 +338,19 @@ enum EndpointFormat { ResponsesApi, /// Ollama native chat (/api/chat) — legacy compatibility OllamaChat, + /// Anthropic Messages API (/v1/messages) + AnthropicMessages, } -/// Detect format from endpoint path. No domain checks, no guessing. -fn detect_format(endpoint: &str) -> EndpointFormat { - if endpoint.contains("/api/chat") { - EndpointFormat::OllamaChat - } else { - EndpointFormat::ResponsesApi +/// Resolve request format from explicit provider, preserving path-based Ollama +/// compatibility only for the protected OpenAI/default lane. +fn detect_format(endpoint: &str, provider: ProviderKind) -> EndpointFormat { + match provider { + ProviderKind::AnthropicMessages => EndpointFormat::AnthropicMessages, + ProviderKind::OpenAiResponses if endpoint.contains("/api/chat") => { + EndpointFormat::OllamaChat + } + ProviderKind::OpenAiResponses => EndpointFormat::ResponsesApi, } } @@ -448,92 +481,95 @@ enum InputContent { Image { image_url: String }, } -fn image_mime_from_path(path: &std::path::Path) -> Option<&'static str> { - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()) - .unwrap_or_default(); - match ext.as_str() { - "png" => Some("image/png"), - "jpg" | "jpeg" => Some("image/jpeg"), - "webp" => Some("image/webp"), - "gif" => Some("image/gif"), - "bmp" => Some("image/bmp"), - "tif" | "tiff" => Some("image/tiff"), - _ => None, - } +/// Anthropic Messages request format (/v1/messages) +#[derive(Debug, Serialize)] +struct AnthropicMessagesRequest { + model: String, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, + messages: Vec, + max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, } -fn strip_image_attachments(user_message: &str) -> (String, Vec) { - let mut out_lines: Vec = Vec::new(); - let mut image_paths: Vec = Vec::new(); - let mut in_block = false; - - for line in user_message.lines() { - let trimmed = line.trim(); - - if trimmed == "ATTACHMENTS (image paths)" { - // Drop a preceding separator if present to avoid leaving a dangling "---". - if out_lines - .last() - .is_some_and(|l| l.trim() == "---" || l.trim() == "—") - { - out_lines.pop(); - } - in_block = true; - continue; - } - - if in_block { - if trimmed.is_empty() { - in_block = false; - continue; - } - if let Some(rest) = trimmed.strip_prefix("- ") { - let p = rest.trim(); - if !p.is_empty() { - image_paths.push(PathBuf::from(p)); - } - continue; - } - // Unexpected line → end block, keep the line. - in_block = false; - out_lines.push(line.to_string()); - continue; - } +#[derive(Debug, Serialize)] +struct AnthropicMessage { + role: &'static str, + content: Vec, +} - out_lines.push(line.to_string()); - } +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +enum AnthropicContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { source: AnthropicImageSource }, +} - (out_lines.join("\n"), image_paths) +#[derive(Debug, Serialize)] +struct AnthropicImageSource { + #[serde(rename = "type")] + source_type: &'static str, + media_type: String, + data: String, } -fn encode_image_as_data_url(path: &PathBuf) -> Option { +fn encode_image_as_data_url(path: &std::path::Path) -> Option { use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; - const MAX_IMAGE_BYTES: u64 = 8 * 1024 * 1024; // 8MB per image + // Marker parsing, MIME mapping and the size cap are shared with the agent + // send path via `crate::attachment` so both routes honor one contract. + let (bytes, mime) = + crate::attachment::load_image_for_vision(path, crate::attachment::MAX_VISION_IMAGE_BYTES)?; + let b64 = BASE64.encode(bytes); + Some(format!("data:{mime};base64,{b64}")) +} - let mime = image_mime_from_path(path)?; +fn build_responses_user_content(user_message: &str) -> Vec { + // Kept in sync with `MAX_AGENT_VISION_IMAGES` in the agent send path. + const MAX_IMAGES: usize = 16; - let meta = std::fs::metadata(path).ok()?; - if meta.len() > MAX_IMAGE_BYTES { + let (mut cleaned, mut image_paths) = + crate::attachment::parse_image_attachment_block(user_message); + if image_paths.len() > MAX_IMAGES { warn!( - "Skipping image attachment (too large, {} bytes): {}", - meta.len(), - path.display() + "Too many image attachments ({}); keeping first {}", + image_paths.len(), + MAX_IMAGES ); - return None; + image_paths.truncate(MAX_IMAGES); } - let bytes = std::fs::read(path).ok()?; - let b64 = BASE64.encode(bytes); - Some(format!("data:{mime};base64,{b64}")) + if !image_paths.is_empty() { + let names = image_paths + .iter() + .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .collect::>() + .join(", "); + cleaned.push_str("\n\n[Attached images: "); + cleaned.push_str(&names); + cleaned.push_str("]\n"); + } + + let mut content = vec![InputContent::Text { text: cleaned }]; + for p in image_paths { + let Some(url) = encode_image_as_data_url(&p) else { + warn!("Failed to encode image attachment: {}", p.display()); + continue; + }; + content.push(InputContent::Image { image_url: url }); + } + content } -fn build_responses_user_content(user_message: &str) -> Vec { - const MAX_IMAGES: usize = 4; +fn build_anthropic_user_content(user_message: &str) -> Vec { + // Kept in sync with `MAX_AGENT_VISION_IMAGES` in the agent send path. + const MAX_IMAGES: usize = 16; - let (mut cleaned, mut image_paths) = strip_image_attachments(user_message); + let (mut cleaned, mut image_paths) = + crate::attachment::parse_image_attachment_block(user_message); if image_paths.len() > MAX_IMAGES { warn!( "Too many image attachments ({}); keeping first {}", @@ -554,17 +590,38 @@ fn build_responses_user_content(user_message: &str) -> Vec { cleaned.push_str("]\n"); } - let mut content = vec![InputContent::Text { text: cleaned }]; + let mut content = Vec::new(); + if !cleaned.is_empty() || image_paths.is_empty() { + content.push(AnthropicContentBlock::Text { text: cleaned }); + } + for p in image_paths { let Some(url) = encode_image_as_data_url(&p) else { warn!("Failed to encode image attachment: {}", p.display()); continue; }; - content.push(InputContent::Image { image_url: url }); + let Some(source) = anthropic_image_source_from_data_url(&url) else { + warn!( + "Failed to convert image attachment for Anthropic: {}", + p.display() + ); + continue; + }; + content.push(AnthropicContentBlock::Image { source }); } content } +fn anthropic_image_source_from_data_url(url: &str) -> Option { + let payload = url.strip_prefix("data:")?; + let (media_type, data) = payload.split_once(";base64,")?; + Some(AnthropicImageSource { + source_type: "base64", + media_type: media_type.to_string(), + data: data.to_string(), + }) +} + /// Responses API response format #[derive(Debug, Deserialize)] struct ResponsesResponse { @@ -590,6 +647,27 @@ struct ContentPart { summary: Option, } +/// Anthropic Messages response format +#[derive(Debug, Deserialize)] +struct AnthropicMessagesResponse { + #[serde(default)] + id: Option, + #[serde(default)] + content: Vec, + #[serde(default)] + stop_reason: Option, + #[serde(default)] + stop_details: Option, +} + +#[derive(Debug, Deserialize)] +struct AnthropicResponseContent { + #[serde(rename = "type")] + part_type: String, + #[serde(default)] + text: Option, +} + /// Legacy chat message (for Ollama compatibility) #[derive(Debug, Serialize, Clone)] struct ChatMessage { @@ -646,6 +724,32 @@ fn extract_output_channels(output: &[OutputItem]) -> ProviderOutput { } } +fn extract_anthropic_text(response: &AnthropicMessagesResponse) -> String { + response + .content + .iter() + .filter(|part| part.part_type == "text") + .filter_map(|part| part.text.as_deref()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("") + .trim() + .to_string() +} + +fn anthropic_response_id(response: &AnthropicMessagesResponse) -> &str { + response.id.as_deref().unwrap_or("unknown") +} + +fn anthropic_stop_detail(response: &AnthropicMessagesResponse) -> String { + response + .stop_details + .as_ref() + .map(Value::to_string) + .or_else(|| response.stop_reason.clone()) + .unwrap_or_else(|| "unknown stop reason".to_string()) +} + // No token limits - let the API decide. Tokens are cheap, lost notes are not. /// Check if output is effectively the same as input (raw-like) @@ -921,17 +1025,26 @@ pub async fn format_text_with_status_channels( cleaned.clone() }; - // Route based on endpoint path — no domain heuristics + // Route from explicit provider selection, retaining endpoint-path Ollama + // compatibility only for the default OpenAI Responses lane. let endpoint = if assistive { get_assistive_endpoint().unwrap_or_default() } else { get_formatting_endpoint().unwrap_or_default() }; - let endpoint_format = detect_format(&endpoint); + let provider = resolve_provider(if assistive { + LlmMode::Assistive + } else { + LlmMode::Formatting + }); + let endpoint_format = detect_format(&endpoint, provider); // Streaming is always enabled. Callbacks only decide whether UI receives live chunks. let streaming_enabled = use_streaming(); - let route = match (endpoint_format, streaming_enabled) { + let should_stream = + streaming_enabled && matches!(endpoint_format, EndpointFormat::ResponsesApi); + let route = match (endpoint_format, should_stream) { (EndpointFormat::OllamaChat, _) => "ollama", + (EndpointFormat::AnthropicMessages, _) => "anthropic-messages-json", (EndpointFormat::ResponsesApi, true) => "responses-sse", (EndpointFormat::ResponsesApi, false) => "responses-json", }; @@ -951,13 +1064,13 @@ pub async fn format_text_with_status_channels( inter_chunk_timeout: retry_policy.inter_chunk_timeout, }; let mut retryable_error = true; - let result_opt = if streaming_enabled && endpoint_format != EndpointFormat::OllamaChat { + let result_opt = if should_stream { match call_provider_once( endpoint_format, &user_message, &system_prompt, assistive, - streaming_enabled, + should_stream, stream_context.clone(), ) .await @@ -988,7 +1101,7 @@ pub async fn format_text_with_status_channels( &user_message, &system_prompt, assistive, - streaming_enabled, + should_stream, stream_context.clone(), ), ) @@ -1020,7 +1133,13 @@ pub async fn format_text_with_status_channels( }; if let Some(output) = result_opt { - let formatted = output.assistant_text; + // Deterministic protected-vocabulary pass AFTER the LLM. The model can + // silently corrupt proper nouns ("Loctree" -> "Luxury") or drop + // operator/tool/agent names while rewriting prose; re-applying the + // lexicon restores any registered mispronunciation to its canonical + // form. Safe + idempotent: it only rewrites known variants, never + // ordinary language. Applies to both formatting and assistive modes. + let formatted = crate::stream_postprocess::apply_lexicon(&output.assistant_text); let reasoning_text = output.reasoning_text; // Detect AI refusal responses (OpenAI content policy) @@ -1124,6 +1243,9 @@ async fn call_provider_once( ) -> Result { match endpoint_format { EndpointFormat::OllamaChat => call_ollama(user_message, system_prompt, assistive).await, + EndpointFormat::AnthropicMessages => { + call_anthropic_messages(user_message, system_prompt, assistive).await + } EndpointFormat::ResponsesApi => { if streaming_enabled { call_llm_endpoint_streaming(user_message, system_prompt, assistive, stream_context) @@ -1135,6 +1257,110 @@ async fn call_provider_once( } } +fn normalize_anthropic_messages_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim().trim_end_matches('/'); + let base = trimmed + .trim_end_matches("/v1/messages") + .trim_end_matches("/v1/responses") + .trim_end_matches("/v1"); + format!("{base}/v1/messages") +} + +async fn call_anthropic_messages( + user_message: &str, + system_prompt: &str, + assistive: bool, +) -> Result { + let (configured_endpoint, model) = if assistive { + (get_assistive_endpoint()?, get_assistive_model()?) + } else { + (get_formatting_endpoint()?, get_formatting_model()?) + }; + let endpoint = normalize_anthropic_messages_endpoint(&configured_endpoint); + let api_key = get_anthropic_api_key()?; + let policy = capability_policy(ProviderKind::AnthropicMessages, &model); + let temperature = policy.sanitize_temperature(get_temperature(assistive)); + let max_tokens = env_u32( + "CODESCRIBE_ANTHROPIC_MAX_TOKENS", + DEFAULT_ANTHROPIC_MAX_TOKENS, + ); + + trace!( + "Anthropic Messages request: endpoint={}, model={}, mode={}, temp={:?}, max_tokens={}", + endpoint, + model, + if assistive { "assistive" } else { "formatting" }, + temperature, + max_tokens + ); + + let request = AnthropicMessagesRequest { + model, + system: Some(system_prompt.to_string()).filter(|value| !value.trim().is_empty()), + messages: vec![AnthropicMessage { + role: "user", + content: build_anthropic_user_content(user_message), + }], + max_tokens, + temperature, + }; + + let response = get_client() + .post(&endpoint) + .header("x-api-key", &api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .context("Anthropic request failed")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Anthropic HTTP {} - {}", status, body); + } + + let anthropic_response: AnthropicMessagesResponse = response + .json() + .await + .context("Failed to parse Anthropic response")?; + + if policy.refusal_stop_reason + && matches!(anthropic_response.stop_reason.as_deref(), Some("refusal")) + { + anyhow::bail!( + "Anthropic refusal stop (id: {}): {}", + anthropic_response_id(&anthropic_response), + anthropic_stop_detail(&anthropic_response) + ); + } + + let assistant_text = extract_anthropic_text(&anthropic_response); + if assistant_text.is_empty() { + anyhow::bail!( + "No text content in Anthropic response (id: {}, stop_reason: {})", + anthropic_response_id(&anthropic_response), + anthropic_stop_detail(&anthropic_response) + ); + } + + if matches!( + anthropic_response.stop_reason.as_deref(), + Some("max_tokens") + ) { + anyhow::bail!( + "Anthropic response truncated by max_tokens (id: {})", + anthropic_response_id(&anthropic_response) + ); + } + + Ok(ProviderOutput { + assistant_text, + reasoning_text: None, + }) +} + /// Call LLM endpoint using /v1/responses API /// /// Uses mode-aware config: LLM_{FORMATTING,ASSISTIVE}_{ENDPOINT,MODEL,API_KEY} @@ -1448,11 +1674,18 @@ pub fn has_api_key() -> bool { return false; } + let provider = resolve_provider(LlmMode::Formatting); + let endpoint_format = detect_format(&endpoint, provider); + // OllamaChat doesn't need API key - if matches!(detect_format(&endpoint), EndpointFormat::OllamaChat) { + if matches!(endpoint_format, EndpointFormat::OllamaChat) { return true; } + if matches!(endpoint_format, EndpointFormat::AnthropicMessages) { + return get_anthropic_api_key().is_ok(); + } + // Responses API requires API key get_formatting_api_key().is_ok() } @@ -1465,6 +1698,67 @@ pub fn is_formatting_available() -> bool { #[cfg(test)] mod tests { use super::*; + use mockito::Matcher; + use serde_json::json; + use serial_test::serial; + + const ANTHROPIC_TEST_ENV_KEYS: &[&str] = &[ + "LLM_FORMATTING_PROVIDER", + "LLM_FORMATTING_ENDPOINT", + "LLM_FORMATTING_MODEL", + "LLM_FORMATTING_API_KEY", + "LLM_FORMATTING_TEMPERATURE", + "LLM_TEMPERATURE", + "LLM_ANTHROPIC_API_KEY", + "CODESCRIBE_ANTHROPIC_MAX_TOKENS", + ]; + + 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) }, + } + } + } + + struct TestEnv { + guards: Vec, + } + + impl TestEnv { + fn clean() -> Self { + Self { + guards: ANTHROPIC_TEST_ENV_KEYS + .iter() + .map(|key| EnvGuard::remove(key)) + .collect(), + } + } + + fn set(&mut self, key: &'static str, value: &str) { + self.guards.push(EnvGuard::set(key, value)); + } + } #[test] fn test_has_repetition_loop() { @@ -1555,6 +1849,173 @@ mod tests { assert_eq!(DEFAULT_AI_RETRY_DELAY_MS, 500); } + #[tokio::test] + #[serial] + async fn anthropic_sonnet_request_keeps_temperature() { + let mut env = TestEnv::clean(); + let mut server = mockito::Server::new_async().await; + env.set("LLM_FORMATTING_ENDPOINT", &server.url()); + env.set("LLM_FORMATTING_MODEL", "claude-sonnet-4-6"); + env.set("LLM_FORMATTING_TEMPERATURE", "0.5"); + env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); + + let mock = server + .mock("POST", "/v1/messages") + .match_header("x-api-key", "anthropic-test-key") + .match_header("anthropic-version", ANTHROPIC_VERSION) + .match_body(Matcher::Json(json!({ + "model": "claude-sonnet-4-6", + "system": "format carefully", + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": "hello world"}] + }], + "max_tokens": DEFAULT_ANTHROPIC_MAX_TOKENS, + "temperature": 0.5 + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "msg_sonnet", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello world."}], + "stop_reason": "end_turn" + }) + .to_string(), + ) + .create_async() + .await; + + let output = call_anthropic_messages("hello world", "format carefully", false) + .await + .expect("sonnet formatting request should succeed"); + + assert_eq!(output.assistant_text, "Hello world."); + mock.assert_async().await; + } + + #[tokio::test] + #[serial] + async fn anthropic_opus_request_strips_temperature() { + let mut env = TestEnv::clean(); + let mut server = mockito::Server::new_async().await; + env.set("LLM_FORMATTING_ENDPOINT", &server.url()); + env.set("LLM_FORMATTING_MODEL", "claude-opus-4-8"); + env.set("LLM_FORMATTING_TEMPERATURE", "0.5"); + env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); + + let mock = server + .mock("POST", "/v1/messages") + .match_body(Matcher::Json(json!({ + "model": "claude-opus-4-8", + "system": "format carefully", + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": "hello world"}] + }], + "max_tokens": DEFAULT_ANTHROPIC_MAX_TOKENS + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "msg_opus", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello world."}], + "stop_reason": "end_turn" + }) + .to_string(), + ) + .create_async() + .await; + + let output = call_anthropic_messages("hello world", "format carefully", false) + .await + .expect("opus formatting request should succeed without temperature"); + + assert_eq!(output.assistant_text, "Hello world."); + mock.assert_async().await; + } + + #[tokio::test] + #[serial] + async fn anthropic_refusal_stop_reason_is_readable_error() { + let mut env = TestEnv::clean(); + let mut server = mockito::Server::new_async().await; + env.set("LLM_FORMATTING_ENDPOINT", &server.url()); + env.set("LLM_FORMATTING_MODEL", "claude-sonnet-4-6"); + env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); + + let mock = server + .mock("POST", "/v1/messages") + .match_body(Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "msg_refusal", + "type": "message", + "role": "assistant", + "content": [], + "stop_reason": "refusal", + "stop_details": {"reason": "safety"} + }) + .to_string(), + ) + .create_async() + .await; + + let err = call_anthropic_messages("hello world", "format carefully", false) + .await + .expect_err("refusal stop_reason should not parse as empty success"); + + let message = err.to_string(); + assert!(message.contains("Anthropic refusal stop")); + assert!(message.contains("safety")); + mock.assert_async().await; + } + + #[tokio::test] + #[serial] + async fn anthropic_happy_path_joins_text_content_blocks() { + let mut env = TestEnv::clean(); + let mut server = mockito::Server::new_async().await; + env.set("LLM_FORMATTING_ENDPOINT", &server.url()); + env.set("LLM_FORMATTING_MODEL", "claude-sonnet-4-6"); + env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); + + let mock = server + .mock("POST", "/v1/messages") + .match_body(Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "msg_joined", + "type": "message", + "role": "assistant", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "text", "text": " world."} + ], + "stop_reason": "end_turn" + }) + .to_string(), + ) + .create_async() + .await; + + let output = call_anthropic_messages("hello world", "format carefully", false) + .await + .expect("text content blocks should parse"); + + assert_eq!(output.assistant_text, "Hello world."); + mock.assert_async().await; + } + #[test] fn empty_content_provider_errors_are_not_retryable() { assert!(!should_retry_provider_error(&anyhow::anyhow!( @@ -1569,6 +2030,9 @@ mod tests { assert!(!should_retry_provider_error(&anyhow::anyhow!( "SSE error bad_request: invalid input" ))); + assert!(!should_retry_provider_error(&anyhow::anyhow!( + "Anthropic refusal stop (id: msg_1): safety" + ))); assert!(should_retry_provider_error(&anyhow::anyhow!( "SSE stream inter-chunk timeout" ))); diff --git a/core/llm/key_liveness.rs b/core/llm/key_liveness.rs new file mode 100644 index 00000000..db104527 --- /dev/null +++ b/core/llm/key_liveness.rs @@ -0,0 +1,414 @@ +//! Minimal API-key liveness probes for Settings. +//! +//! This is intentionally not a general health framework. Each probe makes one +//! cheap provider request and classifies the result into UI-safe buckets: +//! key works, invalid key, no quota/credits, network/unknown, missing, or +//! unsupported. + +use std::time::Duration; + +use reqwest::StatusCode; +use reqwest::blocking::{Client, Response}; +use serde_json::json; + +use crate::config::keychain::{self, KEYCHAIN_ACCOUNTS}; +use crate::config::{ + Config, DEFAULT_ASSISTIVE_MODEL, DEFAULT_FORMATTING_MODEL, DEFAULT_LLM_MODEL, + DEFAULT_OPENAI_RESPONSES_ENDPOINT, +}; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_ANTHROPIC_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +const DEFAULT_ANTHROPIC_MODEL: &str = "claude-opus-4-8"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiKeyLivenessStatus { + Ok, + Invalid, + NoQuota, + Network, + Missing, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiKeyLivenessResult { + pub account: String, + pub status: ApiKeyLivenessStatus, + pub message: String, +} + +impl ApiKeyLivenessResult { + fn new(account: &str, status: ApiKeyLivenessStatus, message: impl Into) -> Self { + Self { + account: account.to_string(), + status, + message: message.into(), + } + } +} + +pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { + if !KEYCHAIN_ACCOUNTS.contains(&account) { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Unsupported, + "unknown Keychain account", + ); + } + + let config = Config::load(); + let Some(api_key) = account_secret(account) else { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Missing, + "key is not configured", + ); + }; + + 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) + .build() + { + Ok(client) => client, + Err(error) => { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + format!("failed to create HTTP client: {error}"), + ); + } + }; + + match account { + "LLM_API_KEY" | "LLM_FORMATTING_API_KEY" | "LLM_ASSISTIVE_API_KEY" => { + probe_openai_key(&client, &config, account, &api_key) + } + "LLM_ANTHROPIC_API_KEY" => probe_anthropic_key(&client, account, &api_key), + "GITHUB_TOKEN" => probe_github_token(&client, account, &api_key), + _ => ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Unsupported, + "no liveness probe is available for this key", + ), + } +} + +/// 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 { + if status.is_success() { + return ApiKeyLivenessStatus::Ok; + } + + let body_lower = body.to_ascii_lowercase(); + if status == StatusCode::TOO_MANY_REQUESTS + || status == StatusCode::PAYMENT_REQUIRED + || body_lower.contains("insufficient_quota") + || body_lower.contains("credit balance is too low") + || body_lower.contains("billing_error") + { + return ApiKeyLivenessStatus::NoQuota; + } + + if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + return ApiKeyLivenessStatus::Invalid; + } + + // Any other client error (4xx, e.g. 400 model_not_found, 404) means the + // server processed the request and the key passed authentication — the key + // is live even if this particular probe request was malformed. Only real + // transport failures (handled at the request boundary) and server-side + // errors (5xx) remain unverifiable. + if status.is_client_error() { + return ApiKeyLivenessStatus::Ok; + } + + ApiKeyLivenessStatus::Network +} + +fn probe_openai_key( + client: &Client, + config: &Config, + account: &str, + api_key: &str, +) -> ApiKeyLivenessResult { + let endpoint = openai_endpoint_for_account(config, account); + let endpoint = normalize_openai_responses_endpoint(&endpoint); + let model = openai_model_for_account(account); + let request = json!({ + "model": model, + "input": [{ + "role": "user", + "content": [{ "type": "input_text", "text": "ping" }] + }], + "max_output_tokens": 1, + "stream": false + }); + + let response = client + .post(endpoint) + .bearer_auth(api_key) + .header("x-api-key", api_key) + .header("Content-Type", "application/json") + .json(&request) + .send(); + + response_result(account, response) +} + +fn probe_anthropic_key(client: &Client, account: &str, api_key: &str) -> ApiKeyLivenessResult { + let endpoint = env_non_empty("LLM_ANTHROPIC_ENDPOINT") + .unwrap_or_else(|| DEFAULT_ANTHROPIC_ENDPOINT.to_string()); + let endpoint = normalize_anthropic_messages_endpoint(&endpoint); + let model = env_non_empty("LLM_ASSISTIVE_MODEL") + .filter(|m| m.starts_with("claude")) + .unwrap_or_else(|| DEFAULT_ANTHROPIC_MODEL.to_string()); + let request = json!({ + "model": model, + "messages": [{ + "role": "user", + "content": [{ "type": "text", "text": "ping" }] + }], + "max_tokens": 1 + }); + + let response = client + .post(endpoint) + .header("x-api-key", api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("Content-Type", "application/json") + .json(&request) + .send(); + + response_result(account, response) +} + +fn probe_github_token(client: &Client, account: &str, api_key: &str) -> ApiKeyLivenessResult { + let endpoint = env_non_empty("CODESCRIBE_GITHUB_PROBE_ENDPOINT") + .unwrap_or_else(|| "https://api.github.com/user".to_string()); + let response = client + .get(endpoint) + .bearer_auth(api_key) + .header("User-Agent", "Codescribe API key liveness probe") + .send(); + + response_result(account, response) +} + +fn response_result( + account: &str, + response: Result, +) -> ApiKeyLivenessResult { + match response { + Ok(response) => { + let status = response.status(); + let body = response.text().unwrap_or_default(); + let probe_status = classify_probe_response(status, &body); + ApiKeyLivenessResult::new(account, probe_status, message_for_status(probe_status)) + } + Err(error) => ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + format!("network error: {error}"), + ), + } +} + +fn message_for_status(status: ApiKeyLivenessStatus) -> &'static str { + match status { + ApiKeyLivenessStatus::Ok => "key accepted and quota available", + ApiKeyLivenessStatus::Invalid => "provider rejected this key", + ApiKeyLivenessStatus::NoQuota => "key is valid, but the account has no quota or credits", + ApiKeyLivenessStatus::Network => "could not verify this key", + ApiKeyLivenessStatus::Missing => "key is not configured", + ApiKeyLivenessStatus::Unsupported => "probe is not supported for this key", + } +} + +fn account_secret(account: &str) -> Option { + env_non_empty(account).or_else(|| { + keychain::load_key(account) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn openai_endpoint_for_account(config: &Config, account: &str) -> String { + let fallback = || { + env_non_empty("LLM_ENDPOINT") + .or_else(|| config.llm_endpoint.clone()) + .unwrap_or_else(|| DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()) + }; + + match account { + "LLM_FORMATTING_API_KEY" => { + env_non_empty("LLM_FORMATTING_ENDPOINT").unwrap_or_else(fallback) + } + "LLM_ASSISTIVE_API_KEY" => env_non_empty("LLM_ASSISTIVE_ENDPOINT").unwrap_or_else(fallback), + _ => fallback(), + } +} + +fn openai_model_for_account(account: &str) -> String { + let is_openai_model = |m: &String| !m.starts_with("claude"); + match account { + "LLM_FORMATTING_API_KEY" => env_non_empty("LLM_FORMATTING_MODEL") + .filter(is_openai_model) + .or_else(|| env_non_empty("LLM_MODEL").filter(is_openai_model)) + .unwrap_or_else(|| DEFAULT_FORMATTING_MODEL.to_string()), + "LLM_ASSISTIVE_API_KEY" => env_non_empty("LLM_ASSISTIVE_MODEL") + .filter(is_openai_model) + .or_else(|| env_non_empty("LLM_MODEL").filter(is_openai_model)) + .unwrap_or_else(|| DEFAULT_ASSISTIVE_MODEL.to_string()), + _ => env_non_empty("LLM_MODEL") + .filter(is_openai_model) + .unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string()), + } +} + +fn normalize_openai_responses_endpoint(endpoint: &str) -> String { + normalize_endpoint( + endpoint, + "/v1/responses", + &["/v1/responses", "/v1/chat/completions", "/v1/completions"], + ) +} + +fn normalize_anthropic_messages_endpoint(endpoint: &str) -> String { + normalize_endpoint(endpoint, "/v1/messages", &["/v1/messages", "/v1/responses"]) +} + +fn normalize_endpoint(endpoint: &str, canonical_suffix: &str, known_suffixes: &[&str]) -> String { + let mut base = endpoint.trim().trim_end_matches('/').to_string(); + for suffix in known_suffixes { + if base.ends_with(suffix) { + base.truncate(base.len() - suffix.len()); + return format!("{base}{canonical_suffix}"); + } + } + if base.ends_with("/v1") { + base.truncate(base.len() - "/v1".len()); + } + format!("{base}{canonical_suffix}") +} + +fn env_non_empty(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_success_as_ok() { + assert_eq!( + classify_probe_response(StatusCode::OK, r#"{"id":"resp_123"}"#), + ApiKeyLivenessStatus::Ok + ); + } + + #[test] + fn classifies_401_and_403_as_invalid() { + assert_eq!( + classify_probe_response(StatusCode::UNAUTHORIZED, "{}"), + ApiKeyLivenessStatus::Invalid + ); + assert_eq!( + classify_probe_response(StatusCode::FORBIDDEN, "{}"), + ApiKeyLivenessStatus::Invalid + ); + } + + #[test] + fn classifies_insufficient_quota_body_as_no_quota() { + assert_eq!( + classify_probe_response( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"insufficient_quota","message":"billing hard limit"}}"# + ), + ApiKeyLivenessStatus::NoQuota + ); + } + + #[test] + fn classifies_429_without_body_as_no_quota() { + assert_eq!( + classify_probe_response(StatusCode::TOO_MANY_REQUESTS, ""), + ApiKeyLivenessStatus::NoQuota + ); + } + + #[test] + fn classifies_anthropic_low_credit_body_as_no_quota() { + assert_eq!( + classify_probe_response( + StatusCode::BAD_REQUEST, + r#"{"type":"error","error":{"type":"invalid_request_error","message":"Your credit balance is too low to access the Anthropic API."}}"# + ), + ApiKeyLivenessStatus::NoQuota + ); + } + + #[test] + fn classifies_402_billing_error_as_no_quota() { + assert_eq!( + classify_probe_response( + StatusCode::PAYMENT_REQUIRED, + r#"{"error":{"type":"billing_error","message":"payment required"}}"# + ), + ApiKeyLivenessStatus::NoQuota + ); + } + + #[test] + fn classifies_400_model_not_found_as_ok() { + // A 400 with a request-level error (not quota/auth) means the server + // accepted the key and processed the request: the key is live. + assert_eq!( + classify_probe_response( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"model_not_found","message":"The model does not exist"}}"# + ), + ApiKeyLivenessStatus::Ok + ); + } + + #[test] + fn classifies_other_client_errors_as_ok() { + assert_eq!( + classify_probe_response(StatusCode::BAD_REQUEST, "bad request"), + ApiKeyLivenessStatus::Ok + ); + assert_eq!( + classify_probe_response(StatusCode::NOT_FOUND, "no such endpoint"), + ApiKeyLivenessStatus::Ok + ); + } + + #[test] + fn classifies_server_errors_as_network_unknown() { + assert_eq!( + classify_probe_response(StatusCode::INTERNAL_SERVER_ERROR, "try later"), + ApiKeyLivenessStatus::Network + ); + assert_eq!( + classify_probe_response(StatusCode::BAD_GATEWAY, "upstream down"), + ApiKeyLivenessStatus::Network + ); + } +} diff --git a/core/llm/mod.rs b/core/llm/mod.rs index 4cae2dce..52efd747 100644 --- a/core/llm/mod.rs +++ b/core/llm/mod.rs @@ -1,3 +1,7 @@ +pub mod account_auth; pub mod ai_formatting; pub mod client; +pub mod key_liveness; +pub mod model_discovery; +pub mod provider; pub mod responses_streaming_manager; diff --git a/core/llm/model_discovery.rs b/core/llm/model_discovery.rs new file mode 100644 index 00000000..f0dcdb98 --- /dev/null +++ b/core/llm/model_discovery.rs @@ -0,0 +1,738 @@ +//! Live provider model discovery for the Settings model picker. +//! +//! Model dropdowns must come from the provider's own `/models` API for the +//! user's key. Static model catalogs go stale exactly when new releases matter, +//! so this module is the single discovery path plus a last-good cache for +//! offline/error states. + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::time::Duration; + +use chrono::Utc; +use reqwest::StatusCode; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +use crate::config::Config; +use crate::config::DEFAULT_OPENAI_RESPONSES_ENDPOINT; +use crate::llm::provider::ProviderKind; + +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +const CACHE_FILE_NAME: &str = "model_discovery_cache.json"; +const ANTHROPIC_MODELS_ENDPOINT: &str = "https://api.anthropic.com/v1/models"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// One discovered provider model. `id` is sent on the wire; `display_name` is +/// provider-provided when available and otherwise falls back to `id`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscoveredModel { + pub id: String, + pub display_name: String, +} + +/// Whether returned models came from the live provider or the last-good cache. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelDiscoveryStatus { + Fresh, + Cached { reason: String }, +} + +/// Successful model discovery result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelDiscoveryResult { + pub provider: ProviderKind, + pub models: Vec, + pub status: ModelDiscoveryStatus, +} + +/// Discovery failure. These map cleanly to UI status strings; no variant carries +/// API key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelDiscoveryError { + NoKey { + provider: ProviderKind, + env_key: &'static str, + }, + Network { + provider: ProviderKind, + message: String, + }, + HttpStatus { + provider: ProviderKind, + status: u16, + message: String, + }, + Parse { + provider: ProviderKind, + message: String, + }, + Cache { + provider: ProviderKind, + message: String, + }, +} + +impl ModelDiscoveryError { + pub const fn provider(&self) -> ProviderKind { + match self { + Self::NoKey { provider, .. } + | Self::Network { provider, .. } + | Self::HttpStatus { provider, .. } + | Self::Parse { provider, .. } + | Self::Cache { provider, .. } => *provider, + } + } + + pub const fn code(&self) -> &'static str { + match self { + Self::NoKey { .. } => "no_key", + Self::Network { .. } => "network", + Self::HttpStatus { .. } => "http_status", + Self::Parse { .. } => "parse", + Self::Cache { .. } => "cache", + } + } + + pub fn message(&self) -> String { + match self { + Self::NoKey { env_key, .. } => format!("{env_key} is not configured"), + Self::Network { message, .. } + | Self::HttpStatus { message, .. } + | Self::Parse { message, .. } + | Self::Cache { message, .. } => message.clone(), + } + } +} + +impl std::fmt::Display for ModelDiscoveryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoKey { provider, env_key } => { + write!(f, "{provider}: no_key: {env_key} is not configured") + } + Self::Network { provider, message } => { + write!(f, "{provider}: network: {message}") + } + Self::HttpStatus { + provider, + status, + message, + } => write!(f, "{provider}: http_status {status}: {message}"), + Self::Parse { provider, message } => write!(f, "{provider}: parse: {message}"), + Self::Cache { provider, message } => write!(f, "{provider}: cache: {message}"), + } + } +} + +impl std::error::Error for ModelDiscoveryError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedProviderModels { + fetched_at: String, + models: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct DiscoveryCacheFile { + providers: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct OpenAiModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct OpenAiModel { + id: String, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelsResponse { + data: Vec, + #[serde(default)] + has_more: bool, + last_id: Option, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModel { + id: String, + display_name: Option, +} + +/// Discover models for a provider using the already-supported config/key path. +/// +/// `Config::load()` is intentionally the first operation: it applies settings, +/// optional `.env`, and Keychain population exactly like the provider runtime. +/// Missing keys are hard `no_key` failures and do not fall back to stale cache; +/// network/http/parse failures return last-good cache when available. +pub fn discover_models( + provider: ProviderKind, +) -> Result { + let config = Config::load(); + let key_name = provider.api_key_env_key(); + let api_key = env_non_empty(key_name).ok_or(ModelDiscoveryError::NoKey { + provider, + env_key: key_name, + })?; + + let client = Client::builder() + .timeout(DISCOVERY_TIMEOUT) + .build() + .map_err(|error| ModelDiscoveryError::Network { + provider, + message: format!("failed to create HTTP client: {error}"), + })?; + + let fetched = match provider { + ProviderKind::OpenAiResponses => fetch_openai_models(&client, &config, &api_key), + ProviderKind::AnthropicMessages => fetch_anthropic_models(&client, &api_key), + }; + + match fetched { + Ok(models) => { + let models = normalize_models(models); + if let Err(error) = write_cache(provider, &models) { + warn!("{error}"); + } + Ok(ModelDiscoveryResult { + provider, + models, + status: ModelDiscoveryStatus::Fresh, + }) + } + Err(error) => match read_cached_models(provider) { + Ok(models) if !models.is_empty() => Ok(ModelDiscoveryResult { + provider, + models, + status: ModelDiscoveryStatus::Cached { + reason: error.message(), + }, + }), + _ => Err(error), + }, + } +} + +fn fetch_openai_models( + client: &Client, + config: &Config, + api_key: &str, +) -> Result, ModelDiscoveryError> { + let provider = ProviderKind::OpenAiResponses; + let endpoint = env_non_empty("LLM_ASSISTIVE_ENDPOINT") + .or_else(|| env_non_empty("LLM_ENDPOINT")) + .or_else(|| config.llm_endpoint.clone()) + .unwrap_or_else(|| DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()); + let endpoint = openai_models_endpoint(&endpoint)?; + + let response = client + .get(endpoint) + .bearer_auth(api_key) + .send() + .map_err(|error| network_error(provider, error))?; + let body = response_body_or_error(provider, response)?; + let parsed: OpenAiModelsResponse = + serde_json::from_str(&body).map_err(|error| ModelDiscoveryError::Parse { + provider, + message: format!("failed to parse OpenAI models response: {error}"), + })?; + + Ok(parsed + .data + .into_iter() + .map(|model| DiscoveredModel { + display_name: model.id.clone(), + id: model.id, + }) + .collect()) +} + +fn fetch_anthropic_models( + client: &Client, + api_key: &str, +) -> Result, ModelDiscoveryError> { + let provider = ProviderKind::AnthropicMessages; + let endpoint = anthropic_models_endpoint(); + let mut after_id: Option = None; + let mut models = Vec::new(); + + loop { + let mut request = client + .get(&endpoint) + .header("x-api-key", api_key) + .header("anthropic-version", ANTHROPIC_VERSION); + if let Some(after) = after_id.as_deref() { + request = request.query(&[("after_id", after)]); + } + + let response = request + .send() + .map_err(|error| network_error(provider, error))?; + let body = response_body_or_error(provider, response)?; + let parsed: AnthropicModelsResponse = + serde_json::from_str(&body).map_err(|error| ModelDiscoveryError::Parse { + provider, + message: format!("failed to parse Anthropic models response: {error}"), + })?; + + let next_after_id = parsed + .last_id + .clone() + .or_else(|| parsed.data.last().map(|model| model.id.clone())); + + models.extend(parsed.data.into_iter().map(|model| { + let display_name = model + .display_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| model.id.clone()); + DiscoveredModel { + id: model.id, + display_name, + } + })); + + if !parsed.has_more { + break; + } + + after_id = Some(next_after_id.ok_or_else(|| ModelDiscoveryError::Parse { + provider, + message: + "Anthropic models response has has_more=true without last_id or data".to_string(), + })?); + } + + Ok(models) +} + +fn response_body_or_error( + provider: ProviderKind, + response: reqwest::blocking::Response, +) -> Result { + let status = response.status(); + let body = response + .text() + .map_err(|error| network_error(provider, error))?; + if status.is_success() { + Ok(body) + } else { + Err(http_status_error(provider, status, &body)) + } +} + +fn network_error(provider: ProviderKind, error: reqwest::Error) -> ModelDiscoveryError { + ModelDiscoveryError::Network { + provider, + message: error.to_string(), + } +} + +fn http_status_error( + provider: ProviderKind, + status: StatusCode, + body: &str, +) -> ModelDiscoveryError { + let mut message = body.trim().replace('\n', " "); + if message.len() > 240 { + message.truncate(240); + message.push_str("..."); + } + if message.is_empty() { + message = status + .canonical_reason() + .unwrap_or("provider returned an error") + .to_string(); + } + ModelDiscoveryError::HttpStatus { + provider, + status: status.as_u16(), + message, + } +} + +fn openai_models_endpoint(endpoint: &str) -> Result { + let provider = ProviderKind::OpenAiResponses; + let mut url = reqwest::Url::parse(endpoint).map_err(|error| ModelDiscoveryError::Parse { + provider, + message: format!("invalid OpenAI endpoint '{endpoint}': {error}"), + })?; + url.set_query(None); + url.set_fragment(None); + + let segments: Vec = url + .path_segments() + .map(|parts| { + parts + .filter(|segment| !segment.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + let mut next = segments; + + if next.last().is_some_and(|segment| segment == "models") { + // already right + } else if next + .last() + .is_some_and(|segment| segment == "responses" || segment == "completions") + { + next.pop(); + if next.last().is_some_and(|segment| segment == "chat") { + next.pop(); + } + next.push("models".to_string()); + } else { + next.push("models".to_string()); + } + + url.path_segments_mut() + .map_err(|_| ModelDiscoveryError::Parse { + provider, + message: format!("invalid OpenAI endpoint base '{endpoint}'"), + })? + .clear() + .extend(next.iter().map(String::as_str)); + Ok(url.to_string()) +} + +fn normalize_models(models: Vec) -> Vec { + let mut seen = HashSet::new(); + models + .into_iter() + .filter_map(|model| { + let id = model.id.trim().to_string(); + if id.is_empty() || !seen.insert(id.clone()) { + return None; + } + let display_name = model.display_name.trim().to_string(); + Some(DiscoveredModel { + display_name: if display_name.is_empty() { + id.clone() + } else { + display_name + }, + id, + }) + }) + .collect() +} + +fn cache_path() -> std::path::PathBuf { + Config::config_dir().join(CACHE_FILE_NAME) +} + +fn read_cache_file() -> Result { + let path = cache_path(); + match fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw).map_err(|error| ModelDiscoveryError::Cache { + provider: ProviderKind::OpenAiResponses, + message: format!("failed to parse {}: {error}", path.display()), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(DiscoveryCacheFile::default()) + } + Err(error) => Err(ModelDiscoveryError::Cache { + provider: ProviderKind::OpenAiResponses, + message: format!("failed to read {}: {error}", path.display()), + }), + } +} + +fn read_cached_models(provider: ProviderKind) -> Result, ModelDiscoveryError> { + let cache = read_cache_file().map_err(|error| ModelDiscoveryError::Cache { + provider, + message: error.message(), + })?; + Ok(cache + .providers + .get(provider.as_str()) + .map(|entry| normalize_models(entry.models.clone())) + .unwrap_or_default()) +} + +fn write_cache( + provider: ProviderKind, + models: &[DiscoveredModel], +) -> Result<(), ModelDiscoveryError> { + let path = cache_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| ModelDiscoveryError::Cache { + provider, + message: format!("failed to create {}: {error}", parent.display()), + })?; + } + + let mut cache = read_cache_file().unwrap_or_default(); + cache.providers.insert( + provider.as_str().to_string(), + CachedProviderModels { + fetched_at: Utc::now().to_rfc3339(), + models: models.to_vec(), + }, + ); + + let raw = serde_json::to_string_pretty(&cache).map_err(|error| ModelDiscoveryError::Cache { + provider, + message: format!("failed to serialize model discovery cache: {error}"), + })?; + fs::write(&path, raw).map_err(|error| ModelDiscoveryError::Cache { + provider, + message: format!("failed to write {}: {error}", path.display()), + }) +} + +fn env_non_empty(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn anthropic_models_endpoint() -> String { + #[cfg(test)] + if let Some(endpoint) = env_non_empty("CODESCRIBE_TEST_ANTHROPIC_MODELS_ENDPOINT") { + return endpoint; + } + + ANTHROPIC_MODELS_ENDPOINT.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Matcher; + use serial_test::serial; + use tempfile::TempDir; + + #[test] + #[serial] + fn openai_models_parse_and_cache_round_trips() { + let mut env = TestEnv::new(); + let mut server = mockito::Server::new(); + env.set("LLM_ASSISTIVE_API_KEY", "sk-test"); + env.set( + "LLM_ASSISTIVE_ENDPOINT", + &format!("{}/v1/responses", server.url()), + ); + + let _mock = server + .mock("GET", "/v1/models") + .match_header("authorization", "Bearer sk-test") + .with_status(200) + .with_body(r#"{"object":"list","data":[{"id":"gpt-live"},{"id":"gpt-other"}]}"#) + .create(); + + let result = discover_models(ProviderKind::OpenAiResponses).unwrap(); + + assert_eq!(result.status, ModelDiscoveryStatus::Fresh); + assert_eq!( + result.models, + vec![ + DiscoveredModel { + id: "gpt-live".to_string(), + display_name: "gpt-live".to_string(), + }, + DiscoveredModel { + id: "gpt-other".to_string(), + display_name: "gpt-other".to_string(), + }, + ] + ); + + let cached = read_cached_models(ProviderKind::OpenAiResponses).unwrap(); + assert_eq!(cached, result.models); + env.keepalive(); + } + + #[test] + #[serial] + fn anthropic_models_parse_display_names_and_pagination() { + let mut env = TestEnv::new(); + let mut server = mockito::Server::new(); + env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test"); + env.set( + "CODESCRIBE_TEST_ANTHROPIC_MODELS_ENDPOINT", + &format!("{}/v1/models", server.url()), + ); + + let _page_1 = server + .mock("GET", "/v1/models") + .match_header("x-api-key", "anthropic-test") + .match_header("anthropic-version", ANTHROPIC_VERSION) + .with_status(200) + .with_body( + r#"{"data":[{"id":"claude-a","display_name":"Claude A"}],"has_more":true,"last_id":"claude-a"}"#, + ) + .create(); + let _page_2 = server + .mock("GET", "/v1/models") + .match_query(Matcher::UrlEncoded("after_id".to_string(), "claude-a".to_string())) + .match_header("x-api-key", "anthropic-test") + .match_header("anthropic-version", ANTHROPIC_VERSION) + .with_status(200) + .with_body( + r#"{"data":[{"id":"claude-b","display_name":"Claude B"},{"id":"claude-c"}],"has_more":false}"#, + ) + .create(); + + let result = discover_models(ProviderKind::AnthropicMessages).unwrap(); + + assert_eq!(result.status, ModelDiscoveryStatus::Fresh); + assert_eq!( + result.models, + vec![ + DiscoveredModel { + id: "claude-a".to_string(), + display_name: "Claude A".to_string(), + }, + DiscoveredModel { + id: "claude-b".to_string(), + display_name: "Claude B".to_string(), + }, + DiscoveredModel { + id: "claude-c".to_string(), + display_name: "claude-c".to_string(), + }, + ] + ); + env.keepalive(); + } + + #[test] + #[serial] + fn no_key_returns_error_without_request() { + let mut env = TestEnv::new(); + let mut server = mockito::Server::new(); + env.remove("LLM_ANTHROPIC_API_KEY"); + env.set( + "CODESCRIBE_TEST_ANTHROPIC_MODELS_ENDPOINT", + &format!("{}/v1/models", server.url()), + ); + let _mock = server.mock("GET", "/v1/models").expect(0).create(); + + let err = discover_models(ProviderKind::AnthropicMessages).unwrap_err(); + + assert_eq!(err.code(), "no_key"); + assert_eq!(err.provider(), ProviderKind::AnthropicMessages); + env.keepalive(); + } + + #[test] + #[serial] + fn network_error_uses_last_good_cache() { + let mut env = TestEnv::new(); + let mut server = mockito::Server::new(); + env.set("LLM_ASSISTIVE_API_KEY", "sk-test"); + env.set( + "LLM_ASSISTIVE_ENDPOINT", + &format!("{}/v1/responses", server.url()), + ); + + let _ok = server + .mock("GET", "/v1/models") + .with_status(200) + .with_body(r#"{"data":[{"id":"gpt-cached"}]}"#) + .create(); + let fresh = discover_models(ProviderKind::OpenAiResponses).unwrap(); + assert_eq!(fresh.status, ModelDiscoveryStatus::Fresh); + + let _fail = server + .mock("GET", "/v1/models") + .with_status(503) + .with_body("temporarily unavailable") + .create(); + let cached = discover_models(ProviderKind::OpenAiResponses).unwrap(); + + assert_eq!( + cached.status, + ModelDiscoveryStatus::Cached { + reason: "temporarily unavailable".to_string(), + } + ); + assert_eq!(cached.models, fresh.models); + env.keepalive(); + } + + #[test] + fn openai_endpoint_normalizes_common_api_paths() { + assert_eq!( + openai_models_endpoint("https://api.openai.com/v1/responses").unwrap(), + "https://api.openai.com/v1/models" + ); + assert_eq!( + openai_models_endpoint("https://api.openai.com/v1/chat/completions").unwrap(), + "https://api.openai.com/v1/models" + ); + assert_eq!( + openai_models_endpoint("https://proxy.example/openai").unwrap(), + "https://proxy.example/openai/models" + ); + } + + struct TestEnv { + _tmp: TempDir, + guards: Vec, + } + + impl TestEnv { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + let mut this = Self { + _tmp: tmp, + guards: Vec::new(), + }; + let data_dir = this._tmp.path().to_string_lossy().to_string(); + this.set("CODESCRIBE_DATA_DIR", &data_dir); + this.set("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + this.remove("LLM_ASSISTIVE_API_KEY"); + this.remove("LLM_ANTHROPIC_API_KEY"); + this.remove("CODESCRIBE_TEST_ANTHROPIC_MODELS_ENDPOINT"); + this.remove("LLM_ASSISTIVE_ENDPOINT"); + this.remove("LLM_ENDPOINT"); + this + } + + fn set(&mut self, key: &'static str, value: &str) { + self.guards.push(EnvGuard::set(key, value)); + } + + fn remove(&mut self, key: &'static str) { + self.guards.push(EnvGuard::remove(key)); + } + + fn keepalive(&self) {} + } + + 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) }, + } + } + } +} diff --git a/core/llm/provider.rs b/core/llm/provider.rs new file mode 100644 index 00000000..dffa340a --- /dev/null +++ b/core/llm/provider.rs @@ -0,0 +1,745 @@ +//! Canonical LLM provider identity and per-model capability policy. +//! +//! This module is the single source of truth for *which* LLM wire protocol a +//! request targets ([`ProviderKind`]) and *what* that protocol will accept for a +//! given model ([`CapabilityPolicy`]). It exists because OpenAI Responses and +//! Anthropic Messages disagree on request shape — and, critically, because two +//! Anthropic models disagree *with each other*: +//! +//! - `claude-opus-4-8` (assistive) rejects `temperature`/`top_p`/`top_k` and a +//! manual `thinking.budget_tokens` with HTTP 400. +//! - `claude-sonnet-4-6` (formatting) still accepts `temperature` and only +//! *deprecates* `budget_tokens` (not a hard 400). +//! +//! Encoding that asymmetry here keeps the request builders (OpenAI today, +//! Anthropic in W2/W3) from sharing unsafe assumptions. This layer is pure data + +//! parsing: it performs **no** HTTP and holds **no** provider implementation. +//! +//! OpenAI is the default everywhere. Nothing in this module changes the OpenAI +//! request path — [`capability_policy`] returns a permissive policy for +//! [`ProviderKind::OpenAiResponses`] so the existing Responses builder keeps +//! sending `temperature` and using `previous_response_id` exactly as before. + +use std::str::FromStr; + +use crate::llm::account_auth; + +use tracing::warn; + +/// Canonical LLM provider identity — the wire protocol a request targets. +/// +/// The string forms are the stable on-the-wire / env-var spellings. New +/// providers append variants here; the request layer branches on this enum +/// rather than sniffing endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProviderKind { + /// OpenAI Responses API (`/v1/responses`). The default. + OpenAiResponses, + /// Anthropic Messages API (`/v1/messages`). + AnthropicMessages, +} + +impl ProviderKind { + /// Canonical lowercase-kebab spelling used in env vars and persisted config. + pub const fn as_str(self) -> &'static str { + match self { + ProviderKind::OpenAiResponses => "openai-responses", + ProviderKind::AnthropicMessages => "anthropic-messages", + } + } + + /// Human-readable label for provider pickers (Settings UI). + pub const fn display_name(self) -> &'static str { + match self { + ProviderKind::OpenAiResponses => "OpenAI (Responses)", + ProviderKind::AnthropicMessages => "Anthropic (Messages)", + } + } + + /// Env var / Keychain account holding the assistive-lane API key for this + /// provider. OpenAI shares the assistive-lane key; Anthropic has its own so + /// the two secrets coexist and switching providers never overwrites a key. + pub const fn api_key_env_key(self) -> &'static str { + match self { + ProviderKind::OpenAiResponses => "LLM_ASSISTIVE_API_KEY", + ProviderKind::AnthropicMessages => "LLM_ANTHROPIC_API_KEY", + } + } +} + +/// How a provider authenticates requests for a lane. +/// +/// `ApiKey` is the default and preserves the existing request builders. The +/// provider-account path is an explicit opt-in foundation for future ChatGPT +/// sign-in; it does not change any caller until a request path chooses this +/// mode and asks for a bearer header. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum AuthMode { + #[default] + ApiKey, + ProviderAccount, +} + +impl AuthMode { + pub const fn as_str(self) -> &'static str { + match self { + AuthMode::ApiKey => "api-key", + AuthMode::ProviderAccount => "provider-account", + } + } +} + +impl std::fmt::Display for AuthMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseAuthModeError(pub String); + +impl std::fmt::Display for ParseAuthModeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "unknown auth mode '{}' (expected 'api-key' or 'provider-account')", + self.0 + ) + } +} + +impl std::error::Error for ParseAuthModeError {} + +impl FromStr for AuthMode { + type Err = ParseAuthModeError; + + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "api-key" | "api_key" | "apikey" | "key" => Ok(AuthMode::ApiKey), + "provider-account" | "provider_account" | "account" | "chatgpt" => { + Ok(AuthMode::ProviderAccount) + } + other => Err(ParseAuthModeError(other.to_string())), + } + } +} + +/// Every provider identity, in Settings-picker order. The request layer branches +/// on [`ProviderKind`]; Settings discovers model options via live provider APIs. +pub const ALL_PROVIDERS: [ProviderKind; 2] = [ + ProviderKind::OpenAiResponses, + ProviderKind::AnthropicMessages, +]; + +impl Default for ProviderKind { + /// OpenAI Responses is the default provider — never regress this without a + /// test that explicitly configures another provider. + fn default() -> Self { + ProviderKind::OpenAiResponses + } +} + +impl std::fmt::Display for ProviderKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Error returned when a provider string cannot be mapped to a [`ProviderKind`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseProviderError(pub String); + +impl std::fmt::Display for ParseProviderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "unknown LLM provider '{}' (expected 'openai-responses' or 'anthropic-messages')", + self.0 + ) + } +} + +impl std::error::Error for ParseProviderError {} + +impl FromStr for ProviderKind { + type Err = ParseProviderError; + + /// Parse a provider identity. Case-insensitive, surrounding whitespace + /// trimmed. Accepts the canonical kebab spelling plus the bare vendor name + /// as a friendly alias. Anything else is an error (callers decide whether to + /// fall back to the default — see [`resolve_provider`]). + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "openai-responses" | "openai" | "openai_responses" => Ok(ProviderKind::OpenAiResponses), + "anthropic-messages" | "anthropic" | "anthropic_messages" => { + Ok(ProviderKind::AnthropicMessages) + } + other => Err(ParseProviderError(other.to_string())), + } + } +} + +/// How a provider/model treats a manual thinking-budget (`budget_tokens`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetTokensPolicy { + /// Sending `budget_tokens` is a hard HTTP 400 — omit it entirely and use + /// adaptive thinking instead (`claude-opus-4-8`). + Hard400, + /// `budget_tokens` is deprecated but still functional as a transitional + /// escape hatch (`claude-sonnet-4-6`). Prefer adaptive thinking. + Deprecated, + /// The concept does not apply / imposes no restriction from this policy + /// (OpenAI Responses). + NotApplicable, +} + +/// Per-`(provider, model)` request capability policy. +/// +/// The request builder consults this before emitting a request so it never sends +/// a parameter the target will reject. Booleans are "is this allowed / relevant +/// for this model"; they are intentionally coarse — value-level granularity +/// (e.g. which `effort` tiers exist) is the builder's concern, not this gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapabilityPolicy { + /// Whether non-default sampling params (`temperature`/`top_p`/`top_k`) may be + /// sent. `false` ⇒ omit them (Opus-4.8 rejects with 400). + pub allow_sampling_params: bool, + /// How a manual `budget_tokens` is treated. + pub budget_tokens: BudgetTokensPolicy, + /// Whether Anthropic adaptive thinking (`thinking:{type:"adaptive"}`) is a + /// supported request shape for this model. OpenAI: `false` (different concept). + pub adaptive_thinking: bool, + /// Whether `output_config.effort` is supported. + pub effort: bool, + /// Whether `refusal` arrives as a `stop_reason` on a successful HTTP 200 and + /// must be branched on before reading content (Anthropic). OpenAI: `false`. + pub refusal_stop_reason: bool, + /// Whether the provider supports server-side conversation chaining via a + /// `previous_response_id` (OpenAI Responses). Anthropic replays messages, so + /// `false`. + pub previous_response_id: bool, + /// Whether this `(provider, model)` accepts image (vision) input blocks. + /// `false` ⇒ the send path must surface a readable error instead of silently + /// dropping attached images. Unknown Anthropic models default to the current + /// vision-capable policy; this flag is the honest seam for a future text-only + /// model family. + pub supports_vision: bool, +} + +impl CapabilityPolicy { + /// Sanitize a requested temperature against this policy: returns the value + /// only when sampling params are allowed, otherwise `None` (omit the param). + /// + /// This is the seam W2/W3 call when building an Anthropic request so a + /// non-default `temperature` never reaches an Opus-4.8 send. + pub fn sanitize_temperature(&self, requested: Option) -> Option { + if self.allow_sampling_params { + requested + } else { + None + } + } +} + +/// Model-family classification used to pick an Anthropic capability policy. +/// +/// CORRECTION.md pins behaviour for `claude-opus-4-8` and `claude-sonnet-4-6` +/// specifically; we generalise conservatively by family so a future +/// `opus-4-9`/`sonnet-4-7` inherits the safe shape rather than the permissive +/// one. An unrecognised Anthropic model falls back to the strict (Opus) policy — +/// omitting sampling params can never cause a 400, sending them can. +fn anthropic_policy_for_model(model: &str) -> CapabilityPolicy { + let m = model.to_ascii_lowercase(); + if m.contains("sonnet") { + // claude-sonnet-4-6 (formatting): tolerates temperature; budget_tokens + // deprecated (not a hard 400). + CapabilityPolicy { + allow_sampling_params: true, + budget_tokens: BudgetTokensPolicy::Deprecated, + adaptive_thinking: true, + effort: true, + refusal_stop_reason: true, + previous_response_id: false, + supports_vision: true, + } + } else { + // claude-opus-4-8 (assistive) and unknown Anthropic models: strict. + // Sampling params → 400; manual budget_tokens → 400. + CapabilityPolicy { + allow_sampling_params: false, + budget_tokens: BudgetTokensPolicy::Hard400, + adaptive_thinking: true, + effort: true, + refusal_stop_reason: true, + previous_response_id: false, + supports_vision: true, + } + } +} + +/// The permissive OpenAI Responses policy. Kept in one place so it is obvious +/// that the OpenAI request path is unchanged by this layer. +const fn openai_policy() -> CapabilityPolicy { + CapabilityPolicy { + allow_sampling_params: true, + budget_tokens: BudgetTokensPolicy::NotApplicable, + adaptive_thinking: false, + effort: true, + refusal_stop_reason: false, + previous_response_id: true, + supports_vision: true, + } +} + +/// Whether the given `(provider, model)` accepts image (vision) input. Thin +/// accessor over [`capability_policy`] for send paths that only need the vision +/// gate (e.g. the composer-attachment bridge). Keeps the vision decision in the +/// capability layer rather than duplicated at the FFI boundary. +pub fn provider_supports_vision(provider: ProviderKind, model: &str) -> bool { + capability_policy(provider, model).supports_vision +} + +/// Resolve the capability policy for a `(provider, model)` pair. +/// +/// This is the per-model matrix from CORRECTION.md. OpenAI ignores `model` (its +/// policy is uniform and permissive); Anthropic branches on model family. +pub fn capability_policy(provider: ProviderKind, model: &str) -> CapabilityPolicy { + match provider { + ProviderKind::OpenAiResponses => openai_policy(), + ProviderKind::AnthropicMessages => anthropic_policy_for_model(model), + } +} + +/// Which formatting/assistive lane a provider value is being resolved for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LlmMode { + /// Fast/cheap formatting path (`LLM_FORMATTING_PROVIDER`). + Formatting, + /// Assistive / agent path (`LLM_ASSISTIVE_PROVIDER`). + Assistive, +} + +impl LlmMode { + /// The env var carrying the provider identity for this lane. + pub const fn provider_env_key(self) -> &'static str { + match self { + LlmMode::Formatting => "LLM_FORMATTING_PROVIDER", + LlmMode::Assistive => "LLM_ASSISTIVE_PROVIDER", + } + } + + /// The env var carrying the auth mode for this lane. + pub const fn auth_mode_env_key(self) -> &'static str { + match self { + LlmMode::Formatting => "LLM_FORMATTING_AUTH_MODE", + LlmMode::Assistive => "LLM_ASSISTIVE_AUTH_MODE", + } + } +} + +/// Resolve the configured provider for a lane from process env, defaulting to +/// OpenAI. +/// +/// An unset/empty value ⇒ [`ProviderKind::OpenAiResponses`]. An *invalid* +/// value is logged and also falls back to OpenAI — misconfiguration must never +/// silently route to an unintended provider, and OpenAI is the protected +/// default. Callers wanting strict validation should use [`ProviderKind::from_str`] +/// directly. +pub fn resolve_provider(mode: LlmMode) -> ProviderKind { + let key = mode.provider_env_key(); + match std::env::var(key) { + Ok(raw) if !raw.trim().is_empty() => match ProviderKind::from_str(&raw) { + Ok(kind) => kind, + Err(e) => { + warn!("{key}: {e}; falling back to {}", ProviderKind::default()); + ProviderKind::default() + } + }, + _ => ProviderKind::default(), + } +} + +/// Resolve the configured auth mode for a lane from process env, defaulting to +/// API keys. Invalid values are logged and fall back to `ApiKey`, so account +/// auth can never become active by typo. +pub fn resolve_auth_mode(mode: LlmMode) -> AuthMode { + let key = mode.auth_mode_env_key(); + match std::env::var(key) { + Ok(raw) if !raw.trim().is_empty() => match AuthMode::from_str(&raw) { + Ok(kind) => kind, + Err(e) => { + warn!("{key}: {e}; falling back to {}", AuthMode::default()); + AuthMode::default() + } + }, + _ => AuthMode::default(), + } +} + +/// Optional Authorization header for the provider-account path. +/// +/// Request builders are intentionally unchanged in this wave. Future callers can +/// ask this helper for a bearer header when `AuthMode=ProviderAccount`; the +/// default `ApiKey` mode returns `Ok(None)` and preserves the current API-key +/// behavior exactly. +pub async fn provider_account_authorization_header( + provider: ProviderKind, + mode: LlmMode, +) -> Result, account_auth::AccountAuthError> { + if resolve_auth_mode(mode) != AuthMode::ProviderAccount { + return Ok(None); + } + account_auth::authorization_header(provider).await.map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + // ---- identity defaults ---- + + #[test] + fn default_provider_is_openai() { + assert_eq!(ProviderKind::default(), ProviderKind::OpenAiResponses); + assert_eq!(ProviderKind::default().as_str(), "openai-responses"); + assert_eq!(AuthMode::default(), AuthMode::ApiKey); + } + + #[test] + fn as_str_roundtrips_through_from_str() { + for kind in [ + ProviderKind::OpenAiResponses, + ProviderKind::AnthropicMessages, + ] { + assert_eq!(ProviderKind::from_str(kind.as_str()), Ok(kind)); + } + } + + // ---- provider parsing ---- + + #[test] + fn parses_canonical_and_alias_spellings() { + assert_eq!( + ProviderKind::from_str("openai-responses"), + Ok(ProviderKind::OpenAiResponses) + ); + assert_eq!( + ProviderKind::from_str(" OpenAI "), + Ok(ProviderKind::OpenAiResponses) + ); + assert_eq!( + ProviderKind::from_str("anthropic-messages"), + Ok(ProviderKind::AnthropicMessages) + ); + assert_eq!( + ProviderKind::from_str("ANTHROPIC"), + Ok(ProviderKind::AnthropicMessages) + ); + } + + #[test] + fn invalid_provider_is_an_error() { + let err = ProviderKind::from_str("gemini").unwrap_err(); + assert_eq!(err, ParseProviderError("gemini".to_string())); + assert!(err.to_string().contains("gemini")); + } + + #[test] + fn parses_auth_mode_spellings() { + assert_eq!(AuthMode::from_str("api-key"), Ok(AuthMode::ApiKey)); + assert_eq!( + AuthMode::from_str("provider_account"), + Ok(AuthMode::ProviderAccount) + ); + assert!(AuthMode::from_str("oauth-ish").is_err()); + } + + // ---- per-model capability policy ---- + + #[test] + fn openai_policy_is_permissive_and_unchanged() { + // The OpenAI request path must not be perturbed: sampling allowed, + // previous_response_id chaining kept, no Anthropic-only concepts. + let p = capability_policy(ProviderKind::OpenAiResponses, "gpt-5.5"); + assert!(p.allow_sampling_params); + assert!(p.previous_response_id); + assert!(!p.refusal_stop_reason); + assert!(!p.adaptive_thinking); + assert_eq!(p.budget_tokens, BudgetTokensPolicy::NotApplicable); + assert!( + p.supports_vision, + "OpenAI Responses models accept image input by default" + ); + // Model must not matter for OpenAI. + assert_eq!( + capability_policy(ProviderKind::OpenAiResponses, "gpt-4.1"), + p + ); + } + + #[test] + fn opus_4_8_rejects_sampling_and_hard_400s_budget_tokens() { + let p = capability_policy(ProviderKind::AnthropicMessages, "claude-opus-4-8"); + assert!( + !p.allow_sampling_params, + "Opus-4.8 rejects temperature/top_p/top_k" + ); + assert_eq!(p.budget_tokens, BudgetTokensPolicy::Hard400); + assert!(p.adaptive_thinking); + assert!(p.effort); + assert!(p.refusal_stop_reason); + assert!(!p.previous_response_id); + // A non-default temperature is stripped for Opus. + assert_eq!(p.sanitize_temperature(Some(0.7)), None); + } + + #[test] + fn sonnet_4_6_tolerates_temperature_and_deprecates_budget_tokens() { + let p = capability_policy(ProviderKind::AnthropicMessages, "claude-sonnet-4-6"); + assert!(p.allow_sampling_params, "Sonnet-4.6 tolerates temperature"); + assert_eq!(p.budget_tokens, BudgetTokensPolicy::Deprecated); + assert!(p.adaptive_thinking); + assert!(p.effort); + assert!(p.refusal_stop_reason); + assert!(!p.previous_response_id); + // Temperature survives for Sonnet. + assert_eq!(p.sanitize_temperature(Some(0.3)), Some(0.3)); + } + + #[test] + fn unknown_anthropic_model_falls_back_to_strict_policy() { + // Safety: omitting sampling can't 400; sending it can. Unknown ⇒ strict. + let p = capability_policy(ProviderKind::AnthropicMessages, "claude-future-9"); + assert!(!p.allow_sampling_params); + assert_eq!(p.budget_tokens, BudgetTokensPolicy::Hard400); + } + + // ---- provider identity (display / key account) ---- + + #[test] + fn every_provider_has_display_name_and_key_account() { + for kind in ALL_PROVIDERS { + assert!(!kind.display_name().is_empty()); + assert!(!kind.api_key_env_key().is_empty()); + } + } + + #[test] + fn anthropic_key_account_is_distinct_from_openai() { + assert_eq!( + ProviderKind::OpenAiResponses.api_key_env_key(), + "LLM_ASSISTIVE_API_KEY" + ); + assert_eq!( + ProviderKind::AnthropicMessages.api_key_env_key(), + "LLM_ANTHROPIC_API_KEY" + ); + } + + #[test] + fn default_and_unknown_models_are_vision_capable() { + assert!(provider_supports_vision( + ProviderKind::OpenAiResponses, + "gpt-5.5" + )); + assert!(provider_supports_vision( + ProviderKind::AnthropicMessages, + "claude-opus-4-8" + )); + assert!(provider_supports_vision( + ProviderKind::AnthropicMessages, + "claude-future-9" + )); + } + + // ---- env resolution (serialized: mutates process env) ---- + + #[test] + #[serial] + fn resolve_provider_defaults_to_openai_when_unset() { + let prev_f = std::env::var("LLM_FORMATTING_PROVIDER").ok(); + let prev_a = std::env::var("LLM_ASSISTIVE_PROVIDER").ok(); + unsafe { + std::env::remove_var("LLM_FORMATTING_PROVIDER"); + std::env::remove_var("LLM_ASSISTIVE_PROVIDER"); + } + + assert_eq!( + resolve_provider(LlmMode::Formatting), + ProviderKind::OpenAiResponses + ); + assert_eq!( + resolve_provider(LlmMode::Assistive), + ProviderKind::OpenAiResponses + ); + + restore("LLM_FORMATTING_PROVIDER", prev_f); + restore("LLM_ASSISTIVE_PROVIDER", prev_a); + } + + #[test] + #[serial] + fn resolve_provider_reads_mode_specific_values() { + let prev_f = std::env::var("LLM_FORMATTING_PROVIDER").ok(); + let prev_a = std::env::var("LLM_ASSISTIVE_PROVIDER").ok(); + unsafe { + std::env::set_var("LLM_FORMATTING_PROVIDER", "openai-responses"); + std::env::set_var("LLM_ASSISTIVE_PROVIDER", "anthropic-messages"); + } + + assert_eq!( + resolve_provider(LlmMode::Formatting), + ProviderKind::OpenAiResponses + ); + assert_eq!( + resolve_provider(LlmMode::Assistive), + ProviderKind::AnthropicMessages + ); + + restore("LLM_FORMATTING_PROVIDER", prev_f); + restore("LLM_ASSISTIVE_PROVIDER", prev_a); + } + + #[test] + #[serial] + fn resolve_provider_falls_back_to_openai_on_invalid() { + let prev = std::env::var("LLM_ASSISTIVE_PROVIDER").ok(); + unsafe { std::env::set_var("LLM_ASSISTIVE_PROVIDER", "not-a-provider") }; + + assert_eq!( + resolve_provider(LlmMode::Assistive), + ProviderKind::OpenAiResponses + ); + + restore("LLM_ASSISTIVE_PROVIDER", prev); + } + + #[test] + #[serial] + fn resolve_auth_mode_defaults_to_api_key_when_unset() { + let prev_f = std::env::var("LLM_FORMATTING_AUTH_MODE").ok(); + let prev_a = std::env::var("LLM_ASSISTIVE_AUTH_MODE").ok(); + unsafe { + std::env::remove_var("LLM_FORMATTING_AUTH_MODE"); + std::env::remove_var("LLM_ASSISTIVE_AUTH_MODE"); + } + + assert_eq!(resolve_auth_mode(LlmMode::Formatting), AuthMode::ApiKey); + assert_eq!(resolve_auth_mode(LlmMode::Assistive), AuthMode::ApiKey); + + restore("LLM_FORMATTING_AUTH_MODE", prev_f); + restore("LLM_ASSISTIVE_AUTH_MODE", prev_a); + } + + #[test] + #[serial] + fn resolve_auth_mode_reads_mode_specific_values_and_falls_back_on_invalid() { + let prev_f = std::env::var("LLM_FORMATTING_AUTH_MODE").ok(); + let prev_a = std::env::var("LLM_ASSISTIVE_AUTH_MODE").ok(); + unsafe { + std::env::set_var("LLM_FORMATTING_AUTH_MODE", "provider-account"); + std::env::set_var("LLM_ASSISTIVE_AUTH_MODE", "bad-mode"); + } + + assert_eq!( + resolve_auth_mode(LlmMode::Formatting), + AuthMode::ProviderAccount + ); + assert_eq!(resolve_auth_mode(LlmMode::Assistive), AuthMode::ApiKey); + + restore("LLM_FORMATTING_AUTH_MODE", prev_f); + restore("LLM_ASSISTIVE_AUTH_MODE", prev_a); + } + + #[tokio::test] + #[serial] + async fn api_key_mode_returns_no_provider_account_header() { + let prev = std::env::var("LLM_ASSISTIVE_AUTH_MODE").ok(); + unsafe { std::env::remove_var("LLM_ASSISTIVE_AUTH_MODE") }; + + let header = provider_account_authorization_header( + ProviderKind::OpenAiResponses, + LlmMode::Assistive, + ) + .await + .unwrap(); + + assert_eq!(header, None); + restore("LLM_ASSISTIVE_AUTH_MODE", prev); + } + + #[tokio::test] + #[serial] + async fn provider_account_mode_refreshes_expired_token_and_returns_bearer() { + use crate::llm::account_auth::{ + AccountTokens, OPENAI_ACCOUNT_TOKENS_ACCOUNT, OPENAI_CLIENT_ID_ENV, OPENAI_ISSUER_ENV, + load_account_tokens, store_account_tokens, + }; + + let prev_mode = std::env::var("LLM_ASSISTIVE_AUTH_MODE").ok(); + let prev_client = std::env::var(OPENAI_CLIENT_ID_ENV).ok(); + let prev_issuer = std::env::var(OPENAI_ISSUER_ENV).ok(); + let prev_disable = std::env::var("CODESCRIBE_DISABLE_KEYCHAIN").ok(); + let prev_tokens = std::env::var(OPENAI_ACCOUNT_TOKENS_ACCOUNT).ok(); + let mut server = mockito::Server::new_async().await; + let _refresh = server + .mock("POST", "/oauth/token") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("grant_type".to_string(), "refresh_token".to_string()), + mockito::Matcher::UrlEncoded("client_id".to_string(), "client".to_string()), + mockito::Matcher::UrlEncoded( + "refresh_token".to_string(), + "old-refresh".to_string(), + ), + ])) + .with_status(200) + .with_body( + r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}"#, + ) + .expect(1) + .create_async() + .await; + + unsafe { + std::env::set_var("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + std::env::set_var("LLM_ASSISTIVE_AUTH_MODE", "provider-account"); + std::env::set_var(OPENAI_CLIENT_ID_ENV, "client"); + std::env::set_var(OPENAI_ISSUER_ENV, server.url()); + } + let expired = AccountTokens { + provider: ProviderKind::OpenAiResponses.as_str().to_string(), + access_token: "old-access".to_string(), + refresh_token: Some("old-refresh".to_string()), + id_token: None, + token_type: "Bearer".to_string(), + expires_at_unix: Some(0), + }; + store_account_tokens(ProviderKind::OpenAiResponses, &expired).unwrap(); + + let header = provider_account_authorization_header( + ProviderKind::OpenAiResponses, + LlmMode::Assistive, + ) + .await + .unwrap(); + + assert_eq!(header.as_deref(), Some("Bearer new-access")); + let stored = load_account_tokens(ProviderKind::OpenAiResponses).unwrap(); + assert_eq!(stored.access_token, "new-access"); + assert_eq!(stored.refresh_token.as_deref(), Some("new-refresh")); + + restore("LLM_ASSISTIVE_AUTH_MODE", prev_mode); + restore(OPENAI_CLIENT_ID_ENV, prev_client); + restore(OPENAI_ISSUER_ENV, prev_issuer); + restore("CODESCRIBE_DISABLE_KEYCHAIN", prev_disable); + restore(OPENAI_ACCOUNT_TOKENS_ACCOUNT, prev_tokens); + } + + fn restore(key: &str, prev: Option) { + match prev { + Some(v) => unsafe { std::env::set_var(key, v) }, + None => unsafe { std::env::remove_var(key) }, + } + } +} diff --git a/core/mcp/client.rs b/core/mcp/client.rs index f28edb81..71cdfdbe 100644 --- a/core/mcp/client.rs +++ b/core/mcp/client.rs @@ -8,16 +8,22 @@ use anyhow::{Context, Result, bail}; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use serde::Deserialize; use serde_json::{Value, json}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; -use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; use tokio::time::timeout; -use tracing::debug; +use tracing::{debug, warn}; use crate::agent::ToolResultContent; const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(500); +/// Upper bound on how long a failure-path stderr drain may block. A crashed +/// server exits and yields EOF well within this; a still-alive server that +/// holds stderr open is capped here so the diagnostic never hangs the caller. +const STDERR_DRAIN_TIMEOUT: Duration = Duration::from_millis(200); +/// Max characters of collapsed stderr carried into a WARN line. +const STDERR_LOG_MAX_CHARS: usize = 200; const FALLBACK_PATHS: &[&str] = &[ "/opt/homebrew/bin", "/usr/local/bin", @@ -81,6 +87,46 @@ pub struct McpTool { pub input_schema: Value, } +/// Server identity advertised in the `initialize` handshake result. All fields +/// are optional: a server may omit `serverInfo` or `protocolVersion`, and the +/// probe still succeeds on the strength of a valid `tools/list`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct McpHandshake { + #[serde(rename = "protocolVersion", default)] + pub protocol_version: Option, + #[serde(rename = "serverInfo", default)] + pub server_info: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct McpServerInfo { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub version: Option, +} + +impl McpHandshake { + /// Server-advertised name, if any (e.g. `prview.mcp.v1`). + pub fn server_name(&self) -> Option { + self.server_info.as_ref().and_then(|info| info.name.clone()) + } + + /// Server-advertised version, if any (e.g. `0.4.0`). + pub fn server_version(&self) -> Option { + self.server_info + .as_ref() + .and_then(|info| info.version.clone()) + } +} + +/// Full result of a health probe: the handshake identity plus the live tools. +#[derive(Debug, Clone, Default)] +pub struct McpProbe { + pub handshake: McpHandshake, + pub tools: Vec, +} + pub fn default_mcp_config_path() -> Result { let home = std::env::var("HOME").context("HOME environment variable is not set")?; Ok(PathBuf::from(home).join(".codescribe").join("mcp.json")) @@ -104,22 +150,54 @@ impl McpClient { } pub async fn list_tools(&self) -> Result> { - let mut connection = StdioConnection::spawn(&self.config, self.timeout).await?; + Ok(self.probe().await?.tools) + } + + /// Spawn + `initialize` + `tools/list` in one exchange, returning BOTH the + /// server's advertised identity (name / version / protocol from the + /// `initialize` handshake) and its live tool list. `list_tools` is a thin + /// wrapper that keeps only the tools; the Settings health probe uses the full + /// result to surface real handshake data next to a server. + pub async fn probe(&self) -> Result { + let mut connection = match StdioConnection::spawn(&self.config, self.timeout).await { + Ok(connection) => connection, + Err(error) => { + warn!( + "MCP server '{}' failed to spawn: {error}", + self.config.command + ); + return Err(error); + } + }; let result = async { - connection.initialize().await?; + let handshake = connection.initialize().await?; let response = connection.request("tools/list", json!({})).await?; - parse_tools_list(response) + let tools = parse_tools_list(response)?; + Ok(McpProbe { handshake, tools }) } .await; + if let Err(error) = &result { + let stderr = connection.drain_stderr().await; + warn_handshake_failure(&self.config.command, "tools/list", error, &stderr); + } let shutdown = connection.shutdown().await; if let Err(error) = shutdown { - debug!("MCP shutdown after tools/list failed: {error}"); + debug!("MCP shutdown after probe failed: {error}"); } result } pub async fn call_tool(&self, name: &str, arguments: Value) -> Result> { - let mut connection = StdioConnection::spawn(&self.config, self.timeout).await?; + let mut connection = match StdioConnection::spawn(&self.config, self.timeout).await { + Ok(connection) => connection, + Err(error) => { + warn!( + "MCP server '{}' failed to spawn for tool '{name}': {error}", + self.config.command + ); + return Err(error); + } + }; let result = async { connection.initialize().await?; let response = connection @@ -134,6 +212,10 @@ impl McpClient { parse_tool_call_result(response) } .await; + if let Err(error) = &result { + let stderr = connection.drain_stderr().await; + warn_handshake_failure(&self.config.command, "tools/call", error, &stderr); + } let shutdown = connection.shutdown().await; if let Err(error) = shutdown { debug!("MCP shutdown after tools/call failed: {error}"); @@ -142,10 +224,24 @@ impl McpClient { } } +/// Emit a WARN for a spawn-survived-but-handshake/call-failed MCP exchange, +/// enriched with the process stderr (already collapsed and truncated) when the +/// server wrote anything before failing. +fn warn_handshake_failure(command: &str, phase: &str, error: &anyhow::Error, stderr: &str) { + if stderr.is_empty() { + warn!("MCP server '{command}' {phase} failed: {error}"); + } else { + warn!("MCP server '{command}' {phase} failed: {error} — stderr: {stderr}"); + } +} + struct StdioConnection { child: Child, stdin: ChildStdin, stdout: Lines>, + /// Piped stderr, read only on the failure path (see `drain_stderr`). Taken + /// out once drained so shutdown does not touch it again. + stderr: Option, next_id: u64, response_timeout: Duration, } @@ -161,7 +257,11 @@ impl StdioConnection { .env("PATH", &effective_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()); + // Pipe (not null) so a spawn-survived-but-handshake-failed server's + // stderr can be surfaced in a WARN. One-shot per call, drained on the + // failure path and closed at shutdown, so it cannot back-pressure a + // healthy call. + .stderr(Stdio::piped()); let mut child = command.spawn().map_err(|err| { // Give the most common failure a concrete, actionable reason instead @@ -186,31 +286,52 @@ impl StdioConnection { .stdout .take() .context("MCP server stdout was not piped")?; + let stderr = child.stderr.take(); Ok(Self { child, stdin, stdout: BufReader::new(stdout).lines(), + stderr, next_id: 1, response_timeout, }) } - async fn initialize(&mut self) -> Result<()> { - self.request( - "initialize", - json!({ - "protocolVersion": MCP_PROTOCOL_VERSION, - "capabilities": {}, - "clientInfo": { - "name": "codescribe", - "version": env!("CARGO_PKG_VERSION"), - }, - }), - ) - .await?; + /// Best-effort read of whatever the server wrote to stderr, collapsed to a + /// single line and truncated for logging. Bounded by `STDERR_DRAIN_TIMEOUT` + /// so a still-running child that holds stderr open cannot block the caller. + async fn drain_stderr(&mut self) -> String { + let Some(stderr) = self.stderr.take() else { + return String::new(); + }; + let mut reader = BufReader::new(stderr); + let mut buffer = Vec::new(); + // On timeout the read future is dropped; bytes already read stay in + // `buffer`, which is enough for a diagnostic snippet. + let _ = timeout(STDERR_DRAIN_TIMEOUT, reader.read_to_end(&mut buffer)).await; + truncate_stderr(&String::from_utf8_lossy(&buffer)) + } + + async fn initialize(&mut self) -> Result { + let result = self + .request( + "initialize", + json!({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { + "name": "codescribe", + "version": env!("CARGO_PKG_VERSION"), + }, + }), + ) + .await?; self.notification("notifications/initialized", json!({})) - .await + .await?; + // A well-formed server returns `serverInfo` + `protocolVersion`; a slightly + // off shape must not fail the whole probe, so parse leniently to defaults. + Ok(serde_json::from_value(result).unwrap_or_default()) } async fn request(&mut self, method: &str, params: Value) -> Result { @@ -429,6 +550,20 @@ fn default_input_schema() -> Value { json!({ "type": "object" }) } +/// Collapse multi-line/whitespace-heavy stderr into one truncated log-friendly +/// line. +fn truncate_stderr(raw: &str) -> String { + let collapsed = raw.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= STDERR_LOG_MAX_CHARS { + return collapsed; + } + collapsed + .chars() + .take(STDERR_LOG_MAX_CHARS.saturating_sub(3)) + .collect::() + + "..." +} + #[cfg(test)] mod tests { use std::fs; @@ -484,6 +619,24 @@ mod tests { ); } + #[tokio::test] + async fn mcp_probe_captures_handshake_identity() { + let client = McpClient::new(mock_server("")); + + let probe = client + .probe() + .await + .expect("mock MCP server should complete the handshake"); + + assert_eq!(probe.tools.len(), 1); + assert_eq!(probe.handshake.server_name().as_deref(), Some("mock-mcp")); + assert_eq!(probe.handshake.server_version().as_deref(), Some("0.1.0")); + assert_eq!( + probe.handshake.protocol_version.as_deref(), + Some("2025-06-18") + ); + } + #[tokio::test] async fn mcp_calls_tool_over_stdio() { let client = McpClient::new(mock_server("")); diff --git a/core/mcp/config_store.rs b/core/mcp/config_store.rs new file mode 100644 index 00000000..8a32779b --- /dev/null +++ b/core/mcp/config_store.rs @@ -0,0 +1,556 @@ +//! MCP config store — CRUD over `~/.codescribe/mcp.json` for the Settings +//! management UI. +//! +//! Two hard guarantees so a hand-edited config is never silently destroyed: +//! 1. **Unknown fields are preserved.** Mutations operate on the raw JSON tree +//! (`serde_json::Value`) and only touch the specific server entry's +//! `command` / `args` / `enabled`. Per-server `env`, `timeout_seconds`, any +//! custom keys, and unrelated top-level keys survive untouched. +//! 2. **Writes are atomic.** We serialize to a sibling temp file, `fsync`, then +//! `rename` over the target (atomic on the same filesystem) so a crash mid +//! write can never leave a truncated `mcp.json`. +//! +//! A present-but-invalid `mcp.json` makes every mutation error out *before* +//! writing — we refuse to overwrite JSON we could not parse. +//! +//! This module also hosts the one-shot "test this server" runner (spawn + +//! `initialize` + `tools/list`) used by the Settings Test button. + +use std::io::Write; +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use serde_json::{Map, Value}; + +use crate::mcp::{McpClient, McpConfigFile, McpServerConfig, default_mcp_config_path}; + +const SERVERS_KEY: &str = "mcpServers"; + +/// A server row for the management UI: identity + spawn shape + the NAMES of any +/// env vars (never their values — secrets stay on disk). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpServerSummary { + pub name: String, + pub command: String, + pub args: Vec, + pub env_keys: Vec, + pub enabled: bool, +} + +/// Desired spawn shape when adding / updating a server through the UI. Env is not +/// edited here (secrets stay file-side); `update_server` preserves any existing +/// `env` block. +#[derive(Debug, Clone)] +pub struct McpServerSpec { + pub name: String, + pub command: String, + pub args: Vec, + pub enabled: bool, +} + +/// List every configured server (sorted by name) from the canonical config path +/// (`~/.codescribe/mcp.json`). A missing `mcp.json` is an empty list, never an +/// error — the UI shows an empty section with an add form. +pub fn list_servers() -> Result> { + list_servers_at(&default_mcp_config_path()?) +} + +fn list_servers_at(path: &Path) -> Result> { + let Some(config) = McpConfigFile::load_optional(path)? else { + return Ok(Vec::new()); + }; + let mut out: Vec = config + .servers + .into_iter() + .map(|(name, cfg)| { + let mut env_keys: Vec = cfg.env.into_keys().collect(); + env_keys.sort(); + McpServerSummary { + name, + command: cfg.command, + args: cfg.args, + env_keys, + enabled: cfg.enabled.unwrap_or(true), + } + }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +/// Add a new server. Errors if the name already exists so a real edit is never +/// silently overwritten by an "add". Creates `mcp.json` (and its parent dir) when +/// absent. +pub fn add_server(spec: &McpServerSpec) -> Result<()> { + add_server_at(&default_mcp_config_path()?, spec) +} + +fn add_server_at(path: &Path, spec: &McpServerSpec) -> Result<()> { + validate_name(&spec.name)?; + validate_command(&spec.command)?; + + let mut root = load_value(path)?; + { + let servers = servers_map_mut(&mut root)?; + if servers.contains_key(&spec.name) { + bail!("MCP server \"{}\" already exists", spec.name); + } + servers.insert(spec.name.clone(), server_object(spec)); + } + write_atomic(path, &root) +} + +/// Update an existing server's spawn shape in place, PRESERVING every other field +/// of that entry (`env`, `timeout_seconds`, custom keys) and every unrelated +/// top-level key. Errors if the named server does not exist. +pub fn update_server(name: &str, spec: &McpServerSpec) -> Result<()> { + update_server_at(&default_mcp_config_path()?, name, spec) +} + +fn update_server_at(path: &Path, name: &str, spec: &McpServerSpec) -> Result<()> { + validate_command(&spec.command)?; + + let mut root = load_value(path)?; + { + let servers = servers_map_mut(&mut root)?; + let entry = servers + .get_mut(name) + .with_context(|| format!("MCP server \"{name}\" not found"))?; + let obj = entry + .as_object_mut() + .with_context(|| format!("MCP server \"{name}\" is not a JSON object"))?; + obj.insert("command".to_string(), Value::String(spec.command.clone())); + obj.insert("args".to_string(), args_value(&spec.args)); + obj.insert("enabled".to_string(), Value::Bool(spec.enabled)); + } + write_atomic(path, &root) +} + +/// Remove a server. Errors if it does not exist. +pub fn remove_server(name: &str) -> Result<()> { + remove_server_at(&default_mcp_config_path()?, name) +} + +fn remove_server_at(path: &Path, name: &str) -> Result<()> { + let mut root = load_value(path)?; + { + let servers = servers_map_mut(&mut root)?; + if servers.remove(name).is_none() { + bail!("MCP server \"{name}\" not found"); + } + } + write_atomic(path, &root) +} + +/// Health-probe result for one server: the identity it advertised in the +/// `initialize` handshake (name / version / protocol, each optional) plus its +/// live tool count. Surfaced next to the server in the Settings management list. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct McpProbeSummary { + pub server_name: Option, + pub server_version: Option, + pub protocol_version: Option, + pub tool_count: usize, +} + +/// Spawn the named server, handshake, and return its identity + live tool count. +/// Blocking: runs the async discovery on a dedicated thread + one-shot +/// current-thread runtime so it is safe to call from a synchronous FFI context +/// (and from within an already-running runtime). `timeout` bounds the whole +/// handshake. +pub fn probe_server_blocking(name: &str, timeout: Duration) -> Result { + probe_server_blocking_at(&default_mcp_config_path()?, name, timeout) +} + +fn probe_server_blocking_at(path: &Path, name: &str, timeout: Duration) -> Result { + let config = McpConfigFile::load(path)?; + let server = config + .servers + .get(name) + .with_context(|| format!("MCP server \"{name}\" not found"))? + .clone(); + run_probe_blocking(server, timeout) +} + +/// Tool-count-only convenience over [`probe_server_blocking`], preserved for the +/// simpler "how many tools" callers. +pub fn test_server_blocking(name: &str, timeout: Duration) -> Result { + Ok(probe_server_blocking(name, timeout)?.tool_count) +} + +#[cfg(test)] +fn test_server_blocking_at(path: &Path, name: &str, timeout: Duration) -> Result { + Ok(probe_server_blocking_at(path, name, timeout)?.tool_count) +} + +// --- internals ------------------------------------------------------------ + +fn run_probe_blocking(server: McpServerConfig, timeout: Duration) -> Result { + std::thread::spawn(move || -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("Failed to create MCP test runtime")?; + runtime.block_on(async move { + let client = McpClient::new(server).with_timeout(timeout); + let probe = client.probe().await?; + Ok(McpProbeSummary { + server_name: probe.handshake.server_name(), + server_version: probe.handshake.server_version(), + protocol_version: probe.handshake.protocol_version.clone(), + tool_count: probe.tools.len(), + }) + }) + }) + .join() + .map_err(|_| anyhow::anyhow!("MCP test thread panicked"))? +} + +fn server_object(spec: &McpServerSpec) -> Value { + let mut obj = Map::new(); + obj.insert("command".to_string(), Value::String(spec.command.clone())); + obj.insert("args".to_string(), args_value(&spec.args)); + obj.insert("enabled".to_string(), Value::Bool(spec.enabled)); + Value::Object(obj) +} + +fn args_value(args: &[String]) -> Value { + Value::Array(args.iter().cloned().map(Value::String).collect()) +} + +/// Load the raw config tree for mutation. A missing file yields a fresh +/// `{ "mcpServers": {} }`. A present-but-unparseable file is a hard error — we +/// refuse to clobber JSON we could not read. +fn load_value(path: &Path) -> Result { + if !path.exists() { + let mut root = Map::new(); + root.insert(SERVERS_KEY.to_string(), Value::Object(Map::new())); + return Ok(Value::Object(root)); + } + // Sanitize before reading: `canonicalize` resolves `..` segments and symlinks + // to a real absolute path, so the value handed to the filesystem is a + // validated path rather than an unchecked string. + let path = path + .canonicalize() + .with_context(|| format!("Failed to resolve MCP config {}", path.display()))?; + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read MCP config {}", path.display()))?; + serde_json::from_str(&content).with_context(|| { + format!( + "{} is not valid JSON — refusing to overwrite it", + path.display() + ) + }) +} + +/// Borrow the `mcpServers` object mutably, creating it if absent. Errors if the +/// root or `mcpServers` is present but not a JSON object. +fn servers_map_mut(root: &mut Value) -> Result<&mut Map> { + let obj = root + .as_object_mut() + .context("mcp.json root must be a JSON object")?; + let entry = obj + .entry(SERVERS_KEY.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + entry + .as_object_mut() + .context("\"mcpServers\" must be a JSON object") +} + +/// Atomic write: serialize pretty, write a sibling temp, fsync, rename over the +/// target. Best-effort cleanup of the temp on failure. +fn write_atomic(path: &Path, value: &Value) -> Result<()> { + let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); + if let Some(parent) = parent { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create config dir {}", parent.display()))?; + } + + let mut bytes = serde_json::to_vec_pretty(value).context("Failed to serialize mcp.json")?; + bytes.push(b'\n'); + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("mcp.json"); + let tmp_name = format!(".{file_name}.tmp.{}", std::process::id()); + let tmp_path = match parent { + Some(parent) => parent.join(tmp_name), + None => std::path::PathBuf::from(tmp_name), + }; + + let write_result = (|| -> Result<()> { + let mut file = std::fs::File::create(&tmp_path) + .with_context(|| format!("Failed to create temp {}", tmp_path.display()))?; + file.write_all(&bytes) + .context("Failed to write temp mcp.json")?; + file.sync_all().context("Failed to fsync temp mcp.json")?; + Ok(()) + })(); + + if let Err(error) = write_result { + let _ = std::fs::remove_file(&tmp_path); + return Err(error); + } + + std::fs::rename(&tmp_path, path).map_err(|error| { + let _ = std::fs::remove_file(&tmp_path); + anyhow::Error::new(error).context(format!("Failed to install {}", path.display())) + }) +} + +fn validate_name(name: &str) -> Result<()> { + let trimmed = name.trim(); + if trimmed.is_empty() { + bail!("MCP server name is empty"); + } + if trimmed != name { + bail!("MCP server name must not have surrounding whitespace"); + } + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') + { + bail!( + "MCP server name \"{name}\" contains unsupported characters (use letters, digits, '_' or '-')" + ); + } + Ok(()) +} + +fn validate_command(command: &str) -> Result<()> { + if command.trim().is_empty() { + bail!("MCP server command is empty"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use serde_json::json; + + use super::*; + + fn spec(name: &str, command: &str, args: &[&str]) -> McpServerSpec { + McpServerSpec { + name: name.to_string(), + command: command.to_string(), + args: args.iter().map(|a| a.to_string()).collect(), + enabled: true, + } + } + + fn read_raw(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("parse") + } + + #[test] + fn add_then_list_roundtrips() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); // does not exist yet + + assert!(list_servers_at(&path).expect("list empty").is_empty()); + + add_server_at(&path, &spec("loctree-mcp", "loctree-mcp", &["mcp"])).expect("add"); + add_server_at(&path, &spec("aicx-mcp", "aicx", &["mcp"])).expect("add"); + + let servers = list_servers_at(&path).expect("list"); + // Sorted by name. + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "aicx-mcp"); + assert_eq!(servers[1].name, "loctree-mcp"); + assert_eq!(servers[1].command, "loctree-mcp"); + assert_eq!(servers[1].args, vec!["mcp".to_string()]); + assert!(servers[1].enabled); + } + + #[test] + fn add_preserves_unknown_fields() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + // A hand-edited config with an unknown top-level key and a server carrying + // an env block + a custom field. + let original = json!({ + "mcpServers": { + "keep": { + "command": "keeper", + "env": { "SECRET_TOKEN": "s3cr3t" }, + "customField": 123 + } + }, + "topLevelExtra": true + }); + std::fs::write(&path, original.to_string()).expect("seed"); + + add_server_at(&path, &spec("added", "added-cmd", &["x"])).expect("add"); + + let raw = read_raw(&path); + // Unknown top-level key intact. + assert_eq!(raw["topLevelExtra"], json!(true)); + // Existing server's env + custom field untouched (secret preserved). + assert_eq!(raw["mcpServers"]["keep"]["customField"], json!(123)); + assert_eq!( + raw["mcpServers"]["keep"]["env"]["SECRET_TOKEN"], + json!("s3cr3t") + ); + // New server present. + assert_eq!(raw["mcpServers"]["added"]["command"], json!("added-cmd")); + } + + #[test] + fn update_preserves_env_and_custom_keys() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + let original = json!({ + "mcpServers": { + "srv": { + "command": "old", + "args": ["a"], + "enabled": true, + "env": { "TOKEN": "keepme" }, + "timeout_seconds": 42, + "weird": [1, 2, 3] + } + } + }); + std::fs::write(&path, original.to_string()).expect("seed"); + + let mut updated = spec("srv", "new-cmd", &["b", "c"]); + updated.enabled = false; + update_server_at(&path, "srv", &updated).expect("update"); + + let raw = read_raw(&path); + assert_eq!(raw["mcpServers"]["srv"]["command"], json!("new-cmd")); + assert_eq!(raw["mcpServers"]["srv"]["args"], json!(["b", "c"])); + assert_eq!(raw["mcpServers"]["srv"]["enabled"], json!(false)); + // Preserved untouched. + assert_eq!(raw["mcpServers"]["srv"]["env"]["TOKEN"], json!("keepme")); + assert_eq!(raw["mcpServers"]["srv"]["timeout_seconds"], json!(42)); + assert_eq!(raw["mcpServers"]["srv"]["weird"], json!([1, 2, 3])); + } + + #[test] + fn remove_deletes_only_the_named_server() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + add_server_at(&path, &spec("a", "a", &[])).expect("add"); + add_server_at(&path, &spec("b", "b", &[])).expect("add"); + + remove_server_at(&path, "a").expect("remove"); + let names: Vec = list_servers_at(&path) + .expect("list") + .into_iter() + .map(|s| s.name) + .collect(); + assert_eq!(names, vec!["b".to_string()]); + + // Removing a missing server errors. + assert!(remove_server_at(&path, "ghost").is_err()); + } + + #[test] + fn invalid_json_is_never_clobbered() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + std::fs::write(&path, "{ not valid json").expect("seed garbage"); + + // Every mutation refuses to run and leaves the file byte-for-byte intact. + assert!(add_server_at(&path, &spec("x", "x", &[])).is_err()); + assert!(remove_server_at(&path, "x").is_err()); + assert_eq!( + std::fs::read_to_string(&path).expect("still there"), + "{ not valid json" + ); + } + + #[test] + fn rejects_invalid_names_and_empty_command() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + + assert!(add_server_at(&path, &spec("bad name", "cmd", &[])).is_err()); + assert!(add_server_at(&path, &spec("", "cmd", &[])).is_err()); + assert!(add_server_at(&path, &spec("ok", " ", &[])).is_err()); + // Nothing was written by the rejected adds. + assert!(!path.exists()); + } + + #[test] + fn duplicate_add_is_rejected() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + add_server_at(&path, &spec("dup", "cmd", &[])).expect("add"); + assert!(add_server_at(&path, &spec("dup", "other", &[])).is_err()); + // Original command survives the rejected duplicate. + assert_eq!( + list_servers_at(&path).expect("list")[0].command, + "cmd".to_string() + ); + } + + #[test] + fn written_config_is_valid_and_reparses_through_typed_loader() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + add_server_at(&path, &spec("srv", "cmd", &["one", "two"])).expect("add"); + // Atomic write must leave a clean file the typed loader accepts. + let config = McpConfigFile::load(&path).expect("typed reload"); + let server = config.servers.get("srv").expect("server present"); + assert_eq!(server.command, "cmd"); + assert_eq!(server.args, vec!["one".to_string(), "two".to_string()]); + } + + #[test] + fn test_server_blocking_lists_mock_tools() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + let script = repo_root() + .join("tests") + .join("fixtures") + .join("mock_mcp.py"); + let mut server = spec("mock", "python3", &[&script.display().to_string()]); + server.enabled = true; + add_server_at(&path, &server).expect("add"); + + let count = test_server_blocking_at(&path, "mock", Duration::from_secs(5)).expect("test"); + assert_eq!(count, 1, "mock server exposes one tool"); + } + + #[test] + fn probe_server_blocking_reports_handshake_identity() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + let script = repo_root() + .join("tests") + .join("fixtures") + .join("mock_mcp.py"); + let mut server = spec("mock", "python3", &[&script.display().to_string()]); + server.enabled = true; + add_server_at(&path, &server).expect("add"); + + let summary = + probe_server_blocking_at(&path, "mock", Duration::from_secs(5)).expect("probe"); + assert_eq!(summary.tool_count, 1); + assert_eq!(summary.server_name.as_deref(), Some("mock-mcp")); + assert_eq!(summary.server_version.as_deref(), Some("0.1.0")); + assert_eq!(summary.protocol_version.as_deref(), Some("2025-06-18")); + } + + #[test] + fn test_server_blocking_errors_for_missing_server() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("mcp.json"); + add_server_at(&path, &spec("present", "python3", &[])).expect("add"); + assert!(test_server_blocking_at(&path, "absent", Duration::from_secs(1)).is_err()); + } + + fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("core manifest has a repo parent") + .to_path_buf() + } +} diff --git a/core/mcp/mod.rs b/core/mcp/mod.rs index 775dee9c..13d8faf9 100644 --- a/core/mcp/mod.rs +++ b/core/mcp/mod.rs @@ -1,3 +1,11 @@ pub mod client; +pub mod config_store; -pub use client::{McpClient, McpConfigFile, McpServerConfig, McpTool, default_mcp_config_path}; +pub use client::{ + McpClient, McpConfigFile, McpHandshake, McpProbe, McpServerConfig, McpServerInfo, McpTool, + default_mcp_config_path, +}; +pub use config_store::{ + McpProbeSummary, McpServerSpec, McpServerSummary, add_server, list_servers, + probe_server_blocking, remove_server, test_server_blocking, update_server, +}; diff --git a/core/memory.rs b/core/memory.rs new file mode 100644 index 00000000..b4e9b1a9 --- /dev/null +++ b/core/memory.rs @@ -0,0 +1,51 @@ +//! Process memory hygiene helpers. +//! +//! After a recording's transcription completes, large transient buffers (audio, +//! mel spectrograms, candle/ORT scratch) are freed — but the system allocator +//! keeps the dirty pages cached for reuse instead of returning them to the OS. +//! Measured on a fresh run: ~700 MB of freed-but-retained `MALLOC_LARGE` after +//! only three recordings (37 MB actually live), inflating `phys_footprint` +//! toward the multi-GB figures users see in Activity Monitor. This module asks +//! the allocator to give that memory back at natural quiescent points. + +/// Ask the system allocator to return freed-but-retained pages to the OS. +/// +/// On macOS this calls `malloc_zone_pressure_relief(NULL, 0)`, which madvises +/// free pages in every malloc zone back to the kernel. It is safe to call at +/// any time; the cost is a scan of free regions, so call it at natural +/// quiescent points (e.g. once after each recording finishes), never in a hot +/// loop. No-op on non-macOS targets. +pub fn release_freed_heap() { + #[cfg(target_os = "macos")] + { + // SAFETY: FFI to a stable libmalloc entry point. A NULL zone means + // "all zones" and a goal of 0 means "release as much as possible". + // Returns the number of bytes handed back to the OS. + let released = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) }; + tracing::debug!("release_freed_heap: allocator returned {released} bytes to the OS"); + } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + /// `size_t malloc_zone_pressure_relief(malloc_zone_t *zone, size_t goal);` + /// from ``. Not exposed by the `libc` crate as a function, + /// so we declare it directly. + fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn release_freed_heap_is_callable_and_safe() { + // Allocate and drop a large buffer so there is something to reclaim, + // then ensure the call does not panic (and is a no-op off macOS). + let big = vec![0u8; 32 * 1024 * 1024]; + let len = big.len(); + drop(big); + assert_eq!(len, 32 * 1024 * 1024); + release_freed_heap(); + } +} diff --git a/core/pipeline/contracts.rs b/core/pipeline/contracts.rs index ecf06743..16debb35 100644 --- a/core/pipeline/contracts.rs +++ b/core/pipeline/contracts.rs @@ -3,7 +3,7 @@ //! These types define the boundaries between pipeline stages: //! AudioChunk → SpeechUtterance → RawTranscript → PostprocessResult → TranscriptDelta → DeltaSink //! -//! Vibecrafted with AI Agents by VetCoders (c)2026 VetCoders +//! Vibecrafted with AI Agents by Vetcoders (c)2026 Vetcoders use serde::{Deserialize, Serialize}; @@ -550,7 +550,8 @@ pub trait DeltaSink: Send + Sync { /// and why, not how to display it. UI decides presentation. /// /// Data flow: AudioChunk → VAD → Whisper → PostProcess → EngineEvent -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum EngineEvent { /// VAD detected speech start. VadStart { speech_prob: f32, ts_ms: u64 }, @@ -625,6 +626,32 @@ pub enum EngineEvent { confidence_flags: Vec, }, + /// Replace a bounded char range inside an already-committed utterance. + /// + /// This is the ADR "never rewrite from zero" patch primitive for Layers 1, + /// 2, and 4. `start` and `end` are char offsets within `utterance_id`. + ReplaceRange { + utterance_id: u64, + start: usize, + end: usize, + text: String, + source: LayerSource, + }, + + /// Insert a visible annotation at a char position inside an utterance. + InsertAnnotation { + utterance_id: u64, + position: usize, + text: String, + kind: AnnotationKind, + }, + + /// Mark the session text buffer immutable after the final layered pass. + SessionFinalised { + session_id: String, + layer_summary: LayerSummary, + }, + /// Content dropped by engine intelligence. Drop { kind: DropKind, @@ -660,8 +687,97 @@ pub enum EngineEvent { Warning { code: String, message: String }, } +/// Layer that produced a bounded replacement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LayerSource { + TailPatch, + Lexicon, + InlineLlm, + FinalBam, +} + +/// Kind of annotation inserted into the visible transcript. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnnotationKind { + HesitationPause, + Paralingual { label: String }, +} + +/// Session-end summary for layered transcript mutation telemetry. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LayerSummary { + pub tail_patch_replacements: u64, + pub lexicon_replacements: u64, + pub inline_llm_replacements: u64, + pub final_bam_replacements: u64, + pub annotations_inserted: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TranscriptEventApplyError { + InvalidRange { + start: usize, + end: usize, + len: usize, + }, +} + +impl EngineEvent { + /// Apply ADR-visible bounded mutations to a committed utterance buffer. + /// + /// Append/backspace remain represented by `TranscriptDelta`; this helper is + /// only for range replacement and annotation insertion contracts. + pub fn apply_to_committed_text( + &self, + target: &mut String, + ) -> Result { + match self { + Self::ReplaceRange { + start, end, text, .. + } => { + let (start_byte, end_byte) = char_range_to_byte_range(target, *start, *end)?; + target.replace_range(start_byte..end_byte, text); + Ok(true) + } + Self::InsertAnnotation { position, text, .. } => { + let (position_byte, _) = char_range_to_byte_range(target, *position, *position)?; + target.insert_str(position_byte, text); + Ok(true) + } + _ => Ok(false), + } + } +} + +fn char_range_to_byte_range( + text: &str, + start: usize, + end: usize, +) -> Result<(usize, usize), TranscriptEventApplyError> { + let len = text.chars().count(); + if start > end || end > len { + return Err(TranscriptEventApplyError::InvalidRange { start, end, len }); + } + + let start_byte = char_offset_to_byte_index(text, start); + let end_byte = char_offset_to_byte_index(text, end); + Ok((start_byte, end_byte)) +} + +fn char_offset_to_byte_index(text: &str, offset: usize) -> usize { + if offset == text.chars().count() { + return text.len(); + } + text.char_indices() + .nth(offset) + .map(|(byte_index, _)| byte_index) + .unwrap_or(text.len()) +} + /// Why the engine dropped content. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DropKind { /// Whisper hallucination pattern detected (e.g. "thank you", "subscribe"). @@ -998,6 +1114,107 @@ mod tests { } } + #[test] + fn engine_event_replace_range_roundtrip_clone_and_apply() { + let event = EngineEvent::ReplaceRange { + utterance_id: 7, + start: 6, + end: 10, + text: "world".to_string(), + source: LayerSource::Lexicon, + }; + + let cloned = event.clone(); + assert_eq!(cloned, event); + + let json = serde_json::to_value(&event).expect("serialize replace_range"); + assert_eq!( + json.get("type").and_then(serde_json::Value::as_str), + Some("replace_range") + ); + assert_eq!( + json.get("source").and_then(serde_json::Value::as_str), + Some("lexicon") + ); + + let roundtrip: EngineEvent = + serde_json::from_value(json).expect("deserialize replace_range"); + assert_eq!(roundtrip, event); + + let mut committed = "hello wrld".to_string(); + let applied = roundtrip + .apply_to_committed_text(&mut committed) + .expect("apply bounded replacement"); + assert!(applied); + assert_eq!(committed, "hello world"); + } + + #[test] + fn engine_event_insert_annotation_roundtrip_clone_and_apply() { + let event = EngineEvent::InsertAnnotation { + utterance_id: 9, + position: 8, + text: "[śmiech] ".to_string(), + kind: AnnotationKind::Paralingual { + label: "laugh".to_string(), + }, + }; + + let cloned = event.clone(); + assert_eq!(cloned, event); + + let json = serde_json::to_value(&event).expect("serialize insert_annotation"); + assert_eq!( + json.get("type").and_then(serde_json::Value::as_str), + Some("insert_annotation") + ); + + let roundtrip: EngineEvent = + serde_json::from_value(json).expect("deserialize insert_annotation"); + assert_eq!(roundtrip, event); + + let mut committed = "Pacjent spokojny".to_string(); + let applied = roundtrip + .apply_to_committed_text(&mut committed) + .expect("apply annotation insert"); + assert!(applied); + assert_eq!(committed, "Pacjent [śmiech] spokojny"); + } + + #[test] + fn engine_event_session_finalised_roundtrip_and_noop_apply() { + let event = EngineEvent::SessionFinalised { + session_id: "session-abc".to_string(), + layer_summary: LayerSummary { + tail_patch_replacements: 1, + lexicon_replacements: 2, + inline_llm_replacements: 3, + final_bam_replacements: 4, + annotations_inserted: 5, + }, + }; + + let cloned = event.clone(); + assert_eq!(cloned, event); + + let json = serde_json::to_value(&event).expect("serialize session_finalised"); + assert_eq!( + json.get("type").and_then(serde_json::Value::as_str), + Some("session_finalised") + ); + + let roundtrip: EngineEvent = + serde_json::from_value(json).expect("deserialize session_finalised"); + assert_eq!(roundtrip, event); + + let mut committed = "immutable".to_string(); + let applied = roundtrip + .apply_to_committed_text(&mut committed) + .expect("session finalised apply is a noop"); + assert!(!applied); + assert_eq!(committed, "immutable"); + } + // ── RawTranscript confidence metadata ── #[test] diff --git a/core/pipeline/sinks.rs b/core/pipeline/sinks.rs index c5a924a0..7e2f8718 100644 --- a/core/pipeline/sinks.rs +++ b/core/pipeline/sinks.rs @@ -77,14 +77,20 @@ impl DeltaSink for CollectorSink { /// of drops, stats, or VAD events). pub struct DeltaSinkAdapter { inner: Arc, - last_text: Mutex, + state: Mutex, +} + +#[derive(Debug, Default)] +struct DeltaSinkState { + last_text: String, + needs_separator: bool, } impl DeltaSinkAdapter { pub fn new(sink: Arc) -> Self { Self { inner: sink, - last_text: Mutex::new(String::new()), + state: Mutex::new(DeltaSinkState::default()), } } @@ -102,36 +108,56 @@ impl EventSink for DeltaSinkAdapter { // every subsequent lock on this hot path. match event { EngineEvent::Preview { text, .. } => { - let mut last = self.last_text.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(delta) = TranscriptDelta::from_diff(&last, text) { - self.inner.apply(&delta); - *last = text.clone(); - } + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + emit_text_delta(&self.inner, &mut state, text); } - EngineEvent::Correction { text, .. } => { - let mut last = self.last_text.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(delta) = TranscriptDelta::from_diff(&last, text) { - self.inner.apply(&delta); - *last = text.clone(); + EngineEvent::Correction { + text, + previous_text, + .. + } => { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if state.last_text.is_empty() && state.needs_separator && !previous_text.is_empty() + { + if let Some(delta) = TranscriptDelta::from_diff(previous_text, text) { + self.inner.apply(&delta); + } + } else { + emit_text_delta(&self.inner, &mut state, text); } } EngineEvent::UtteranceFinal { text, .. } => { - let mut last = self.last_text.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(delta) = TranscriptDelta::from_diff(&last, text) { - self.inner.apply(&delta); + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + emit_text_delta(&self.inner, &mut state, text); + state.last_text.clear(); + if !text.trim().is_empty() { + state.needs_separator = true; } - // Reset for next utterance. - *last = String::new(); } EngineEvent::NoSpeech { .. } => { - let mut last = self.last_text.lock().unwrap_or_else(|e| e.into_inner()); - *last = String::new(); + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + state.last_text.clear(); } _ => {} } } } +fn emit_text_delta(sink: &Arc, state: &mut DeltaSinkState, text: &str) { + if state.last_text.is_empty() && state.needs_separator && !text.is_empty() { + let delta = TranscriptDelta::append(format!(" {text}")); + sink.apply(&delta); + state.last_text = text.to_string(); + state.needs_separator = false; + return; + } + + if let Some(delta) = TranscriptDelta::from_diff(&state.last_text, text) { + sink.apply(&delta); + } + state.last_text = text.to_string(); +} + /// Fan-out sink that forwards each event to multiple sinks. pub struct FanoutEventSink { sinks: Vec>, @@ -328,13 +354,61 @@ mod tests { confidence_flags: Vec::new(), }); - // After final, last_text resets — next preview starts fresh + // After final, next utterance must append with a word separator. adapter.on_event(&EngineEvent::Preview { rev: 2, text: "Second".to_string(), }); let deltas = collector.collected(); - assert_eq!(deltas.last().unwrap(), "Second"); + assert_eq!(deltas.last().unwrap(), " Second"); + + let mut rendered = String::new(); + for delta in deltas { + TranscriptDelta::from_raw(delta).apply(&mut rendered); + } + assert_eq!(rendered, "First Second"); + } + + #[test] + fn test_delta_sink_adapter_correction_after_utterance_final_does_not_duplicate() { + let collector = Arc::new(CollectorSink::new()); + let adapter = DeltaSinkAdapter::new(collector.clone() as Arc); + + adapter.on_event(&EngineEvent::Preview { + rev: 1, + text: "First".to_string(), + }); + adapter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "First".to_string(), + raw_text: "First".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(), + }); + adapter.on_event(&EngineEvent::Correction { + rev: 2, + text: "First fixed".to_string(), + previous_text: "First".to_string(), + }); + adapter.on_event(&EngineEvent::Preview { + rev: 3, + text: "Second".to_string(), + }); + + let deltas = collector.collected(); + assert_eq!(deltas, vec!["First", " fixed", " Second"]); + + let mut rendered = String::new(); + for delta in deltas { + TranscriptDelta::from_raw(delta).apply(&mut rendered); + } + assert_eq!(rendered, "First fixed Second"); } #[test] diff --git a/core/pipeline/stream_postprocess.rs b/core/pipeline/stream_postprocess.rs index 0aad358b..3a793a18 100644 --- a/core/pipeline/stream_postprocess.rs +++ b/core/pipeline/stream_postprocess.rs @@ -16,12 +16,30 @@ const BUILTIN_LEXICONS: &[(&str, &str)] = &[( include_str!("../../assets/programming.jsonl"), )]; const SEED_JSONL: &str = include_str!("../../assets/seed.jsonl"); +/// Curated operator/command vocabulary. Spoken Polish UI-command phrases and +/// their Whisper mis-hears normalize to the canonical *code token* the codebase +/// actually uses (e.g. "schowek"/"schowku"/"schowka"/"schopku" -> "clipboard"). +/// Loaded rules-only via `load_seed_jsonl` (seed format gives whole-word + +/// case control), so these common words never enter `protected_canonicals` and +/// never trip the downstream loss-detection gate. Canonicals were confirmed +/// real and high-frequency via `loct occurrences` before being chosen. +const OPERATOR_VOCAB_JSONL: &str = include_str!("../../assets/operator_vocabulary.jsonl"); +/// Curated proper-noun / operator-vocabulary lexicon. Unlike the generic +/// programming/seed sources, entries here are case-normalizing: a variant that +/// differs from the canonical only by casing (e.g. "aicx" -> "AICX") still +/// produces a rewrite rule. The list is hand-vetted so capitalization is always +/// correct for these terms — generic English words (rust, rest, diesel) are NOT +/// in this file, so they never get capitalized. +const PROTECTED_TERMS_JSONL: &str = include_str!("../../assets/protected_terms.jsonl"); const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.93; const DEFAULT_NOVELTY_THRESHOLD: f32 = 0.12; const MAX_EMBED_CHARS: usize = 512; const MAX_DROPS_IN_ROW: u8 = 2; const FINAL_PASS_ARTIFACT_TOKENS: &[&str] = &["going", "use"]; +pub const WHISPER_INITIAL_PROMPT_TOKEN_BUDGET: usize = 224; +const WHISPER_INITIAL_PROMPT_PREFIX: &str = "Vocabulary:"; +pub const STT_INITIAL_PROMPT_ENABLED_ENV: &str = "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED"; lazy_static! { // Whisper sometimes emits trailing emoticon artifacts like ":D", ":-D", "::D", often repeated. @@ -87,6 +105,14 @@ struct Lexicon { custom_rules: Vec, custom_path: PathBuf, custom_mtime: Option, + /// Canonical forms of curated protected terms (proper nouns, operator + /// vocabulary). Used by `protected_terms_lost` to flag when an LLM or other + /// downstream pass silently drops or mutates a protected term. + protected_canonicals: Vec, + /// Canonical terms from the operator's custom dictionary. These feed Whisper's + /// initial prompt after protected terms, without becoming protected-term loss + /// sentinels. + custom_canonicals: Vec, } static GLOBAL_LEXICON: LazyLock> = LazyLock::new(|| { @@ -114,6 +140,21 @@ impl Lexicon { let seed_count = load_seed_jsonl(SEED_JSONL, "seed", &mut builtin_rules); let seed_ms = t_seed.elapsed().as_millis(); + // Operator/command vocabulary: spoken Polish UI commands + their + // mis-hears normalize to the canonical code token. Seed format (rules + // only) keeps these common words out of `protected_canonicals`. + let operator_count = load_seed_jsonl(OPERATOR_VOCAB_JSONL, "operator", &mut builtin_rules); + + // Protected terms load LAST among builtin sources so their brand casing + // wins over any generic earlier rule that produced a lower-cased form. + let mut protected_canonicals = Vec::new(); + let protected_count = load_protected_jsonl( + PROTECTED_TERMS_JSONL, + "protected", + &mut builtin_rules, + &mut protected_canonicals, + ); + let custom_path = Config::config_dir().join("lexicon.custom.jsonl"); let custom_mtime = fs::metadata(&custom_path) .ok() @@ -121,8 +162,16 @@ impl Lexicon { let t_custom = Instant::now(); let mut custom_rules = Vec::new(); + let mut custom_canonicals = Vec::new(); let custom_count = load_custom_lexicon() - .map(|content| load_legacy_jsonl(&content, "custom", &mut custom_rules)) + .map(|content| { + load_legacy_jsonl_with_terms( + &content, + "custom", + &mut custom_rules, + Some(&mut custom_canonicals), + ) + }) .unwrap_or(0); let custom_ms = t_custom.elapsed().as_millis(); @@ -131,13 +180,16 @@ impl Lexicon { if total > 0 { info!( - "Loaded {} lexicon rules in {}ms (legacy={} in {}ms, seed={} in {}ms, custom={} in {}ms, custom_path={})", + "Loaded {} lexicon rules in {}ms (legacy={} in {}ms, seed={} in {}ms, operator={}, protected={} terms={}, custom={} in {}ms, custom_path={})", total, total_ms, legacy_count, legacy_ms, seed_count, seed_ms, + operator_count, + protected_count, + protected_canonicals.len(), custom_count, custom_ms, custom_path.display(), @@ -154,6 +206,8 @@ impl Lexicon { custom_rules, custom_path, custom_mtime, + protected_canonicals, + custom_canonicals, } } @@ -165,10 +219,18 @@ impl Lexicon { return; } self.custom_rules.clear(); + self.custom_canonicals.clear(); let custom_count = fs::read_to_string(&self.custom_path) .ok() .filter(|c| !c.trim().is_empty()) - .map(|content| load_legacy_jsonl(&content, "custom", &mut self.custom_rules)) + .map(|content| { + load_legacy_jsonl_with_terms( + &content, + "custom", + &mut self.custom_rules, + Some(&mut self.custom_canonicals), + ) + }) .unwrap_or(0); self.custom_mtime = current_mtime; info!( @@ -208,6 +270,14 @@ impl Lexicon { fn rule_count(&self) -> usize { self.builtin_rules.len() + self.custom_rules.len() } + + fn whisper_initial_prompt(&self) -> Option { + build_whisper_initial_prompt( + &self.protected_canonicals, + &self.custom_canonicals, + WHISPER_INITIAL_PROMPT_TOKEN_BUDGET, + ) + } } fn maybe_reload_global_lexicon() { @@ -224,7 +294,126 @@ fn apply_global_lexicon(text: &str) -> String { lexicon.apply(text) } +/// Deterministically apply the global lexicon (builtin + seed + protected + +/// custom) to `text`, hot-reloading the custom file if it changed. +/// +/// This is the single deterministic protected-vocabulary pass. It is safe to run +/// at any layer (it only rewrites registered mispronunciations to their +/// canonical form) and is idempotent for canonical output. Use it to re-assert +/// operator vocabulary AFTER a non-deterministic stage such as an LLM +/// formatting/assistive pass, which can otherwise silently corrupt proper nouns +/// (e.g. "Loctree" -> "Luxury"). +pub fn apply_lexicon(text: &str) -> String { + maybe_reload_global_lexicon(); + apply_global_lexicon(text) +} + +/// Build the domain-vocabulary hint fed into Whisper's `initial_prompt`. +/// +/// Protected terms are selected before custom dictionary terms, duplicates are +/// removed case-insensitively, and the final string is trimmed to the Whisper +/// prompt budget before decoding begins. +pub fn build_whisper_initial_prompt( + protected_terms: &[String], + custom_terms: &[String], + token_budget: usize, +) -> Option { + if token_budget == 0 { + return None; + } + + let mut seen = HashSet::new(); + let mut selected = Vec::new(); + let mut used_tokens = 1usize; // `Vocabulary:` + + for term in protected_terms.iter().chain(custom_terms.iter()) { + let term = term.trim(); + if term.is_empty() { + continue; + } + if !seen.insert(term.to_lowercase()) { + continue; + } + + let term_tokens = estimated_prompt_tokens(term) + 1; // term plus separator/punctuation + if used_tokens + term_tokens > token_budget { + break; + } + + used_tokens += term_tokens; + selected.push(term.to_string()); + } + + (!selected.is_empty()) + .then(|| format!("{WHISPER_INITIAL_PROMPT_PREFIX} {}.", selected.join("; "))) +} + +pub fn stt_initial_prompt_enabled() -> bool { + match std::env::var(STT_INITIAL_PROMPT_ENABLED_ENV) { + Ok(value) => matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" | "enabled" + ), + Err(_) => Config::load_without_keychain().stt_initial_prompt_enabled, + } +} + +pub fn whisper_initial_prompt() -> Option { + if !stt_initial_prompt_enabled() { + return None; + } + maybe_reload_global_lexicon(); + let lexicon = GLOBAL_LEXICON + .read() + .expect("global lexicon read lock poisoned"); + lexicon.whisper_initial_prompt() +} + +fn estimated_prompt_tokens(term: &str) -> usize { + term.split_whitespace().count().max(1) +} + +/// Whole-word, case-insensitive containment check for a (possibly multi-word) +/// term. Mirrors the lexicon's own matching: internal whitespace is treated +/// flexibly so "Fn Shift" matches across variable spacing. +fn contains_term_ci(haystack: &str, term: &str) -> bool { + build_word_regex(term) + .map(|re| re.is_match(haystack)) + .unwrap_or(false) +} + +/// Report curated protected terms that were present in `before` but are missing +/// from `after` — i.e. silently dropped or mutated by a downstream stage +/// (typically an LLM formatting/assistive pass). Returns canonical forms in a +/// stable, deduplicated order so the quality loop and operator can see exactly +/// which operator vocabulary was lost. +pub fn protected_terms_lost(before: &str, after: &str) -> Vec { + let canonicals = { + let lexicon = GLOBAL_LEXICON + .read() + .expect("global lexicon read lock poisoned"); + lexicon.protected_canonicals.clone() + }; + + let mut lost = Vec::new(); + for term in canonicals { + if contains_term_ci(before, &term) && !contains_term_ci(after, &term) { + lost.push(term); + } + } + lost +} + fn load_legacy_jsonl(source: &str, label: &str, rules: &mut Vec) -> usize { + load_legacy_jsonl_with_terms(source, label, rules, None) +} + +fn load_legacy_jsonl_with_terms( + source: &str, + label: &str, + rules: &mut Vec, + mut canonicals: Option<&mut Vec>, +) -> usize { let mut added = 0usize; for (idx, line) in source.lines().enumerate() { let line = line.trim(); @@ -245,6 +434,12 @@ fn load_legacy_jsonl(source: &str, label: &str, rules: &mut Vec) -> } }; + if let Some(canonicals) = &mut canonicals + && !canonicals.iter().any(|c| c == &entry.term) + { + canonicals.push(entry.term.clone()); + } + // Merge top-level mispronunciations with extras.mispronunciations // (veterinary.jsonl stores them in extras, programming.jsonl at top level) let mut all_mis = entry.mispronunciations; @@ -257,6 +452,131 @@ fn load_legacy_jsonl(source: &str, label: &str, rules: &mut Vec) -> continue; } + if label == "custom" && is_unsafe_plain_custom_rule(&entry.term, mis) { + debug!( + "Skipping unsafe custom lexicon rule {} -> {}", + mis, entry.term + ); + continue; + } + + if let Some(pattern) = build_word_regex(mis) { + rules.push(LexiconRule { + pattern, + replacement: entry.term.clone(), + }); + added += 1; + } + } + } + + added +} + +fn is_unsafe_plain_custom_rule(term: &str, variant: &str) -> bool { + let term = term.trim(); + let variant = variant.trim(); + + if !is_plain_lowercase_language_phrase(term) || !is_plain_lowercase_language_phrase(variant) { + return false; + } + + if normalized_without_polish_diacritics(term) == normalized_without_polish_diacritics(variant) { + return false; + } + + // A custom single-token Polish word -> different Polish word rewrite is too + // broad for global STT postprocessing. Those entries are often reference + // diffs or inflections, not acoustic mis-hears. + term.split_whitespace().count() == 1 && variant.split_whitespace().count() <= 2 +} + +fn is_plain_lowercase_language_phrase(input: &str) -> bool { + let mut saw_letter = false; + let mut saw_space = false; + + for ch in input.chars() { + if ch.is_whitespace() { + saw_space = true; + continue; + } + if !ch.is_alphabetic() || ch.is_uppercase() { + return false; + } + saw_letter = true; + } + + saw_letter && (saw_space || input.split_whitespace().count() == 1) +} + +fn normalized_without_polish_diacritics(input: &str) -> String { + input + .to_lowercase() + .chars() + .map(|ch| match ch { + 'ą' => 'a', + 'ć' => 'c', + 'ę' => 'e', + 'ł' => 'l', + 'ń' => 'n', + 'ó' => 'o', + 'ś' => 's', + 'ź' | 'ż' => 'z', + _ => ch, + }) + .collect() +} + +/// Load curated protected-term entries (legacy `term`+`mispronunciations` shape). +/// +/// Differs from [`load_legacy_jsonl`] in two deliberate ways: +/// 1. A variant is skipped only when it is *exactly* equal to the canonical, so +/// case-only variants ("aicx" -> "AICX") still produce a normalization rule. +/// This is safe ONLY because the source file is hand-vetted to proper nouns. +/// 2. Each canonical is recorded in `canonicals` so the quality loop can detect +/// when a protected term is lost downstream (e.g. by an LLM rewrite). +fn load_protected_jsonl( + source: &str, + label: &str, + rules: &mut Vec, + canonicals: &mut Vec, +) -> usize { + let mut added = 0usize; + for (idx, line) in source.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let entry: LegacyEntry = match serde_json::from_str(line) { + Ok(entry) => entry, + Err(e) => { + warn!( + "Protected lexicon line {} ({}) failed to parse: {}", + idx + 1, + label, + e + ); + continue; + } + }; + + if !canonicals.iter().any(|c| c == &entry.term) { + canonicals.push(entry.term.clone()); + } + + let mut all_mis = entry.mispronunciations; + if let Some(extras) = entry.extras { + all_mis.extend(extras.mispronunciations); + } + + for mis in all_mis.iter() { + // Skip only exact duplicates; case-only differences are intentional + // normalization rules (the whole point of this curated source). + if mis == &entry.term { + continue; + } + if let Some(pattern) = build_word_regex(mis) { rules.push(LexiconRule { pattern, @@ -702,6 +1022,31 @@ fn truncate_for_embedding(text: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + use std::ffi::OsString; + + struct EnvRestore { + key: &'static str, + previous: Option, + } + + impl EnvRestore { + fn capture(key: &'static str) -> Self { + Self { + key, + previous: std::env::var_os(key), + } + } + } + + impl Drop for EnvRestore { + 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) }, + } + } + } #[test] fn test_lexicon_rewrite() { @@ -723,7 +1068,95 @@ mod tests { assert_eq!( output, - "Bede nagrywal cos o loctree i nagrywanie o loctree." + "Bede nagrywal cos o Loctree i nagrywanie o Loctree." + ); + } + + #[test] + fn whisper_initial_prompt_empty_terms_returns_none() { + assert_eq!(build_whisper_initial_prompt(&[], &[], 224), None); + assert_eq!( + build_whisper_initial_prompt(&["Loctree".to_string()], &[], 0), + None + ); + } + + #[test] + fn whisper_initial_prompt_dedupes_case_and_prioritizes_protected_terms() { + let protected = vec![ + "Loctree".to_string(), + "AICX".to_string(), + "loctree".to_string(), + ]; + let custom = vec![ + "Codescribe".to_string(), + "aicx".to_string(), + "Operator Console".to_string(), + ]; + + let prompt = + build_whisper_initial_prompt(&protected, &custom, 224).expect("expected prompt"); + + assert_eq!( + prompt, + "Vocabulary: Loctree; AICX; Codescribe; Operator Console." + ); + } + + #[test] + fn whisper_initial_prompt_truncates_to_token_budget() { + let protected = vec![ + "Loctree".to_string(), + "Operator Console".to_string(), + "AICX".to_string(), + ]; + + let prompt = build_whisper_initial_prompt(&protected, &["Codescribe".to_string()], 4) + .expect("expected truncated prompt"); + + assert_eq!(prompt, "Vocabulary: Loctree."); + } + + #[test] + #[serial] + fn whisper_initial_prompt_defaults_off_even_with_builtin_terms() { + let _data_dir = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _env_path = EnvRestore::capture("CODESCRIBE_ENV_PATH"); + let _prompt_enabled = EnvRestore::capture(STT_INITIAL_PROMPT_ENABLED_ENV); + let temp_dir = tempfile::tempdir().expect("temp data dir"); + + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", temp_dir.path()); + std::env::remove_var("CODESCRIBE_ENV_PATH"); + std::env::remove_var(STT_INITIAL_PROMPT_ENABLED_ENV); + } + + assert!(!stt_initial_prompt_enabled()); + assert_eq!( + whisper_initial_prompt(), + None, + "fresh/default config must not inject a Whisper initial prompt" + ); + } + + #[test] + #[serial] + fn whisper_initial_prompt_is_opt_in() { + let _data_dir = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _env_path = EnvRestore::capture("CODESCRIBE_ENV_PATH"); + let _prompt_enabled = EnvRestore::capture(STT_INITIAL_PROMPT_ENABLED_ENV); + let temp_dir = tempfile::tempdir().expect("temp data dir"); + + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", temp_dir.path()); + std::env::remove_var("CODESCRIBE_ENV_PATH"); + std::env::set_var(STT_INITIAL_PROMPT_ENABLED_ENV, "1"); + } + + let prompt = whisper_initial_prompt().expect("opt-in prompt should be built"); + assert!( + prompt.contains("Loctree"), + "prompt should include protected terms" ); } @@ -787,6 +1220,8 @@ mod tests { custom_mtime: std::fs::metadata(&custom_path) .ok() .and_then(|m| m.modified().ok()), + protected_canonicals: Vec::new(), + custom_canonicals: Vec::new(), }; // No rules yet @@ -811,6 +1246,52 @@ mod tests { ); assert_eq!(lexicon.rule_count(), 1); assert_eq!(lexicon.custom_rules.len(), 1); + assert_eq!(lexicon.custom_canonicals, vec!["FooBar".to_string()]); + } + + #[test] + fn test_custom_lexicon_skips_plain_word_regression_rules() { + let json = r#" +{"term":"zobacz","mispronunciations":["zobaczcie"]} +{"term":"robimy","mispronunciations":["zrobimy", "robi się"]} +{"term":"stary","mispronunciations":["stara"]} +"#; + let mut custom_rules = Vec::new(); + + let count = load_legacy_jsonl_with_terms(json, "custom", &mut custom_rules, None); + + assert_eq!(count, 0, "plain word-to-word custom rules are unsafe"); + + let lexicon = Lexicon { + builtin_rules: Vec::new(), + custom_rules, + custom_path: PathBuf::from("/nonexistent/lexicon.custom.jsonl"), + custom_mtime: None, + protected_canonicals: Vec::new(), + custom_canonicals: Vec::new(), + }; + let input = "Zobaczcie, jeśli nie zrobimy tego teraz, stara."; + assert_eq!(lexicon.apply(input), input); + } + + #[test] + fn test_custom_lexicon_allows_diacritic_only_rules() { + let json = r#"{"term":"zażółć","mispronunciations":["zazolc"]}"#; + let mut custom_rules = Vec::new(); + + let count = load_legacy_jsonl_with_terms(json, "custom", &mut custom_rules, None); + + assert_eq!(count, 1); + + let lexicon = Lexicon { + builtin_rules: Vec::new(), + custom_rules, + custom_path: PathBuf::from("/nonexistent/lexicon.custom.jsonl"), + custom_mtime: None, + protected_canonicals: Vec::new(), + custom_canonicals: Vec::new(), + }; + assert_eq!(lexicon.apply("zazolc gesla jazn"), "zażółć gesla jazn"); } #[test] @@ -828,6 +1309,8 @@ mod tests { custom_rules: Vec::new(), custom_path: custom_path.clone(), custom_mtime: None, // Force initial load + protected_canonicals: Vec::new(), + custom_canonicals: Vec::new(), }; // First reload loads the rule @@ -864,6 +1347,8 @@ mod tests { custom_mtime: std::fs::metadata(&custom_path) .ok() .and_then(|m| m.modified().ok()), + protected_canonicals: Vec::new(), + custom_canonicals: Vec::new(), }; // Write custom rule @@ -955,4 +1440,138 @@ mod tests { lexicon.rule_count() ); } + + /// Build a hermetic builtin-only lexicon (programming + seed + protected), + /// with NO operator custom file, so protected-term regression assertions are + /// deterministic regardless of the host's ~/.codescribe/lexicon.custom.jsonl. + fn builtin_only_lexicon() -> Lexicon { + let mut rules = Vec::new(); + for (label, source) in BUILTIN_LEXICONS { + load_legacy_jsonl(source, label, &mut rules); + } + load_seed_jsonl(SEED_JSONL, "seed", &mut rules); + load_seed_jsonl(OPERATOR_VOCAB_JSONL, "operator", &mut rules); + let mut canonicals = Vec::new(); + load_protected_jsonl( + PROTECTED_TERMS_JSONL, + "protected", + &mut rules, + &mut canonicals, + ); + Lexicon { + builtin_rules: rules, + custom_rules: Vec::new(), + custom_path: PathBuf::from("/nonexistent/lexicon.custom.jsonl"), + custom_mtime: None, + protected_canonicals: canonicals, + custom_canonicals: Vec::new(), + } + } + + #[test] + fn test_protected_terms_loctree_not_luxury() { + let lex = builtin_only_lexicon(); + // The reported regression: Whisper/LLM emits the acoustic homophone + // "Luxury" for the product name. The lexicon must restore "Loctree". + assert_eq!( + lex.apply("Odpalam luxury na repo"), + "Odpalam Loctree na repo" + ); + assert_eq!(lex.apply("locktree i loktree"), "Loctree i Loctree"); + // Canonical already correct stays correct. + assert_eq!(lex.apply("Loctree daje sight"), "Loctree daje sight"); + } + + #[test] + fn test_protected_terms_preserve_brand_casing() { + let lex = builtin_only_lexicon(); + assert_eq!(lex.apply("vibe crafted"), "Vibecrafted"); + assert_eq!(lex.apply("code scribe"), "Codescribe"); + assert_eq!(lex.apply("vet coders"), "Vetcoders"); + // Case-only normalization (curated protected source only). + assert_eq!(lex.apply("mam aicx w repo"), "mam AICX w repo"); + assert_eq!(lex.apply("przez mcp"), "przez MCP"); + assert_eq!(lex.apply("a i c x"), "AICX"); + assert_eq!(lex.apply("m c p"), "MCP"); + assert_eq!(lex.apply("github"), "GitHub"); + assert_eq!(lex.apply("git hub"), "GitHub"); + } + + #[test] + fn test_protected_terms_multiword_phrases() { + let lex = builtin_only_lexicon(); + assert_eq!(lex.apply("fn shift"), "Fn Shift"); + assert_eq!(lex.apply("fun shift"), "Fn Shift"); + assert_eq!(lex.apply("living intent queue"), "Living Intent Queue"); + assert_eq!( + lex.apply("assistive talk anytime"), + "Assistive Talk Anytime" + ); + // Already-correct phrases are preserved verbatim. + assert_eq!( + lex.apply("Collapsible Tool Evidence"), + "Collapsible Tool Evidence" + ); + } + + #[test] + fn test_protected_terms_do_not_overcorrect_ordinary_language() { + let lex = builtin_only_lexicon(); + // "rest", "harmony", "diesel" exist as case-only variants in + // programming.jsonl but the legacy loader skips case-equal variants, so + // ordinary English/Polish must pass through untouched. + let sentence = "I need some rest in harmony near the diesel engine"; + assert_eq!(lex.apply(sentence), sentence); + let pl = "To jest zwykłe zdanie bez żadnych nazw własnych"; + assert_eq!(lex.apply(pl), pl); + } + + #[test] + fn test_polish_ui_command_phrase_preservation() { + // Regression class: Polish UI command phrases (and their Whisper + // mis-hears) must normalize to the canonical code token, never leak the + // garbage mutant. The reported goblin: "schowku" -> "schopku". + let lex = builtin_only_lexicon(); + // The reported mutant and the whole "schowek" inflection family collapse + // to the invariant code token (clipboard never inflects in Polish). + assert_eq!(lex.apply("wrzuć do schopku"), "wrzuć do clipboard"); + assert_eq!(lex.apply("otwórz schowek"), "otwórz clipboard"); + assert_eq!(lex.apply("wrzuć do schowka"), "wrzuć do clipboard"); + assert_eq!(lex.apply("zajrzyj do schowku"), "zajrzyj do clipboard"); + // Other operator commands normalize to their code token. + assert_eq!(lex.apply("zrób skrinszot"), "zrób screenshot"); + assert_eq!(lex.apply("zrób zrzut ekranu"), "zrób screenshot"); + assert_eq!(lex.apply("wklej to"), "paste to"); + assert_eq!(lex.apply("pokaż zaznaczenie"), "pokaż selection"); + assert_eq!(lex.apply("zapisz transkrypt"), "zapisz transcript"); + // Ordinary text without command vocabulary is untouched. + let plain = "To jest zwykłe zdanie o kotach i psach"; + assert_eq!(lex.apply(plain), plain); + } + + #[test] + fn test_protected_terms_lost_detects_corruption() { + // Uses the GLOBAL lexicon; builtin protected canonicals (Loctree, + // Codescribe, MCP, ...) are always present regardless of custom file. + let lost = protected_terms_lost("I run Loctree through MCP", "I run Luxury through MCP"); + assert_eq!(lost, vec!["Loctree".to_string()]); + + // Nothing lost when the term survives. + let none = protected_terms_lost("Codescribe is great", "Codescribe is wonderful"); + assert!(none.is_empty()); + } + + #[test] + fn test_apply_lexicon_is_idempotent_on_canonical() { + // Re-applying after an LLM pass must reach a fixed point (no oscillation / + // corruption). Uses the GLOBAL lexicon, so we only assert robustness + // properties that an operator custom file cannot flip: the pass converges + // and Loctree/AICX/MCP (which no builtin/operator rule downgrades) survive. + let once = apply_lexicon("Loctree, AICX and MCP keep working"); + let twice = apply_lexicon(&once); + assert_eq!(once, twice, "lexicon apply must be idempotent on canonical"); + assert!(once.contains("Loctree")); + assert!(once.contains("AICX")); + assert!(once.contains("MCP")); + } } diff --git a/core/pipeline/streaming/correction.rs b/core/pipeline/streaming/correction.rs index c1532f7e..19246989 100644 --- a/core/pipeline/streaming/correction.rs +++ b/core/pipeline/streaming/correction.rs @@ -114,12 +114,12 @@ pub(crate) fn postprocess_correction_with_snapshot( } pub(crate) fn correction_is_stale( - expected_preview_rev: u64, - current_preview_rev: u64, - expected_text: &str, - current_text: &str, + expected_boundary_rev: u64, + current_boundary_rev: u64, + _expected_text: &str, + _current_text: &str, ) -> bool { - expected_preview_rev != current_preview_rev || expected_text != current_text + expected_boundary_rev != current_boundary_rev } /// Build correction baseline text for replacement semantics across boundaries. @@ -217,12 +217,12 @@ pub(crate) fn schedule_partial_pass( pipeline_language: Option, correction_audio_buf: &mut Vec, correction_in_flight: &mut Option, - correction_expected_preview_rev: &mut Option, + correction_expected_boundary_rev: &mut Option, correction_expected_text: &mut Option, correction_suffix_snapshot: &mut Option, suffix_snapshot: &str, - preview_rev: u64, - accumulated_text: &str, + boundary_rev: u64, + baseline_text: &str, speech_ms_since_partial: u64, trigger: PartialPassTrigger, partial_telemetry: &mut PartialPassTelemetry, @@ -244,8 +244,8 @@ pub(crate) fn schedule_partial_pass( } debug!( - expected_rev = preview_rev, - baseline_len = accumulated_text.chars().count(), + expected_boundary_rev = boundary_rev, + baseline_len = baseline_text.chars().count(), audio_sec = audio_duration_s, silero_speech_sec = silero_speech_seconds(speech_ms_since_partial), trigger = ?trigger, @@ -261,8 +261,8 @@ pub(crate) fn schedule_partial_pass( ) { Ok(handle) => { partial_telemetry.record_run(trigger); - *correction_expected_preview_rev = Some(preview_rev); - *correction_expected_text = Some(accumulated_text.to_string()); + *correction_expected_boundary_rev = Some(boundary_rev); + *correction_expected_text = Some(baseline_text.to_string()); *correction_suffix_snapshot = Some(suffix_snapshot.to_string()); *correction_in_flight = Some(handle); true diff --git a/core/pipeline/streaming/offline.rs b/core/pipeline/streaming/offline.rs index dc519873..b1d0b172 100644 --- a/core/pipeline/streaming/offline.rs +++ b/core/pipeline/streaming/offline.rs @@ -2,12 +2,12 @@ //! session path; compiled only for tests and the `offline_eval` feature //! (the module is cfg-gated in `mod.rs`). -use anyhow::{Result, anyhow}; +use anyhow::Result; use tracing::{debug, info}; use crate::pipeline::dedup::dedup_chunk_overlap; use crate::pipeline::stream_postprocess::StreamPostProcessor; -use crate::stt::whisper::singleton::engine as get_engine; +use crate::stt::whisper::singleton::transcribe_chunk; use super::tuning::{env_bool_default, env_f32}; @@ -53,11 +53,9 @@ pub fn transcribe_streaming_samples( effective_audio_sec ); - let engine_mutex = get_engine()?; - let mut engine = engine_mutex - .lock() - .map_err(|e| anyhow!("Lock error: {}", e))?; - + // Per-chunk engine acquisition via the singleton: chunks are independent + // (overlap dedup happens on the text below), and routing through the + // singleton keeps the idle-unload/reload bookkeeping consistent. let mut out = String::new(); let mut offset = 0usize; let mut chunks_processed = 0usize; @@ -68,7 +66,7 @@ pub fn transcribe_streaming_samples( let chunk = &samples[offset..end]; let chunk_sec = chunk.len() as f32 / sample_rate as f32; let t_chunk = std::time::Instant::now(); - let text = engine.transcribe_with_language(chunk, sample_rate, language)?; + let text = transcribe_chunk(chunk, sample_rate, language)?; let chunk_ms = t_chunk.elapsed().as_millis(); chunks_processed += 1; diff --git a/core/pipeline/streaming/pipeline.rs b/core/pipeline/streaming/pipeline.rs index 06f1e357..8c1dd9fa 100644 --- a/core/pipeline/streaming/pipeline.rs +++ b/core/pipeline/streaming/pipeline.rs @@ -5,7 +5,7 @@ use crate::pipeline::contracts::TranscriptSegment; use crate::pipeline::dedup::{strip_segment_overlap, strip_suffix_overlap_live}; use crate::pipeline::stream_postprocess::StreamPostProcessor; -use super::quality_gate::is_hallucination; +use super::quality_gate::is_hallucination_with_quality; // ── TranscriptionPipeline ──────────────────────────────────────────────────── @@ -72,7 +72,17 @@ impl TranscriptionPipeline { text: &str, segments: &[TranscriptSegment], ) -> Result { - if is_hallucination(text, self.language.as_deref()) { + self.postprocess_with_reason_and_segments_with_quality(text, segments, None) + } + + /// Segment-aware postprocess with engine confidence metadata. + pub(crate) fn postprocess_with_reason_and_segments_with_quality( + &mut self, + text: &str, + segments: &[TranscriptSegment], + avg_logprob: Option, + ) -> Result { + if is_hallucination_with_quality(text, self.language.as_deref(), avg_logprob) { self.hallucination_drops += 1; return Err(PostprocessDrop::Hallucination); } diff --git a/core/pipeline/streaming/quality_gate.rs b/core/pipeline/streaming/quality_gate.rs index c90ea4a4..e278d96f 100644 --- a/core/pipeline/streaming/quality_gate.rs +++ b/core/pipeline/streaming/quality_gate.rs @@ -22,11 +22,10 @@ const WHISPER_HALLUCINATIONS_COMMON: &[&str] = &[ "www.", ]; -const WHISPER_HALLUCINATIONS_PL: &[&str] = &[ - "napisy stworzone przez społeczność", - "tłumaczenie", - "transkrypcja", -]; +const WHISPER_HALLUCINATIONS_PL: &[&str] = &["napisy stworzone przez społeczność"]; + +const WHISPER_HALLUCINATIONS_PL_LOW_CONFIDENCE: &[&str] = &["tłumaczenie", "transkrypcja"]; +const PL_EXACT_LOW_CONFIDENCE_LOGPROB: f32 = -1.0; const SHORT_SPEECH_WHITELIST: &[&str] = &[ "tak", "nie", "co?", "co", "dobra", "dobrze", "ok", "okej", "no", "no?", "mhm", "aha", "jasne", @@ -98,8 +97,11 @@ pub(crate) fn should_drop_short_utterance( /// Categorical speech-ratio gate: use Silero VAD as a binary classifier. /// -/// Computes the fraction of the chunk that Silero classified as speech -/// (prob >= threshold). If the ratio falls below `MIN_SPEECH_RATIO_FOR_INFERENCE`, +/// Computes the fraction of the chunk that Silero classified as speech. The +/// `speech_vad_samples` count is accumulated upstream (chunker) using the +/// segment-open `neg_threshold` (~0.35), not the higher onset threshold, so +/// speech in the 0.35–0.50 band is counted as speech here rather than silence. +/// If the ratio falls below `MIN_SPEECH_RATIO_FOR_INFERENCE`, /// the chunk is predominantly silence and should not be sent to Whisper /// (which would hallucinate on it). /// @@ -146,14 +148,24 @@ pub(crate) fn utterance_vad_speech_pct( Some(((speech_vad_samples as f32 / audio_16k as f32) * 100.0).min(100.0)) } -pub(crate) fn is_hallucination(text: &str, language: Option<&str>) -> bool { +pub(crate) fn is_hallucination_with_quality( + text: &str, + language: Option<&str>, + avg_logprob: Option, +) -> bool { let lower = text.trim().to_lowercase(); if SHORT_SPEECH_WHITELIST.iter().any(|w| lower == *w) { return false; } let is_pl = is_polish_language(language); + let low_confidence = avg_logprob.is_some_and(|avg| avg < PL_EXACT_LOW_CONFIDENCE_LOGPROB); if WHISPER_HALLUCINATIONS_COMMON.iter().any(|h| lower == *h) || (is_pl && WHISPER_HALLUCINATIONS_PL.iter().any(|h| lower == *h)) + || (is_pl + && low_confidence + && WHISPER_HALLUCINATIONS_PL_LOW_CONFIDENCE + .iter() + .any(|h| lower == *h)) { return true; } @@ -161,7 +173,12 @@ pub(crate) fn is_hallucination(text: &str, language: Option<&str>) -> bool { && (WHISPER_HALLUCINATIONS_COMMON .iter() .any(|h| lower.contains(h)) - || (is_pl && WHISPER_HALLUCINATIONS_PL.iter().any(|h| lower.contains(h)))) + || (is_pl && WHISPER_HALLUCINATIONS_PL.iter().any(|h| lower.contains(h))) + || (is_pl + && low_confidence + && WHISPER_HALLUCINATIONS_PL_LOW_CONFIDENCE + .iter() + .any(|h| lower.contains(h)))) && lower.split_whitespace().count() <= 4 { return true; @@ -231,41 +248,90 @@ mod tests { #[test] fn hallucination_existing_matches_preserved() { // Exact-match list entries still flagged. - assert!(is_hallucination("Thank you", None)); - assert!(is_hallucination(" Dziękuję za uwagę ", Some("pl"))); - assert!(is_hallucination( + assert!(is_hallucination_with_quality("Thank you", None, None)); + assert!(is_hallucination_with_quality( + " Dziękuję za uwagę ", + Some("pl"), + None + )); + assert!(is_hallucination_with_quality( "Napisy stworzone przez społeczność", - Some("pl") + Some("pl"), + None )); // Whitelist + normal speech still pass. - assert!(!is_hallucination("Tak", Some("pl"))); - assert!(!is_hallucination("This is a normal sentence.", Some("en"))); + assert!(!is_hallucination_with_quality("Tak", Some("pl"), None)); + assert!(!is_hallucination_with_quality( + "This is a normal sentence.", + Some("en"), + None + )); + } + + #[test] + fn hallucination_polish_exact_terms_require_low_confidence() { + assert!(!is_hallucination_with_quality( + "tłumaczenie", + Some("pl"), + None + )); + assert!(!is_hallucination_with_quality( + "transkrypcja", + Some("pl"), + None + )); + assert!(!is_hallucination_with_quality( + "tłumaczenie", + Some("pl"), + Some(-0.2) + )); + assert!(is_hallucination_with_quality( + "tłumaczenie", + Some("pl"), + Some(-1.2) + )); + assert!(is_hallucination_with_quality( + "transkrypcja", + Some("pl"), + Some(-1.2) + )); } #[test] fn hallucination_repetition_detected() { // Single word looped many times (long enough to be implausible speech). - assert!(is_hallucination( + assert!(is_hallucination_with_quality( "do do do do do do do do do do", - Some("pl") + Some("pl"), + None )); // Two-word phrase looped. - assert!(is_hallucination( + assert!(is_hallucination_with_quality( "do widzenia do widzenia do widzenia do widzenia do widzenia do widzenia do widzenia do widzenia", - Some("pl") + Some("pl"), + None )); } #[test] fn hallucination_legit_repeat() { // Short legitimate repeats must NOT be flagged (below min-words gate). - assert!(!is_hallucination("tak tak tak", Some("pl"))); - assert!(!is_hallucination("no no no", Some("pl"))); - assert!(!is_hallucination("nie nie nie nie", Some("pl"))); + assert!(!is_hallucination_with_quality( + "tak tak tak", + Some("pl"), + None + )); + assert!(!is_hallucination_with_quality("no no no", Some("pl"), None)); + assert!(!is_hallucination_with_quality( + "nie nie nie nie", + Some("pl"), + None + )); // Longer but varied real speech (no single-word dominance) passes. - assert!(!is_hallucination( + assert!(!is_hallucination_with_quality( "i wtedy poszedłem do sklepu żeby kupić chleb mleko oraz masło", - Some("pl") + Some("pl"), + None )); // Repetition heuristic helper: empty / short are inert. assert!(!is_repetition_hallucination("")); diff --git a/core/pipeline/streaming/session.rs b/core/pipeline/streaming/session.rs index 2a4b3ed0..fb606f4f 100644 --- a/core/pipeline/streaming/session.rs +++ b/core/pipeline/streaming/session.rs @@ -7,16 +7,20 @@ use std::sync::Arc; use anyhow::{Result, anyhow}; use futures_util::StreamExt; -use futures_util::stream::FuturesOrdered; +use futures_util::stream::{FuturesOrdered, FuturesUnordered}; use tokio::sync::mpsc; use tokio::time::{Duration, Instant}; use tracing::{debug, error, info, warn}; use crate::audio::chunker::{SpeechEvent, SpeechSession}; use crate::pipeline::contracts::{ - DropKind, EngineEvent, EventSink, TranscriptSegment, collect_confidence_flags, + DropKind, EngineEvent, EventSink, LayerSource, LayerSummary, TranscriptSegment, + collect_confidence_flags, }; use crate::stt::scheduler::{SttLane, SttScheduler, SttTaskHandle}; +use crate::stt::tail_patcher::{ + TailPatchConfig, TailPatchOutcome, compute_tail_patch, layered_phase, +}; use crate::vad; use super::correction::{ @@ -43,6 +47,12 @@ use super::tuning::{inference_max_concurrency, interim_vad_accumulate_samples}; /// exceed the partial-pass cadence so no spoken tail is ever dropped before a /// Refine consumes it. const CORRECTION_WINDOW_SEC: f32 = 18.0; +/// Maximum text retained for Refine's window baseline. +/// +/// Text has no exact timestamps here, so keep a conservative character tail +/// that comfortably covers 18s of dense speech while bounding clone/compare +/// work in long sessions. +const CORRECTION_WINDOW_TEXT_MAX_CHARS: usize = 4096; /// Trim `buf` in place so it retains at most `window_sec` of trailing audio at /// `sample_rate`. Returns the number of leading samples drained. @@ -56,6 +66,38 @@ fn cap_correction_buffer(buf: &mut Vec, sample_rate: u32, window_sec: f32) drain_n } +fn cap_correction_window_text(text: &mut String, max_chars: usize) -> usize { + let char_count = text.chars().count(); + if max_chars == 0 || char_count <= max_chars { + return 0; + } + + let drain_chars = char_count - max_chars; + let drain_bytes = text + .char_indices() + .nth(drain_chars) + .map(|(idx, _)| idx) + .unwrap_or(text.len()); + text.drain(..drain_bytes); + let trimmed = text.trim_start(); + if trimmed.len() != text.len() { + *text = trimmed.to_string(); + } + drain_chars +} + +fn append_to_correction_window_text(window_text: &mut String, text: &str, max_chars: usize) { + let text = text.trim(); + if text.is_empty() { + return; + } + if !window_text.is_empty() { + window_text.push(' '); + } + window_text.push_str(text); + cap_correction_window_text(window_text, max_chars); +} + // ── Unified session config ─────────────────────────────────────────────────── /// Configuration for a transcription session. @@ -125,6 +167,97 @@ pub(crate) fn enqueue_pending_utterance( } } +fn tail_patch_enabled() -> bool { + layered_phase().is_some_and(|phase| phase >= 1) +} + +fn record_semantic_gate_drop(counter: &mut u64, quality_gate_dropped: bool, is_final: bool) { + if is_final && quality_gate_dropped { + *counter = counter.saturating_add(1); + } +} + +async fn compute_tail_patch_job( + utterance_id: u64, + committed_text: String, + audio: Vec, + sample_rate: u32, + language: Option, + config: TailPatchConfig, +) -> Result<(u64, TailPatchOutcome)> { + debug_assert_eq!( + committed_text.trim(), + committed_text, + "tail-patch committed_text must be the exact, pre-trimmed UtteranceFinal text \ + (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(( + utterance_id, + compute_tail_patch(&committed_text, &retranscribed.text, utterance_id, &config), + )) + }) + .await + .map_err(|e| anyhow!("tail patch worker task failed: {e}"))? +} + +fn emit_tail_patch_result( + event_sink: &dyn EventSink, + result: Result<(u64, TailPatchOutcome)>, +) -> u64 { + match result { + Ok((utterance_id, TailPatchOutcome::Patches(events))) => { + let mut emitted = 0u64; + for event in events { + if matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) { + emitted = emitted.saturating_add(1); + } + event_sink.on_event(&event); + } + debug!(utterance_id, emitted, "Applied tail patch replacements"); + emitted + } + Ok((utterance_id, TailPatchOutcome::NoChange)) => { + debug!(utterance_id, "Tail patch found no changes"); + 0 + } + Ok((utterance_id, TailPatchOutcome::Skipped { reason })) => { + debug!(utterance_id, reason, "Tail patch skipped"); + 0 + } + Err(e) => { + warn!("Tail patch failed; keeping Layer 0 committed text: {}", e); + event_sink.on_event(&EngineEvent::Warning { + code: "tail_patch_error".to_string(), + message: format!("{}", e), + }); + 0 + } + } +} + +fn emit_session_finalised( + event_sink: &dyn EventSink, + session_id: String, + tail_patch_replacements: u64, +) { + event_sink.on_event(&EngineEvent::SessionFinalised { + session_id, + layer_summary: LayerSummary { + tail_patch_replacements, + ..LayerSummary::default() + }, + }); +} + // ── Unified transcription session (event-based) ───────────────────────────── /// Unified transcription session exposed as a single event-emitting pipeline. @@ -145,6 +278,7 @@ pub(crate) async fn transcription_session( } = config; info!("Transcription session started (event-based pipeline)"); + let session_id = uuid::Uuid::new_v4().to_string(); let mut session = if let Some(sec) = utterance_silence_sec { SpeechSession::new_utterance_with_silence(sample_rate, sec) @@ -153,15 +287,18 @@ pub(crate) async fn transcription_session( }; let output_sample_rate = session.output_sample_rate(); let stt_scheduler = SttScheduler::new(); + let tail_patch_enabled = tail_patch_enabled(); + let tail_patch_config = TailPatchConfig::from_env(); let mut pipeline = TranscriptionPipeline::new(language); let mut preview_rev: u64 = 0; let mut utterance_id: u64 = 0; let mut scheduler_utterance_id: u64 = 1; let mut total_utterances: u64 = 0; - let semantic_gate_drops: u64 = 0; + let mut semantic_gate_drops: u64 = 0; let mut filtered_empty_drops: u64 = 0; let mut corrections_applied: u64 = 0; + let mut tail_patch_replacements: u64 = 0; let mut partial_telemetry = PartialPassTelemetry::default(); let mut vad_started = false; let mut speech_activity_observed = false; @@ -193,12 +330,12 @@ pub(crate) async fn transcription_session( // chunk suffixes that advanced during Phase 1 preview processing. let mut utterance_boundary_suffix = String::new(); - // Fix D: Speech-window-scoped text/rev for partial-pass stale guard. + // Fix D: Speech-window-scoped text and boundary revision for partial-pass stale guard. // Unlike accumulated_text (cleared on UtteranceFinal), these track all text // emitted in the current correction window — giving schedule_partial_pass // a stable baseline that survives utterance boundaries. let mut window_text = String::new(); - let mut window_rev: u64 = 0; + let mut boundary_rev: u64 = 0; // Decouple audio ingestion from Whisper inference. const MAX_PENDING_UTTERANCES: usize = 64; @@ -231,10 +368,11 @@ pub(crate) async fn transcription_session( "Phase 1 inference pipeline configured" ); let mut inference_pipeline = FuturesOrdered::new(); + let mut tail_patch_pipeline = FuturesUnordered::new(); // Phase 2 (buffered correction) — request tracked for stale guards. let mut correction_in_flight: Option = None; - let mut correction_expected_preview_rev: Option = None; + let mut correction_expected_boundary_rev: Option = None; let mut correction_expected_text: Option = None; let mut correction_suffix_snapshot: Option = None; @@ -316,6 +454,11 @@ pub(crate) async fn transcription_session( audio, inference_audio_len: inference_audio.len(), is_final, + tail_patch_audio: if is_final && tail_patch_enabled { + Some(inference_audio.clone()) + } else { + None + }, speech_vad_samples, }; @@ -357,11 +500,11 @@ pub(crate) async fn transcription_session( pipeline.language.clone(), &mut correction_audio_buf, &mut correction_in_flight, - &mut correction_expected_preview_rev, + &mut correction_expected_boundary_rev, &mut correction_expected_text, &mut correction_suffix_snapshot, &suffix_snapshot, - window_rev, + boundary_rev, &window_text, partial_trigger_state.silero_speech_ms_since_partial, trigger, @@ -378,6 +521,7 @@ pub(crate) async fn transcription_session( && pending_utterances.is_empty() && inference_pipeline.is_empty() && correction_in_flight.is_none() + && tail_patch_pipeline.is_empty() { break; } @@ -626,11 +770,11 @@ pub(crate) async fn transcription_session( pipeline.language.clone(), &mut correction_audio_buf, &mut correction_in_flight, - &mut correction_expected_preview_rev, + &mut correction_expected_boundary_rev, &mut correction_expected_text, &mut correction_suffix_snapshot, &suffix_snapshot, - window_rev, + boundary_rev, &window_text, partial_trigger_state.silero_speech_ms_since_partial, trigger, @@ -644,26 +788,26 @@ pub(crate) async fn transcription_session( result = async { correction_in_flight.as_mut().unwrap().recv().await }, if correction_in_flight.is_some() => { - // Fix D: Use window_rev as fallback (schedule_partial_pass now stores window_rev). - let expected_preview_rev = correction_expected_preview_rev.take().unwrap_or(window_rev); + let expected_boundary_rev = + correction_expected_boundary_rev.take().unwrap_or(boundary_rev); let expected_text = correction_expected_text.take().unwrap_or_default(); let suffix_snapshot = correction_suffix_snapshot.take().unwrap_or_default(); match result { Ok(raw) => { // Fix D: Compare against window-scoped state (survives utterance boundaries). if correction_is_stale( - expected_preview_rev, - window_rev, + expected_boundary_rev, + boundary_rev, &expected_text, &window_text, ) { partial_telemetry.record_stale(); debug!( - expected_preview_rev, - window_rev, + expected_boundary_rev, + boundary_rev, expected_len = expected_text.chars().count(), current_len = window_text.chars().count(), - "Suppressing stale correction (window advanced or text changed)" + "Suppressing stale correction (boundary advanced)" ); } else { match postprocess_correction_with_snapshot( @@ -734,7 +878,13 @@ pub(crate) async fn transcription_session( } // Drain the pipeline. FuturesOrdered guarantees results arrive in the order submitted. // This is critical for timestamp calculation and text accumulation. - Some((result, item)) = inference_pipeline.next() => { + Some(result) = tail_patch_pipeline.next() => { + tail_patch_replacements = tail_patch_replacements + .saturating_add(emit_tail_patch_result(event_sink.as_ref(), result)); + } + // Drain the pipeline. FuturesOrdered guarantees results arrive in the order submitted. + // This is critical for timestamp calculation and text accumulation. + Some((result, mut item)) = inference_pipeline.next() => { // Track audio duration for timestamp computation. let chunk_start_samples = utterance_audio_samples; utterance_audio_samples += item.audio.len(); @@ -757,14 +907,22 @@ pub(crate) async fn transcription_session( match result { Ok(raw_transcript) => { + let raw_avg_logprob = raw_transcript.avg_logprob; + let raw_compression_ratio = raw_transcript.compression_ratio; + let raw_quality_gate_dropped = raw_transcript.quality_gate_dropped; + record_semantic_gate_drop( + &mut semantic_gate_drops, + raw_quality_gate_dropped, + item.is_final, + ); if item.is_final { - utterance_avg_logprob = raw_transcript.avg_logprob; - utterance_compression_ratio = raw_transcript.compression_ratio; - utterance_quality_gate_dropped = raw_transcript.quality_gate_dropped; + utterance_avg_logprob = raw_avg_logprob; + utterance_compression_ratio = raw_compression_ratio; + utterance_quality_gate_dropped = raw_quality_gate_dropped; } else if utterance_avg_logprob.is_none() { - utterance_avg_logprob = raw_transcript.avg_logprob; - utterance_compression_ratio = raw_transcript.compression_ratio; - if raw_transcript.quality_gate_dropped { + utterance_avg_logprob = raw_avg_logprob; + utterance_compression_ratio = raw_compression_ratio; + if raw_quality_gate_dropped { utterance_quality_gate_dropped = true; } } @@ -814,9 +972,10 @@ pub(crate) async fn transcription_session( ), }); } else { - match pipeline.postprocess_with_reason_and_segments( + match pipeline.postprocess_with_reason_and_segments_with_quality( &raw_text, &raw_segments, + raw_avg_logprob, ) { Ok(cleaned) => { if item.is_final { @@ -825,11 +984,12 @@ pub(crate) async fn transcription_session( if !cleaned_final.is_empty() { // Fix D: Append FINAL text to window-scoped state // (not replace — window spans multiple utterances). - if !window_text.is_empty() { - window_text.push(' '); - } - window_text.push_str(cleaned_final); - window_rev += 1; + append_to_correction_window_text( + &mut window_text, + cleaned_final, + CORRECTION_WINDOW_TEXT_MAX_CHARS, + ); + boundary_rev += 1; } else { // Keep the latest preview when FINAL postprocess is empty. // Otherwise silence boundary may never emit UtteranceFinal, @@ -847,12 +1007,14 @@ pub(crate) async fn transcription_session( } accumulated_text.push_str(cleaned.trim()); - // Fix D: Mirror into window-scoped state for partial-pass stale guard. - if !window_text.is_empty() { - window_text.push(' '); - } - window_text.push_str(cleaned.trim()); - window_rev += 1; + // Fix D: Mirror into window-scoped state for partial-pass baseline. + // Do not bump boundary_rev here: interim previews are same-boundary + // drafts and must not stale a live Refine correction. + append_to_correction_window_text( + &mut window_text, + cleaned.trim(), + CORRECTION_WINDOW_TEXT_MAX_CHARS, + ); debug!( rev = preview_rev, @@ -900,6 +1062,11 @@ pub(crate) async fn transcription_session( if item.is_final { utterance_id += 1; total_utterances += 1; + // TRIM CONTRACT (single owner): this .trim() is the ONE place that + // guarantees UtteranceFinal.text == tail-patch committed_text == + // already-trimmed. ReplaceRange char offsets are computed against + // THIS string; the SwiftUI sink stores it verbatim (its own trim is + // an idempotent no-op). Do not emit untrimmed text from any path. let final_text = accumulated_text.trim().to_string(); let end_ts = utterance_start_s + utterance_audio_samples as f32 / output_sample_rate as f32; @@ -928,7 +1095,7 @@ pub(crate) async fn transcription_session( ); event_sink.on_event(&EngineEvent::UtteranceFinal { utterance_id, - text: final_text, + text: final_text.clone(), raw_text: raw_text.clone(), start_ts: utterance_start_s, end_ts, @@ -939,6 +1106,18 @@ pub(crate) async fn transcription_session( quality_gate_dropped, confidence_flags, }); + if tail_patch_enabled + && let Some(audio) = item.tail_patch_audio.take() + { + tail_patch_pipeline.push(compute_tail_patch_job( + utterance_id, + final_text, + audio, + output_sample_rate, + pipeline.language.clone(), + tail_patch_config, + )); + } } else { utterance_segments.clear(); } @@ -974,11 +1153,11 @@ pub(crate) async fn transcription_session( pipeline.language.clone(), &mut correction_audio_buf, &mut correction_in_flight, - &mut correction_expected_preview_rev, + &mut correction_expected_boundary_rev, &mut correction_expected_text, &mut correction_suffix_snapshot, &suffix_snapshot, - window_rev, + boundary_rev, &window_text, partial_trigger_state.silero_speech_ms_since_partial, trigger, @@ -1076,6 +1255,7 @@ pub(crate) async fn transcription_session( } let reason = if speech_activity_observed || pipeline.hallucination_drops > 0 + || semantic_gate_drops > 0 || filtered_empty_drops > 0 || dropped_utterances > 0 { @@ -1105,6 +1285,8 @@ pub(crate) async fn transcription_session( partial_dropped_count: partial_telemetry.dropped_count, }); + emit_session_finalised(event_sink.as_ref(), session_id, tail_patch_replacements); + if dropped_utterances > 0 { warn!( "Session dropped {} utterance(s) due to backpressure or scheduler stalls", @@ -1113,11 +1295,12 @@ pub(crate) async fn transcription_session( } info!( - "Transcription session finished: {} utterances, {} hallucination drops, {} semantic gate drops, {} filtered empty drops, partial_runs={} (utterance={}, speech={}, watchdog={}, stale={}, coalesced={}, dropped={})", + "Transcription session finished: {} utterances, {} hallucination drops, {} semantic gate drops, {} filtered empty drops, {} tail patches, partial_runs={} (utterance={}, speech={}, watchdog={}, stale={}, coalesced={}, dropped={})", total_utterances, pipeline.hallucination_drops, semantic_gate_drops, filtered_empty_drops, + tail_patch_replacements, partial_telemetry.runs_total, partial_telemetry.trigger_utterance_count, partial_telemetry.trigger_speech_count, @@ -1143,6 +1326,7 @@ struct UtteranceWorkItem { audio: Vec, inference_audio_len: usize, is_final: bool, + tail_patch_audio: Option>, speech_vad_samples: u64, } @@ -1306,6 +1490,102 @@ pub async fn collect_buffered_engine_events( mod session_tests { use super::*; + #[test] + fn semantic_gate_drop_counter_tracks_quality_gate_flag() { + let mut drops = 0; + record_semantic_gate_drop(&mut drops, false, true); + assert_eq!(drops, 0); + + record_semantic_gate_drop(&mut drops, true, false); + assert_eq!( + drops, 0, + "interim preview drops must not count as utterance drops" + ); + + record_semantic_gate_drop(&mut drops, true, true); + assert_eq!(drops, 1); + } + + #[test] + fn tail_patch_result_emits_replace_range_events() { + let collector = SessionEventCollector::new(); + let emitted = emit_tail_patch_result( + &collector, + Ok(( + 42, + TailPatchOutcome::Patches(vec![EngineEvent::ReplaceRange { + utterance_id: 42, + start: 4, + end: 7, + text: "kot".to_string(), + source: LayerSource::TailPatch, + }]), + )), + ); + + assert_eq!(emitted, 1); + assert!(matches!( + collector.events().as_slice(), + [EngineEvent::ReplaceRange { + utterance_id: 42, + start: 4, + end: 7, + text, + source: LayerSource::TailPatch, + }] if text == "kot" + )); + } + + #[test] + fn final_text_trim_contract_keeps_tail_patch_offsets_aligned() { + // Simulate the emit site: accumulated_text carries whitespace, the single + // trim owner produces final_text, and that SAME string is both + // UtteranceFinal.text and the tail-patch committed_text. + let accumulated = " ala ma kota "; + let final_text = accumulated.trim().to_string(); + + // 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( + &final_text, + " ala ma psa \n", + 1, + &TailPatchConfig::default(), + ); + let TailPatchOutcome::Patches(events) = &outcome else { + panic!("expected Patches (not Skipped/NoChange), got {outcome:?}"); + }; + + // Offsets must apply cleanly against the exact string the consumer holds. + let mut buf = final_text.clone(); + for event in events { + let applied = event + .apply_to_committed_text(&mut buf) + .expect("patch offsets must be in range for the trimmed final_text"); + assert!(applied, "ReplaceRange must mutate the committed buffer"); + } + assert_eq!(buf, "ala ma psa"); + } + + #[test] + fn session_finalised_emits_layer_summary() { + let collector = SessionEventCollector::new(); + emit_session_finalised(&collector, "session-test".to_string(), 3); + + assert!(matches!( + collector.events().as_slice(), + [EngineEvent::SessionFinalised { + session_id, + layer_summary, + }] if session_id == "session-test" + && layer_summary.tail_patch_replacements == 3 + && layer_summary.lexicon_replacements == 0 + && layer_summary.inline_llm_replacements == 0 + && layer_summary.final_bam_replacements == 0 + && layer_summary.annotations_inserted == 0 + )); + } + #[test] fn correction_buffer_window_cap() { let sr = 16_000u32; @@ -1339,4 +1619,33 @@ mod session_tests { assert_eq!(cap_correction_buffer(&mut buf, sr, 0.0), 0); assert_eq!(buf.len(), 100); } + + #[test] + fn correction_window_text_cap_keeps_recent_tail() { + let max_chars = 64usize; + let mut window_text = String::new(); + + for idx in 0..40 { + append_to_correction_window_text( + &mut window_text, + &format!("utterance-{idx:02}"), + max_chars, + ); + assert!( + window_text.chars().count() <= max_chars, + "window text {} chars exceeds cap {}", + window_text.chars().count(), + max_chars + ); + } + + assert!( + !window_text.contains("utterance-00"), + "old text should be evicted from the correction window" + ); + assert!( + window_text.ends_with("utterance-39"), + "fresh correction tail must stay available" + ); + } } diff --git a/core/pipeline/streaming/tests.rs b/core/pipeline/streaming/tests.rs index 48c4aa7e..6b7b6318 100644 --- a/core/pipeline/streaming/tests.rs +++ b/core/pipeline/streaming/tests.rs @@ -1,4 +1,7 @@ use std::collections::VecDeque; +use std::fs::File; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; use std::sync::{Arc, Condvar, Mutex as StdMutex}; use anyhow::Result; @@ -34,14 +37,33 @@ fn pending_item_with_marker(is_final: bool, marker: f32) -> PendingUtteranceWork #[test] fn test_postprocess_components() { // Hallucination - assert!(is_hallucination("Thank you", None)); - assert!(is_hallucination(" Dziękuję za uwagę ", Some("pl"))); - assert!(is_hallucination( + assert!(is_hallucination_with_quality("Thank you", None, None)); + assert!(is_hallucination_with_quality( + " Dziękuję za uwagę ", + Some("pl"), + None + )); + assert!(is_hallucination_with_quality( "Napisy stworzone przez społeczność", - Some("pl") + Some("pl"), + None + )); + assert!(!is_hallucination_with_quality("Tak", Some("pl"), None)); // Whitelisted + assert!(!is_hallucination_with_quality( + "This is a normal sentence.", + Some("en"), + None + )); + assert!(!is_hallucination_with_quality( + "tłumaczenie", + Some("pl"), + Some(-0.2) + )); + assert!(is_hallucination_with_quality( + "tłumaczenie", + Some("pl"), + Some(-1.2) )); - assert!(!is_hallucination("Tak", Some("pl"))); // Whitelisted - assert!(!is_hallucination("This is a normal sentence.", Some("en"))); // Overlap let mut pipeline = TranscriptionPipeline::new(None); @@ -977,14 +999,14 @@ fn test_correction_delta_polish_diacritics() { } #[test] -fn test_correction_stale_guard_detects_preview_rev_drift() { +fn test_correction_stale_guard_detects_boundary_rev_drift() { assert!(correction_is_stale(7, 8, "draft", "draft")); assert!(!correction_is_stale(7, 7, "draft", "draft")); } #[test] -fn test_correction_stale_guard_detects_text_drift() { - assert!(correction_is_stale(9, 9, "ala ma", "ala ma kota")); +fn test_correction_stale_guard_allows_text_drift_with_same_boundary() { + assert!(!correction_is_stale(9, 9, "ala ma", "ala ma kota")); } #[test] @@ -1016,7 +1038,8 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { let infer = Arc::new( move |samples: Vec, _sample_rate: u32, - _language: Option| + _language: Option, + _initial_prompt: Option| -> Result { let id = samples.first().copied().unwrap_or_default() as u32; started_ref @@ -1046,7 +1069,7 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { let collector = Arc::new(CollectorEventSink::new()); let event_sink: Arc = collector.clone(); let mut correction_in_flight: Option = None; - let mut correction_expected_preview_rev: Option = None; + let mut correction_expected_boundary_rev: Option = None; let mut correction_expected_text: Option = None; let mut correction_suffix_snapshot: Option = None; let mut partial_telemetry = PartialPassTelemetry::default(); @@ -1058,7 +1081,7 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { Some("en".to_string()), &mut first_audio, &mut correction_in_flight, - &mut correction_expected_preview_rev, + &mut correction_expected_boundary_rev, &mut correction_expected_text, &mut correction_suffix_snapshot, "suffix-a", @@ -1074,9 +1097,9 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { "correction audio buffer should be consumed on schedule" ); assert_eq!( - correction_expected_preview_rev, + correction_expected_boundary_rev, Some(7), - "tracked preview revision should match first scheduled correction" + "tracked boundary revision should match first scheduled correction" ); assert_eq!( correction_expected_text.as_deref(), @@ -1100,7 +1123,7 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { Some("en".to_string()), &mut second_audio, &mut correction_in_flight, - &mut correction_expected_preview_rev, + &mut correction_expected_boundary_rev, &mut correction_expected_text, &mut correction_suffix_snapshot, "suffix-b", @@ -1127,9 +1150,9 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { assert_eq!(partial_telemetry.stale_count, 0); assert_eq!(partial_telemetry.dropped_count, 0); assert_eq!( - correction_expected_preview_rev, + correction_expected_boundary_rev, Some(8), - "new schedule should overwrite tracked preview revision" + "new schedule should overwrite tracked boundary revision" ); assert_eq!( correction_expected_text.as_deref(), @@ -1195,7 +1218,8 @@ async fn test_schedule_partial_pass_repeated_coalescing_under_async_pressure() { let infer = Arc::new( move |samples: Vec, _sample_rate: u32, - _language: Option| + _language: Option, + _initial_prompt: Option| -> Result { let id = samples.first().copied().unwrap_or_default() as u32; started_ref @@ -1225,7 +1249,7 @@ async fn test_schedule_partial_pass_repeated_coalescing_under_async_pressure() { let collector = Arc::new(CollectorEventSink::new()); let event_sink: Arc = collector.clone(); let mut correction_in_flight: Option = None; - let mut correction_expected_preview_rev: Option = None; + let mut correction_expected_boundary_rev: Option = None; let mut correction_expected_text: Option = None; let mut correction_suffix_snapshot: Option = None; let mut partial_telemetry = PartialPassTelemetry::default(); @@ -1252,7 +1276,7 @@ async fn test_schedule_partial_pass_repeated_coalescing_under_async_pressure() { Some("en".to_string()), &mut audio, &mut correction_in_flight, - &mut correction_expected_preview_rev, + &mut correction_expected_boundary_rev, &mut correction_expected_text, &mut correction_suffix_snapshot, &expected_suffix, @@ -1267,7 +1291,7 @@ async fn test_schedule_partial_pass_repeated_coalescing_under_async_pressure() { audio.is_empty(), "schedule should consume correction audio buffer" ); - assert_eq!(correction_expected_preview_rev, Some(expected_rev)); + assert_eq!(correction_expected_boundary_rev, Some(expected_rev)); assert_eq!( correction_expected_text.as_deref(), Some(expected_text.as_str()) @@ -1408,6 +1432,220 @@ async fn transcription_session_emits_no_speech_and_stats_for_empty_input() { assert_eq!(stats_count, 1, "expected exactly one Stats event"); } +#[test] +fn transcription_events_keep_monotonic_previews_before_final() { + let sink = CollectorEventSink::new(); + sink.on_event(&EngineEvent::Preview { + rev: 1, + text: "ala".to_string(), + }); + sink.on_event(&EngineEvent::Preview { + rev: 2, + text: "ala ma".to_string(), + }); + sink.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(), + }); + + let events = sink.events(); + let final_pos = events + .iter() + .position(|event| matches!(event, EngineEvent::UtteranceFinal { .. })) + .expect("synthetic stream should include a final event"); + let preview_revs: Vec<(usize, u64)> = events + .iter() + .enumerate() + .filter_map(|(pos, event)| match event { + EngineEvent::Preview { rev, .. } => Some((pos, *rev)), + _ => None, + }) + .collect(); + + assert!( + !preview_revs.is_empty(), + "stream should emit at least one preview before final" + ); + assert!( + preview_revs.iter().all(|(pos, _)| *pos < final_pos), + "all previews must be emitted before UtteranceFinal" + ); + for pair in preview_revs.windows(2) { + assert!( + pair[0].1 < pair[1].1, + "Preview revisions must increase monotonically" + ); + } +} + +#[derive(Clone)] +struct BenchTimedEventSink { + started_at: Instant, + events: Arc>>, +} + +impl BenchTimedEventSink { + fn new() -> Self { + Self { + started_at: Instant::now(), + events: Arc::new(StdMutex::new(Vec::new())), + } + } + + fn events(&self) -> Vec<(u128, EngineEvent)> { + self.events + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } +} + +impl EventSink for BenchTimedEventSink { + fn on_event(&self, event: &EngineEvent) { + self.events + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((self.started_at.elapsed().as_millis(), event.clone())); + } +} + +fn bench_manifest_audio_paths(path: &str) -> Result> { + // Bench helper reads a local developer-provided manifest TSV path; no untrusted/network input. + let file = File::open(path)?; // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + let mut lines = BufReader::new(file).lines(); + let header = lines + .next() + .transpose()? + .ok_or_else(|| anyhow::anyhow!("latency manifest is empty"))?; + let columns: Vec<&str> = header.split('\t').collect(); + let staged_audio_idx = columns + .iter() + .position(|name| *name == "staged_audio") + .ok_or_else(|| anyhow::anyhow!("latency manifest lacks staged_audio column"))?; + + let mut audio_paths = Vec::new(); + for line in lines { + let line = line?; + if line.trim().is_empty() { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + let Some(audio_path) = fields.get(staged_audio_idx) else { + continue; + }; + if !audio_path.is_empty() { + audio_paths.push(PathBuf::from(audio_path)); + } + } + Ok(audio_paths) +} + +fn bench_entry_id(path: &std::path::Path) -> String { + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("audio"); + let parent = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + .unwrap_or("bench"); + format!("{parent}__{stem}") +} + +#[tokio::test] +#[ignore = "env-driven STT bench probe invoked by scripts/bench-stt.sh"] +async fn bench_stt_scheduler_latency_probe_from_env() -> Result<()> { + let manifest_path = std::env::var("BENCH_STT_LATENCY_MANIFEST")?; + let output_path = std::env::var("BENCH_STT_LATENCY_OUT")?; + let language = std::env::var("BENCH_STT_LANGUAGE").ok(); + let audio_paths = bench_manifest_audio_paths(&manifest_path)?; + + crate::stt::whisper::init()?; + + let mut out = File::create(&output_path)?; + writeln!( + out, + "id\taudio_path\tduration_sec\tfirst_preview_ms\tfinal_ms\tsession_done_ms\tpreviews\tfinals\tfinal_chars" + )?; + + for audio_path in audio_paths { + let (samples, sample_rate) = crate::audio::load_audio_file(&audio_path)?; + let duration_sec = samples.len() as f64 / sample_rate as f64; + let chunk_size = ((sample_rate as f32) * 0.1).round().max(1.0) as usize; + let (tx, rx) = mpsc::channel::>(8); + let sink = Arc::new(BenchTimedEventSink::new()); + let event_sink: Arc = sink.clone(); + let session_started = Instant::now(); + let session = tokio::spawn(transcription_session( + rx, + event_sink, + SessionConfig { + sample_rate, + language: language.clone(), + stream_log_path: None, + utterance_silence_sec: None, + }, + )); + + for chunk in samples.chunks(chunk_size) { + tx.send(chunk.to_vec()) + .await + .map_err(|_| anyhow::anyhow!("transcription session dropped channel"))?; + } + drop(tx); + + session + .await + .map_err(|e| anyhow::anyhow!("transcription session join error: {e}"))?; + + let events = sink.events(); + let first_preview_ms = events.iter().find_map(|(ms, event)| match event { + EngineEvent::Preview { text, .. } if !text.trim().is_empty() => Some(*ms), + _ => None, + }); + let mut final_ms = None; + let mut previews = 0usize; + let mut finals = 0usize; + let mut final_chars = 0usize; + for (ms, event) in &events { + match event { + EngineEvent::Preview { text, .. } if !text.trim().is_empty() => { + previews += 1; + } + EngineEvent::UtteranceFinal { text, .. } => { + finals += 1; + final_ms = Some(*ms); + final_chars += text.chars().count(); + } + _ => {} + } + } + + writeln!( + out, + "{}\t{}\t{:.3}\t{}\t{}\t{}\t{}\t{}\t{}", + bench_entry_id(&audio_path), + audio_path.display(), + duration_sec, + first_preview_ms.map_or_else(|| "NA".to_string(), |ms| ms.to_string()), + final_ms.map_or_else(|| "NA".to_string(), |ms| ms.to_string()), + session_started.elapsed().as_millis(), + previews, + finals, + final_chars + )?; + } + + Ok(()) +} + #[tokio::test] async fn transcription_session_silent_callbacks_keep_no_speech_stats_coherent() { let (tx, rx) = mpsc::channel::>(1); @@ -1608,28 +1846,28 @@ fn test_fix_a_final_uses_boundary_suffix_not_nonfinal_suffix() { ); } -// ── Fix D contract: window-scoped stale guard survives utterance boundaries ── +// ── Fix D contract: boundary-scoped stale guard survives utterance boundaries ── #[test] -fn test_fix_d_stale_guard_with_window_rev_survives_final() { +fn test_fix_d_stale_guard_with_boundary_rev_survives_final() { // Before Fix D: schedule_partial_pass stored preview_rev / accumulated_text. // After FINAL: accumulated_text.clear() → correction_is_stale could pass // when it shouldn't (empty == empty). // - // After Fix D: schedule_partial_pass stores window_rev / window_text. - // FINAL increments window_rev → correction_is_stale correctly detects staleness. + // After Fix D: schedule_partial_pass stores boundary_rev / window_text. + // FINAL increments boundary_rev → correction_is_stale correctly detects staleness. - let window_rev_at_schedule: u64 = 5; + let boundary_rev_at_schedule: u64 = 5; let window_text_at_schedule = "cześć jak się masz"; // Simulate FINAL boundary advancing window state. - let window_rev_after_final: u64 = 6; // FINAL incremented it + let boundary_rev_after_final: u64 = 6; // FINAL incremented it let window_text_after_final = "cześć jak się masz dobrze"; assert!( correction_is_stale( - window_rev_at_schedule, - window_rev_after_final, + boundary_rev_at_schedule, + boundary_rev_after_final, window_text_at_schedule, window_text_after_final, ), @@ -1639,37 +1877,73 @@ fn test_fix_d_stale_guard_with_window_rev_survives_final() { #[test] fn test_fix_d_stale_guard_passes_when_window_unchanged() { - // When no FINAL or new text arrives between schedule and correction result, - // the window state matches and correction should apply. - let window_rev: u64 = 5; + // When no boundary arrives between schedule and correction result, + // correction should apply. + let boundary_rev: u64 = 5; let window_text = "cześć jak się masz"; assert!( - !correction_is_stale(window_rev, window_rev, window_text, window_text), + !correction_is_stale(boundary_rev, boundary_rev, window_text, window_text), "correction should not be stale when window state unchanged" ); } #[test] -fn test_fix_d_empty_accumulated_text_after_final_detected_by_window_rev() { +fn test_refine_stale_guard_allows_interim_preview_text_drift() { + // Interim previews are same-boundary drafts. They can change text while the + // Refine lane is in flight, but they must not make live correction inert. + let boundary_rev_at_schedule: u64 = 5; + let boundary_rev_after_interim_previews: u64 = 5; + let expected_text = "cześć jak"; + let current_text_after_interim_previews = "cześć jak się masz"; + + assert!( + !correction_is_stale( + boundary_rev_at_schedule, + boundary_rev_after_interim_previews, + expected_text, + current_text_after_interim_previews, + ), + "same-boundary interim preview text drift must not stale a Refine correction" + ); +} + +#[test] +fn test_refine_stale_guard_rejects_after_boundary_change() { + let boundary_rev_at_schedule: u64 = 5; + let boundary_rev_after_final: u64 = 6; + + assert!( + correction_is_stale( + boundary_rev_at_schedule, + boundary_rev_after_final, + "cześć jak", + "cześć jak się masz", + ), + "real boundary changes must still stale older Refine corrections" + ); +} + +#[test] +fn test_fix_d_empty_accumulated_text_after_final_detected_by_boundary_rev() { // Edge case: FINAL clears accumulated_text. Before Fix D, stale guard // compared "" vs "" → not stale → correction applies to empty text. - // After Fix D, window_rev incremented by FINAL → stale. + // After Fix D, boundary_rev incremented by FINAL → stale. // Old behavior (broken): accumulated_text scope — expected "hello world" // vs current "" (cleared by FINAL). This would pass if revs matched // (both based on preview_rev which didn't increment for FINAL). - // New behavior: window scope - let window_rev_at_schedule: u64 = 3; + // New behavior: boundary scope + let boundary_rev_at_schedule: u64 = 3; let window_text_at_schedule = "hello world"; - let window_rev_after_final: u64 = 4; // FINAL bumped it + let boundary_rev_after_final: u64 = 4; // FINAL bumped it let window_text_after_final = "hello world and more"; // FINAL appended assert!( correction_is_stale( - window_rev_at_schedule, - window_rev_after_final, + boundary_rev_at_schedule, + boundary_rev_after_final, window_text_at_schedule, window_text_after_final, ), diff --git a/core/pipeline/tests/regressions.rs b/core/pipeline/tests/regressions.rs index 827f13f1..96bdfeee 100644 --- a/core/pipeline/tests/regressions.rs +++ b/core/pipeline/tests/regressions.rs @@ -2,7 +2,7 @@ use serial_test::serial; use std::fs; use std::path::{Path, PathBuf}; -use crate::audio::chunker::SpeechSession; +use crate::audio::chunker::{DEFAULT_BUFFERED_SILENCE_SEC, SpeechSession}; use crate::vad; struct EnvGuard { @@ -67,10 +67,9 @@ fn utterance_silence_default_regression() { .max(1.0) as usize; assert_eq!(stream.min_silence_samples(), stream_expected); - // Utterance mode should keep VadConfig default silence unless explicitly overridden. - let utter_expected = (base.max_silence_duration_sec * vad::VAD_SAMPLE_RATE as f32) - .round() - .max(1.0) as usize; + // Utterance mode uses the buffered cadence default unless explicitly overridden. + let utter_expected = + (DEFAULT_BUFFERED_SILENCE_SEC * vad::VAD_SAMPLE_RATE as f32).round() as usize; assert_eq!(utterance.min_silence_samples(), utter_expected); } @@ -120,7 +119,10 @@ fn runtime_contract_blocks_legacy_worker_symbols() { let mut guarded_sources: Vec<(String, String)> = [ "core/audio/streaming_recorder.rs", "app/controller/mod.rs", - "bin/codescribe.rs", + // Runtime entrypoint that installs the hotkey listener and hosts the + // RecordingController for the SwiftUI app — successor to the removed + // `bin/codescribe.rs` tray binary. + "bridge/src/hotkeys.rs", ] .into_iter() .map(|relative_path| { diff --git a/core/quality/qube_daemon.rs b/core/quality/qube_daemon.rs index 5e8a79db..944ad1e1 100644 --- a/core/quality/qube_daemon.rs +++ b/core/quality/qube_daemon.rs @@ -286,7 +286,7 @@ fn write_analysis_files(output_dir: &Path, analysis: &LoopAnalysis) -> Result<() fn render_analysis_markdown(analysis: &LoopAnalysis) -> String { let mut out = String::new(); - out.push_str("# CodeScribe Quality Loop Analysis\n\n"); + out.push_str("# Codescribe Quality Loop Analysis\n\n"); out.push_str(&format!("Generated: {}\n\n", analysis.generated_at)); out.push_str(&format!("- Current report: {}\n", analysis.current_report)); if let Some(baseline) = &analysis.baseline_report { @@ -1359,7 +1359,7 @@ mod tests { #[test] fn test_normalize_tokens_punctuation_to_space() { - let tokens = normalize_tokens("CodeScribe's test-case, version 2.0!"); + let tokens = normalize_tokens("Codescribe's test-case, version 2.0!"); assert_eq!( tokens, vec!["codescribe", "s", "test", "case", "version", "2", "0"] @@ -1784,7 +1784,7 @@ mod tests { }], }; let md = render_analysis_markdown(&analysis); - assert!(md.contains("# CodeScribe Quality Loop Analysis")); + assert!(md.contains("# Codescribe Quality Loop Analysis")); assert!(md.contains("Baseline report")); assert!(md.contains("## Regressions")); assert!(md.contains("## Updates")); diff --git a/core/quality/qube_report.rs b/core/quality/qube_report.rs index e94076e4..e3cf78b6 100644 --- a/core/quality/qube_report.rs +++ b/core/quality/qube_report.rs @@ -66,14 +66,12 @@ impl MetricsReference { #[serde(rename_all = "snake_case")] pub enum LocalTranscriptionMode { LocalWhisper, - CodeScribeIpc, } impl LocalTranscriptionMode { fn as_str(self) -> &'static str { match self { Self::LocalWhisper => "local_whisper", - Self::CodeScribeIpc => "codescribe_ipc", } } } @@ -274,7 +272,7 @@ pub async fn run(config: QualityReportConfig) -> Result { crate::stt::init_active_engine() .context("Failed to init active STT engine via core::stt")?; } else { - info!("Local Whisper init skipped: quality report uses CodeScribe IPC transcription"); + info!("Local Whisper init skipped: quality report uses Codescribe IPC transcription"); } // Resume: skip pairs that already have artifacts. @@ -473,7 +471,7 @@ async fn process_pair( let (_speech_only, vad_stats) = crate::vad::extract_speech(&samples, sample_rate); let raw_transcript = - transcribe_raw_for_report(&audio_canon, &samples, sample_rate, config, &mut errors).await; + transcribe_raw_for_report(&samples, sample_rate, config, &mut errors).await; let raw_semantics = classify_raw_semantics( raw_transcript.as_ref(), vad_stats.no_speech_reason.as_deref(), @@ -525,6 +523,20 @@ async fn process_pair( None }; + // Protected-vocabulary audit: flag operator/tool/agent names that survived + // the post-lexicon transcript but were dropped or mutated by the AI pass. + // This makes technical-name corruption visible to the operator instead of + // silently shipping "plausible prose" that lost the intended terms. + if let (Some(post_text), Some(ai_text)) = (post.as_deref(), ai_formatted.as_deref()) { + let lost = crate::stream_postprocess::protected_terms_lost(post_text, ai_text); + if !lost.is_empty() { + errors.push(format!( + "Protected terms lost in AI formatting: {}", + lost.join(", ") + )); + } + } + let cloud = cloud_jobs.take_for(&id, &mut errors).await; let metrics_reference = match config.metrics_reference { @@ -719,7 +731,7 @@ fn write_report_files( fn render_markdown(report: &QualityReport) -> String { let mut out = String::new(); - out.push_str("# CodeScribe Quality Report\n\n"); + out.push_str("# Codescribe Quality Report\n\n"); out.push_str(&format!("Generated: {}\n\n", report.generated_at)); out.push_str(&format!( "Metrics reference: {}\n\n", @@ -778,7 +790,7 @@ fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { let mut body = String::new(); body.push_str(&format!( - "

CodeScribe Quality Report

Generated: {}

Metrics reference: {}

Raw semantics: text_committed={} • quality_gate_dropped={} • no_speech_detected={}

", + "

Codescribe Quality Report

Generated: {}

Metrics reference: {}

Raw semantics: text_committed={} • quality_gate_dropped={} • no_speech_detected={}

", html_escape(&report.generated_at), html_escape(&report.environment.metrics_reference), report.summary.raw_text_committed, @@ -938,7 +950,7 @@ fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { -CodeScribe Quality Report +Codescribe Quality Report