diff --git a/.env.debug.example b/.env.debug.example index 58a99ccb..a4841c72 100644 --- a/.env.debug.example +++ b/.env.debug.example @@ -77,34 +77,34 @@ AI_FORMATTING_ENABLED=1 # AI_PROVIDER=harmony # legacy (harmony|ollama) # AI_MAX_TOKENS=0 # 0 = no limit # AI_ASSISTIVE_MAX_TOKENS=0 # 0 = no limit -TRANSCRIPT_SEND_MODE=end_of_utterance # end_of_utterance | streaming + +# end_of_utterance | streaming: +TRANSCRIPT_SEND_MODE=end_of_utterance # Retry policy for AI calls # CODESCRIBE_AI_MAX_RETRIES=2 # CODESCRIBE_AI_RETRY_DELAY_MS=500 # CODESCRIBE_AI_ATTEMPT_TIMEOUT_MS=5000 +# CODESCRIBE_AI_OLLAMA_ATTEMPT_TIMEOUT_MS=5000 # ============================================================================= # STREAMING / CHUNKING / BUFFERED MODE (IMPORTANT) # ============================================================================= -CODESCRIBE_STREAM_CHUNK_SEC=15.0 # Chunk size in seconds (0.5..30) +CODESCRIBE_STREAM_CHUNK_SEC=4.0 # Chunk size in seconds (0.5..30) CODESCRIBE_STREAM_OVERLAP_RATIO=0.25 # Overlap ratio (0.05..0.8) of chunk size CODESCRIBE_BUFFERED_STREAM=1 # 1 enables VAD buffered mode (default) -CODESCRIBE_BUFFER_DELAY_MS=3000 # Delay before emitting buffered text -CODESCRIBE_TYPING_CPS=30 # Emission speed in buffered mode +# CODESCRIBE_EVENT_PIPELINE=0 # 1 enables event-based pipeline (experimental) +CODESCRIBE_BUFFER_DELAY_MS=1800 # Delay before emitting buffered text +CODESCRIBE_TYPING_CPS=36 # Emission speed in buffered mode # VAD for buffered mode (Silero neural network) -CODESCRIBE_VAD_THRESHOLD=0.5 -CODESCRIBE_VAD_MIN_SPEECH_SEC=0.1 -CODESCRIBE_VAD_MAX_SILENCE_SEC=1.2 -CODESCRIBE_VAD_MAX_UTTERANCE_SEC=60 -CODESCRIBE_VAD_PRE_ROLL_SEC=0.3 +# VAD internals are hardcoded in core/vad/config.rs (Silero defaults). # ============================================================================= # STREAM POSTPROCESS (REPETITION / GATING) # ============================================================================= -# CODESCRIBE_STREAM_SIMILARITY=0.90 # similarity threshold -# CODESCRIBE_STREAM_NOVELTY=0.20 # novelty threshold +# CODESCRIBE_STREAM_SIMILARITY=0.93 # similarity threshold +# CODESCRIBE_STREAM_NOVELTY=0.12 # novelty threshold # CODESCRIBE_STREAM_DISABLE_EMBEDDINGS=0 # ============================================================================= @@ -131,7 +131,7 @@ HOLD_START_DELAY_MS=800 # UI / OVERLAYS / FEEDBACK # ============================================================================= SHOW_TRAY_GLYPH=1 -HOLD_INDICATOR=1 # bool (legacy string values ignored) +HOLD_INDICATOR=1# bool (legacy string values ignored) HOLD_BADGE_SIZE=12 HOLD_BADGE_OFFSET_X=10 HOLD_BADGE_OFFSET_Y=-10 @@ -158,7 +158,6 @@ DUMP_AUDIO_LOGS=1 # ============================================================================= # QUALITY LOOP / REPORTING # ============================================================================= -# QUALITY_DEBUG_MODE=1 # CODESCRIBE_QUALITY_DISABLE_CLOUD=1 # ============================================================================= diff --git a/.env.example b/.env.example index 6387f33d..f28f5835 100644 --- a/.env.example +++ b/.env.example @@ -1,43 +1,172 @@ -# CodeScribe .env (minimal + recommended) -# Source of truth: docs/env.md +# CodeScribe .env (power-user overrides) +# Priority: env vars > ~/.codescribe/.env > settings.json # Copy: cp .env.example ~/.codescribe/.env # ============================================================================= -# LOCAL EMBEDDED (RECOMMENDED) +# CORE STT (LOCAL BY DEFAULT) # ============================================================================= USE_LOCAL_STT=1 -WHISPER_LANGUAGE=pl LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 +# WHISPER_LANGUAGE=pl +# CODESCRIBE_WHISPER_INITIAL_PROMPT=CodeScribe, LibraxisAI, Docker, GitHub +# WHISPER_INITIAL_PROMPT=... # legacy alias +# CODESCRIBE_MODEL_PATH=/path/to/whisper/model # dev override # ============================================================================= -# AI FORMATTING (OPTIONAL) +# CLOUD STT (OPTIONAL) # ============================================================================= -# AI_FORMATTING_ENABLED=1 +# USE_LOCAL_STT=0 +# STT_ENDPOINT=https://api.libraxis.cloud/v1/audio/transcriptions +# STT_API_KEY=... +# CODESCRIBE_LOOP_USE_CLOUD_STT=1 # quality loop only + +# ============================================================================= +# HOTKEYS +# ============================================================================= +HOLD_MODS=fn +HOLD_EXCLUSIVE=1 +TOGGLE_TRIGGER=double_option +HOLD_START_DELAY_MS=800 +DOUBLE_TAP_INTERVAL_MS=200 +TOGGLE_SILENCE_SEC=5.0 +# HOTKEY_DOUBLE_TAP_LEFT=0 # legacy (auto-migrated to TOGGLE_TRIGGER) +# HOTKEY_DOUBLE_TAP_RIGHT=0 # legacy (auto-migrated to TOGGLE_TRIGGER) + +# ============================================================================= +# OVERLAY / UI +# ============================================================================= +OVERLAY_POSITION_MODE=snapped_top_right +# OVERLAY_CUSTOM_X=0 +# OVERLAY_CUSTOM_Y=0 +SHOW_TRAY_GLYPH=1 +SHOW_DOCK_ICON=1 +HOLD_INDICATOR=1 +HOLD_BADGE_SIZE=8 +HOLD_BADGE_OFFSET_X=10 +HOLD_BADGE_OFFSET_Y=-10 + +# ============================================================================= +# AUDIO + SOUND +# ============================================================================= +AUTO_SILENCE=0 +BEEP_ON_START=1 +SOUND_NAME=Tink +SOUND_VOLUME=0.25 +# AUDIO_INPUT_DEVICE=Built-in Microphone + +# ============================================================================= +# CLIPBOARD + HISTORY +# ============================================================================= +RESTORE_CLIPBOARD=1 +RESTORE_CLIPBOARD_DELAY_MS=1000 +HISTORY_ENABLED=1 +DUMP_AUDIO_LOGS=1 + +# ============================================================================= +# QUICK NOTES +# ============================================================================= +QUICK_NOTES_ENABLED=0 +QUICK_NOTES_SAVE_ONLY=0 +# CODESCRIBE_NOTES_DIR=~/Git/vista/notes + +# ============================================================================= +# AI FORMATTING / ASSISTIVE +# ============================================================================= +AI_FORMATTING_ENABLED=1 +TRANSCRIPT_SEND_MODE=streaming # streaming | full +# AI_MAX_TOKENS=1200 +# AI_ASSISTIVE_MAX_TOKENS=2000 + +# Shared LLM defaults # LLM_ENDPOINT=https://api.openai.com/v1/responses # LLM_MODEL=gpt-4.1-mini # LLM_API_KEY=sk-... +# LLM_TEMPERATURE=0.2 +LLM_USE_STREAMING=1 -# Formatting overrides (optional) -# LLM_FORMATTING_ENDPOINT= -# LLM_FORMATTING_MODEL= -# LLM_FORMATTING_API_KEY= +# Retry/timeout policy +# CODESCRIBE_AI_MAX_RETRIES=3 +# CODESCRIBE_AI_RETRY_DELAY_MS=2000 +# CODESCRIBE_AI_ATTEMPT_TIMEOUT_MS=30000 +# CODESCRIBE_AI_OLLAMA_ATTEMPT_TIMEOUT_MS=5000 +# CODESCRIBE_AI_INTER_CHUNK_TIMEOUT_MS=30000 +# CODESCRIBE_AI_CLIENT_TIMEOUT_MS=90000 +# CODESCRIBE_AI_CONNECT_TIMEOUT_MS=5000 +# CODESCRIBE_AI_POOL_IDLE_TIMEOUT_MS=90000 +# CODESCRIBE_AI_TCP_KEEPALIVE_MS=30000 -# Assistive overrides (optional) -# LLM_ASSISTIVE_ENDPOINT= -# LLM_ASSISTIVE_MODEL= +# Assistive overrides +# LLM_ASSISTIVE_ENDPOINT=https://api.openai.com/v1/responses +# LLM_ASSISTIVE_MODEL=gpt-5.2 # LLM_ASSISTIVE_API_KEY= +# LLM_ASSISTIVE_TEMPERATURE=0.3 + +# Formatting overrides +# LLM_FORMATTING_ENDPOINT=http://localhost:11434/v1/responses +# LLM_FORMATTING_MODEL=llama3.2 +# LLM_FORMATTING_API_KEY= +# LLM_FORMATTING_TEMPERATURE=0.1 # ============================================================================= -# CLOUD STT (OPTIONAL, CLOUD-ONLY) +# ASSISTIVE CONTEXT CAPTURE # ============================================================================= -# USE_LOCAL_STT=0 -# STT_ENDPOINT=https://api.libraxis.cloud/v1/audio/transcriptions -# STT_API_KEY=... +# ASSISTIVE_CONTEXT_MAX_CHARS=20000 +# ASSISTIVE_CONTEXT_COPY_DELAY_MS=150 + +# ============================================================================= +# ATTACHMENTS / OCR +# ============================================================================= +# CODESCRIBE_ATTACH_PDF_OCR=1 +# CODESCRIBE_ATTACH_PDF_OCR_LANG=pol+eng +# CODESCRIBE_ATTACH_PDF_PAGES=3 +# CODESCRIBE_DRAWER_INCLUDE_LEGACY=0 + +# ============================================================================= +# STREAMING / POSTPROCESS +# ============================================================================= +# Buffered (magic) tuning +# CODESCRIBE_BUFFERED_INTERIM_SEC=1.2 +# CODESCRIBE_BUFFERED_CORRECTION_PREFIX=0.50 +CODESCRIBE_STREAM_CHUNK_SEC=2.0 +CODESCRIBE_STREAM_OVERLAP_RATIO=0.25 +# CODESCRIBE_BUFFER_DELAY_MS=280 +# CODESCRIBE_TYPING_CPS=90 +# CODESCRIBE_EMIT_WORDS_MAX=2 +CODESCRIBE_STREAM_SIMILARITY=0.90 +CODESCRIBE_STREAM_NOVELTY=0.20 +# CODESCRIBE_STREAM_DISABLE_EMBEDDINGS=1 +# CODESCRIBE_STREAM_FORCE_EMBEDDINGS=1 +# CODESCRIBE_STREAM_LEXICON=1 +# CODESCRIBE_STREAM_LOG=1 +# CODESCRIBE_STREAM_LOG_PATH=/tmp/codescribe_stream.log + +# ============================================================================= +# VAD (SILERO) +# ============================================================================= +# VAD internals are hardcoded in core/vad/config.rs (Silero defaults). + +# ============================================================================= +# PATHS / STORAGE (ADVANCED) +# ============================================================================= +# CODESCRIBE_DATA_DIR=/custom/data/dir +# CODESCRIBE_ENV_PATH=/custom/.env + +# ============================================================================= +# EMBEDDER (E5) - RUNTIME OVERRIDES +# ============================================================================= +# CODESCRIBE_EMBEDDER_PATH=/path/to/e5 +# CODESCRIBE_EMBEDDER_REPO=intfloat/multilingual-e5-small + +# ============================================================================= +# BUILD-TIME EMBEDDING (RELEASE ONLY) +# ============================================================================= +# CODESCRIBE_NO_EMBED=1 +# CODESCRIBE_EMBED_MODEL=whisper-large-v3-turbo-mlx-q8 +# CODESCRIBE_EMBED_TTS=1 +# CODESCRIBE_TTS_PATH=/path/to/tts # ============================================================================= -# STREAMING TUNING (OPTIONAL) +# LOGGING (OPTIONAL) # ============================================================================= -# CODESCRIBE_STREAM_CHUNK_SEC=12 -# CODESCRIBE_STREAM_OVERLAP_RATIO=0.25 -# CODESCRIBE_STREAM_SIMILARITY=0.90 -# CODESCRIBE_STREAM_NOVELTY=0.20 +# RUST_LOG=info +# LOG_LEVEL=error diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..33b07bbf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Report a reproducible defect in CodeScribe +title: "[bug] " +labels: ["bug", "triage"] +assignees: [] +--- + +## Summary + +Describe the defect in one or two sentences. + +## Environment + +- CodeScribe version: +- macOS version: +- Device (Apple Silicon model): +- Install mode (`make install`, `.app`, other): + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +What should happen? + +## Actual Behavior + +What happened instead? + +## Logs / Diagnostics + +Attach relevant logs and output: + +- `codescribe -v` output +- `codescribe-run.log` / `codescribe-debug.log` +- screenshots if UI/overlay related + +## Additional Context + +Any other details that may help reproduction. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..146303f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: mailto:security@vetcoders.io + about: Report security issues privately by email + - name: Team contact + url: mailto:hello@vetcoders.io + about: General project contact diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..24755192 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,35 @@ +--- +name: Feature request +about: Propose an improvement or new capability +title: "[feature] " +labels: ["enhancement", "triage"] +assignees: [] +--- + +## Problem to Solve + +What workflow is currently difficult or blocked? + +## Proposed Solution + +Describe the behavior you want. + +## Alternatives Considered + +What other options did you evaluate? + +## Value / Impact + +Who benefits and how? + +## Scope + +- [ ] Core runtime +- [ ] Agent / overlay +- [ ] Settings UI +- [ ] Packaging / distribution +- [ ] Documentation + +## Additional Notes + +Mockups, references, or constraints. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..e211b28d --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,40 @@ +name: Landing Pages + +on: + push: + branches: [main, develop] + paths: + - "docs/landing/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + name: Build landing artifact + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/landing + + deploy: + name: Deploy landing + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3cacd740 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release DMG + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + dmg: + name: Build and publish DMG + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Build DMG artifact + run: make dmg + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + files: CodeScribe_*.dmg + generate_release_notes: true diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e09d38b7..200db953 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -24,7 +24,7 @@ env: jobs: format: name: Format Check - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index e79df65b..6f74abe8 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -1,6 +1,3 @@ -# Semgrep OSS Security Scan -# Created by M&K (c)2026 VetCoders - name: Semgrep on: @@ -16,11 +13,17 @@ concurrency: jobs: semgrep: name: Security Scan - runs-on: ubuntu-latest - container: - image: semgrep/semgrep + runs-on: self-hosted + steps: - uses: actions/checkout@v4 + - name: Install Semgrep (using brew) + run: brew install semgrep + + # Alternative (using pip): + # - name: Install Semgrep via pip + # run: pip3 install semgrep + - name: Run Semgrep run: semgrep scan --config auto --error . diff --git a/.gitignore b/.gitignore index 8a1e7ade..c05a29ed 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ nohup.out.backup /.ai-archive /.ai-context /.aiassistant +/.tools *claude* *codex* /agents @@ -76,3 +77,11 @@ docs/runtime-diagnosis.md /logs/*.log *.debug.log /core/models +*.log + +# AI Context +.ai-context/* +!.ai-context/share/ +!.ai-context/share/summary.md +!.ai-context/share/timeline.md +AGENTS.md diff --git a/.prettierignore b/.prettierignore index a6603041..5cd52374 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,19 @@ **/.git/** +**/.claude/** +**/.codex/** +**/.codex.json +**/.codex.md +**/.codescribe/** +**/.mypy_cache/** +**/agents/** +**/.ai-archive/** +**/.ai-context/** +**/.loctree/** +**/.pytest_cache/** +**/.venv/** **/.DS_Store +**/claude_memory_*.md +**/CodeScribe_memory_*.md **/node_modules/** **/target/** **/dist/** diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7c516a..8564b93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Focus Ring Polish** – Removed default macOS focus rings from settings buttons for a cleaner UI. +- **Improved Settings Layout** – Adjusted spacing in "Setup" tab to prevent button overlapping. + +### Changed + +- **Tray Menu Cleanup** – Moved hotkey/audio/VAD settings to the new Settings Window. Tray menu is now focused on essential actions (Show Overlay, Quit). +- **Architecture** – Centralized configuration management in the Settings Window (Overlay), removing legacy tray-based logic. + +## [v0.7.14] – 2026-02-07 + +### Added + +- **Settings window (Bootstrap)** with tiered config (settings.json) + Keychain-backed API keys. +- **Fn-first hotkeys** (Globe/Fn as default hold modifier) with Shift/Cmd modifiers for Chat/Selection. +- **Configurable double‑tap interval** and **toggle silence auto‑send** (hands‑off UX). +- **MiniLM embedder** (paraphrase‑multilingual‑MiniLM‑L12‑v2) embedded by default for lightweight semantic gating. +- **Model caching in `make install-app`** (Whisper + embedder auto‑download if missing). + +### Changed + +- **Default hotkeys** → Hold `Fn` + double‑tap `Option` (left=normal, right=assistive). +- **Buffered streaming default** for smoother live transcription display. +- **Token limits default to 0** (API decides) to avoid truncation. + +### Fixed + +- **UTF‑8 slicing panic** in streaming overlap (diacritics/emoji safe). +- **Toggle streaming append** now keeps a single bubble per session (no spam bubbles). +- **Overlay header controls** restored on top of split view. +- **Bootstrap deadlocks** removed by shortening lock scopes during UI build. + ## [v0.7.2-dev] – 2026-01-20 ### Added + - **Hands-off Chat Overlay** – Full chat interface in overlay with history, user/assistant roles, and input field. - **Persistence** – Chat history is preserved between sessions; messages do not disappear on close. - **Auto-send Toggle** – UI checkbox to control automatic sending vs. draft mode. @@ -18,12 +52,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **UI Improvements** – Input field at top, reversed message flow (newest first), selectable text for copying. ### Fixed + - **Quality Gates** – Resolved `cargo check` and `make check` warnings; improved code quality. - **Reliability** – Fixed issue where overlay would reset state unexpectedly. ## [v0.7.0] – 2026-01-17 ### Added + - **Strict Embedded Policy** – Whisper model is always embedded into release binary. Zero external model files, zero exceptions. - **IPC server** – New IPC server and message types for stable runtime integration. - **Quality loop** – Automated transcription quality assessment loop. @@ -33,15 +69,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **serial_test** – E2E test serialization to reduce race conditions. ### Changed + - **Version unification** – Consistent versioning across the project. - **Security hardening** – `cap-std` and file operation restrictions to allowed paths only. ### Fixed + - SSE formatting and final text collection fixes. ## [v0.6.3] – 2026-01-16 ### Added + - **New hotkey architecture** – Each hotkey now determines the processing mode: - **Ctrl Hold** = ALWAYS RAW (fast dictation, no AI processing, ignores AI toggle) - **Double Option** = respects AI_FORMATTING_ENABLED toggle setting @@ -56,18 +95,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 immediate handshake and no timeout issues ### Changed + - **Timeout increased to 90s** – GPT-5.x with longer inputs needs more time - **Token limits removed** – All token limits set to 0 (API decides). Tokens are cheap, lost notes are not. - **force_raw_mode flag** – New controller state flag for explicit RAW mode override ### Fixed + - **Timeout issues with GPT-5.2** – Streaming mode eliminates 30s timeout failures ## [v0.6.2] – 2026-01-16 ### Added -- **Whisper Live (streaming transcription)** – Local transcription now happens *during recording*. + +- **Whisper Live (streaming transcription)** – Local transcription now happens _during recording_. Audio from the CPAL callback is chunked and processed in the background, so on `stop()` we only finalize the last chunk for near-instant time-to-paste. - **StreamingRecorder** – New streaming capture/transcription pipeline built around a non-blocking @@ -76,12 +118,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 embedded-model strategy (no bundling `Resources/models/*` that would duplicate ~900MB). ### Changed + - **Docs & pitch** – Documentation and README now highlight the core differentiator: embedded Whisper - + live streaming transcription. + - live streaming transcription. ## [v0.6.1] – 2026-01-14 ### Added + - **Model embedded in binary** – Release builds now include the Whisper model directly via `include_bytes!`, eliminating runtime model loading and disk I/O. Binary size ~888MB with model welded in. Debug builds still use external model path. @@ -95,6 +139,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instance with automatic embedded vs external path resolution. ### Changed + - **Responses API optimization** – Instructions are now sent only on first request; subsequent requests rely on `previous_response_id` to preserve context, reducing payload size. - **Build safety** – Release builds now hard-fail when model is missing. Dev-only: set @@ -106,6 +151,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 provider separation, audio settings, and debug options. ### Fixed + - **Clippy warnings** – Resolved unused imports, dead code, and type complexity warnings. - **E2E tests** – Fixed `LLM_HOST` → `LLM_ENDPOINT` migration in all test files. - **Borrow checker** – Fixed move-after-borrow in AI formatting trace logging. @@ -113,6 +159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [v0.6.0] – 2026-01-13 ### 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)) @@ -131,6 +178,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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)) - **Backend architecture** – Removed the Python backend and updated the Rust CI pipeline to match. @@ -155,106 +203,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([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)) - **Backend cleanup** – Ensured backend processes are killed on all known ports. ([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)) ### Changed (Internal) + - **Foundations** – Landed the initial Rust-based architecture groundwork. ([5a17c3a](https://github.com/VetCoders/CodeScribe/commit/5a17c3a)) ## v0.4.3 – 2025-11-21 -- TODO: Add release notes. +- Internal updates. ## v0.4.1 – 2025-11-11 -- TODO: Add release notes. +- Internal updates. ## v0.4.0 – 2025-11-11 -- **License clarification** – Switched from MIT to BSD 4-Clause (Original BSD) to - make attribution to Maciej Gad & Loctree explicit in any advertising or - bundled distribution. If this acknowledgement requirement becomes a burden for - downstream adopters we can soften it to BSD 3-Clause, but for now the - advertising clause captures the desired attribution policy. -- **Configurator hardening** – `hardware_detector.py` now works cross-platform, - checks for Ollama/Tailscale binaries before probing, scales MAX tokens with - available RAM, and no longer spews text unless the CLI entry point runs. -- **First-run portability** – onboarding config lives in a platform-aware - support directory, carries a `config_version`, and logs JSON errors instead of - silently dropping them. Cancelling the wizard leaves the config untouched so - the user can retry next launch. -- **Backend & API hardening** – Whisper/format servers no longer configure - logging at import time, enforce 20 MB upload limits with MIME/extension - checks, expose SSE heartbeats so proxies stay connected, and run uvicorn via - the fully qualified `codescribe.whisper_server:app` target. -- **Tooling & packaging** – PID/port files are written with 0600 perms, - packaging scripts locate `src/codescribe/assets/icon.png` automatically, - manual tests clean up temporary WAVs, launcher scripts pre-create `.pids/` - and `logs/`, and the DMG Readme now includes the required BSD attribution. -- **CI & types** – Added `src/codescribe/py.typed`, made Ruff/mypy part of the - macOS workflow with concurrency guards, and dropped the outdated - `docs/legacy` bundle ahead of the public release. -- **Menu robustness** – Submenus are now built before attaching to the tray, - auto-healed if rumps strips them, and the Quit dialog activates the app so - the alert always appears on top. - - - -## Phase I – `develop` vs `main` - -**Platform & Backends** -- Introduced `CodeScribeServer` as a single-instance backend runner with lazy - MLX loading and a documented CLI so the React/Tauri Vista client can share the - same transcription core. -- Added transcript telemetry hooks plus developer metrics scripts and new - backend endpoint guards to tighten observability and error handling. -- Patched critical audio leaks, remote binding safeguards, and background launch - prompts to keep recorder lifecycles predictable on macOS. - -**AI & Formatting** -- Landed the Ollama LLM backend, multimodal chat client, and the initial dual - mode AI formatting pipeline (Light+ by default, Harmony/Ollama assistive mode - when enabled). -- Added conveniences for Polish Whisper fine-tunes, refined model selection, and - relaxed overly aggressive formatting to avoid Markdown hallucinations. - -**UX & Tooling** -- Rebuilt the tray menus (appearance, permissions, history) and introduced live - transcription glyph customizations plus extra developer tools. -- Added `.env.example`, run/debug profiles, troubleshooting docs, MLX cheat - sheets, and improved diagnostics for quickstart scripts. - -## Phase II – `develop` vs `functional` - -**Runtime Modularization** -- Split the monolithic tray runtime into controllers/mixins (`recording`, - `history`, `models`, `appearance`, etc.) so hotkeys, menus, and async loops can - evolve independently. -- Added compatibility shims for legacy imports (`whisper_server`, client config) - to keep Vista integrations working during the refactor. - -**Configuration & Tests** -- Simplified environment management: consolidated env templates, updated - sitecustomize hooks, and made the settings store the single source of truth for - AI/provider toggles. -- Refactored manual Ollama tests to share helpers and moved utility specs under - `tests/manual`, alongside new pytest-based diagnostics. - -**Quality of Life** -- Hardened exception handling across the client/backend boundary, added smoke - tests around the new controllers, and refreshed documentation to mirror the - current tree/layout. - -[Unreleased]: https://github.com/VetCoders/CodeScribe/compare/v0.7.0...HEAD +- **License clarification** – Switched from MIT to BSD 4-Clause. +- **Configurator hardening** – `hardware_detector.py` cross-platform improvements. +- **First-run portability** – Onboarding config improvements. +- **Backend & API hardening** – Robustness improvements. +- **Tooling & packaging** – Packaging script enhancements. +- **CI & types** – Type checking and CI improvements. +- **Menu robustness** – Tray menu stability fixes. + +[unreleased]: https://github.com/VetCoders/CodeScribe/compare/v0.7.14...HEAD +[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 diff --git a/Cargo.lock b/Cargo.lock index c1486f53..0753ea88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -69,12 +60,6 @@ dependencies = [ "equator", ] -[[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.10.0" @@ -167,9 +152,6 @@ name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -dependencies = [ - "backtrace", -] [[package]] name = "arbitrary" @@ -236,6 +218,17 @@ dependencies = [ "serde_json", ] +[[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.111", +] + [[package]] name = "atk" version = "0.18.2" @@ -314,21 +307,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] - [[package]] name = "base64" version = "0.13.1" @@ -473,9 +451,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzip2" @@ -539,7 +517,7 @@ dependencies = [ "rand 0.9.2", "rand_distr", "rayon", - "safetensors 0.4.5", + "safetensors", "thiserror 1.0.69", "ug", "ug-metal", @@ -572,7 +550,7 @@ dependencies = [ "metal 0.27.0", "num-traits", "rayon", - "safetensors 0.4.5", + "safetensors", "serde", "thiserror 1.0.69", ] @@ -635,15 +613,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cblas-sys" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6feecd82cce51b0204cf063f0041d69f24ce83f680d87514b004248e7b0fa65" -dependencies = [ - "libc", -] - [[package]] name = "cc" version = "1.2.48" @@ -759,27 +728,32 @@ dependencies = [ [[package]] name = "codescribe" -version = "0.7.8" +version = "0.8.0" dependencies = [ "anyhow", "arboard", + "async-trait", + "base64 0.22.1", + "block", "chrono", "clap", "codescribe-core", "core-foundation 0.10.1", "core-graphics 0.25.0", - "coreml-rs", "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", @@ -796,9 +770,10 @@ dependencies = [ [[package]] name = "codescribe-core" -version = "0.7.8" +version = "0.8.0" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "candle-core", "candle-nn", @@ -810,21 +785,21 @@ dependencies = [ "directories", "dirs", "dotenvy", - "fastembed", "flate2", "futures-util", "hound", - "indicatif 0.18.3", + "indicatif", "lazy_static", "mockito", "moshi", - "ndarray 0.17.2", + "ndarray", "ndarray-npy", "openai-harmony", "ort", "rand 0.8.5", "regex", "reqwest", + "security-framework 3.5.1", "serde", "serde_json", "serial_test", @@ -885,19 +860,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "console" version = "0.16.2" @@ -1014,24 +976,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "coreml-rs" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07b8b0377b033730a8947a64deea233a2706e67e3776d98fadc97f9634f8fe4c" -dependencies = [ - "anyhow", - "bytemuck", - "flate2", - "half", - "ndarray 0.15.6", - "num", - "swift-bridge", - "swift-bridge-build", - "tempdir", - "thiserror 2.0.17", -] - [[package]] name = "cpal" version = "0.17.1" @@ -1540,22 +1484,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "fastembed" -version = "5.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a3f841f27a44bcc32214f8df75cc9b6cea55dbbebbfe546735690eab5bb2d2" -dependencies = [ - "anyhow", - "hf-hub", - "ndarray 0.17.2", - "ort", - "safetensors 0.7.0", - "serde", - "serde_json", - "tokenizers", -] - [[package]] name = "fastrand" version = "2.3.0" @@ -1623,12 +1551,6 @@ 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" @@ -1691,12 +1613,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "fuchsia-cprng" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" - [[package]] name = "futures" version = "0.3.31" @@ -2165,12 +2081,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "gio" version = "0.18.4" @@ -2344,7 +2254,6 @@ dependencies = [ "num-traits", "rand 0.9.2", "rand_distr", - "serde", "zerocopy", ] @@ -2359,13 +2268,6 @@ 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 = "heck" @@ -2391,27 +2293,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hf-hub" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" -dependencies = [ - "dirs", - "http", - "indicatif 0.17.11", - "libc", - "log", - "native-tls", - "rand 0.9.2", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.17", - "ureq 2.12.1", - "windows-sys 0.60.2", -] - [[package]] name = "hmac" version = "0.12.1" @@ -2515,7 +2396,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.4", + "webpki-roots", ] [[package]] @@ -2755,26 +2636,13 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console 0.15.11", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - [[package]] name = "indicatif" version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ - "console 0.16.2", + "console", "portable-atomic", "unicode-width", "unit-prefix", @@ -3279,7 +3147,8 @@ dependencies = [ [[package]] name = "moshi" version = "0.6.4" -source = "git+https://github.com/kyutai-labs/moshi?branch=main#fa205be69cf69b1a7e8cd9f815dbb9c130f605ef" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5153be1c62a5f77bf31797931c5df6f155ad2df2f95c6f241720132046ed2259" dependencies = [ "candle-core", "candle-nn", @@ -3332,27 +3201,11 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] -[[package]] -name = "ndarray" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" -dependencies = [ - "cblas-sys", - "libc", - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "rawpointer", - "serde", -] - [[package]] name = "ndarray" version = "0.17.2" @@ -3375,7 +3228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58e8a348bca0075000d999d750420d74434fd0d3e0993b456554f885e7657a11" dependencies = [ "byteorder", - "ndarray 0.17.2", + "ndarray", "num-traits", "py_literal", ] @@ -3486,9 +3339,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -3568,18 +3421,12 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.111", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "objc" version = "0.2.7" @@ -3801,15 +3648,6 @@ dependencies = [ "cc", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -3924,11 +3762,11 @@ version = "2.0.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" dependencies = [ - "ndarray 0.17.2", + "ndarray", "ort-sys", "smallvec", "tracing", - "ureq 3.1.4", + "ureq", ] [[package]] @@ -3939,7 +3777,7 @@ checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" dependencies = [ "hmac-sha256", "lzma-rust2", - "ureq 3.1.4", + "ureq", ] [[package]] @@ -4182,19 +4020,10 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" dependencies = [ - "toml_datetime 0.6.3", + "toml_datetime", "toml_edit 0.20.2", ] -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit 0.23.7", -] - [[package]] name = "proc-macro-error" version = "1.0.4" @@ -4380,19 +4209,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" -dependencies = [ - "fuchsia-cprng", - "libc", - "rand_core 0.3.1", - "rdrand", - "winapi", -] - [[package]] name = "rand" version = "0.8.5" @@ -4434,21 +4250,6 @@ dependencies = [ "rand_core 0.9.3", ] -[[package]] -name = "rand_core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" -dependencies = [ - "rand_core 0.4.2", -] - -[[package]] -name = "rand_core" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" - [[package]] name = "rand_core" version = "0.6.4" @@ -4588,15 +4389,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rdrand" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" -dependencies = [ - "rand_core 0.3.1", -] - [[package]] name = "reborrow" version = "0.5.5" @@ -4672,15 +4464,6 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - [[package]] name = "reqwest" version = "0.12.24" @@ -4727,7 +4510,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.4", + "webpki-roots", ] [[package]] @@ -4750,12 +4533,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -4806,7 +4583,6 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -4858,17 +4634,6 @@ dependencies = [ "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" @@ -4945,6 +4710,19 @@ dependencies = [ "security-framework-sys", ] +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework-sys" version = "2.15.0" @@ -5243,51 +5021,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "swift-bridge" -version = "0.1.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384ed39ea10f1cefabb197b7d8e67f0034b15a94ccbb1038b8e020da59bfb0be" -dependencies = [ - "swift-bridge-build", - "swift-bridge-macro", -] - -[[package]] -name = "swift-bridge-build" -version = "0.1.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71b36df21e7f8a8b5eeb718d2e71f9cfc308477bfb705981cca705de9767dcb7" -dependencies = [ - "proc-macro2", - "swift-bridge-ir", - "syn 1.0.109", - "tempfile", -] - -[[package]] -name = "swift-bridge-ir" -version = "0.1.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c73bd16155df50708b92306945656e57d62d321290a7db490f299f709fb31c83" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "swift-bridge-macro" -version = "0.1.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a13dc0dc875d85341dec5b5344a7d713f20eb5650b71086b27d09a6ece272f" -dependencies = [ - "proc-macro2", - "quote", - "swift-bridge-ir", - "syn 1.0.109", -] - [[package]] name = "symphonia" version = "0.5.5" @@ -5490,7 +5223,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -5644,16 +5376,6 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" -[[package]] -name = "tempdir" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" -dependencies = [ - "rand 0.4.6", - "remove_dir_all", -] - [[package]] name = "tempfile" version = "3.23.0" @@ -5732,30 +5454,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5799,7 +5521,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "indicatif 0.18.3", + "indicatif", "itertools", "log", "macro_rules_attribute", @@ -5903,7 +5625,7 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned", - "toml_datetime 0.6.3", + "toml_datetime", "toml_edit 0.20.2", ] @@ -5916,15 +5638,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.19.15" @@ -5932,8 +5645,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.12.1", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "toml_datetime", + "winnow", ] [[package]] @@ -5945,29 +5658,8 @@ dependencies = [ "indexmap 2.12.1", "serde", "serde_spanned", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.23.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" -dependencies = [ - "indexmap 2.12.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow 0.7.14", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow 0.7.14", + "toml_datetime", + "winnow", ] [[package]] @@ -6147,7 +5839,7 @@ dependencies = [ "num-traits", "num_cpus", "rayon", - "safetensors 0.4.5", + "safetensors", "serde", "thiserror 1.0.69", "tracing", @@ -6219,26 +5911,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "native-tls", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "ureq" version = "3.1.4" @@ -6479,15 +6151,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.4", -] - [[package]] name = "webpki-roots" version = "1.0.4" @@ -6942,15 +6605,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - [[package]] name = "winx" version = "0.36.4" diff --git a/Cargo.toml b/Cargo.toml index e20e47bf..59cf878c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,24 +3,31 @@ resolver = "2" members = [".", "core"] [workspace.package] -version = "0.7.8" +version = "0.8.0" edition = "2024" authors = ["VetCoders "] [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +async-trait = "0.1" +base64 = "0.22" tokio = { version = "1", features = ["full"] } anyhow = "1" tracing = "0.1" [package] name = "codescribe" -version = "0.7.8" +version = "0.8.0" edition = "2024" description = "Speech-to-text tray app for macOS - Pure Rust" authors = ["VetCoders "] license = "Apache-2.0" +repository = "https://github.com/VetCoders/CodeScribe" +homepage = "https://github.com/VetCoders/CodeScribe" +documentation = "https://github.com/VetCoders/CodeScribe/tree/main/docs" +keywords = ["speech-to-text", "whisper", "macos", "dictation", "audio"] +categories = ["command-line-utilities", "multimedia::audio"] [[bin]] name = "codescribe" @@ -37,6 +44,10 @@ path = "bin/codescribe_loop.rs" [lib] path = "app/lib.rs" +[features] +default = ["liquid_glass"] +liquid_glass = [] + [dependencies] # Core library codescribe_core = { package = "codescribe-core", path = "core" } @@ -62,6 +73,8 @@ reqwest = { version = "0.12", features = ["json", "multipart", "blocking", "stre # WebSocket client for voice chat tokio-tungstenite = { version = "0.28", features = ["native-tls"] } futures-util = "0.3" +async-trait = "0.1" +base64 = "0.22" # Clipboard arboard = "3" @@ -103,27 +116,31 @@ libc = "0.2" image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(target_os = "macos")'.dependencies] -# candle-coreml = "0.3" -coreml-rs = "0.5" # macOS specific - for better hotkey support if rdev fails core-foundation = "0.10" core-graphics = "0.25" # UI utilities (hold badge, caret tracking) - modern objc2-* crates -objc2-app-kit = { version = "0.3", features = ["NSWindow", "NSView", "NSColor", "NSEvent", "NSBezierPath", "NSGraphics", "NSResponder", "NSVisualEffectView"] } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSWindow", "NSView", "NSColor", "NSEvent", "NSBezierPath", "NSGraphics", "NSResponder", "NSVisualEffectView", "NSGlassEffectView", "NSApplication"] } +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" # Grand Central Dispatch for main thread operations dispatch = "0.2" [dev-dependencies] +codescribe_core = { package = "codescribe-core", path = "core", features = ["offline_eval"] } tempfile = "3" mockito = "1" serial_test = "3" +hound = "3.5" [lints.rust] # Allow unexpected_cfgs from objc crate's msg_send! macro (uses cargo-clippy cfg internally) -# embed_model is set by build.rs when model is available -unexpected_cfgs = { level = "allow", check-cfg = ['cfg(embed_model)'] } +# embed_model/embed_tts/embed_e5 are set by build.rs when models are available +unexpected_cfgs = { level = "allow", check-cfg = ['cfg(embed_model)', 'cfg(embed_tts)', 'cfg(embed_e5)'] } [profile.release] opt-level = "z" diff --git a/Makefile b/Makefile index 073a4353..120fbfde 100644 --- a/Makefile +++ b/Makefile @@ -6,13 +6,23 @@ bump bump-patch bump-minor bump-major version \ lint format test test-quick test-e2e test-e2e-real test-sse test-formatting test-all \ demo demo-raw demo-assistive check fix clean help \ - dmg dmg-signed dmg-full notarize download-model \ + dmg dmg-signed release-full notarize download-model download-e5 download-embedder ensure-models \ hooks SHELL := /bin/bash VERSION_FILE := Cargo.toml EDITOR ?= $(shell command -v code || command -v nvim || command -v vim || echo nano) ENV_LOAD := set -a; [ -f $$HOME/.codescribe/.env ] && source $$HOME/.codescribe/.env; set +a +# macOS: use a stable codesign identity to avoid TCC (Accessibility/Input Monitoring) resets after rebuilds. +# Example: +# CODESCRIBE_CODESIGN_IDENTITY="Apple Development: Your Name (TEAMID)" make install-app +CODESCRIBE_CODESIGN_IDENTITY ?= - +CODESCRIBE_APP_NAME ?= CodeScribe +CODESCRIBE_DISPLAY_NAME ?= CodeScribe +CODESCRIBE_BUNDLE_ID ?= com.codescribe.app +CODESCRIBE_MIN_MACOS ?= +CODESCRIBE_LSUIELEMENT ?= true +CODESCRIBE_ENTITLEMENTS ?= scripts/entitlements.plist # Test defaults (reference/cloud unless forced local) TEST_USE_LOCAL_LLM ?= 0 @@ -51,6 +61,7 @@ release: install: @echo "Installing CodeScribe (with embedded model)..." + @./scripts/ensure-models.sh @cargo install --path . --force @mkdir -p ~/.codescribe @pwd > ~/.codescribe/repo_path @@ -58,6 +69,7 @@ install: install-no-embed: @echo "Installing CodeScribe (no embedded model)..." + @./scripts/ensure-models.sh @CODESCRIBE_NO_EMBED=1 cargo install --path . --force @mkdir -p ~/.codescribe @pwd > ~/.codescribe/repo_path @@ -72,22 +84,56 @@ config: fi @$(EDITOR) ~/.codescribe/.env -bundle: release +bundle: ensure-models release @echo "Creating macOS app bundle..." - @mkdir -p bundle/CodeScribe.app/Contents/{MacOS,Resources} - @cp target/release/codescribe bundle/CodeScribe.app/Contents/MacOS/ - @cp assets/AppIcon.icns bundle/CodeScribe.app/Contents/Resources/ 2>/dev/null || true + @mkdir -p bundle/$(CODESCRIBE_APP_NAME).app/Contents/{MacOS,Resources} + @cp target/release/codescribe bundle/$(CODESCRIBE_APP_NAME).app/Contents/MacOS/ + @cp target/release/codescribe-loop bundle/$(CODESCRIBE_APP_NAME).app/Contents/MacOS/ 2>/dev/null || true + @cp target/release/codescribe-quality 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/'); \ - if [ -f bundle/CodeScribe.app/Contents/Info.plist ]; then \ - sed -i '' "s/0\.[0-9]*\.[0-9]*$$VERSION' \ + '' \ + '' \ + '' \ + " 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.' \ + '' \ + '' \ + > 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" + @echo "Bundle ready: bundle/$(CODESCRIBE_APP_NAME).app" install-app: bundle @echo "Installing to /Applications..." - @rm -rf /Applications/CodeScribe.app - @cp -R bundle/CodeScribe.app /Applications/ - @echo "Installed: /Applications/CodeScribe.app" + @mkdir -p /Applications + @rsync -a --delete bundle/$(CODESCRIBE_APP_NAME).app/ /Applications/$(CODESCRIBE_APP_NAME).app/ + @if [ "$(CODESCRIBE_CODESIGN_IDENTITY)" = "-" ]; then \ + echo "Codesigning ad-hoc (no signing identity found)."; \ + echo "NOTE: macOS Accessibility/Input Monitoring may need re-grant after reinstall."; \ + echo "TIP: create a local codesign cert (e.g. 'CodeScribe Dev') and set CODESCRIBE_CODESIGN_IDENTITY to keep permissions stable."; \ + codesign --force --deep --sign - --identifier $(CODESCRIBE_BUNDLE_ID) /Applications/$(CODESCRIBE_APP_NAME).app; \ + else \ + echo "Codesigning with identity: $(CODESCRIBE_CODESIGN_IDENTITY)"; \ + codesign --force --deep --options runtime --entitlements "$(CODESCRIBE_ENTITLEMENTS)" --sign "$(CODESCRIBE_CODESIGN_IDENTITY)" --identifier $(CODESCRIBE_BUNDLE_ID) /Applications/$(CODESCRIBE_APP_NAME).app; \ + fi + @echo "Codesign summary:" + @codesign --display --verbose=2 /Applications/$(CODESCRIBE_APP_NAME).app 2>&1 | sed -n '1,12p' || true + @echo "Installed: /Applications/$(CODESCRIBE_APP_NAME).app" # ============================================================================ # Run @@ -164,37 +210,85 @@ lint: @echo "=== Clippy ===" @cargo clippy --workspace -- -D warnings +TEST_LOG := /tmp/codescribe-tests.log + +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 "╚══════════════════════════════════════════════════════════╝" | tee -a "$$LOG"; \ +open -a Console "$$LOG" +endef + test: - @echo "=== Tests (workspace) ===" - @$(ENV_LOAD); $(APPLY_TEST_LLM); cargo test --workspace --all-targets -- --nocapture - @echo "=== Tests (ignored / real API) ===" - @$(ENV_LOAD); $(APPLY_TEST_LLM); cargo test --workspace --all-targets -- --ignored --nocapture + @$(TEST_SETUP); \ + echo "=== Tests (workspace) ===" | tee -a "$$LOG"; \ + $(ENV_LOAD); $(APPLY_TEST_LLM); \ + cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; \ + 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"; \ + 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"; \ + echo "Done. Log: $$LOG" | tee -a "$$LOG" test-quick: - @echo "=== Tests (quick, no real API) ===" - @$(ENV_LOAD); $(APPLY_TEST_LLM); cargo test --workspace --all-targets -- --nocapture + @$(TEST_SETUP); \ + 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"; \ + echo "Done. Log: $$LOG" | tee -a "$$LOG" test-e2e: - @echo "=== E2E Tests (mock) ===" - @$(ENV_LOAD); $(APPLY_TEST_LLM); cargo test e2e --release -- --nocapture + @$(TEST_SETUP); \ + echo "=== E2E Tests (mock) ===" | tee -a "$$LOG"; \ + $(ENV_LOAD); $(APPLY_TEST_LLM); \ + cargo test e2e --release -- --nocapture 2>&1 | tee -a "$$LOG"; \ + echo "Done. Log: $$LOG" | tee -a "$$LOG" test-e2e-real: - @echo "=== E2E Tests (real API) ===" - @echo "Requires: LLM_API_KEY, LLM_ASSISTIVE_API_KEY" - @$(ENV_LOAD); $(APPLY_TEST_LLM); cargo test e2e --release -- --ignored --nocapture + @$(TEST_SETUP); \ + 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"; \ + echo "Done. Log: $$LOG" | tee -a "$$LOG" test-sse: - @echo "=== SSE Streaming Tests ===" - @$(ENV_LOAD); $(APPLY_TEST_LLM); cargo test e2e_sse --release -- --ignored --nocapture + @$(TEST_SETUP); \ + echo "=== SSE Streaming Tests ===" | 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"; \ + echo "Done. Log: $$LOG" | tee -a "$$LOG" test-formatting: - @echo "=== AI Formatting Tests ===" - @$(ENV_LOAD); $(APPLY_TEST_LLM); cargo test formatting --release -- --nocapture + @$(TEST_SETUP); \ + echo "=== AI Formatting Tests ===" | tee -a "$$LOG"; \ + $(ENV_LOAD); $(APPLY_TEST_LLM); \ + cargo test formatting --release -- --nocapture 2>&1 | tee -a "$$LOG"; \ + echo "Done. Log: $$LOG" | tee -a "$$LOG" test-all: - @echo "=== Full Test Suite ===" - @$(MAKE) test - @echo "Done." + @$(TEST_SETUP); \ + echo "=== Full Test Suite ===" | tee -a "$$LOG"; \ + $(ENV_LOAD); $(APPLY_TEST_LLM); \ + cargo test --workspace --all-targets -- --nocapture 2>&1 | tee -a "$$LOG"; \ + echo "=== Ignored / Real API ===" | tee -a "$$LOG"; \ + $(ENV_LOAD); $(APPLY_TEST_LLM); \ + cargo test --workspace --all-targets -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + 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"; \ + echo "=== SSE Streaming ===" | tee -a "$$LOG"; \ + $(ENV_LOAD); $(APPLY_TEST_LLM); \ + cargo test e2e_sse --release -- --ignored --nocapture 2>&1 | tee -a "$$LOG"; \ + echo "Done. Log: $$LOG" | tee -a "$$LOG" demo: @echo "=== Full Pipeline Demo ===" @@ -264,9 +358,12 @@ help: @echo "Release & Distribution:" @echo " make dmg Build DMG (ad-hoc signed)" @echo " make dmg-signed Build DMG (Developer ID signed)" - @echo " make dmg-full Build DMG with embedded model (~888MB)" + @echo " make release-full Build + sign + notarize DMG (release-ready)" @echo " make notarize Notarize DMG with Apple" @echo " make download-model Download Whisper model from HF" + @echo " make download-e5 Download E5 embedder model from HF" + @echo " make download-embedder Download MiniLM embedder from HF" + @echo " make ensure-models Download Whisper+MiniLM if missing from cache" @echo "" @echo "Run:" @echo " make start Start CodeScribe" @@ -301,13 +398,13 @@ help: # ============================================================================ dmg: - @./scripts/build-release.sh + @./scripts/build-dmg.sh dmg-signed: - @./scripts/build-release.sh --sign + @./scripts/build-dmg.sh --sign -dmg-full: - @./scripts/build-release.sh --sign +release-full: + @./scripts/build-dmg.sh --sign --notarize notarize: @if ls CodeScribe_*.dmg 1> /dev/null 2>&1; then \ @@ -319,3 +416,12 @@ notarize: download-model: @./scripts/download-model.sh + +download-e5: + @./scripts/download-e5.sh + +download-embedder: + @./scripts/download-embedder.sh + +ensure-models: + @./scripts/ensure-models.sh diff --git a/README.md b/README.md index b5477170..e18b02b7 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ flowchart TB > **Note:** The diagram above shows the **target architecture** with Tauri GUI (Voice Lab / Teacher / Settings). Current release is a **native macOS tray app** (without Tauri), and the Lab UI is **future-only**. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for current implementation details. -> **Status:** current release (see `Cargo.toml`) — **Strictly Embedded Model** (~888MB binary, zero exceptions) + *Whisper Live* (streaming transcription). +> **Status:** current release (see `Cargo.toml`) — **Embedded Whisper + MiniLM** + _Whisper Live_ (streaming transcription) + tiered settings (settings.json + Keychain). See: [`docs/WHISPER_LIVE.md`](docs/WHISPER_LIVE.md) | [`docs/BACKLOG.md`](docs/BACKLOG.md) | [`docs/ARCHITECTURE_VISION.md`](docs/ARCHITECTURE_VISION.md) @@ -81,12 +81,12 @@ LLM_ENDPOINT=https://api.openai.com/v1/responses LLM_MODEL=gpt-4.1-mini LLM_API_KEY=sk-proj-xxx -# Formatting mode overrides (Ctrl hold - cleanup only) +# Formatting mode overrides (Formatting mode / cleanup pass) LLM_FORMATTING_ENDPOINT=https://api.libraxis.cloud/v1/responses LLM_FORMATTING_MODEL=gpt-4.1-mini LLM_FORMATTING_API_KEY=vista-xxx -# Assistive mode overrides (Ctrl+Shift - AI augmentation) +# Assistive mode overrides (Assistive mode / agent chat) LLM_ASSISTIVE_ENDPOINT=https://api.openai.com/v1/responses LLM_ASSISTIVE_MODEL=gpt-4.1 LLM_ASSISTIVE_API_KEY=sk-proj-xxx @@ -98,7 +98,7 @@ LLM_ASSISTIVE_API_KEY=sk-proj-xxx - **Pure Rust Implementation** — Native macOS app built entirely in Rust with candle-core + Metal GPU - **Strictly Embedded Whisper** — Model is welded into the binary (~888MB). No external files, zero disk I/O, no exceptions. -- **Whisper Live** — Streaming transcription happens *during recording* (chunks + overlap), so `stop()` is +- **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 @@ -106,7 +106,7 @@ LLM_ASSISTIVE_API_KEY=sk-proj-xxx - **CLI Suite** — `codescribe`, `codescribe-quality`, `codescribe-loop` - **Metal GPU Acceleration** — Hardware-accelerated inference on Apple Silicon - **System Tray App** — Minimal menu-bar presence with animated status glyphs -- **Global Hotkeys** — Hold Ctrl or double-tap Option to record +- **Global Hotkeys** — Hold Fn (default) or double‑tap Option to record - **Provider Separation** — Different LLM providers for formatting vs assistive mode - **AI Formatting** — Optional post-processing via Responses API - **Slug Filenames** — Transcripts named with first 3 words for easy identification @@ -114,7 +114,7 @@ LLM_ASSISTIVE_API_KEY=sk-proj-xxx ## Tech Stack | Component | Technology | Purpose | -|------------------|-----------------------------------|----------------------------| +| ---------------- | --------------------------------- | -------------------------- | | Language | Rust 2024 Edition | Native performance | | ML Framework | candle-core + candle-transformers | Whisper inference | | GPU Acceleration | Metal (Apple Silicon) | Hardware-accelerated STT | @@ -124,7 +124,7 @@ LLM_ASSISTIVE_API_KEY=sk-proj-xxx | HTTP Client | reqwest | LLM API calls | | API Format | openai-harmony | Responses API support | | Security | cap-std | Path safety hardening | -| Embeddings | fastembed | Local vector utilities | +| Embeddings | candle-transformers (MiniLM) | Local semantic gating | ## Installation @@ -141,22 +141,30 @@ LLM_ASSISTIVE_API_KEY=sk-proj-xxx git clone https://github.com/VetCoders/CodeScribe.git cd CodeScribe -# Download Whisper model (required for embedding) -make download-model - -# Install CLI (~888MB with embedded model) +# Install CLI (embedded Whisper + MiniLM) make install # Verify installation codescribe --version ``` +### Install via Release DMG + +Tagged builds can publish a signed-or-ad-hoc DMG through GitHub Releases: + +1. Open [Releases](https://github.com/VetCoders/CodeScribe/releases) +2. Download `CodeScribe_.dmg` +3. Drag `CodeScribe.app` into `Applications` + +> **Current truth:** source install is the guaranteed path inside this repo. The release workflow lives in `.github/workflows/release.yml` and publishes DMGs on `v*` tags. + ### Build Options ```bash make build # Debug build (external model) -make release # Release build (embedded model) -make install # Install with embedded model (~888MB) +make release # Release build (embedded Whisper + MiniLM) +make install # Install CLI with embedded models +make install-app # Build + install macOS .app (auto-downloads models if missing) make install-no-embed # Dev-only: install without embedding (needs CODESCRIBE_MODEL_PATH) ``` @@ -179,12 +187,26 @@ codescribe transcribe -l pl audio.m4a codescribe transcribe -f audio.mp3 # with AI formatting ``` +## Default Hotkeys (macOS) + +- **Dictation**: hold your configured modifier (default: **Hold Fn/Globe**) → release to send + paste +- **Formatting**: **Double‑tap Left Option** → hands‑free recording + AI formatting (auto‑paste ON) +- **Assistive (Agent)**: **Double‑tap Right Option** → voice‑chat overlay + agent response (auto‑paste OFF) + +Hotkeys are configured in **Settings → Modes & Shortcuts**. Double‑tap modes auto‑send an utterance when you pause, and stop on the next double‑tap. + +## Settings & Secrets + +- GUI settings: `~/Library/Application Support/CodeScribe/settings.json` +- API keys: macOS Keychain (`com.vetcoders.codescribe`) +- Power‑user overrides: `~/.codescribe/.env` + ## How It Works ```mermaid flowchart TD A[Hotkey Press] --> B{Mode?} - B -->|Hold Ctrl| C[Start Recording] + B -->|Hold Fn| C[Start Recording] B -->|Double Option| C C --> D[Recording] D -->|live chunks| E[Whisper STT (streaming)] @@ -202,21 +224,20 @@ flowchart TD ### Recording Modes -| Mode | Trigger | Description | -|-----------------------|----------------------------|------------------------------------------------| -| **Hold-to-talk** | Hold `Ctrl` (800ms delay) | Release to transcribe + paste (raw transcript) | -| **Hold Assistive** | Hold `Ctrl+Shift` | AI augmentation mode | -| **Toggle Formatting** | Double-tap `Left Option` | AI-formatted version of speech | -| **Toggle Assistive** | Double-tap `Right Option` | Augmented AI response | +| Mode | Trigger | Description | +| --------------------- | ------------------------- | ------------------------------------------ | +| **Dictation** | Hold `Fn/Globe` (default) | Fast transcript (AI optional), auto‑paste | +| **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). ## Configuration -Config file: `~/.codescribe/.env` +GUI settings live in `settings.json`, secrets in Keychain, and power‑user overrides in `~/.codescribe/.env`. ```bash -# Create/edit config +# Open config helper (creates ~/.codescribe/.env if missing) make config ``` @@ -392,7 +413,7 @@ make download-model # Download Whisper model ## Code Quality | Tool | Purpose | Config | -|----------------|------------|-------------------| +| -------------- | ---------- | ----------------- | | **Clippy** | Linting | `-D warnings` | | **rustfmt** | Formatting | Rust 2024 edition | | **cargo test** | Testing | Unit + E2E | @@ -426,8 +447,8 @@ Grant permissions in System Settings > Privacy & Security when prompted. ### In Progress -- [ ] Voice Activity Detection (VAD) for auto-stop — *implemented but not integrated* -- [ ] Overlay text preview — *code exists, not fully integrated* +- [ ] Voice Activity Detection (VAD) for auto-stop — _implemented but not integrated_ +- [ ] Overlay text preview — _code exists, not fully integrated_ ### Planned diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..02faca29 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# Security Policy + +## Supported Versions + +CodeScribe follows rolling support for the latest stable release. + +| Version | Supported | +| ------- | --------- | +| 0.8.x | Yes | +| < 0.8.0 | No | + +## Reporting a Vulnerability + +If you discover a security issue, please do not open a public issue. + +Use one of these private channels: + +- Email: `security@vetcoders.io` +- Fallback: `hello@vetcoders.io` + +Please include: + +- Affected version +- Reproduction steps +- Impact assessment +- Any suggested mitigation + +## Response Targets + +- Initial acknowledgment: within 2 business days +- Triage decision: within 5 business days +- Fix timeline: shared after triage +- Public disclosure: after mitigation is available + +## Disclosure Process + +1. We acknowledge and triage the report. +2. We reproduce and classify severity. +3. We implement and validate a fix. +4. We publish release notes and attribution (if requested). + +## Scope Notes + +For local-first workflows, this project handles: + +- Microphone/audio capture +- Local model execution +- Optional API traffic to configured LLM providers + +Please include provider and endpoint details in your report when relevant. diff --git a/app/agent/mod.rs b/app/agent/mod.rs new file mode 100644 index 00000000..7021c2e2 --- /dev/null +++ b/app/agent/mod.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use codescribe_core::agent::AgentProvider; + +pub mod openai_provider; +#[cfg(target_os = "macos")] +pub mod tools; + +pub use openai_provider::OpenAiProvider; + +pub fn create_openai_provider_from_env() -> Result { + OpenAiProvider::from_env() +} + +pub fn create_default_provider() -> Result> { + Ok(Box::new(OpenAiProvider::from_env()?)) +} diff --git a/app/agent/openai_provider.rs b/app/agent/openai_provider.rs new file mode 100644 index 00000000..014dc43d --- /dev/null +++ b/app/agent/openai_provider.rs @@ -0,0 +1,470 @@ +use std::env; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use reqwest::Client; +use serde::Serialize; +use serde_json::{Value, json}; +use tokio::sync::{Mutex, mpsc}; + +use codescribe_core::agent::{ + AgentEvent, AgentProvider, ContentBlock, Message, Role, StreamOptions, ToolDefinition, +}; +use codescribe_core::llm::responses_streaming_manager::{ + ResponsesStreamingManager, StreamCallbacks, +}; + +const DEFAULT_INITIAL_RESPONSE_TIMEOUT_MS: u64 = 30_000; +const DEFAULT_INTER_CHUNK_TIMEOUT_MS: u64 = 30_000; + +#[derive(Clone)] +pub struct OpenAiProvider { + client: Client, + endpoint: String, + api_key: String, + default_model: String, + use_previous_response_id: bool, + previous_response_id: Arc>>, + initial_response_timeout: Duration, + inter_chunk_timeout: Duration, +} + +impl OpenAiProvider { + pub fn from_env() -> Result { + let endpoint = get_env_non_empty("LLM_ASSISTIVE_ENDPOINT", "LLM endpoint (assistive)")?; + let default_model = get_env_non_empty("LLM_ASSISTIVE_MODEL", "LLM model (assistive)")?; + let api_key = get_env_non_empty("LLM_ASSISTIVE_API_KEY", "LLM API key (assistive)")?; + + let use_previous_response_id = + parse_env_bool("CODESCRIBE_AGENT_USE_PREVIOUS_RESPONSE_ID", true); + 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(Duration::from_secs(3600)) + .build() + .context("Failed to create OpenAI agent HTTP client")?; + + Ok(Self { + client, + endpoint, + api_key, + default_model, + use_previous_response_id, + previous_response_id: Arc::new(Mutex::new(None)), + initial_response_timeout, + inter_chunk_timeout, + }) + } +} + +#[async_trait] +impl AgentProvider for OpenAiProvider { + 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() + }; + + let previous_response_id = if self.use_previous_response_id { + self.previous_response_id.lock().await.clone() + } else { + None + }; + + let request = OpenAiResponsesRequest { + model, + input: build_request_input_items(messages, previous_response_id.as_deref())?, + previous_response_id, + instructions: options.system_prompt.clone(), + max_output_tokens: options.max_tokens, + temperature: options.temperature, + tools: build_tool_payload(tools), + stream: true, + }; + + let manager = ResponsesStreamingManager::new( + &self.client, + &self.endpoint, + &self.api_key, + StreamCallbacks { + assistant: None, + reasoning: None, + }, + self.initial_response_timeout, + self.inter_chunk_timeout, + ); + + let mut provider_rx = manager.stream_agent(&request).await?; + + if !self.use_previous_response_id { + return Ok(provider_rx); + } + + let (tx, rx) = mpsc::channel(256); + let previous_response_id = Arc::clone(&self.previous_response_id); + + tokio::spawn(async move { + while let Some(event) = provider_rx.recv().await { + if let AgentEvent::ResponseDone { response_id } = &event + && let Some(response_id) = response_id + && !response_id.is_empty() + { + let mut lock = previous_response_id.lock().await; + *lock = Some(response_id.clone()); + } + + if tx.send(event).await.is_err() { + break; + } + } + }); + + 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 { + "openai-responses" + } +} + +#[derive(Debug, Serialize)] +struct OpenAiResponsesRequest { + model: String, + input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + previous_response_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + tools: Vec, + stream: bool, +} + +#[derive(Debug, Serialize)] +struct OpenAiToolDefinition { + #[serde(rename = "type")] + tool_type: &'static str, + name: String, + description: String, + parameters: Value, +} + +fn build_tool_payload(tools: &[ToolDefinition]) -> Vec { + tools + .iter() + .map(|tool| OpenAiToolDefinition { + tool_type: "function", + name: tool.name.clone(), + description: tool.description.clone(), + parameters: tool.input_schema.clone(), + }) + .collect() +} + +fn build_request_input_items( + messages: &[Message], + previous_response_id: Option<&str>, +) -> Result> { + build_input_items(request_messages(messages, previous_response_id)) +} + +fn request_messages<'a>( + messages: &'a [Message], + previous_response_id: Option<&str>, +) -> &'a [Message] { + if previous_response_id.is_none() { + return messages; + } + + let mut start = messages.len(); + while start > 0 && messages[start - 1].role == Role::User { + start -= 1; + } + + &messages[start..] +} + +fn build_input_items(messages: &[Message]) -> Result> { + let mut items = Vec::new(); + + for message in messages { + let mut content = Vec::new(); + for block in &message.content { + match block { + ContentBlock::Text(text) => { + if !text.is_empty() { + content.push(json!({ + "type": "input_text", + "text": text + })); + } + } + ContentBlock::Image { data, media_type } => { + content.push(json!({ + "type": "input_image", + "image_url": to_data_uri(data, media_type) + })); + } + ContentBlock::ToolUse { id, name, input } => { + let arguments = serde_json::to_string(input).with_context(|| { + format!("Failed to serialize arguments for tool '{name}'") + })?; + items.push(json!({ + "type": "function_call", + "call_id": id, + "name": name, + "arguments": arguments + })); + } + ContentBlock::ToolResult { + tool_use_id, + content: tool_content, + is_error, + } => { + items.push(json!({ + "type": "function_call_output", + "call_id": tool_use_id, + "output": format_tool_output(tool_content, *is_error)? + })); + } + } + } + + if !content.is_empty() { + items.push(json!({ + "type": "message", + "role": role_to_str(message.role), + "content": content + })); + } + } + + Ok(items) +} + +fn format_tool_output(content: &[ContentBlock], is_error: bool) -> Result { + let mut parts = Vec::new(); + for block in content { + match block { + ContentBlock::Text(text) => { + if !text.trim().is_empty() { + parts.push(json!({ + "type": "text", + "text": text.trim() + })); + } + } + ContentBlock::Image { data, media_type } => { + parts.push(json!({ + "type": "image", + "media_type": media_type, + "data": BASE64.encode(data) + })); + } + ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } => {} + } + } + + if parts.is_empty() { + let fallback = if is_error { + "Tool execution failed" + } else { + "Tool executed successfully" + }; + return Ok(fallback.to_string()); + } + + if parts.len() == 1 + && let Some(text) = parts[0].get("text").and_then(Value::as_str) + { + return Ok(if is_error { + format!("ERROR: {text}") + } else { + text.to_string() + }); + } + + let payload = json!({ + "is_error": is_error, + "content": parts + }); + serde_json::to_string(&payload).context("Failed to serialize tool output payload") +} + +fn role_to_str(role: Role) -> &'static str { + match role { + Role::User => "user", + Role::Assistant => "assistant", + Role::System => "system", + } +} + +fn to_data_uri(data: &[u8], media_type: &str) -> String { + let media_type = { + let normalized = media_type.trim(); + if normalized.is_empty() { + "image/png" + } else { + normalized + } + }; + format!("data:{media_type};base64,{}", BASE64.encode(data)) +} + +fn get_env_non_empty(key: &str, label: &str) -> Result { + let value = 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 { + env::var(key) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) +} + +fn parse_env_bool(key: &str, default: bool) -> bool { + match env::var(key) { + Ok(value) => match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => default, + }, + Err(_) => default, + } +} + +#[cfg(test)] +mod tests { + use super::{build_request_input_items, request_messages}; + use codescribe_core::agent::{ContentBlock, Message, Role}; + use serde_json::json; + + #[test] + fn request_messages_replays_full_history_without_previous_response_id() { + let messages = vec![ + Message::new(Role::User, vec![ContentBlock::Text("first".to_string())]), + Message::new( + Role::Assistant, + vec![ContentBlock::Text("second".to_string())], + ), + ]; + + let selected = request_messages(&messages, None); + assert_eq!(selected, messages.as_slice()); + } + + #[test] + fn request_messages_uses_only_trailing_user_messages_with_previous_response_id() { + let messages = vec![ + Message::new( + Role::User, + vec![ContentBlock::Text("earlier turn".to_string())], + ), + Message::new( + Role::Assistant, + vec![ContentBlock::ToolUse { + id: "call_1".to_string(), + name: "read_file".to_string(), + input: json!({"path": "/tmp/ignored.txt"}), + }], + ), + Message::new( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: "call_1".to_string(), + content: vec![ContentBlock::Text("tool output".to_string())], + is_error: false, + }], + ), + Message::new( + Role::User, + vec![ContentBlock::Text("follow-up".to_string())], + ), + ]; + + let selected = request_messages(&messages, Some("resp_prev")); + assert_eq!(selected.len(), 2); + assert!(selected.iter().all(|message| message.role == Role::User)); + } + + #[test] + fn build_request_input_items_skips_prior_history_when_resuming_chain() { + let messages = vec![ + Message::new( + Role::User, + vec![ContentBlock::Text("earlier turn".to_string())], + ), + Message::new( + Role::Assistant, + vec![ContentBlock::ToolUse { + id: "call_1".to_string(), + name: "read_file".to_string(), + input: json!({"path": "/tmp/ignored.txt"}), + }], + ), + Message::new( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: "call_1".to_string(), + content: vec![ContentBlock::Text("tool output".to_string())], + is_error: false, + }], + ), + ]; + + let items = build_request_input_items(&messages, Some("resp_prev")) + .expect("request input items should build"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0]["type"], "function_call_output"); + assert_eq!(items[0]["call_id"], "call_1"); + } +} diff --git a/app/agent/tools/clipboard.rs b/app/agent/tools/clipboard.rs new file mode 100644 index 00000000..4a15c188 --- /dev/null +++ b/app/agent/tools/clipboard.rs @@ -0,0 +1,182 @@ +use anyhow::{Context, Result, bail}; +use arboard::Clipboard; +use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; +use image::codecs::png::PngEncoder; +use image::{ExtendedColorType, ImageEncoder, RgbaImage}; +use serde_json::{Value, json}; + +const OK_TEXT: &str = "ok"; + +pub fn register(registry: &mut ToolRegistry) { + registry + .register( + read_clipboard_definition(), + Box::new(|input| Box::pin(handle_read(input))), + ) + .expect("register read_clipboard tool"); + registry + .register( + write_clipboard_definition(), + Box::new(|input| Box::pin(handle_write(input))), + ) + .expect("register write_clipboard tool"); +} + +fn read_clipboard_definition() -> ToolDefinition { + ToolDefinition { + name: "read_clipboard".to_string(), + description: "Read the current clipboard content. Returns text content, or image data if clipboard contains an image.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + } +} + +fn write_clipboard_definition() -> ToolDefinition { + ToolDefinition { + name: "write_clipboard".to_string(), + description: "Write text to the system clipboard.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to write to clipboard" + } + }, + "required": ["text"] + }), + } +} + +async fn handle_read(_input: Value) -> Vec { + match read_clipboard_with( + crate::os::clipboard::get_clipboard, + read_clipboard_image_png, + ) { + Ok(content) => vec![content], + Err(error) => vec![ToolResultContent::Error(error.to_string())], + } +} + +async fn handle_write(input: Value) -> Vec { + match write_clipboard_with(&input, crate::os::clipboard::set_clipboard) { + Ok(content) => vec![content], + Err(error) => vec![ToolResultContent::Error(error.to_string())], + } +} + +fn read_clipboard_with( + get_text: GetText, + get_image: GetImage, +) -> Result +where + GetText: Fn() -> Result, + GetImage: Fn() -> Result>>, +{ + match get_text() { + Ok(text) => return Ok(ToolResultContent::Text(text)), + Err(error) => { + tracing::debug!("text clipboard read failed, trying image fallback: {error}"); + } + } + + if let Some(image_bytes) = get_image()? { + return Ok(ToolResultContent::Image { + data: image_bytes, + media_type: "image/png".to_string(), + }); + } + + bail!("Clipboard is empty or contains unsupported data") +} + +fn write_clipboard_with( + input: &Value, + mut setter: SetClipboard, +) -> Result +where + SetClipboard: FnMut(&str) -> Result<()>, +{ + let text = input + .get("text") + .and_then(Value::as_str) + .context("Missing required string field 'text'")?; + + setter(text).context("Failed to write clipboard text")?; + Ok(ToolResultContent::Text(OK_TEXT.to_string())) +} + +fn read_clipboard_image_png() -> Result>> { + let mut clipboard = Clipboard::new().context("Failed to initialize clipboard")?; + let image = match clipboard.get_image() { + Ok(image) => image, + Err(_) => return Ok(None), + }; + + let width = u32::try_from(image.width).context("Clipboard image width exceeds u32")?; + let height = u32::try_from(image.height).context("Clipboard image height exceeds u32")?; + + let rgba = RgbaImage::from_raw(width, height, image.bytes.into_owned()) + .context("Invalid clipboard image buffer")?; + + let mut png_data = Vec::new(); + let encoder = PngEncoder::new(&mut png_data); + encoder + .write_image(rgba.as_raw(), width, height, ExtendedColorType::Rgba8) + .context("Failed to encode clipboard image as PNG")?; + + Ok(Some(png_data)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_clipboard_returns_text_first() { + let result = read_clipboard_with( + || Ok("Hello clipboard".to_string()), + || Ok(Some(vec![1_u8, 2_u8, 3_u8])), + ) + .expect("read clipboard should succeed"); + + assert_eq!( + result, + ToolResultContent::Text("Hello clipboard".to_string()) + ); + } + + #[test] + fn read_clipboard_falls_back_to_image() { + let result = read_clipboard_with( + || bail!("text unavailable"), + || Ok(Some(vec![9_u8, 8_u8, 7_u8])), + ) + .expect("image fallback should succeed"); + + assert_eq!( + result, + ToolResultContent::Image { + data: vec![9_u8, 8_u8, 7_u8], + media_type: "image/png".to_string(), + } + ); + } + + #[test] + fn write_clipboard_uses_setter() { + let input = json!({ "text": "Paste me" }); + let mut written = String::new(); + + let result = write_clipboard_with(&input, |text| { + written = text.to_string(); + Ok(()) + }) + .expect("write clipboard should succeed"); + + assert_eq!(written, "Paste me"); + assert_eq!(result, ToolResultContent::Text("ok".to_string())); + } +} diff --git a/app/agent/tools/filesystem.rs b/app/agent/tools/filesystem.rs new file mode 100644 index 00000000..8faa40b9 --- /dev/null +++ b/app/agent/tools/filesystem.rs @@ -0,0 +1,154 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; +use serde_json::{Value, json}; + +const MAX_FILE_SIZE_BYTES: u64 = 512 * 1024; +const MAX_TEXT_CHARS: usize = 40_000; + +pub fn register(registry: &mut ToolRegistry) { + registry + .register( + read_file_definition(), + Box::new(|input| Box::pin(handle_read_file(input))), + ) + .expect("register read_file tool"); +} + +fn read_file_definition() -> ToolDefinition { + ToolDefinition { + name: "read_file".to_string(), + description: + "Read the text content of a file. Only works for UTF-8 text files under 40K characters." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path to the file to read" + } + }, + "required": ["path"] + }), + } +} + +async fn handle_read_file(input: Value) -> Vec { + match read_file_from_input(&input) { + Ok(content) => vec![ToolResultContent::Text(content)], + Err(error) => vec![ToolResultContent::Error(error.to_string())], + } +} + +fn read_file_from_input(input: &Value) -> Result { + let path_str = input + .get("path") + .and_then(Value::as_str) + .context("Missing required string field 'path'")?; + + let path = validate_path_for_read(path_str)?; + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Path is canonicalized and restricted to $HOME or /tmp in validate_path_for_read(). + let mut content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read UTF-8 text from {}", path.display()))?; + + if content.chars().count() > MAX_TEXT_CHARS { + content = content.chars().take(MAX_TEXT_CHARS).collect(); + } + + Ok(content) +} + +fn validate_path_for_read(path_str: &str) -> Result { + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Input path is validated below (absolute, canonicalized, file-only, root-restricted). + let path = PathBuf::from(path_str); + if !path.is_absolute() { + bail!("Path must be absolute: {path_str}"); + } + + if !path.exists() { + bail!("Path does not exist: {path_str}"); + } + + if !path.is_file() { + bail!("Path is not a file: {path_str}"); + } + + let canonical = path + .canonicalize() + .with_context(|| format!("Failed to canonicalize path: {path_str}"))?; + + ensure_allowed_path(&canonical)?; + + let metadata = fs::metadata(&canonical) + .with_context(|| format!("Failed to inspect file metadata: {}", canonical.display()))?; + if metadata.len() > MAX_FILE_SIZE_BYTES { + bail!( + "File exceeds size limit ({} bytes): {}", + MAX_FILE_SIZE_BYTES, + canonical.display() + ); + } + + Ok(canonical) +} + +fn ensure_allowed_path(path: &Path) -> Result<()> { + let home_var = std::env::var("HOME").context("HOME environment variable is not set")?; + let home_path = canonical_or_original(PathBuf::from(home_var)); + let tmp_path = canonical_or_original(PathBuf::from("/tmp")); + + if is_path_allowed(path, &home_path, &tmp_path) { + return Ok(()); + } + + bail!( + "Path is outside allowed directories ($HOME or /tmp): {}", + path.display() + ) +} + +fn canonical_or_original(path: PathBuf) -> PathBuf { + match path.canonicalize() { + Ok(canonical) => canonical, + Err(_) => path, + } +} + +pub(crate) fn is_path_allowed(path: &Path, home: &Path, tmp: &Path) -> bool { + path.starts_with(home) || path.starts_with(tmp) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_under_home_is_allowed() { + let home = PathBuf::from("/Users/tester"); + let tmp = PathBuf::from("/private/tmp"); + let path = PathBuf::from("/Users/tester/Documents/note.txt"); + + assert!(is_path_allowed(&path, &home, &tmp)); + } + + #[test] + fn path_under_tmp_is_allowed() { + let home = PathBuf::from("/Users/tester"); + let tmp = PathBuf::from("/private/tmp"); + let path = PathBuf::from("/private/tmp/codescribe-test.txt"); + + assert!(is_path_allowed(&path, &home, &tmp)); + } + + #[test] + fn path_outside_allowed_roots_is_rejected() { + let home = PathBuf::from("/Users/tester"); + let tmp = PathBuf::from("/private/tmp"); + let path = PathBuf::from("/etc/hosts"); + + assert!(!is_path_allowed(&path, &home, &tmp)); + } +} diff --git a/app/agent/tools/mod.rs b/app/agent/tools/mod.rs new file mode 100644 index 00000000..4d4d5f97 --- /dev/null +++ b/app/agent/tools/mod.rs @@ -0,0 +1,46 @@ +pub mod clipboard; +pub mod filesystem; +pub mod screenshot; +pub mod selection; +pub mod typing; + +use codescribe_core::agent::ToolRegistry; + +pub fn register_all_tools(registry: &mut ToolRegistry) { + screenshot::register(registry); + clipboard::register(registry); + selection::register(registry); + filesystem::register(registry); + typing::register(registry); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_all_tools_registers_expected_names() { + let mut registry = ToolRegistry::new(); + register_all_tools(&mut registry); + + let mut names = registry + .definitions() + .into_iter() + .map(|definition| definition.name) + .collect::>(); + names.sort(); + + assert_eq!( + names, + vec![ + "get_frontmost_app".to_string(), + "get_selected_text".to_string(), + "read_clipboard".to_string(), + "read_file".to_string(), + "take_screenshot".to_string(), + "type_text".to_string(), + "write_clipboard".to_string(), + ] + ); + } +} diff --git a/app/agent/tools/screenshot.rs b/app/agent/tools/screenshot.rs new file mode 100644 index 00000000..ac4ec1ba --- /dev/null +++ b/app/agent/tools/screenshot.rs @@ -0,0 +1,354 @@ +use std::ffi::c_void; +use std::ptr; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; +use core_foundation::base::{CFRelease, CFType, TCFType, kCFAllocatorDefault}; +use core_foundation::data::{CFData, CFDataCreateMutable, CFDataRef, CFMutableDataRef}; +use core_foundation::dictionary::{CFDictionary, CFDictionaryRef}; +use core_foundation::number::CFNumber; +use core_foundation::string::{CFString, CFStringRef}; +use core_graphics::base::{kCGBitmapByteOrder32Big, kCGImageAlphaLast, kCGRenderingIntentDefault}; +use core_graphics::color_space::CGColorSpace; +use core_graphics::data_provider::CGDataProvider; +use core_graphics::display::CGDisplay; +use core_graphics::geometry::CG_ZERO_RECT; +use core_graphics::image::CGImage; +use core_graphics::window::{ + CGWindowID, kCGNullWindowID, kCGWindowImageBoundsIgnoreFraming, kCGWindowImageDefault, + kCGWindowLayer, kCGWindowListExcludeDesktopElements, kCGWindowListOptionIncludingWindow, + kCGWindowListOptionOnScreenOnly, kCGWindowNumber, +}; +use image::RgbaImage; +use image::imageops::FilterType; +use serde_json::{Value, json}; + +const MAX_SCREENSHOT_EDGE: u32 = 1568; + +#[derive(Clone, Copy)] +enum CaptureRegion { + Full, + Frontmost, +} + +pub fn register(registry: &mut ToolRegistry) { + registry + .register( + screenshot_definition(), + Box::new(|input| Box::pin(handle_take_screenshot(input))), + ) + .expect("register take_screenshot tool"); +} + +fn screenshot_definition() -> ToolDefinition { + ToolDefinition { + name: "take_screenshot".to_string(), + description: + "Capture a screenshot of the screen or a specific region. Returns the image as PNG data." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "region": { + "type": "string", + "enum": ["full", "frontmost"], + "default": "full", + "description": "What to capture: 'full' for entire screen, 'frontmost' for the frontmost window" + } + } + }), + } +} + +async fn handle_take_screenshot(input: Value) -> Vec { + match capture_and_encode(input) { + Ok(png_bytes) => vec![ToolResultContent::Image { + data: png_bytes, + media_type: "image/png".to_string(), + }], + Err(error) => vec![ToolResultContent::Error(error.to_string())], + } +} + +fn capture_and_encode(input: Value) -> Result> { + let region = parse_region(&input)?; + let image = capture_image(region)?; + + if longest_edge(&image)? <= MAX_SCREENSHOT_EDGE { + return encode_png(&image); + } + + let rgba = cgimage_to_rgba(&image)?; + let (target_width, target_height) = scaled_dimensions(rgba.width(), rgba.height()); + let resized = image::imageops::resize(&rgba, target_width, target_height, FilterType::Lanczos3); + let cg_image = rgba_to_cgimage(&resized)?; + + encode_png(&cg_image) +} + +fn parse_region(input: &Value) -> Result { + let region = input + .get("region") + .and_then(Value::as_str) + .unwrap_or("full"); + + match region { + "full" => Ok(CaptureRegion::Full), + "frontmost" => Ok(CaptureRegion::Frontmost), + other => bail!("Invalid region '{other}'. Expected 'full' or 'frontmost'"), + } +} + +fn capture_image(region: CaptureRegion) -> Result { + match region { + CaptureRegion::Full => capture_full_screen(), + CaptureRegion::Frontmost => capture_frontmost_window(), + } +} + +fn capture_full_screen() -> Result { + CGDisplay::screenshot( + CG_ZERO_RECT, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID, + kCGWindowImageDefault, + ) + .context( + "Failed to capture full-screen screenshot. Screen Recording permission may be required", + ) +} + +fn capture_frontmost_window() -> Result { + let window_id = frontmost_window_id().context("No frontmost window available for capture")?; + + CGDisplay::screenshot( + CG_ZERO_RECT, + kCGWindowListOptionIncludingWindow, + window_id, + kCGWindowImageBoundsIgnoreFraming, + ) + .context("Failed to capture frontmost window. Screen Recording permission may be required") +} + +fn frontmost_window_id() -> Option { + // Required by spec: discover visible windows using OnScreenOnly, then capture frontmost one. + let list = CGDisplay::window_list_info( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + None, + )?; + + let window_number_key = unsafe { CFString::wrap_under_get_rule(kCGWindowNumber) }; + let window_layer_key = unsafe { CFString::wrap_under_get_rule(kCGWindowLayer) }; + + for raw_entry in list.get_all_values() { + if raw_entry.is_null() { + continue; + } + + let dictionary: CFDictionary = + unsafe { TCFType::wrap_under_get_rule(raw_entry as CFDictionaryRef) }; + + let layer = cf_i64(&dictionary, &window_layer_key).unwrap_or_default(); + if layer != 0 { + continue; + } + + let window_id = + cf_i64(&dictionary, &window_number_key).and_then(|value| u32::try_from(value).ok()); + if window_id.is_some() { + return window_id; + } + } + + None +} + +fn cf_i64(dictionary: &CFDictionary, key: &CFString) -> Option { + dictionary + .find(key) + .and_then(|value| value.downcast::()) + .and_then(|number| number.to_i64()) +} + +fn longest_edge(image: &CGImage) -> Result { + let width = u32::try_from(image.width()).context("Screenshot width exceeds u32")?; + let height = u32::try_from(image.height()).context("Screenshot height exceeds u32")?; + Ok(width.max(height)) +} + +fn scaled_dimensions(width: u32, height: u32) -> (u32, u32) { + let longest = width.max(height); + if longest <= MAX_SCREENSHOT_EDGE { + return (width, height); + } + + let scale = f64::from(MAX_SCREENSHOT_EDGE) / f64::from(longest); + let scaled_width = (f64::from(width) * scale).round().max(1.0) as u32; + let scaled_height = (f64::from(height) * scale).round().max(1.0) as u32; + (scaled_width, scaled_height) +} + +fn cgimage_to_rgba(image: &CGImage) -> Result { + if image.bits_per_component() != 8 || image.bits_per_pixel() != 32 { + bail!( + "Unsupported screenshot format (bits_per_component={}, bits_per_pixel={})", + image.bits_per_component(), + image.bits_per_pixel() + ); + } + + let width = image.width(); + let height = image.height(); + let bytes_per_row = image.bytes_per_row(); + + let min_row_bytes = width + .checked_mul(4) + .context("Screenshot row width overflow")?; + if bytes_per_row < min_row_bytes { + bail!("Invalid screenshot row stride") + } + + let source_data = image.data(); + let source = source_data.bytes(); + let expected_len = bytes_per_row + .checked_mul(height) + .context("Screenshot byte size overflow")?; + if source.len() < expected_len { + bail!("Screenshot image buffer is truncated") + } + + let pixel_count = width + .checked_mul(height) + .context("Screenshot pixel count overflow")?; + let out_len = pixel_count + .checked_mul(4) + .context("Screenshot output buffer overflow")?; + + let mut output = vec![0_u8; out_len]; + for row in 0..height { + let row_offset = row + .checked_mul(bytes_per_row) + .context("Screenshot row offset overflow")?; + for col in 0..width { + let src = row_offset + .checked_add( + col.checked_mul(4) + .context("Screenshot source offset overflow")?, + ) + .context("Screenshot source offset overflow")?; + let dst = (row * width + col) + .checked_mul(4) + .context("Screenshot destination offset overflow")?; + + // CGWindowListCreateImage commonly provides BGRA; convert to RGBA. + output[dst] = source[src + 2]; + output[dst + 1] = source[src + 1]; + output[dst + 2] = source[src]; + output[dst + 3] = source[src + 3]; + } + } + + let width_u32 = u32::try_from(width).context("Screenshot width exceeds u32")?; + let height_u32 = u32::try_from(height).context("Screenshot height exceeds u32")?; + RgbaImage::from_raw(width_u32, height_u32, output).context("Failed to build RGBA screenshot") +} + +fn rgba_to_cgimage(image: &RgbaImage) -> Result { + let width = usize::try_from(image.width()).context("Resized width exceeds usize")?; + let height = usize::try_from(image.height()).context("Resized height exceeds usize")?; + let bytes_per_row = width + .checked_mul(4) + .context("Resized image row stride overflow")?; + + let color_space = CGColorSpace::create_device_rgb(); + let data = Arc::new(image.clone().into_raw()); + let provider = CGDataProvider::from_buffer(data); + + Ok(CGImage::new( + width, + height, + 8, + 32, + bytes_per_row, + &color_space, + kCGBitmapByteOrder32Big | kCGImageAlphaLast, + &provider, + true, + kCGRenderingIntentDefault, + )) +} + +fn encode_png(image: &CGImage) -> Result> { + let mutable_data = unsafe { CFDataCreateMutable(kCFAllocatorDefault, 0) }; + if mutable_data.is_null() { + bail!("Failed to allocate mutable CFData for PNG encoding") + } + + let png_ut_type = CFString::new("public.png"); + let destination = unsafe { + CGImageDestinationCreateWithData( + mutable_data, + png_ut_type.as_concrete_TypeRef(), + 1, + ptr::null(), + ) + }; + + if destination.is_null() { + unsafe { CFRelease(mutable_data as _) }; + bail!("Failed to create ImageIO destination for PNG encoding") + } + + let finalized = unsafe { + let image_ref = image.as_ref() as *const _ as core_graphics::sys::CGImageRef; + CGImageDestinationAddImage(destination, image_ref, ptr::null()); + let finalized = CGImageDestinationFinalize(destination); + CFRelease(destination as _); + finalized + }; + + if !finalized { + unsafe { CFRelease(mutable_data as _) }; + bail!("Failed to finalize PNG encoding") + } + + let immutable_data = unsafe { CFData::wrap_under_create_rule(mutable_data as CFDataRef) }; + Ok(immutable_data.bytes().to_vec()) +} + +#[link(name = "ImageIO", kind = "framework")] +unsafe extern "C" { + fn CGImageDestinationCreateWithData( + data: CFMutableDataRef, + r#type: CFStringRef, + count: usize, + options: CFDictionaryRef, + ) -> *mut c_void; + + fn CGImageDestinationAddImage( + destination: *mut c_void, + image: core_graphics::sys::CGImageRef, + properties: CFDictionaryRef, + ); + + fn CGImageDestinationFinalize(destination: *mut c_void) -> bool; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_region_defaults_to_full() { + let region = parse_region(&json!({})).expect("parse region should succeed"); + assert!(matches!(region, CaptureRegion::Full)); + } + + #[test] + fn scaled_dimensions_respect_max_edge() { + let (w, h) = scaled_dimensions(4000, 2000); + assert_eq!(w, 1568); + assert_eq!(h, 784); + } +} diff --git a/app/agent/tools/selection.rs b/app/agent/tools/selection.rs new file mode 100644 index 00000000..b12ee568 --- /dev/null +++ b/app/agent/tools/selection.rs @@ -0,0 +1,59 @@ +use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; +use serde_json::{Value, json}; + +pub fn register(registry: &mut ToolRegistry) { + registry + .register( + get_selected_text_definition(), + Box::new(|input| Box::pin(handle_get_selected_text(input))), + ) + .expect("register get_selected_text tool"); + registry + .register( + get_frontmost_app_definition(), + Box::new(|input| Box::pin(handle_get_frontmost_app(input))), + ) + .expect("register get_frontmost_app tool"); +} + +fn get_selected_text_definition() -> ToolDefinition { + ToolDefinition { + name: "get_selected_text".to_string(), + description: "Get the currently selected text in the frontmost application. Uses macOS Accessibility API with Cmd+C fallback for web browsers.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + } +} + +fn get_frontmost_app_definition() -> ToolDefinition { + ToolDefinition { + name: "get_frontmost_app".to_string(), + description: "Get the name of the currently active (frontmost) application.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + } +} + +async fn handle_get_selected_text(_input: Value) -> Vec { + let context = crate::os::selection::capture_assistive_context(); + let selected_text = context + .selected_text + .filter(|text| !text.trim().is_empty()) + .unwrap_or_else(|| "No text selected".to_string()); + + vec![ToolResultContent::Text(selected_text)] +} + +async fn handle_get_frontmost_app(_input: Value) -> Vec { + let context = crate::os::selection::capture_frontmost_app_only(); + let frontmost_app = context + .frontmost_app + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "Unknown".to_string()); + + vec![ToolResultContent::Text(frontmost_app)] +} diff --git a/app/agent/tools/typing.rs b/app/agent/tools/typing.rs new file mode 100644 index 00000000..be24e711 --- /dev/null +++ b/app/agent/tools/typing.rs @@ -0,0 +1,46 @@ +use anyhow::{Context, Result}; +use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; +use serde_json::{Value, json}; + +pub fn register(registry: &mut ToolRegistry) { + registry + .register( + type_text_definition(), + Box::new(|input| Box::pin(handle_type_text(input))), + ) + .expect("register type_text tool"); +} + +fn type_text_definition() -> ToolDefinition { + ToolDefinition { + name: "type_text".to_string(), + description: "Type text into the currently focused application by simulating keyboard input. The text is pasted via clipboard (Cmd+V) for reliability.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text to type" + } + }, + "required": ["text"] + }), + } +} + +async fn handle_type_text(input: Value) -> Vec { + match type_text_from_input(&input) { + Ok(content) => vec![content], + Err(error) => vec![ToolResultContent::Error(error.to_string())], + } +} + +fn type_text_from_input(input: &Value) -> Result { + let text = input + .get("text") + .and_then(Value::as_str) + .context("Missing required string field 'text'")?; + + crate::os::clipboard::paste_and_restore(text).context("Failed to type text via clipboard")?; + Ok(ToolResultContent::Text("ok".to_string())) +} diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index 655972f1..18c0089b 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -2,11 +2,21 @@ //! //! Session state management and utility functions. +use chrono::Utc; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use tokio::sync::RwLock; +use std::sync::Mutex as StdMutex; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc}; +use tracing::{debug, warn}; use crate::config::Config; +use anyhow::{Context, Result}; +use codescribe_core::agent::{ + AgentSession, AgentUiEvent, ContentBlock, Message, Role, StreamOptions, Thread, ThreadMessage, + ThreadStore, ToolRegistry, +}; +use serde_json::json; /// Global flag for current session mode. /// true = assistive (chat UI), false = non-assistive (simple transcription overlay) @@ -37,67 +47,1148 @@ pub fn is_conversation_session() -> bool { IS_CONVERSATION_SESSION.load(Ordering::SeqCst) } -/// Route transcription delta to transcription overlay (ALWAYS) -/// Chat is a CONSUMER of transcription, not the display target. +/// 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. pub fn route_transcription_delta(delta: &str) { - // Transcription ALWAYS goes to transcription_overlay - // Voice chat is a CONSUMER - user decides when to send to AI - crate::append_transcription_delta(delta); + if is_assistive_session() { + crate::voice_chat_ui::append_voice_chat_user_delta(delta); + } else { + // Non-assistive: live dictation preview in ephemeral overlay + crate::transcription_overlay::append_transcription_delta(delta); + } +} + +/// DeltaSink that routes deltas to the active UI overlay. +/// +/// Uses `is_assistive_session()` to decide: chat bubble vs transcription overlay. +/// Plugs into `PresentationEmitter` → `BufferedEmitter` → delta chain. +pub struct RoutingDeltaSink; + +impl codescribe_core::pipeline::contracts::DeltaSink for RoutingDeltaSink { + fn apply(&self, delta: &codescribe_core::pipeline::contracts::TranscriptDelta) { + route_transcription_delta(&delta.delta); + } +} + +const AGENT_UI_CHANNEL_CAPACITY: usize = 256; +static AGENT_THREAD_GENERATION: AtomicU64 = AtomicU64::new(1); +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"; + +struct AgentRuntime { + session: AgentSession, + ui_rx: mpsc::Receiver, + thread_store_id: String, +} + +#[derive(Default)] +struct AgentRuntimeState { + runtime: Option, + runtime_generation: u64, + runtime_degraded: bool, +} + +#[derive(Default)] +struct AgentUiOverlayState { + streamed_any_delta: bool, + saw_reasoning_delta: bool, +} + +impl AgentRuntimeState { + fn ensure_runtime(&mut self, runtime_generation: u64) -> Result<(&mut AgentRuntime, bool)> { + self.ensure_runtime_with(runtime_generation, initialize_agent_runtime) + } + + fn ensure_runtime_with( + &mut self, + runtime_generation: u64, + initialize_runtime: F, + ) -> Result<(&mut AgentRuntime, bool)> + where + F: FnOnce() -> Result, + { + let mut recovered_from_degraded = false; + if self.runtime_generation != runtime_generation { + self.runtime = None; + self.runtime_generation = runtime_generation; + } + if self.runtime.is_none() { + self.runtime = Some(initialize_runtime()?); + if self.runtime_degraded { + self.runtime_degraded = false; + recovered_from_degraded = true; + } + } + let runtime = self + .runtime + .as_mut() + .context("Agent runtime was not initialized")?; + Ok((runtime, recovered_from_degraded)) + } + + fn mark_runtime_degraded(&mut self) -> bool { + self.runtime = None; + if self.runtime_degraded { + false + } else { + self.runtime_degraded = true; + true + } + } + + fn rotate_for_new_thread_with( + &mut self, + runtime_generation: u64, + initialize_runtime: Init, + persist_runtime: Persist, + ) -> Result + where + Init: FnOnce() -> Result, + Persist: FnOnce(&AgentRuntime) -> Result<()>, + { + let previous_runtime = self + .runtime + .as_ref() + .filter(|runtime| !runtime.session.messages().is_empty()); + let should_persist_previous = previous_runtime.is_some(); + if let Some(runtime) = previous_runtime { + persist_runtime(runtime)?; + } + + self.runtime_generation = runtime_generation; + match initialize_runtime() { + Ok(runtime) => { + self.runtime = Some(runtime); + self.runtime_degraded = false; + Ok(should_persist_previous) + } + Err(error) => { + self.runtime = None; + self.runtime_degraded = true; + Err(error).context("Failed to initialize Agent runtime for new thread") + } + } + } +} + +fn current_agent_thread_generation() -> u64 { + AGENT_THREAD_GENERATION.load(Ordering::SeqCst) +} + +fn shared_agent_runtime_state_slot() -> &'static StdMutex>>> +{ + SHARED_AGENT_RUNTIME_STATE.get_or_init(|| StdMutex::new(None)) +} + +fn set_shared_agent_runtime_state(runtime_state: Arc>) { + 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() + .unwrap_or_else(|e| e.into_inner()); + if let Some(state) = guard.as_ref() { + return Arc::clone(state); + } + + let runtime_state = Arc::new(TokioMutex::new(AgentRuntimeState { + runtime_generation: current_agent_thread_generation(), + ..AgentRuntimeState::default() + })); + *guard = Some(Arc::clone(&runtime_state)); + runtime_state +} + +pub(crate) fn request_new_agent_thread_boundary() -> u64 { + let generation = AGENT_THREAD_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + debug!("Agent runtime thread boundary rotated (generation={generation})"); + generation +} + +pub(crate) async fn reset_agent_runtime_for_new_thread() -> Result { + let generation = request_new_agent_thread_boundary(); + 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::voice_chat_ui::set_voice_chat_runtime_degraded(false, None); + if persisted_previous { + crate::voice_chat_ui::refresh_drawer(); + } + Ok(generation) + } + Err(error) => { + crate::voice_chat_ui::set_voice_chat_runtime_degraded( + true, + Some(RUNTIME_DEGRADED_REASON), + ); + Err(error) + } + } +} + +fn initialize_agent_runtime() -> Result { + let mut registry = ToolRegistry::new(); + crate::agent::tools::register_all_tools(&mut registry); + + let provider = crate::agent::create_default_provider() + .context("Failed to create default agent provider")?; + let (ui_tx, ui_rx) = mpsc::channel(AGENT_UI_CHANNEL_CAPACITY); + let session = AgentSession::new(provider, Arc::new(registry), ui_tx); + + Ok(AgentRuntime { + session, + ui_rx, + thread_store_id: ThreadStore::generate_id(), + }) +} + +fn build_agent_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(crate::config::get_assistive_prompt()), + max_tokens, + temperature: None, + } +} + +fn apply_agent_ui_event(event: AgentUiEvent, overlay_state: &mut AgentUiOverlayState) { + match event { + AgentUiEvent::TextDelta(delta) => { + if delta.is_empty() { + return; + } + if !overlay_state.streamed_any_delta && !delta.trim().is_empty() { + crate::voice_chat_ui::update_voice_chat_status("Answering... (80%)"); + } + overlay_state.streamed_any_delta = true; + crate::voice_chat_ui::append_voice_chat_assistant_delta(&delta); + } + AgentUiEvent::TextDone(text) => { + if !overlay_state.streamed_any_delta && !text.trim().is_empty() { + crate::voice_chat_ui::set_voice_chat_text(&text); + } + } + AgentUiEvent::ReasoningDelta(delta) => { + if delta.trim().is_empty() { + return; + } + if !overlay_state.saw_reasoning_delta { + crate::voice_chat_ui::update_voice_chat_status("Reasoning... (60%)"); + overlay_state.saw_reasoning_delta = true; + } + } + AgentUiEvent::ToolExecuting { name, .. } => { + crate::voice_chat_ui::update_voice_chat_status(&format!("Tool running: {name}")); + crate::voice_chat_ui::add_voice_chat_system_message(&format!( + "Tool call started: {name}" + )); + } + AgentUiEvent::ToolResult { name, summary, .. } => { + crate::voice_chat_ui::update_voice_chat_status("Thinking... (70%)"); + crate::voice_chat_ui::add_voice_chat_system_message(&format!( + "Tool call finished: {name} ({summary})" + )); + } + AgentUiEvent::Done => {} + AgentUiEvent::Error(message) => { + crate::voice_chat_ui::update_voice_chat_status("Agent runtime failed"); + crate::voice_chat_ui::add_voice_chat_error_message(&message); + } + } +} + +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); + } + } + _ => {} + } +} + +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) + } +} + +fn derive_thread_title(messages: &[Message]) -> String { + let candidate = messages + .iter() + .find(|message| message.role == Role::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.is_empty() { + title = "CodeScribe Agent Chat".to_string(); + } + title +} + +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 + }) +} + +fn normalize_assistive_thread_text(text: &str) -> Option { + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn persist_runtime_thread(runtime: &AgentRuntime) -> Result<()> { + let store = ThreadStore::new().context("Failed to initialize ThreadStore")?; + let now = Utc::now(); + let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_else(|_| "unknown".to_string()); + + let mut thread = store + .load_thread(&runtime.thread_store_id) + .unwrap_or_else(|_| Thread { + id: runtime.thread_store_id.clone(), + created_at: now, + updated_at: now, + title: "CodeScribe Agent Chat".to_string(), + mode: "assistive".to_string(), + tags: vec!["agent".to_string(), "overlay".to_string()], + notes: Vec::new(), + messages: Vec::new(), + summary: None, + total_tokens: None, + provider: "openai-responses".to_string(), + model: model.clone(), + }); + + thread.updated_at = now; + thread.title = derive_thread_title(runtime.session.messages()); + thread.summary = derive_thread_summary(runtime.session.messages()); + thread.messages = runtime + .session + .messages() + .iter() + .map(ThreadMessage::from) + .collect(); + thread.provider = "openai-responses".to_string(); + thread.model = model; + + store + .save_thread(&thread) + .context("Failed to persist agent thread to ThreadStore")?; + Ok(()) +} + +fn persist_legacy_assistive_thread(user_text: &str, assistant_text: &str) -> Result<()> { + let Some(user_text) = normalize_assistive_thread_text(user_text) else { + return Ok(()); + }; + let Some(assistant_text) = normalize_assistive_thread_text(assistant_text) else { + return Ok(()); + }; + + let store = ThreadStore::new().context("Failed to initialize ThreadStore")?; + let now = Utc::now(); + let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_else(|_| "unknown".to_string()); + + let mut title = user_text.chars().take(72).collect::(); + if title.is_empty() { + title = "CodeScribe Agent Chat".to_string(); + } + let mut summary = assistant_text.chars().take(240).collect::(); + if summary.is_empty() { + summary = "Assistant response".to_string(); + } + let metadata = Some(json!({"source":"legacy-fallback"})); + + let thread = Thread { + id: ThreadStore::generate_id(), + created_at: now, + updated_at: now, + title, + mode: "assistive".to_string(), + tags: vec![ + "agent".to_string(), + "overlay".to_string(), + "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, + }, + ], + summary: Some(summary), + total_tokens: None, + provider: "legacy-formatter".to_string(), + model, + }; + + store + .save_thread(&thread) + .context("Failed to persist legacy assistive thread to ThreadStore")?; + Ok(()) +} + +async fn run_agent_send_path( + runtime_state: &mut AgentRuntimeState, + runtime_generation: u64, + text: String, + stream_options: StreamOptions, +) -> Result<()> { + let (runtime, recovered_from_degraded) = match runtime_state.ensure_runtime(runtime_generation) + { + Ok(state) => state, + Err(error) => { + runtime_state.mark_runtime_degraded(); + crate::voice_chat_ui::set_voice_chat_runtime_degraded( + true, + Some(RUNTIME_DEGRADED_REASON), + ); + return Err(error).context("Agent runtime unavailable"); + } + }; + if recovered_from_degraded { + crate::voice_chat_ui::set_voice_chat_runtime_degraded(false, None); + crate::voice_chat_ui::add_voice_chat_system_message(RUNTIME_RECOVERED_MESSAGE); + } + let mut overlay_state = AgentUiOverlayState::default(); + + let send_result = { + let (session, ui_rx) = (&mut runtime.session, &mut runtime.ui_rx); + let send_future = session.send(text, Vec::new(), &stream_options); + tokio::pin!(send_future); + + let result = loop { + tokio::select! { + result = &mut send_future => break result, + maybe_event = ui_rx.recv() => { + match maybe_event { + Some(event) => apply_agent_ui_event(event, &mut overlay_state), + None => break Err(anyhow::anyhow!("Agent UI event channel closed")), + } + } + } + }; + + while let Ok(event) = ui_rx.try_recv() { + apply_agent_ui_event(event, &mut overlay_state); + } + + result + }; + + match send_result { + Ok(()) => { + crate::voice_chat_ui::update_voice_chat_status("AI Response:"); + if overlay_state.streamed_any_delta { + crate::voice_chat_ui::finalize_voice_chat_assistant_message(); + } + if let Err(error) = persist_runtime_thread(runtime) { + warn!("Failed to persist agent thread: {}", error); + } else { + crate::voice_chat_ui::refresh_drawer(); + } + crate::voice_chat_ui::set_voice_chat_sending(false); + Ok(()) + } + Err(error) => { + if overlay_state.streamed_any_delta { + crate::voice_chat_ui::finalize_voice_chat_assistant_message(); + } + runtime_state.mark_runtime_degraded(); + crate::voice_chat_ui::set_voice_chat_runtime_degraded( + true, + Some(RUNTIME_DEGRADED_REASON), + ); + Err(error).context("AgentSession send failed") + } + } +} + +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::voice_chat_ui::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()), + true, + delta_callback, + None, + ) + .await; + + match result.status { + crate::ai_formatting::AiFormatStatus::Applied => { + crate::voice_chat_ui::update_voice_chat_status("AI Response:"); + if use_streaming && streamed_any_delta.load(Ordering::SeqCst) { + crate::voice_chat_ui::finalize_voice_chat_assistant_message(); + } else { + crate::voice_chat_ui::set_voice_chat_text(&result.text); + } + if let Some(reasoning_text) = result.reasoning_text { + crate::voice_chat_ui::add_voice_chat_system_message(&format!( + "Reasoning summary:\n{}", + reasoning_text + )); + } + Some(result.text) + } + crate::ai_formatting::AiFormatStatus::Failed => { + crate::voice_chat_ui::update_voice_chat_status("AI Failed"); + crate::voice_chat_ui::add_voice_chat_error_message("AI Failed"); + Some("AI Failed".to_string()) + } + crate::ai_formatting::AiFormatStatus::Skipped => { + crate::voice_chat_ui::set_voice_chat_sending(false); + None + } + } +} + +async fn run_agent_send_with_fallback( + runtime_state: &Arc>, + text: String, + whisper_language: crate::config::Language, + ai_assistive_max_tokens: i32, +) { + let stream_options = build_agent_stream_options(ai_assistive_max_tokens); + let agent_result = { + let mut guard = runtime_state.lock().await; + let runtime_generation = current_agent_thread_generation(); + run_agent_send_path(&mut guard, runtime_generation, text.clone(), stream_options).await + }; + + if let Err(error) = agent_result { + warn!( + "Agent runtime failed, switching this response to legacy fallback: {}", + error + ); + debug!("Legacy fallback input length: {}", text.len()); + crate::voice_chat_ui::set_voice_chat_sending(true); + crate::voice_chat_ui::set_voice_chat_runtime_degraded(true, Some(RUNTIME_DEGRADED_REASON)); + crate::voice_chat_ui::update_voice_chat_status("Agent fallback active"); + crate::voice_chat_ui::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::voice_chat_ui::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::voice_chat_ui::refresh_drawer(); + } + } + } +} + +pub(crate) async fn send_assistive_with_agent_runtime( + text: String, + whisper_language: crate::config::Language, + ai_assistive_max_tokens: i32, +) { + let runtime_state = shared_agent_runtime_state(); + run_agent_send_with_fallback( + &runtime_state, + text, + whisper_language, + ai_assistive_max_tokens, + ) + .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::voice_chat_ui::set_voice_chat_runtime_degraded(true, Some(RUNTIME_DEGRADED_REASON)); + crate::voice_chat_ui::add_voice_chat_system_message( + "Agent runtime unavailable. Legacy formatter fallback is active until recovery.", + ); + } else { + crate::voice_chat_ui::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::voice_chat_ui::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::voice_chat_ui::update_voice_chat_status("Wysyłam..."); + crate::voice_chat_ui::update_voice_chat_status("Sending..."); crate::voice_chat_ui::set_voice_chat_sending(true); - let (lang_str, transcript_mode) = { + let (whisper_language, ai_assistive_max_tokens) = { let cfg = config.read().await; - (cfg.whisper_language, cfg.transcript_send_mode) + (cfg.whisper_language, cfg.ai_assistive_max_tokens) }; + run_agent_send_with_fallback( + &runtime_state, + text, + whisper_language, + ai_assistive_max_tokens, + ) + .await; + }); + }))); +} - let use_streaming = matches!( - transcript_mode, - crate::config::TranscriptSendMode::Streaming - ); +/// Legacy transcript backup for assistive mode is opt-in. +/// +/// Non-assistive dictation keeps legacy transcript persistence unchanged. +pub fn raw_save_enabled(is_assistive: bool) -> bool { + if !is_assistive { + return true; + } - let delta_callback = if use_streaming { - Some(Arc::new(|delta: &str| { - crate::voice_chat_ui::append_voice_chat_assistant_delta(delta); - }) as Arc) - } else { - None - }; + std::env::var(CODESCRIBE_ASSISTIVE_LEGACY_BACKUP_ENV) + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} - let result = crate::ai_formatting::format_text_with_status( - &text, - Some(lang_str.as_str()), - true, - delta_callback, +// ═══════════════════════════════════════════════════════════ +// Event-based routing (new pipeline) +// ═══════════════════════════════════════════════════════════ + +use chrono::SecondsFormat; +use codescribe_core::ipc::{EngineEventWire, IpcEvent, IpcEventPayload}; +use codescribe_core::pipeline::contracts::{EngineEvent, EventSink}; +use tokio::sync::broadcast; + +/// Session-level engine stats snapshot used by controller decisions. +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionEngineStats { + pub hallucination_drops: u64, + pub semantic_gate_drops: u64, + pub filtered_empty_drops: u64, + pub corrections_applied: u64, + pub total_utterances: u64, + pub dropped_audio_chunks: u64, + pub partial_runs_total: u64, + pub trigger_utterance_count: u64, + pub trigger_speech_count: u64, + pub trigger_watchdog_count: u64, + pub partial_stale_count: u64, + pub partial_coalesced_count: u64, + pub partial_dropped_count: u64, +} + +/// Session telemetry captured from `EngineEvent`s. +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionTelemetrySnapshot { + pub no_speech_reason: Option, + pub stats: Option, +} + +pub(crate) type SharedSessionTelemetry = Arc>; + +pub(crate) fn new_session_telemetry() -> SharedSessionTelemetry { + Arc::new(StdMutex::new(SessionTelemetrySnapshot::default())) +} + +pub(crate) fn reset_session_telemetry(shared: &SharedSessionTelemetry) { + let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner()); + *guard = SessionTelemetrySnapshot::default(); +} + +pub(crate) fn snapshot_session_telemetry( + shared: &SharedSessionTelemetry, +) -> SessionTelemetrySnapshot { + shared.lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +/// Captures `NoSpeech`/`Stats` telemetry for controller-level routing decisions. +pub(crate) struct SessionTelemetrySink { + shared: SharedSessionTelemetry, +} + +impl SessionTelemetrySink { + pub(crate) fn new(shared: SharedSessionTelemetry) -> Self { + Self { shared } + } +} + +/// Broadcasts sanitized engine events to IPC subscribers. +pub(crate) struct IpcBroadcastSink { + tx: broadcast::Sender, +} + +impl IpcBroadcastSink { + pub(crate) fn new(tx: broadcast::Sender) -> Self { + Self { tx } + } +} + +impl EventSink for IpcBroadcastSink { + fn on_event(&self, event: &EngineEvent) { + let ipc_event = IpcEvent { + timestamp: chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + payload: IpcEventPayload::Engine(EngineEventWire::from(event)), + }; + let _ = self.tx.send(ipc_event); + } +} + +impl EventSink for SessionTelemetrySink { + fn on_event(&self, event: &EngineEvent) { + let mut guard = self.shared.lock().unwrap_or_else(|e| e.into_inner()); + match event { + EngineEvent::NoSpeech { reason } => { + guard.no_speech_reason = Some(reason.clone()); + } + EngineEvent::Stats { + hallucination_drops, + semantic_gate_drops, + filtered_empty_drops, + corrections_applied, + total_utterances, + dropped_audio_chunks, + partial_runs_total, + trigger_utterance_count, + trigger_speech_count, + trigger_watchdog_count, + partial_stale_count, + partial_coalesced_count, + partial_dropped_count, + } => { + guard.stats = Some(SessionEngineStats { + hallucination_drops: *hallucination_drops, + semantic_gate_drops: *semantic_gate_drops, + filtered_empty_drops: *filtered_empty_drops, + corrections_applied: *corrections_applied, + total_utterances: *total_utterances, + dropped_audio_chunks: *dropped_audio_chunks, + partial_runs_total: *partial_runs_total, + trigger_utterance_count: *trigger_utterance_count, + trigger_speech_count: *trigger_speech_count, + trigger_watchdog_count: *trigger_watchdog_count, + partial_stale_count: *partial_stale_count, + partial_coalesced_count: *partial_coalesced_count, + partial_dropped_count: *partial_dropped_count, + }); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use codescribe_core::agent::{AgentEvent, AgentProvider, ToolDefinition}; + use std::sync::atomic::AtomicUsize; + + struct NoopTestProvider; + + #[async_trait] + impl AgentProvider for NoopTestProvider { + async fn stream( + &self, + _messages: &[Message], + _tools: &[ToolDefinition], + _options: &StreamOptions, + ) -> anyhow::Result> { + let (_tx, rx) = mpsc::channel(1); + 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, + }], ) - .await; + } - match result.status { - crate::ai_formatting::AiFormatStatus::Applied => { - crate::voice_chat_ui::update_voice_chat_status("AI Response:"); - crate::voice_chat_ui::set_voice_chat_text(&result.text); - } - crate::ai_formatting::AiFormatStatus::Failed => { - crate::voice_chat_ui::update_voice_chat_status("AI Failed"); - crate::voice_chat_ui::add_voice_chat_error_message("AI Failed"); - } - crate::ai_formatting::AiFormatStatus::Skipped => { - crate::voice_chat_ui::set_voice_chat_sending(false); - } + 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 { + "noop-test-provider" + } + } + + fn runtime_with_thread_id(thread_store_id: &str) -> AgentRuntime { + let (ui_tx, ui_rx) = mpsc::channel(8); + let session = AgentSession::new( + Box::new(NoopTestProvider), + Arc::new(ToolRegistry::new()), + ui_tx, + ); + AgentRuntime { + session, + ui_rx, + thread_store_id: thread_store_id.to_string(), + } + } + + fn seed_runtime_with_user_message(runtime: &mut AgentRuntime) { + let options = StreamOptions { + model: String::new(), + system_prompt: None, + max_tokens: None, + temperature: None, + }; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime should initialize"); + rt.block_on( + runtime + .session + .send("hello".to_string(), Vec::new(), &options), + ) + .expect("seed message should be recorded"); + } + + #[test] + fn test_session_telemetry_sink_tracks_no_speech_and_stats() { + let shared = new_session_telemetry(); + let sink = SessionTelemetrySink::new(Arc::clone(&shared)); + + sink.on_event(&EngineEvent::NoSpeech { + reason: "vad_no_speech_detected".to_string(), }); - }))); -} + sink.on_event(&EngineEvent::Stats { + dropped_audio_chunks: 3, + hallucination_drops: 2, + semantic_gate_drops: 1, + filtered_empty_drops: 4, + corrections_applied: 5, + total_utterances: 0, + partial_runs_total: 6, + trigger_utterance_count: 2, + trigger_speech_count: 3, + trigger_watchdog_count: 1, + partial_stale_count: 7, + partial_coalesced_count: 8, + partial_dropped_count: 9, + }); + + let snapshot = snapshot_session_telemetry(&shared); + assert_eq!( + snapshot.no_speech_reason.as_deref(), + Some("vad_no_speech_detected") + ); + let stats = snapshot.stats.expect("stats should be captured"); + assert_eq!(stats.hallucination_drops, 2); + assert_eq!(stats.semantic_gate_drops, 1); + assert_eq!(stats.filtered_empty_drops, 4); + assert_eq!(stats.corrections_applied, 5); + assert_eq!(stats.total_utterances, 0); + assert_eq!(stats.dropped_audio_chunks, 3); + assert_eq!(stats.partial_runs_total, 6); + assert_eq!(stats.trigger_utterance_count, 2); + assert_eq!(stats.trigger_speech_count, 3); + assert_eq!(stats.trigger_watchdog_count, 1); + assert_eq!(stats.partial_stale_count, 7); + assert_eq!(stats.partial_coalesced_count, 8); + assert_eq!(stats.partial_dropped_count, 9); + } + + #[test] + fn test_reset_session_telemetry_clears_snapshot() { + let shared = new_session_telemetry(); + { + let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner()); + guard.no_speech_reason = Some("test".to_string()); + guard.stats = Some(SessionEngineStats { + hallucination_drops: 1, + ..Default::default() + }); + } + reset_session_telemetry(&shared); + + let snapshot = snapshot_session_telemetry(&shared); + assert!(snapshot.no_speech_reason.is_none()); + assert!(snapshot.stats.is_none()); + } + + #[test] + fn test_request_new_agent_thread_boundary_is_monotonic() { + let before = current_agent_thread_generation(); + let next = request_new_agent_thread_boundary(); + let now = current_agent_thread_generation(); + + assert!(next > before); + assert!(now >= next); + } + + #[test] + fn test_runtime_generation_reuses_existing_runtime_when_unchanged() { + let mut runtime_state = AgentRuntimeState { + runtime: Some(runtime_with_thread_id("thread_existing")), + runtime_generation: 41, + runtime_degraded: false, + }; + let init_calls = AtomicUsize::new(0); + + let (runtime, recovered) = runtime_state + .ensure_runtime_with(41, || { + init_calls.fetch_add(1, Ordering::SeqCst); + Ok(runtime_with_thread_id("thread_should_not_be_used")) + }) + .expect("runtime should be reused for unchanged generation"); + + assert_eq!(runtime.thread_store_id, "thread_existing"); + assert_eq!(init_calls.load(Ordering::SeqCst), 0); + assert!(!recovered); + assert_eq!(runtime_state.runtime_generation, 41); + } + + #[test] + fn test_runtime_generation_change_rotates_runtime_identity() { + let mut runtime_state = AgentRuntimeState { + runtime: Some(runtime_with_thread_id("thread_old")), + runtime_generation: 12, + runtime_degraded: false, + }; + let init_calls = AtomicUsize::new(0); + + let (runtime, recovered) = runtime_state + .ensure_runtime_with(13, || { + init_calls.fetch_add(1, Ordering::SeqCst); + Ok(runtime_with_thread_id("thread_new")) + }) + .expect("runtime should rotate after generation change"); + + assert_eq!(init_calls.load(Ordering::SeqCst), 1); + assert_eq!(runtime.thread_store_id, "thread_new"); + assert_eq!(runtime_state.runtime_generation, 13); + assert!(!recovered); + } + + #[test] + fn test_new_thread_boundary_forces_fresh_runtime_identity() { + let mut runtime_state = AgentRuntimeState { + runtime: Some(runtime_with_thread_id("thread_before_boundary")), + runtime_generation: current_agent_thread_generation(), + runtime_degraded: false, + }; + let new_generation = request_new_agent_thread_boundary(); + let init_calls = AtomicUsize::new(0); + + let (runtime, recovered) = runtime_state + .ensure_runtime_with(new_generation, || { + init_calls.fetch_add(1, Ordering::SeqCst); + Ok(runtime_with_thread_id("thread_after_boundary")) + }) + .expect("runtime should rotate after explicit boundary request"); + + assert_eq!(init_calls.load(Ordering::SeqCst), 1); + assert_eq!(runtime.thread_store_id, "thread_after_boundary"); + assert_eq!(runtime_state.runtime_generation, new_generation); + assert!(!recovered); + } + + #[test] + fn test_runtime_recovery_clears_degraded_flag_on_reinit() { + let mut runtime_state = AgentRuntimeState { + runtime: None, + runtime_generation: 7, + runtime_degraded: true, + }; + let init_calls = AtomicUsize::new(0); + + let (runtime, recovered) = runtime_state + .ensure_runtime_with(7, || { + init_calls.fetch_add(1, Ordering::SeqCst); + Ok(runtime_with_thread_id("thread_recovered")) + }) + .expect("runtime should reinitialize after degraded state"); + + assert_eq!(init_calls.load(Ordering::SeqCst), 1); + assert_eq!(runtime.thread_store_id, "thread_recovered"); + assert!(recovered); + assert!(!runtime_state.runtime_degraded); + } + + #[test] + fn test_rotate_for_new_thread_persists_previous_thread_with_messages() { + let mut old_runtime = runtime_with_thread_id("thread_old"); + seed_runtime_with_user_message(&mut old_runtime); + let mut runtime_state = AgentRuntimeState { + runtime: Some(old_runtime), + runtime_generation: 21, + runtime_degraded: false, + }; + let persist_calls = AtomicUsize::new(0); + + let persisted = runtime_state + .rotate_for_new_thread_with( + 22, + || Ok(runtime_with_thread_id("thread_new")), + |runtime| { + persist_calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(runtime.thread_store_id, "thread_old"); + assert_eq!(runtime.session.messages().len(), 1); + Ok(()) + }, + ) + .expect("runtime rotation should succeed"); + + assert!(persisted); + assert_eq!(persist_calls.load(Ordering::SeqCst), 1); + assert_eq!(runtime_state.runtime_generation, 22); + assert!(!runtime_state.runtime_degraded); + let runtime = runtime_state + .runtime + .expect("new runtime should be installed"); + assert_eq!(runtime.thread_store_id, "thread_new"); + assert!(runtime.session.messages().is_empty()); + } + + #[test] + fn test_rotate_for_new_thread_skips_persist_when_empty() { + let mut runtime_state = AgentRuntimeState { + runtime: Some(runtime_with_thread_id("thread_old")), + runtime_generation: 4, + runtime_degraded: false, + }; + let persist_calls = AtomicUsize::new(0); + + let persisted = runtime_state + .rotate_for_new_thread_with( + 5, + || Ok(runtime_with_thread_id("thread_new")), + |_runtime| { + persist_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .expect("runtime rotation should succeed"); + + assert!(!persisted); + assert_eq!(persist_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn test_rotate_for_new_thread_marks_degraded_when_reinit_fails() { + let mut runtime_state = AgentRuntimeState { + runtime: Some(runtime_with_thread_id("thread_old")), + runtime_generation: 11, + runtime_degraded: false, + }; + let result = runtime_state.rotate_for_new_thread_with( + 12, + || Err(anyhow::anyhow!("boom")), + |_runtime| Ok(()), + ); -/// Raw transcript saving is always enabled to avoid data loss. -pub fn raw_save_enabled() -> bool { - true + assert!(result.is_err()); + assert_eq!(runtime_state.runtime_generation, 12); + assert!(runtime_state.runtime_degraded); + assert!(runtime_state.runtime.is_none()); + } } diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 829e0d99..988a52e8 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -8,9 +8,10 @@ //! //! ```text //! IDLE + hold_down → (wait 800ms) → REC_HOLD -//! IDLE + toggle_press → REC_TOGGLE +//! IDLE + toggle_press → REC_TOGGLE (continuous) //! REC_HOLD + hold_up → BUSY (process) -//! REC_TOGGLE + toggle_press → BUSY (process) +//! REC_TOGGLE + silence → send (no stop) +//! REC_TOGGLE + toggle_press → IDLE (stop) //! BUSY → (transcribe + format + paste) → IDLE //! ``` //! @@ -28,12 +29,13 @@ pub use helpers::{ }; pub use types::{HotkeyAction, HotkeyInput, HotkeyType, State}; +use crate::presentation::emitter::PresentationEmitter; use crate::stream_postprocess::StreamPostProcessor; use anyhow::{Context, Result}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; -use std::time::Duration; -use tokio::sync::{Mutex, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::{Mutex, RwLock, broadcast}; use tokio::task::JoinHandle; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -42,21 +44,186 @@ use crate::audio::streaming_recorder::StreamingRecorder; use crate::config::Config; use crate::config::models::ModelManager; use crate::os::clipboard; -use crate::tray::{TrayStatus, update_tray_status}; +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_frontmost_app_only, +}; 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::tts::AudioPlayer; // UI state for conversation mode use crate::voice_chat_ui::ConversationModeState; -use helpers::{raw_save_enabled, route_transcription_delta, setup_voice_chat_send_callback}; +use helpers::{ + 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, +}; use types::ValidatedAudioPath; static OVERLAY_CONTROLLER: OnceLock> = OnceLock::new(); +#[derive(Debug, Clone, Copy)] +struct ActionQualityProbe { + raw_chars: usize, + final_chars: usize, + raw_final_diff_ratio: f32, + correction_ratio: f32, + drop_ratio: f32, +} + +impl ActionQualityProbe { + fn from_transcripts( + raw_text: &str, + final_text: &str, + post_stats: &crate::stream_postprocess::StreamPostProcessStats, + ) -> Self { + let raw_chars = raw_text.chars().count(); + let final_chars = final_text.chars().count(); + + let (backspaces, inserted_chars) = + codescribe_core::pipeline::contracts::TranscriptDelta::from_diff(raw_text, final_text) + .map(|delta| { + let backspaces = delta + .delta + .chars() + .filter(|c| *c == codescribe_core::pipeline::contracts::BACKSPACE) + .count(); + let inserted = delta.delta.chars().count().saturating_sub(backspaces); + (backspaces, inserted) + }) + .unwrap_or((0, 0)); + + let span = raw_chars.max(final_chars).max(1); + let raw_final_diff_ratio = ((backspaces + inserted_chars) as f32 / span as f32).min(1.0); + let correction_ratio = (backspaces as f32 / raw_chars.max(1) as f32).min(1.0); + let drop_ratio = if post_stats.input_chunks == 0 { + 0.0 + } else { + post_stats.dropped_chunks as f32 / post_stats.input_chunks as f32 + }; + + Self { + raw_chars, + final_chars, + raw_final_diff_ratio, + correction_ratio, + drop_ratio, + } + } +} + +const QUALITY_GATE_MIN_CHARS: usize = 24; +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); + +struct AtomicFlagGuard { + flag: Arc, +} + +impl AtomicFlagGuard { + fn new(flag: Arc) -> Self { + flag.store(true, Ordering::SeqCst); + Self { flag } + } +} + +impl Drop for AtomicFlagGuard { + fn drop(&mut self) { + self.flag.store(false, Ordering::SeqCst); + } +} + +#[derive(Debug, Clone, Default)] +struct ProcessRecordingOutcome { + no_speech_reason: Option, + commit_trigger: Option, + transcript_present: bool, +} + +impl ProcessRecordingOutcome { + fn no_speech(reason: impl Into) -> Self { + Self { + no_speech_reason: Some(reason.into()), + commit_trigger: None, + transcript_present: false, + } + } +} + +fn should_allow_full_user_bubble_rewrite( + skip_user_bubble: bool, + append_mode: bool, + live_stream_session: bool, +) -> bool { + !skip_user_bubble && !append_mode && !live_stream_session +} + +#[allow(dead_code)] +fn should_allow_full_assistant_rewrite(append_mode: bool, live_stream_session: bool) -> bool { + !append_mode && !live_stream_session +} + +fn should_apply_transcription_action_contract(assistive: bool, live_stream_session: bool) -> bool { + !assistive && !live_stream_session +} + +fn evaluate_quality_commit_trigger( + force_raw: bool, + quality_probe: &ActionQualityProbe, + output_kind: crate::state::history::TranscriptKind, +) -> Option<&'static str> { + if force_raw { + return None; + } + if output_kind == crate::state::history::TranscriptKind::AiFailed { + return Some("ai_failed_fallback"); + } + if quality_probe.raw_chars < QUALITY_GATE_MIN_CHARS + && quality_probe.final_chars < QUALITY_GATE_MIN_CHARS + { + return None; + } + if quality_probe.drop_ratio >= QUALITY_GATE_DROP_RATIO { + return Some("high_drop_ratio"); + } + if quality_probe.raw_final_diff_ratio >= QUALITY_GATE_DIFF_RATIO { + return Some("high_rewrite_ratio"); + } + if quality_probe.correction_ratio >= QUALITY_GATE_CORRECTION_RATIO { + return Some("high_correction_ratio"); + } + None +} + +fn resolve_transcription_action_contract_mode( + force_raw: bool, + force_ai: bool, + ai_formatting_enabled: bool, + ai_key_available: bool, +) -> crate::transcription_overlay::TranscriptionActionContractMode { + if force_raw { + crate::transcription_overlay::TranscriptionActionContractMode::Raw + } else if force_ai || (ai_formatting_enabled && ai_key_available) { + crate::transcription_overlay::TranscriptionActionContractMode::AiFormat + } else { + crate::transcription_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() { @@ -64,7 +231,7 @@ pub fn register_overlay_controller(controller: Arc) { } } -/// Stop the current recording and enter decision mode without waiting for VAD. +/// Stop the current recording and force the finish pipeline without waiting for VAD. pub fn request_recording_commit() { let Some(controller) = OVERLAY_CONTROLLER.get().cloned() else { warn!("Overlay controller not registered; cannot commit recording"); @@ -78,6 +245,47 @@ pub fn request_recording_commit() { }); } +/// 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 +} + /// Recording controller managing state machine and lifecycle pub struct RecordingController { /// Application configuration @@ -87,10 +295,16 @@ pub struct RecordingController { state: Arc>, /// Audio recorder instance - recorder: Arc>, + recorder: Arc>>, - /// Whether assistive formatting mode is enabled (Ctrl+Shift = always AI augmentation) + /// Whether AI assistive mode is enabled for the current session. + /// + /// This is true for: + /// - Hold modes: Chat (Shift) / Selection (Cmd) + /// - Assistive toggle (right Option double-tap, if enabled) assistive_mode: Arc>, + /// Current hold intent (Raw/Chat/Selection) for the active session. + hold_mode: Arc>, /// Whether to force RAW mode (Ctrl Hold without Shift = always raw, ignores AI toggle) /// Toggle mode (Double Option) keeps this false and respects AI_FORMATTING_ENABLED setting. @@ -103,6 +317,14 @@ pub struct RecordingController { /// Task handle for delayed hold-start (800ms default) hold_start_task: Arc>>>, + /// Monotonic generation for hold-start tasks. + /// + /// Every cancel/reschedule bumps this value. Spawned tasks compare their + /// captured generation before/after critical awaits to avoid stale-start races. + #[allow(dead_code)] + hold_start_generation: Arc, + /// Guard flag used to prevent idle-recovery from killing a freshly-starting session. + start_transition_in_flight: Arc, /// Lock to serialize finish_recording calls serial_lock: Arc>, @@ -110,6 +332,25 @@ pub struct RecordingController { /// Flag set by VAD (silence detection) when recording should auto-stop vad_triggered: Arc, + /// Assistive hands-off loop active (Right Option toggle) + assistive_loop_active: Arc, + + /// Toggle session: track whether we've already appended user/assistant text + toggle_user_has_text: Arc, + toggle_assistant_has_text: Arc, + + /// Best-effort selected-text/app context captured for assistive sessions. + /// + /// Must be captured BEFORE showing any overlay window, because overlays + /// may steal focus and destroy the user's selection context. + assistive_context: 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, + // ═══════════════════════════════════════════════════════════ // Conversation mode (Moshi full-duplex) // ═══════════════════════════════════════════════════════════ @@ -129,9 +370,150 @@ pub struct RecordingController { /// Task handle for conversation audio processing loop conversation_task: Arc>>>, + + /// Broadcast stream for IPC subscribers. + event_broadcast: broadcast::Sender, + /// Per-session telemetry from engine events (`NoSpeech`, `Stats`). + session_telemetry: SharedSessionTelemetry, } impl RecordingController { + fn recorder_unavailable_error(context: &str) -> anyhow::Error { + warn!("{context}: streaming recorder unavailable; voice capture is disabled"); + crate::voice_chat_ui::set_voice_chat_runtime_degraded( + true, + Some(RECORDER_RUNTIME_DEGRADED_REASON), + ); + anyhow::anyhow!("{context}: streaming recorder unavailable") + } + + fn init_streaming_recorder(context: &str) -> Option { + match StreamingRecorder::new() { + Ok(recorder) => Some(recorder), + Err(error) => { + warn!("{context}: failed to initialize streaming recorder: {error}"); + crate::voice_chat_ui::set_voice_chat_runtime_degraded( + true, + Some(RECORDER_RUNTIME_DEGRADED_REASON), + ); + None + } + } + } + + fn recorder_from_guard_mut<'a>( + recorder_guard: &'a mut Option, + context: &str, + ) -> Result<&'a mut StreamingRecorder> { + recorder_guard + .as_mut() + .ok_or_else(|| Self::recorder_unavailable_error(context)) + } + + fn recorder_from_guard<'a>( + recorder_guard: &'a Option, + context: &str, + ) -> Result<&'a StreamingRecorder> { + recorder_guard + .as_ref() + .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 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"); + } + + if missing_permissions.is_empty() { + format!( + "Mic unavailable: recorder failed to initialize. Open Settings > Setup to verify 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 > Setup, grant access, then retry your hotkey. Configured shortcuts: Dictation={} • Formatting={} • Assistive={}.", + missing_permissions.join(", "), + dictation_binding, + formatting_binding, + assistive_binding + ) + } + } + + fn present_runtime_recovery_ui(status: &str, message: &str) { + let emit_recovery_message = Self::should_emit_runtime_recovery_message(); + let overlay_visible = crate::voice_chat_ui::is_voice_chat_overlay_visible(); + if emit_recovery_message || !overlay_visible { + crate::show_voice_chat_overlay(); + crate::show_agent_tab(); + } + crate::voice_chat_ui::update_voice_chat_status(status); + if emit_recovery_message { + crate::voice_chat_ui::add_voice_chat_error_message(message); + crate::voice_chat_ui::show_settings_tab(); + } else { + debug!("Runtime recovery UI throttled (cooldown active)"); + } + } + + fn present_recorder_unavailable(context: &str) { + warn!("{context}: recorder unavailable; routing to settings recovery"); + crate::voice_chat_ui::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 mut message = + "Speech backend unavailable. Open Settings > Setup to verify endpoint/runtime service and retry." + .to_string(); + if let Some(detail) = detail.map(str::trim).filter(|text| !text.is_empty()) { + message.push_str(" Details: "); + message.push_str(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(); @@ -141,44 +523,66 @@ impl RecordingController { config.hold_start_delay_ms, config.beep_on_start, config.whisper_language ); - let mut recorder = - StreamingRecorder::new().expect("Failed to initialize streaming recorder"); - recorder.set_delta_callback(Some(Arc::new(|delta| { - route_transcription_delta(delta); - }))); + let recorder = Self::init_streaming_recorder("RecordingController::new"); - let model_manager = ModelManager::new().expect("Failed to initialize model manager"); - if let Ok(models) = model_manager.list_models() - && !models.is_empty() - { - info!("Available local models: {:?}", models); - } + if !cfg!(test) { + match ModelManager::new() { + Ok(model_manager) => { + if let Ok(models) = model_manager.list_models() + && !models.is_empty() + { + info!("Available local models: {:?}", models); + } + } + Err(error) => warn!("Model manager unavailable during startup: {error}"), + } - // Initialize Whisper engine (singleton) - if let Err(e) = crate::whisper::init() { - warn!("Failed to initialize Whisper engine: {}", e); + // 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); + } } let config = Arc::new(RwLock::new(config)); setup_voice_chat_send_callback(Arc::clone(&config)); + if recorder.is_none() { + crate::voice_chat_ui::set_voice_chat_runtime_degraded( + true, + Some(RECORDER_RUNTIME_DEGRADED_REASON), + ); + } + let (event_broadcast, _) = broadcast::channel::(256); + let session_telemetry = new_session_telemetry(); Self { config, state: Arc::new(RwLock::new(State::Idle)), recorder: Arc::new(Mutex::new(recorder)), assistive_mode: Arc::new(RwLock::new(false)), + hold_mode: Arc::new(RwLock::new(HoldMode::Raw)), force_raw_mode: Arc::new(RwLock::new(false)), force_ai_mode: Arc::new(RwLock::new(false)), session_id: Arc::new(RwLock::new(None)), hold_start_task: Arc::new(Mutex::new(None)), + hold_start_generation: Arc::new(AtomicU64::new(0)), + start_transition_in_flight: Arc::new(AtomicBool::new(false)), serial_lock: Arc::new(Mutex::new(())), vad_triggered: Arc::new(AtomicBool::new(false)), + assistive_loop_active: Arc::new(AtomicBool::new(false)), + toggle_user_has_text: Arc::new(AtomicBool::new(false)), + toggle_assistant_has_text: Arc::new(AtomicBool::new(false)), + assistive_context: Arc::new(RwLock::new(None)), + opened_voice_chat_overlay_for_transcription: Arc::new(AtomicBool::new(false)), // Conversation mode (lazy init) conversation_engine: Arc::new(Mutex::new(None)), audio_player: Arc::new(Mutex::new(None)), conversation_stop_flag: Arc::new(AtomicBool::new(false)), conversation_generation: Arc::new(AtomicU64::new(0)), conversation_task: Arc::new(Mutex::new(None)), + event_broadcast, + session_telemetry, } } @@ -193,43 +597,66 @@ impl RecordingController { cfg.hold_start_delay_ms, cfg.beep_on_start, cfg.whisper_language ); - let mut recorder = - StreamingRecorder::new().expect("Failed to initialize streaming recorder"); - recorder.set_delta_callback(Some(Arc::new(|delta| { - route_transcription_delta(delta); - }))); + let recorder = Self::init_streaming_recorder("RecordingController::with_config"); - let model_manager = ModelManager::new().expect("Failed to initialize model manager"); - if let Ok(models) = model_manager.list_models() - && !models.is_empty() - { - info!("Available local models: {:?}", models); + if !cfg!(test) { + match ModelManager::new() { + Ok(model_manager) => { + if let Ok(models) = model_manager.list_models() + && !models.is_empty() + { + info!("Available local models: {:?}", models); + } + } + Err(error) => warn!("Model manager unavailable during startup: {error}"), + } } - // Initialize Whisper engine (singleton) - if let Err(e) = crate::whisper::init() { + // Initialize Whisper engine if not already done (daemon pre-inits) + if !cfg!(test) + && !crate::whisper::is_initialized() + && let Err(e) = crate::whisper::init() + { warn!("Failed to initialize Whisper engine: {}", e); } setup_voice_chat_send_callback(Arc::clone(&config)); + if recorder.is_none() { + crate::voice_chat_ui::set_voice_chat_runtime_degraded( + true, + Some(RECORDER_RUNTIME_DEGRADED_REASON), + ); + } + let (event_broadcast, _) = broadcast::channel::(256); + let session_telemetry = new_session_telemetry(); Self { config, state: Arc::new(RwLock::new(State::Idle)), recorder: Arc::new(Mutex::new(recorder)), assistive_mode: Arc::new(RwLock::new(false)), + hold_mode: Arc::new(RwLock::new(HoldMode::Raw)), force_raw_mode: Arc::new(RwLock::new(false)), force_ai_mode: Arc::new(RwLock::new(false)), session_id: Arc::new(RwLock::new(None)), hold_start_task: Arc::new(Mutex::new(None)), + hold_start_generation: Arc::new(AtomicU64::new(0)), + start_transition_in_flight: Arc::new(AtomicBool::new(false)), serial_lock: Arc::new(Mutex::new(())), vad_triggered: Arc::new(AtomicBool::new(false)), + assistive_loop_active: Arc::new(AtomicBool::new(false)), + toggle_user_has_text: Arc::new(AtomicBool::new(false)), + toggle_assistant_has_text: Arc::new(AtomicBool::new(false)), + assistive_context: Arc::new(RwLock::new(None)), + opened_voice_chat_overlay_for_transcription: Arc::new(AtomicBool::new(false)), // Conversation mode (lazy init) conversation_engine: Arc::new(Mutex::new(None)), audio_player: Arc::new(Mutex::new(None)), conversation_stop_flag: Arc::new(AtomicBool::new(false)), conversation_generation: Arc::new(AtomicU64::new(0)), conversation_task: Arc::new(Mutex::new(None)), + event_broadcast, + session_telemetry, } } @@ -238,6 +665,45 @@ impl RecordingController { *self.state.read().await } + pub fn subscribe_events(&self) -> broadcast::Receiver { + 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 set_state_with_broadcast( + state: &Arc>, + event_broadcast: &broadcast::Sender, + new_state: State, + ) { + let old_state = { + let mut guard = state.write().await; + let old = *guard; + *guard = new_state; + old + }; + + if old_state != new_state { + let _ = event_broadcast.send(IpcEvent { + timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + payload: IpcEventPayload::StateChange { + from: old_state.to_ipc_str().to_string(), + to: new_state.to_ipc_str().to_string(), + }, + }); + } + } + /// Replace controller configuration at runtime pub async fn set_config(&self, config: Config) { *self.config.write().await = config; @@ -260,14 +726,192 @@ impl RecordingController { /// 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; let mut task_guard = self.hold_start_task.lock().await; - if let Some(task) = task_guard.take() - && !task.is_finished() - { - debug!("Cancelling pending hold-start task"); - task.abort(); - let _ = task.await; // Suppress cancellation errors + if let Some(task) = task_guard.take() { + if task.is_finished() { + let _ = task.await; + } else { + debug!("Invalidated pending hold-start task (generation={generation})"); + } + } + } + + fn clear_recorder_callbacks(recorder: &mut StreamingRecorder) { + recorder.set_utterance_callback(None); + recorder.set_utterance_silence_sec(None); + recorder.set_event_sink(None); + } + + #[allow(dead_code)] + async fn ensure_recorder_ready_for_start( + recorder: &mut StreamingRecorder, + context: &str, + ) -> Result<()> { + if recorder.recorder.is_active() { + warn!("{context}: recorder already active before start; forcing stale-session stop"); + recorder + .stop_without_saving() + .await + .with_context(|| format!("{context}: failed stale-session stop"))?; + info!("{context}: stale recorder stopped before start"); + } + + Self::clear_recorder_callbacks(recorder); + Ok(()) + } + + #[allow(dead_code)] + async fn reset_session_after_start_failure(&self, context: &str) { + warn!("{context}: resetting controller flags after failed start"); + 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_context.write().await = None; + self.start_transition_in_flight + .store(false, Ordering::SeqCst); + 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); + set_assistive_session(false); + reset_session_telemetry(&self.session_telemetry); + hide_hold_badge(); + crate::voice_chat_ui::update_voice_chat_status("Ready"); + } + + fn is_already_in_progress_error(error: &anyhow::Error) -> bool { + error + .to_string() + .contains("Recording is already in progress") + } + + async fn recover_stale_recorder_if_idle(&self) { + if self.start_transition_in_flight.load(Ordering::SeqCst) { + debug!("RECOVERY decision: skip idle-recovery while start transition is in-flight"); + return; + } + + let _serial_guard = self.serial_lock.lock().await; + + if self.start_transition_in_flight.load(Ordering::SeqCst) { + debug!( + "RECOVERY decision: skip idle-recovery after lock (start transition still active)" + ); + return; + } + + if *self.state.read().await != State::Idle { + return; + } + + let mut recorder_guard = self.recorder.lock().await; + let Some(recorder) = recorder_guard.as_mut() else { + return; + }; + if !recorder.recorder.is_active() { + return; + } + + warn!("Recorder recovery: detected active stream while controller is IDLE; forcing stop"); + if let Err(e) = recorder.stop_without_saving().await { + warn!("Recorder recovery: forced stop failed: {e}"); } + Self::clear_recorder_callbacks(recorder); + drop(recorder_guard); + + *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.assistive_context.write().await = None; + *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); + set_assistive_session(false); + reset_session_telemetry(&self.session_telemetry); + hide_hold_badge(); + crate::voice_chat_ui::update_voice_chat_status("Ready"); + info!("RECOVERY decision: stale active stream cleared, controller remains IDLE"); + } + + fn configure_hold_event_sink( + recorder: &mut StreamingRecorder, + event_broadcast: broadcast::Sender, + session_telemetry: SharedSessionTelemetry, + ) { + let tb = recorder.transcript_buffer_handle(); + let delta_sink: Arc = + Arc::new(helpers::RoutingDeltaSink); + let pe: Arc = + Arc::new(PresentationEmitter::new(tb, Some(delta_sink), None)); + let ipc_sink: Arc = + Arc::new(helpers::IpcBroadcastSink::new(event_broadcast)); + let telemetry_sink: Arc = + Arc::new(helpers::SessionTelemetrySink::new(session_telemetry)); + recorder.set_event_sink(Some(Arc::new( + codescribe_core::pipeline::sinks::FanoutEventSink::new(vec![ + pe, + ipc_sink, + telemetry_sink, + ]), + ))); + } + + fn configure_toggle_event_sink( + recorder: &mut StreamingRecorder, + controller: Option>, + expected_session: String, + is_assistive_session: bool, + event_broadcast: broadcast::Sender, + session_telemetry: SharedSessionTelemetry, + ) { + let tb = recorder.transcript_buffer_handle(); + let delta_sink: Arc = + Arc::new(helpers::RoutingDeltaSink); + let mut pe = PresentationEmitter::new(tb, Some(delta_sink), None); + + pe.set_utterance_callback(Some(Arc::new(move |text: String| { + if is_assistive_session { + // Close current streaming user bubble at utterance boundary + // so next preview starts a fresh user message. + crate::voice_chat_ui::finalize_voice_chat_user_message(); + } + let controller = controller.clone(); + let expected_session = expected_session.clone(); + tokio::spawn(async move { + if let Some(controller) = controller + && let Err(e) = controller + .handle_toggle_utterance( + text, + expected_session, + is_assistive_session, + true, // skip_user_bubble: Preview already streams into bubble + ) + .await + { + warn!("Toggle utterance processing failed: {}", e); + } + }); + }))); + + let pe: Arc = Arc::new(pe); + let ipc_sink: Arc = + Arc::new(helpers::IpcBroadcastSink::new(event_broadcast)); + let telemetry_sink: Arc = + Arc::new(helpers::SessionTelemetrySink::new(session_telemetry)); + recorder.set_event_sink(Some(Arc::new( + codescribe_core::pipeline::sinks::FanoutEventSink::new(vec![ + pe, + ipc_sink, + telemetry_sink, + ]), + ))); } /// Handle hotkey event - main entry point for state machine @@ -284,35 +928,128 @@ impl RecordingController { /// - **Toggle + force_ai=true**: force AI formatting (normal hands-off) /// - **Toggle + assistive=true**: force Assistive hands-off pub async fn handle_hotkey_event(&self, event: HotkeyInput) -> Result<()> { - let current_state = self.current_state().await; + let mut current_state = self.current_state().await; + + if current_state == State::Idle { + self.recover_stale_recorder_if_idle().await; + current_state = self.current_state().await; + } debug!( - "Hotkey event: type={:?} action={:?} assistive={} force_ai={} state={}", - event.key_type, event.action, event.assistive, event.force_ai, current_state + "Hotkey event: type={:?} action={:?} assistive={} hold_mode={:?} force_raw={} force_ai={} state={}", + event.key_type, + event.action, + event.assistive, + event.hold_mode, + event.force_raw, + event.force_ai, + current_state ); - // Update assistive mode from event (can be upgraded mid-hold if Shift added) - if event.assistive { - *self.assistive_mode.write().await = true; - // Shift pressed = NOT force_raw (Assistive takes precedence) - *self.force_raw_mode.write().await = false; - *self.force_ai_mode.write().await = false; - } else if matches!(event.action, HotkeyAction::Down | HotkeyAction::Press) { - // Only reset on Down/Press, not Up (preserves upgrade during hold) - *self.assistive_mode.write().await = false; - + // Update mode flags from event (supports mid-hold mode changes via Press events). + if matches!(event.action, HotkeyAction::Down | HotkeyAction::Press) { match event.key_type { HotkeyType::Hold => { - // Hold without Shift = force RAW mode - *self.force_raw_mode.write().await = true; - *self.force_ai_mode.write().await = false; + *self.hold_mode.write().await = event.hold_mode; + match event.hold_mode { + HoldMode::Raw => { + // If we're already in an assistive session (Chat/Selection) and the user + // releases Shift/Cmd while still holding Ctrl, the event tap will emit a + // HoldUpdate back to Raw. We *do not* want to flip the UI back to the + // transcription overlay mid-session (it looks like the chat "blinks" + // and then disappears). + // + // We treat assistive mode as "latched" for the duration of a recording. + if matches!(current_state, State::RecHold | State::RecToggle) + && *self.assistive_mode.read().await + { + debug!("Ignoring Raw hold-mode update during assistive session"); + return Ok(()); + } + + *self.assistive_mode.write().await = false; + *self.assistive_context.write().await = None; + *self.force_raw_mode.write().await = !event.force_ai; + *self.force_ai_mode.write().await = event.force_ai; + + if matches!(current_state, State::RecHold | State::RecToggle) { + set_assistive_session(false); + self.opened_voice_chat_overlay_for_transcription + .store(false, Ordering::SeqCst); + crate::show_transcription_overlay(); + crate::enter_recording_mode(); + crate::clear_transcription_text(); + } + } + HoldMode::Chat => { + *self.assistive_mode.write().await = true; + *self.force_raw_mode.write().await = false; + *self.force_ai_mode.write().await = false; + *self.assistive_context.write().await = None; + + // If we switch modes while already recording, update UI immediately. + if matches!(current_state, State::RecHold | State::RecToggle) { + let ctx = tokio::task::spawn_blocking(capture_frontmost_app_only) + .await + .unwrap_or_default(); + *self.assistive_context.write().await = Some(ctx); + crate::voice_chat_ui::set_voice_chat_target_app( + self.assistive_context + .read() + .await + .clone() + .unwrap_or_default() + .frontmost_app, + ); + set_assistive_session(true); + crate::hide_transcription_overlay(); + crate::show_voice_chat_overlay(); + crate::show_agent_tab(); + crate::voice_chat_ui::update_voice_chat_status("Listening..."); + } + } + HoldMode::Selection => { + *self.assistive_mode.write().await = true; + *self.force_raw_mode.write().await = false; + *self.force_ai_mode.write().await = false; + *self.assistive_context.write().await = None; + + // If we switch modes while already recording, update UI immediately. + if matches!(current_state, State::RecHold | State::RecToggle) { + let ctx = tokio::task::spawn_blocking(capture_assistive_context) + .await + .unwrap_or_default(); + *self.assistive_context.write().await = Some(ctx); + crate::voice_chat_ui::set_voice_chat_target_app( + self.assistive_context + .read() + .await + .clone() + .unwrap_or_default() + .frontmost_app, + ); + set_assistive_session(true); + crate::hide_transcription_overlay(); + crate::show_voice_chat_overlay(); + crate::show_agent_tab(); + crate::voice_chat_ui::update_voice_chat_status("Listening..."); + } + } + } } HotkeyType::Toggle => { - *self.force_raw_mode.write().await = false; + *self.hold_mode.write().await = HoldMode::Raw; + *self.assistive_context.write().await = None; + + *self.assistive_mode.write().await = event.assistive; + *self.force_raw_mode.write().await = event.force_raw; *self.force_ai_mode.write().await = event.force_ai; } HotkeyType::Conversation => { + *self.hold_mode.write().await = HoldMode::Raw; + *self.assistive_context.write().await = None; // Conversation mode - full-duplex (no raw/ai flags) + *self.assistive_mode.write().await = false; *self.force_raw_mode.write().await = false; *self.force_ai_mode.write().await = false; } @@ -372,8 +1109,21 @@ impl RecordingController { self.start_toggle_recording(event.assistive).await?; } State::RecToggle => { - info!("Toggle pressed again; finishing recording"); - self.finish_recording().await?; + info!("Toggle pressed again; stopping recording"); + self.assistive_loop_active.store(false, Ordering::SeqCst); + self.stop_toggle_recording().await?; + } + State::RecHold => { + // Safety/UX: if a hands-off toggle is triggered while in hold recording + // (e.g., due to short HOLD_START_DELAY_MS or user timing), allow it to stop. + // We only do this for RAW toggle to avoid surprising behavior for Option toggles. + if event.force_raw { + info!("RAW toggle pressed during hold recording; finishing recording"); + self.assistive_loop_active.store(false, Ordering::SeqCst); + self.finish_recording().await?; + } else { + debug!("Toggle event ignored in REC_HOLD (force_raw=false)"); + } } _ => { debug!("Toggle event ignored in state {}", current_state); @@ -415,6 +1165,15 @@ impl RecordingController { async fn start_conversation_mode(&self) -> Result<()> { info!("Starting conversation mode (Moshi full-duplex)"); + { + 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); + } + } + // 1. Initialize ConversationEngine if needed (lazy init) { let mut engine_guard = self.conversation_engine.lock().await; @@ -474,7 +1233,7 @@ impl RecordingController { helpers::set_conversation_session(true); // 5. Transition to CONVERSATION state - *self.state.write().await = State::Conversation; + self.set_state(State::Conversation).await; info!("STATE TRANSITION: IDLE → CONVERSATION"); // 6. Update UI @@ -483,7 +1242,6 @@ impl RecordingController { crate::voice_chat_ui::show_agent_tab(); crate::voice_chat_ui::update_voice_chat_status("Listening..."); crate::voice_chat_ui::update_conversation_state(ConversationModeState::Listening); - let _ = update_tray_status(TrayStatus::Listening); // 7. Start the conversation audio processing task let engine = Arc::clone(&self.conversation_engine); @@ -492,6 +1250,7 @@ impl RecordingController { let generation_arc = Arc::clone(&self.conversation_generation); let state = Arc::clone(&self.state); let recorder = Arc::clone(&self.recorder); + let event_broadcast = self.event_broadcast.clone(); let task = tokio::spawn(async move { Self::conversation_audio_loop( @@ -502,6 +1261,7 @@ impl RecordingController { generation_arc, generation, state, + event_broadcast, ) .await; }); @@ -514,14 +1274,16 @@ impl RecordingController { /// The main conversation audio processing loop /// /// Runs in a background task: captures audio → ConversationEngine → speaker + #[allow(clippy::too_many_arguments)] async fn conversation_audio_loop( engine: Arc>>, player: Arc>>, - recorder: Arc>, + recorder: Arc>>, stop_flag: Arc, generation_counter: Arc, my_generation: u64, state: Arc>, + event_broadcast: broadcast::Sender, ) { info!( "Conversation audio loop started (generation {})", @@ -537,28 +1299,59 @@ impl RecordingController { // Start recorder with callback that sends to our channel let tx_clone = tx.clone(); { - let mut rec = recorder.lock().await; - rec.recorder.set_callback(Box::new(move |data| { + let mut rec_guard = recorder.lock().await; + let rec = match Self::recorder_from_guard_mut(&mut rec_guard, "Conversation-loop start") + { + Ok(rec) => rec, + Err(error) => { + error!("Conversation mode unavailable: {error}"); + drop(rec_guard); + // Full cleanup on failure: state, session flag, badge, UI + Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; + helpers::set_conversation_session(false); + hide_hold_badge(); + crate::voice_chat_ui::update_conversation_state( + ConversationModeState::Inactive, + ); + Self::present_recorder_unavailable("Conversation-loop start"); + return; + } + }; + rec.recorder.set_callback(Box::new(move |data: &[f32]| { let _ = tx_clone.try_send(data.to_vec()); })); 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 - *state.write().await = State::Idle; + Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; helpers::set_conversation_session(false); hide_hold_badge(); crate::voice_chat_ui::update_voice_chat_status("Recorder error"); crate::voice_chat_ui::update_conversation_state(ConversationModeState::Inactive); crate::voice_chat_ui::add_voice_chat_error_message(&format!("Mic error: {}", e)); - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Error); return; } } // Get actual sample rate from recorder let sample_rate = { - let rec = recorder.lock().await; + let rec_guard = recorder.lock().await; + let rec = match Self::recorder_from_guard(&rec_guard, "Conversation-loop sample rate") { + Ok(rec) => rec, + Err(error) => { + error!("Conversation mode aborted: {error}"); + drop(rec_guard); + Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; + helpers::set_conversation_session(false); + hide_hold_badge(); + crate::voice_chat_ui::update_conversation_state( + ConversationModeState::Inactive, + ); + Self::present_recorder_unavailable("Conversation-loop sample rate"); + return; + } + }; rec.recorder.actual_sample_rate() }; info!("Conversation mode: recording at {}Hz", sample_rate); @@ -694,8 +1487,10 @@ impl RecordingController { // Cleanup: stop recorder { - let mut rec = recorder.lock().await; - let _ = rec.recorder.stop().await; + let mut rec_guard = recorder.lock().await; + if let Some(rec) = rec_guard.as_mut() { + let _ = rec.recorder.stop().await; + } } // Full cleanup if loop exits unexpectedly (e.g., channel closed) @@ -709,12 +1504,11 @@ impl RecordingController { // This loop owns the current session - safe to cleanup stop_flag.store(true, Ordering::SeqCst); - *state.write().await = State::Idle; + Self::set_state_with_broadcast(&state, &event_broadcast, State::Idle).await; helpers::set_conversation_session(false); hide_hold_badge(); crate::voice_chat_ui::update_voice_chat_status("Conversation ended"); crate::voice_chat_ui::update_conversation_state(ConversationModeState::Inactive); - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Idle); info!( "Loop cleanup: conversation ended unexpectedly (gen {})", my_generation @@ -744,9 +1538,13 @@ impl RecordingController { // 3. Stop recorder BEFORE waiting for task (prevents leak on abort) { - let mut rec = self.recorder.lock().await; - let _ = rec.recorder.stop().await; - info!("Recorder stopped in stop_conversation_mode"); + let mut rec_guard = self.recorder.lock().await; + if let Some(rec) = rec_guard.as_mut() { + let _ = rec.recorder.stop().await; + info!("Recorder stopped in stop_conversation_mode"); + } else { + warn!("stop_conversation_mode: recorder unavailable during stop"); + } } // 4. Wait for conversation task to finish (with timeout) @@ -771,32 +1569,37 @@ impl RecordingController { } // 7. Transition back to IDLE - *self.state.write().await = State::Idle; + self.set_state(State::Idle).await; info!("STATE TRANSITION: CONVERSATION → IDLE"); // 8. Update UI hide_hold_badge(); crate::voice_chat_ui::update_voice_chat_status("Conversation ended"); crate::voice_chat_ui::update_conversation_state(ConversationModeState::Inactive); - let _ = update_tray_status(TrayStatus::Idle); Ok(()) } /// Schedule delayed recording start for hold mode async fn schedule_hold_start(&self) -> Result<()> { + // Hold mode never runs the assistive loop + self.assistive_loop_active.store(false, Ordering::SeqCst); // 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"); - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Error); + Self::present_backend_unavailable("Hold-start health check", None); return Ok(()); } Err(e) => { error!("Whisper engine unavailable: {}", e); - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Error); + let detail = e.to_string(); + Self::present_backend_unavailable( + "Hold-start health check", + Some(detail.as_str()), + ); return Ok(()); } } @@ -805,19 +1608,21 @@ impl RecordingController { let config = self.config.read().await; let delay_ms = config.hold_start_delay_ms; let beep = config.beep_on_start; + let sound_volume = config.sound_volume; let language = config.whisper_language; drop(config); // Release read lock - // Capture assistive mode for badge display - let is_assistive = *self.assistive_mode.read().await; + let hold_mode = Arc::clone(&self.hold_mode); debug!( - "Scheduling hold-start after {}ms delay (assistive={})", - delay_ms, is_assistive + "Scheduling hold-start after {}ms delay (hold_mode={:?})", + delay_ms, + *hold_mode.read().await ); // Cancel any existing delayed start self.cancel_pending_hold_start().await; + let task_generation = self.hold_start_generation.load(Ordering::SeqCst); // Reset VAD flag for new session self.vad_triggered.store(false, Ordering::SeqCst); @@ -827,17 +1632,39 @@ impl RecordingController { let recorder = Arc::clone(&self.recorder); let delay = Duration::from_millis(delay_ms); let vad_flag = Arc::clone(&self.vad_triggered); + let assistive_context = Arc::clone(&self.assistive_context); + let event_broadcast = self.event_broadcast.clone(); + let serial_lock = Arc::clone(&self.serial_lock); + 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 tokio::time::sleep(delay).await; + if hold_start_generation.load(Ordering::SeqCst) != task_generation { + debug!("Hold-start cancelled: superseded generation before lock"); + return; + } + + // Serialize with other start/stop operations. + let _serial_guard = serial_lock.lock().await; + + if hold_start_generation.load(Ordering::SeqCst) != task_generation { + debug!("Hold-start cancelled: superseded generation while waiting for lock"); + return; + } + // Check if we're still in IDLE state let current_state = *state.read().await; if current_state != State::Idle { debug!("Hold-start cancelled: state changed to {}", current_state); return; } + let _start_guard = AtomicFlagGuard::new(Arc::clone(&start_transition_in_flight)); // Generate session ID let new_session_id = Uuid::new_v4().to_string(); @@ -845,23 +1672,110 @@ impl RecordingController { info!("Starting hold recording (session={})", new_session_id); + let hold_mode = *hold_mode.read().await; + let is_assistive = matches!(hold_mode, HoldMode::Chat | HoldMode::Selection); + // Start the recorder (skip in tests: no CoreAudio device needed) - // hang_sec is configured via CODESCRIBE_VAD_MAX_SILENCE_SEC env var (single source of truth) - let mut rec = recorder.lock().await; + // hang_sec is derived from hardcoded VAD defaults (single source of truth). + let mut rec_guard = recorder.lock().await; + let rec = match Self::recorder_from_guard_mut(&mut rec_guard, "Hold-start") { + Ok(rec) => rec, + Err(error) => { + error!("Hold-start aborted: {error}"); + drop(rec_guard); + *session_id.write().await = None; + set_assistive_session(false); + Self::present_recorder_unavailable("Hold-start"); + return; + } + }; + if let Err(e) = Self::ensure_recorder_ready_for_start(rec, "Hold-start preflight").await + { + error!("Hold-start aborted: {e}"); + drop(rec_guard); + *session_id.write().await = None; + set_assistive_session(false); + return; + } + // Hold-to-talk: the key-down is the source of truth. Don't auto-stop mid-hold. + rec.recorder.config.auto_silence = false; rec.recorder.set_on_vad_stop(move || { info!("VAD callback: setting vad_triggered flag"); vad_flag.store(true, Ordering::SeqCst); }); - if !cfg!(test) - && let Err(e) = rec.start(Some(language.as_str().to_string())).await - { - error!("Failed to start recorder: {}", e); - return; - } + + // Set session mode for delta routing BEFORE starting the pipeline, + // so the very first deltas route to the correct overlay. + set_assistive_session(is_assistive); + reset_session_telemetry(&session_telemetry); + + // Runtime pipeline is always event-based. Hold mode has no utterance callback; + // text is finalized on key-up in `finish_recording`. + Self::configure_hold_event_sink( + rec, + event_broadcast.clone(), + Arc::clone(&session_telemetry), + ); + if !cfg!(test) { + let start_result = rec + .start_event_session(Some(language.as_str().to_string())) + .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"); + if let Err(stop_err) = rec.stop_without_saving().await { + warn!("Hold-start stale-recorder recovery failed: {stop_err}"); + } + Self::clear_recorder_callbacks(rec); + Self::configure_hold_event_sink( + rec, + event_broadcast.clone(), + Arc::clone(&session_telemetry), + ); + let retry_result = rec + .start_event_session(Some(language.as_str().to_string())) + .await; + if let Err(retry_err) = retry_result { + error!("Failed to start recorder after recovery: {retry_err}"); + Self::clear_recorder_callbacks(rec); + *session_id.write().await = None; + set_assistive_session(false); + return; + } + } else { + error!("Failed to start recorder: {e}"); + Self::clear_recorder_callbacks(rec); + *session_id.write().await = None; + set_assistive_session(false); + return; + } + } + } + + if hold_start_generation.load(Ordering::SeqCst) != task_generation { + warn!("Hold-start superseded after recorder start; stopping stale session"); + if rec.recorder.is_active() + && let Err(stop_err) = rec.stop_without_saving().await + { + warn!("Hold-start stale-session stop failed: {stop_err}"); + } + Self::clear_recorder_callbacks(rec); + *session_id.write().await = None; + set_assistive_session(false); + return; + } + drop(rec_guard); + + // Transition to REC_HOLD as soon as recorder starts to avoid IDLE/active races. + Self::set_state_with_broadcast(&state, &event_broadcast, State::RecHold).await; + info!( + "STATE TRANSITION: IDLE → REC_HOLD (assistive={})", + is_assistive + ); // Play start beep if enabled if beep { - crate::audio::play_sound("Tink"); + crate::audio::play_sound_with_volume("Tink", sound_volume); } // Show badge with appropriate mode (Hold=red solid, Assistive=purple) @@ -872,27 +1786,53 @@ impl RecordingController { }; show_badge_for_mode(badge_mode); - // Set session mode for delta routing - set_assistive_session(is_assistive); - - // ALWAYS show transcription overlay for live preview - crate::clear_transcription_text(); - crate::show_transcription_overlay(); if is_assistive { + opened_overlay_for_transcription.store(false, Ordering::SeqCst); + // Capture context BEFORE showing any overlay (overlays can steal focus). + let ctx = match hold_mode { + HoldMode::Selection => tokio::task::spawn_blocking(capture_assistive_context) + .await + .unwrap_or_default(), + HoldMode::Chat => tokio::task::spawn_blocking(capture_frontmost_app_only) + .await + .unwrap_or_default(), + HoldMode::Raw => tokio::task::spawn_blocking(capture_frontmost_app_only) + .await + .unwrap_or_default(), + }; + *assistive_context.write().await = Some(ctx); + crate::voice_chat_ui::set_voice_chat_target_app( + assistive_context + .read() + .await + .clone() + .unwrap_or_default() + .frontmost_app, + ); + + crate::hide_transcription_overlay(); crate::show_voice_chat_overlay(); crate::show_agent_tab(); + crate::voice_chat_ui::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::voice_chat_ui::set_voice_chat_target_app( + assistive_context + .read() + .await + .clone() + .unwrap_or_default() + .frontmost_app, + ); + opened_overlay_for_transcription.store(false, Ordering::SeqCst); + crate::show_transcription_overlay(); + crate::enter_recording_mode(); + crate::clear_transcription_text(); } - crate::enter_recording_mode(); - - // Transition to REC_HOLD - *state.write().await = State::RecHold; - info!( - "STATE TRANSITION: IDLE → REC_HOLD (assistive={})", - is_assistive - ); - - // Update tray status to Listening - let _ = update_tray_status(TrayStatus::Listening); }); *self.hold_start_task.lock().await = Some(task); @@ -907,12 +1847,16 @@ impl RecordingController { Ok(true) => {} Ok(false) => { warn!("Whisper engine not ready"); - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Error); + Self::present_backend_unavailable("Toggle-start health check", None); return Ok(()); } Err(e) => { error!("Whisper engine unavailable: {}", e); - let _ = crate::tray::update_tray_status(crate::tray::TrayStatus::Error); + let detail = e.to_string(); + Self::present_backend_unavailable( + "Toggle-start health check", + Some(detail.as_str()), + ); return Ok(()); } } @@ -930,44 +1874,117 @@ impl RecordingController { ); return Ok(()); } + let _start_guard = AtomicFlagGuard::new(Arc::clone(&self.start_transition_in_flight)); // Generate session ID let new_session_id = Uuid::new_v4().to_string(); *self.session_id.write().await = Some(new_session_id.clone()); + if is_assistive { + *self.assistive_mode.write().await = true; + *self.force_raw_mode.write().await = false; + *self.force_ai_mode.write().await = false; + } + self.assistive_loop_active + .store(is_assistive, Ordering::SeqCst); + self.toggle_user_has_text.store(false, Ordering::SeqCst); + self.toggle_assistant_has_text + .store(false, Ordering::SeqCst); + info!("Starting toggle recording (session={})", new_session_id); - let config = self.config.read().await; + let config = self.config.read().await.clone(); let language = config.whisper_language; - drop(config); - - // Reset VAD flag and set callback - self.vad_triggered.store(false, Ordering::SeqCst); - let vad_flag = Arc::clone(&self.vad_triggered); - - // Start the recorder with VAD callback - // hang_sec is configured via CODESCRIBE_VAD_MAX_SILENCE_SEC env var (single source of truth) - let mut recorder = self.recorder.lock().await; + let toggle_silence_sec = config.toggle_silence_sec; + let beep_enabled = config.beep_on_start; + let sound_volume = config.sound_volume; + + // Start the recorder + let mut recorder_guard = self.recorder.lock().await; + let recorder = match Self::recorder_from_guard_mut(&mut recorder_guard, "Toggle-start") { + Ok(recorder) => recorder, + Err(error) => { + drop(recorder_guard); + self.reset_session_after_start_failure("Toggle-start").await; + Self::present_recorder_unavailable("Toggle-start"); + return Err(error); + } + }; + if let Err(e) = + Self::ensure_recorder_ready_for_start(recorder, "Toggle-start preflight").await + { + drop(recorder_guard); + self.reset_session_after_start_failure("Toggle-start preflight") + .await; + return Err(e); + } - recorder.recorder.set_on_vad_stop(move || { - info!("VAD callback: setting vad_triggered flag"); - vad_flag.store(true, Ordering::SeqCst); - }); + // Toggle mode: continuous recording; silence only triggers per-utterance send. + recorder.recorder.config.auto_silence = false; + recorder.recorder.set_on_vad_stop(|| {}); + recorder.set_utterance_silence_sec(Some(toggle_silence_sec)); - // Set streaming callback for overlay updates (routed by session mode) - recorder.set_delta_callback(Some(Arc::new(|text: &str| { - route_transcription_delta(text); - }))); + // Set session mode for delta routing BEFORE starting the pipeline, + // so the very first deltas route to the correct overlay. + set_assistive_session(is_assistive); + reset_session_telemetry(&self.session_telemetry); + + // Runtime pipeline is always event-based. + Self::configure_toggle_event_sink( + recorder, + OVERLAY_CONTROLLER.get().cloned(), + new_session_id.clone(), + is_assistive, + self.event_broadcast.clone(), + Arc::clone(&self.session_telemetry), + ); // Skip actual audio stream in tests (no CoreAudio device needed) - if !cfg!(test) { - recorder.start(Some(language.as_str().to_string())).await?; + if !cfg!(test) + && let Err(e) = recorder + .start_event_session(Some(language.as_str().to_string())) + .await + { + if Self::is_already_in_progress_error(&e) { + warn!("Toggle start hit stale recorder lock; forcing stop and retrying once"); + if let Err(stop_err) = recorder.stop_without_saving().await { + warn!("Toggle stale-recorder recovery failed: {stop_err}"); + } + Self::clear_recorder_callbacks(recorder); + Self::configure_toggle_event_sink( + recorder, + OVERLAY_CONTROLLER.get().cloned(), + new_session_id.clone(), + is_assistive, + 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 + { + drop(recorder_guard); + self.reset_session_after_start_failure("Toggle-start retry") + .await; + return Err(anyhow::anyhow!( + "Failed to start event session after recovery: {retry_err}" + )); + } + } else { + drop(recorder_guard); + self.reset_session_after_start_failure("Toggle-start").await; + return Err(e); + } } + drop(recorder_guard); + + // Transition to REC_TOGGLE immediately after recorder starts. + self.set_state(State::RecToggle).await; + info!("STATE TRANSITION: IDLE → REC_TOGGLE (pulsing badge)"); // Play start beep if enabled - let beep_enabled = self.config.read().await.beep_on_start; if beep_enabled { - crate::audio::play_sound("Tink"); + crate::audio::play_sound_with_volume("Tink", sound_volume); } // Show badge with appropriate mode @@ -978,24 +1995,192 @@ impl RecordingController { }; show_badge_for_mode(badge_mode); - // Set session mode for delta routing - set_assistive_session(is_assistive); - - // ALWAYS show transcription overlay for live preview - crate::clear_transcription_text(); - crate::show_transcription_overlay(); 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) + .await + .unwrap_or_default(); + *self.assistive_context.write().await = Some(ctx); + crate::voice_chat_ui::set_voice_chat_target_app( + self.assistive_context + .read() + .await + .clone() + .unwrap_or_default() + .frontmost_app, + ); + + crate::hide_transcription_overlay(); crate::show_voice_chat_overlay(); crate::show_agent_tab(); + crate::voice_chat_ui::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::voice_chat_ui::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::show_transcription_overlay(); + crate::enter_recording_mode(); + crate::clear_transcription_text(); } - crate::enter_recording_mode(); - // Transition to REC_TOGGLE - *self.state.write().await = State::RecToggle; - info!("STATE TRANSITION: IDLE → REC_TOGGLE (pulsing badge)"); + Ok(()) + } - // Update tray status to Listening - let _ = update_tray_status(TrayStatus::Listening); + async fn handle_toggle_utterance( + &self, + raw_text: String, + expected_session: String, + is_assistive: bool, + skip_user_bubble: bool, + ) -> Result<()> { + if raw_text.trim().is_empty() { + if is_assistive { + crate::voice_chat_ui::set_voice_chat_sending(false); + crate::voice_chat_ui::update_voice_chat_status("Listening..."); + } + return Ok(()); + } + + // Skip if another session is active. If session_id is None, allow final flush. + if let Some(current) = self.session_id.read().await.clone() + && current != expected_session + { + debug!("Ignoring stale toggle utterance (session changed)"); + return Ok(()); + } + + let _guard = self.serial_lock.lock().await; + + // Snapshot mode flags + let hold_mode = *self.hold_mode.read().await; + let force_raw = *self.force_raw_mode.read().await; + let force_ai = *self.force_ai_mode.read().await; + + if is_assistive { + let ctx = tokio::task::spawn_blocking(capture_assistive_context) + .await + .unwrap_or_default(); + *self.assistive_context.write().await = Some(ctx); + } else { + let ctx = tokio::task::spawn_blocking(capture_frontmost_app_only) + .await + .unwrap_or_default(); + *self.assistive_context.write().await = Some(ctx); + } + + crate::voice_chat_ui::set_voice_chat_target_app( + self.assistive_context + .read() + .await + .clone() + .unwrap_or_default() + .frontmost_app, + ); + + let config = self.config.read().await.clone(); + let language_opt = Some(config.whisper_language.as_str().to_string()); + let user_needs_separator = false; + let assistant_needs_separator = false; + + let result = self + .process_transcript_text_pipeline(types::TranscriptPipelineParams { + raw_text, + recording_timestamp: chrono::Local::now(), + assistive: is_assistive, + hold_mode, + force_raw, + force_ai, + config, + language_opt, + raw_save_enabled: raw_save_enabled(is_assistive), + audio_path: None, + cloud_text_opt: None, + cloud_handle: None, + append_mode: false, + live_stream_session: true, + user_needs_separator, + assistant_needs_separator, + skip_user_bubble, + }) + .await + .map(|_| ()); + + if *self.state.read().await == State::RecToggle && is_assistive { + crate::voice_chat_ui::set_voice_chat_sending(false); + crate::voice_chat_ui::update_voice_chat_status("Listening..."); + } + + result + } + + async fn stop_toggle_recording(&self) -> Result<()> { + // Ignore if not recording + if *self.state.read().await != State::RecToggle { + return Ok(()); + } + + info!("Stopping toggle recording"); + + // Stop recording and flush buffered worker + let mut recorder_guard = self.recorder.lock().await; + let mut stop_error: Option = None; + if let Some(recorder) = recorder_guard.as_mut() { + if !cfg!(test) + && let Err(e) = recorder.stop_without_saving().await + { + warn!("Toggle stop: recorder stop failed; continuing cleanup: {e}"); + stop_error = Some(e); + } + Self::clear_recorder_callbacks(recorder); + } else { + let error = Self::recorder_unavailable_error("Toggle-stop"); + warn!("Toggle stop: {error}; continuing cleanup"); + stop_error = Some(error); + } + drop(recorder_guard); + + // Reset state + 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.start_transition_in_flight + .store(false, Ordering::SeqCst); + self.assistive_loop_active.store(false, Ordering::SeqCst); + if self.toggle_user_has_text.load(Ordering::SeqCst) { + crate::voice_chat_ui::finalize_voice_chat_user_message(); + } + if self.toggle_assistant_has_text.load(Ordering::SeqCst) { + crate::voice_chat_ui::finalize_voice_chat_assistant_message(); + } + self.toggle_user_has_text.store(false, Ordering::SeqCst); + self.toggle_assistant_has_text + .store(false, Ordering::SeqCst); + set_assistive_session(false); + + hide_hold_badge(); + crate::voice_chat_ui::update_voice_chat_status("Ready"); + + if let Some(e) = stop_error { + return Err(anyhow::anyhow!("Failed to stop recorder: {e}")); + } Ok(()) } @@ -1034,11 +2219,12 @@ impl RecordingController { // Transition to BUSY debug!("STATE TRANSITION: {} → BUSY", current_state); - *self.state.write().await = State::Busy; + self.set_state(State::Busy).await; // Get session ID and mode flags before we reset them let session_id = self.session_id.read().await.clone(); let assistive = *self.assistive_mode.read().await; + let hold_mode = *self.hold_mode.read().await; let force_raw = *self.force_raw_mode.read().await; let force_ai = *self.force_ai_mode.read().await; @@ -1046,63 +2232,121 @@ impl RecordingController { show_badge_for_mode(BadgeMode::Processing); let result = self - .process_recording(session_id, assistive, force_raw, force_ai) + .process_recording(session_id, assistive, hold_mode, force_raw, force_ai) .await; // Always reset to IDLE, even on error - *self.state.write().await = State::Idle; + 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_context.write().await = None; + self.start_transition_in_flight + .store(false, Ordering::SeqCst); + self.assistive_loop_active.store(false, Ordering::SeqCst); + // Keep event-router sink selection in sync with controller state after finish. + set_assistive_session(false); // Hide red dot indicator hide_hold_badge(); // Update tray icon based on result match &result { - Ok(_) => { - let _ = update_tray_status(TrayStatus::Success); + Ok(outcome) => { + crate::voice_chat_ui::update_voice_chat_status("Ready"); info!("Processing finished successfully. State reset to IDLE."); - // After recording finishes, enter decision mode + auto-hide ONLY for non‑assistive flows. - // Assistive/chat overlay must stay alive (bubbles persist; user may keep chatting). - if !assistive { - crate::enter_decision_mode(); - crate::schedule_auto_hide(); + 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::voice_chat_ui::hide_voice_chat_overlay(); + } + crate::hide_transcription_overlay(); + } + } else if !assistive { + let cfg = self.config.read().await.clone(); + let show_decision_overlay = outcome.transcript_present + && !(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::voice_chat_ui::hide_voice_chat_overlay(); + } + + if show_decision_overlay { + let reason = outcome + .commit_trigger + .as_deref() + .unwrap_or("quality_gate_clean"); + info!( + "COMMIT decision: trigger={reason} force_ai={force_ai} force_raw={force_raw}" + ); + crate::enter_decision_mode(); + crate::schedule_auto_hide(); + } else if cfg.quick_notes_enabled && cfg.quick_notes_save_only { + info!("COMMIT decision: skipped (quick_notes_save_only)"); + crate::hide_transcription_overlay(); + } else { + info!("COMMIT decision: skipped (quality gate clean)"); + crate::hide_transcription_overlay(); + } } } Err(e) => { error!("Processing failed: {}", e); - let _ = update_tray_status(TrayStatus::Idle); + crate::voice_chat_ui::update_voice_chat_status("Processing failed"); // Hide overlay immediately on error + let opened = self + .opened_voice_chat_overlay_for_transcription + .swap(false, Ordering::SeqCst); + if opened { + crate::voice_chat_ui::hide_voice_chat_overlay(); + } crate::hide_transcription_overlay(); } } - result + result.map(|_| ()) } /// Process the recording: stop, transcribe, format, paste /// /// ## Mode Logic: - /// - `assistive=true`: ALWAYS AI augmentation (Ctrl+Shift held) - /// - `force_raw=true`: ALWAYS raw transcript (Ctrl held without Shift) + /// - `assistive=true`: ALWAYS AI augmentation (HoldMode::Chat / HoldMode::Selection) + /// - `force_raw=true`: ALWAYS raw transcript (HoldMode::Raw) /// - `force_ai=true`: ALWAYS AI formatting (left double Option) /// - Neither: Toggle mode - respects AI_FORMATTING_ENABLED setting async fn process_recording( &self, _session_id: Option, assistive: bool, + hold_mode: HoldMode, force_raw: bool, force_ai: bool, - ) -> Result<()> { + ) -> Result { + if cfg!(test) { + info!( + "process_recording: skipped in tests (assistive={}, hold_mode={:?}, force_raw={}, force_ai={})", + assistive, hold_mode, force_raw, force_ai + ); + return Ok(ProcessRecordingOutcome::default()); + } + // Stop the recorder and get audio file path - let mut recorder = self.recorder.lock().await; + let mut recorder_guard = self.recorder.lock().await; + let recorder = Self::recorder_from_guard_mut(&mut recorder_guard, "Process-recording")?; let (streaming_text, raw_audio_path_opt) = recorder.stop().await.context("Failed to stop recorder")?; - drop(recorder); // Release lock + drop(recorder_guard); // Release lock // Check audio path validity (if present) let audio_path = if let Some(path) = raw_audio_path_opt { @@ -1124,7 +2368,7 @@ impl RecordingController { let language = config.whisper_language; let language_opt = Some(language.as_str()); let use_local_stt = config.use_local_stt; - let raw_save_enabled = raw_save_enabled(); + let raw_save_enabled = raw_save_enabled(assistive); let cloud_config = if use_local_stt { None @@ -1139,6 +2383,11 @@ impl RecordingController { } }; + // In assistive mode, we want to update overlay state even if the window hasn't been + // realized on the main thread yet. This avoids "dead" overlays due to timing. + let chat_active = assistive; + let assistive_loop = assistive && self.assistive_loop_active.load(Ordering::SeqCst); + let mut raw_text_opt = None; let mut cloud_text_opt = None; let mut cloud_handle: Option>> = None; @@ -1164,15 +2413,60 @@ impl RecordingController { warn!("Cloud STT disabled: STT_ENDPOINT/STT_API_KEY missing"); } + // Optional "final pass" local STT: + // Streaming is the source of truth for live UX and final output by default. + // Enable this only when explicitly requested for diagnostics/experiments. + // + // Default: disabled (set CODESCRIBE_LOCAL_STT_FINAL_PASS=1 to enable). + let local_final_pass_enabled = std::env::var("CODESCRIBE_LOCAL_STT_FINAL_PASS") + .ok() + .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(false); + + if use_local_stt && local_final_pass_enabled { + if let Some(path) = &audio_path { + let wav_path = path.as_path().to_path_buf(); + let lang = language_opt.map(str::to_string); + + if chat_active { + crate::voice_chat_ui::update_voice_chat_status("Finalizing… (20%)"); + } + + info!( + "Running final-pass local STT from audio file (overrides streaming): {}", + wav_path.display() + ); + + match tokio::task::spawn_blocking(move || { + crate::whisper::transcribe_file(&wav_path, lang.as_deref()) + }) + .await + { + Ok(Ok(text)) if !text.trim().is_empty() => { + info!("Final-pass transcription captured ({} chars)", text.len()); + raw_text_opt = Some(text); + } + Ok(Ok(_)) => warn!("Final-pass transcription returned empty text"), + Ok(Err(e)) => warn!("Final-pass transcription failed: {}", e), + Err(e) => warn!("Final-pass transcription task failed: {}", e), + } + } else { + warn!("Final-pass local STT skipped: no audio file available"); + } + } + // 1. Try Streaming Result (Local) - if use_local_stt { + if raw_text_opt.is_none() { if !streaming_text.trim().is_empty() { + if !use_local_stt { + warn!("Using streaming transcript fallback (USE_LOCAL_STT=0)"); + } info!( "Using streaming transcription result ({} chars)", streaming_text.len() ); raw_text_opt = Some(streaming_text); - } else { + } else if use_local_stt { warn!("Streaming returned empty text"); } } @@ -1193,25 +2487,125 @@ impl RecordingController { warn!("Cloud fallback unavailable (cloud disabled or missing credentials)"); } } - - let raw_text = raw_text_opt.ok_or_else(|| anyhow::anyhow!("Empty transcript"))?; + let session_telemetry = snapshot_session_telemetry(&self.session_telemetry); + + let raw_text = match raw_text_opt { + Some(text) if !text.trim().is_empty() => text, + Some(_) | None => { + let reason = session_telemetry + .no_speech_reason + .clone() + .unwrap_or_else(|| "empty_transcript_without_no_speech_event".to_string()); + if let Some(stats) = session_telemetry.stats.as_ref() { + info!( + "NoSpeech outcome: reason={} utterances={} hallu_drops={} semantic_drops={} filtered_empty={} corrections={} dropped_chunks={} partial_runs={} partial_trigger_utt={} partial_trigger_speech={} partial_trigger_watchdog={} partial_stale={} partial_coalesced={} partial_dropped={}", + reason, + stats.total_utterances, + stats.hallucination_drops, + stats.semantic_gate_drops, + stats.filtered_empty_drops, + stats.corrections_applied, + stats.dropped_audio_chunks, + stats.partial_runs_total, + stats.trigger_utterance_count, + stats.trigger_speech_count, + stats.trigger_watchdog_count, + stats.partial_stale_count, + stats.partial_coalesced_count, + stats.partial_dropped_count + ); + } else { + info!("NoSpeech outcome: reason={} stats=unavailable", reason); + } + if assistive_loop { + if chat_active { + crate::voice_chat_ui::set_voice_chat_sending(false); + crate::voice_chat_ui::update_voice_chat_status("Listening..."); + } + warn!("NoSpeech in assistive loop; continuing hands-off listening"); + } + 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 pipeline_outcome = self + .process_transcript_text_pipeline(types::TranscriptPipelineParams { + raw_text, + recording_timestamp, + assistive, + hold_mode, + force_raw, + force_ai, + config, + language_opt, + raw_save_enabled, + audio_path, + cloud_text_opt, + cloud_handle, + append_mode: false, + live_stream_session: false, + user_needs_separator: false, + assistant_needs_separator: false, + skip_user_bubble: false, + }) + .await?; + + Ok(ProcessRecordingOutcome { + no_speech_reason: None, + commit_trigger: pipeline_outcome.commit_trigger, + transcript_present, + }) + } + + async fn process_transcript_text_pipeline( + &self, + p: types::TranscriptPipelineParams, + ) -> Result { + let types::TranscriptPipelineParams { + raw_text, + recording_timestamp, + assistive, + hold_mode, + force_raw, + force_ai, + config, + language_opt, + raw_save_enabled, + audio_path, + cloud_text_opt, + cloud_handle, + append_mode, + live_stream_session, + user_needs_separator, + assistant_needs_separator: _assistant_needs_separator, + skip_user_bubble, + } = p; + let language_opt = language_opt.as_deref(); // ALWAYS-ON: Final post-processing pass (lexicon + cleanup + semantic gate) // This ensures ALL output paths receive clean text regardless of mode. // Contract: every chunk/transcript passes through StreamPostProcessor before // reaching overlay, clipboard, augmentation, or dataset. - let clean_text = { + let (clean_text, postprocess_stats) = { let mut finalizer = StreamPostProcessor::new(); - finalizer + let clean_text = finalizer .process(&raw_text) - .unwrap_or_else(|| raw_text.clone()) + .unwrap_or_else(|| raw_text.clone()); + let stats = finalizer.stats(); + (clean_text, stats) }; info!( - "Post-processed transcript ({} chars, delta={})", + "Post-processed transcript ({} chars, delta={}, drops={}/{}, gate_drops={}, lexicon_rewrites={})", clean_text.len(), - raw_text.len() as i64 - clean_text.len() as i64 + raw_text.len() as i64 - clean_text.len() as i64, + postprocess_stats.dropped_chunks, + postprocess_stats.input_chunks, + postprocess_stats.gate_drops, + postprocess_stats.lexicon_rewrites ); if raw_save_enabled { @@ -1231,11 +2625,20 @@ impl RecordingController { warn!("Detected repetition loop in transcription - will clean up"); } - let chat_active = crate::voice_chat_ui::is_voice_chat_overlay_visible(); + let chat_active = assistive; + + let mut effective_hold_mode = if assistive && matches!(hold_mode, HoldMode::Raw) { + // Toggle-assistive path doesn't have a meaningful hold-mode; treat as Chat + // but allow optional selection context if it was captured. + HoldMode::Chat + } else { + hold_mode + }; + let ai_key_available = crate::ai_formatting::has_api_key(); // Determine final text based on mode (NEW architecture): // - // 1. Ctrl+Shift (assistive=true): ALWAYS AI augmentation (expands, creates plans) + // 1. HoldMode::Chat / HoldMode::Selection (assistive=true): ALWAYS AI augmentation // 2. Ctrl Hold (force_raw=true): ALWAYS raw transcript (ignores AI toggle) // 3. Left double Option (force_ai=true): ALWAYS AI formatting // 4. Toggle (neither): respects AI_FORMATTING_ENABLED toggle @@ -1243,71 +2646,107 @@ impl RecordingController { // This allows users to choose mode via hotkey: // - Quick dictation? → Ctrl (fast, raw) // - Need formatting? → Double Option (respects setting) - // - Need AI help? → Ctrl+Shift (always AI) - let (formatted_text, output_kind) = if assistive { - // Ctrl+Shift: ALWAYS augmentation mode (AI expands content) - info!("Assistive mode (Ctrl+Shift): augmenting transcript via AI"); + // - AI chat? → Hold + Shift (Chat) + // - AI on selection? → Hold + Cmd (Selection) + let (formatted_text, output_kind, mut should_auto_paste) = if assistive { + info!( + "Assistive mode ({:?}): augmenting transcript via AI", + effective_hold_mode + ); if chat_active { - crate::voice_chat_ui::add_voice_chat_user_message(&clean_text); + crate::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::voice_chat_ui::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::voice_chat_ui::append_voice_chat_user_delta("\n\n"); + } + crate::voice_chat_ui::append_voice_chat_user_delta(&clean_text); + self.toggle_user_has_text.store(true, Ordering::SeqCst); + } else { + crate::voice_chat_ui::set_voice_chat_user_text(&clean_text); + } crate::voice_chat_ui::show_agent_tab(); crate::voice_chat_ui::set_voice_chat_sending(true); - crate::voice_chat_ui::update_voice_chat_status("Thinking..."); + crate::voice_chat_ui::update_voice_chat_status("Thinking… (35%)"); } - let lang_str = language_opt.map(String::from); + let mut ctx = self + .assistive_context + .read() + .await + .clone() + .unwrap_or_default(); - // Determine streaming mode from config - let transcript_mode = config.transcript_send_mode; - let use_streaming = matches!( - transcript_mode, - crate::config::TranscriptSendMode::Streaming - ); + // Ensure we have a target app label (best-effort, no selection, no clipboard). + if ctx.frontmost_app.is_none() { + ctx.frontmost_app = tokio::task::spawn_blocking(capture_frontmost_app_only) + .await + .ok() + .and_then(|c| c.frontmost_app); + } - // Callback for streaming AI response to overlay - let delta_callback = if use_streaming && chat_active { - Some(Arc::new(|text: &str| { - crate::voice_chat_ui::append_voice_chat_assistant_delta(text); - }) as Arc) - } else { - None - }; + { + let app = ctx + .frontmost_app + .as_deref() + .unwrap_or("?") + .trim() + .to_string(); + let sel_len = ctx.selected_text.as_deref().unwrap_or("").len(); + crate::voice_chat_ui::update_voice_chat_context_summary(&format!( + "ctx: {} | sel: {}", + app, sel_len + )); + } - let result = crate::ai_formatting::format_text_with_status( - &clean_text, - lang_str.as_deref(), - true, - delta_callback, - ) - .await; - let kind = match result.status { - crate::ai_formatting::AiFormatStatus::Applied => { - if chat_active { - // Display AI response in overlay - crate::voice_chat_ui::update_voice_chat_status("AI Response:"); - crate::voice_chat_ui::set_voice_chat_text(&result.text); - info!( - "Assistive response displayed in overlay ({} chars)", - result.text.len() - ); - } - crate::state::history::TranscriptKind::Ai - } - crate::ai_formatting::AiFormatStatus::Failed => { - if chat_active { - crate::voice_chat_ui::update_voice_chat_status("AI Failed"); - crate::voice_chat_ui::add_voice_chat_error_message("AI Failed"); - } - crate::state::history::TranscriptKind::AiFailed - } - crate::ai_formatting::AiFormatStatus::Skipped => { - if chat_active { - crate::voice_chat_ui::set_voice_chat_sending(false); - } - crate::state::history::TranscriptKind::Raw + let missing_selection = matches!(effective_hold_mode, HoldMode::Selection) + && ctx.selected_text.as_deref().unwrap_or("").trim().is_empty(); + if missing_selection { + warn!( + "Selection mode requested, but no selected text captured; falling back to Chat mode" + ); + effective_hold_mode = HoldMode::Chat; + if chat_active { + crate::voice_chat_ui::update_voice_chat_status( + "Selection unavailable - chat fallback", + ); + crate::voice_chat_ui::add_voice_chat_system_message( + "Selection was not detected. Continuing without selected-text context.", + ); } - }; - (result.text, kind) + } + + // Split behavior: + // - Chat: ignore selection. + // - Selection: if no selection was captured, we already downgraded to Chat mode. + let assistive_input = build_assistive_input(&clean_text, &ctx); + if chat_active { + crate::voice_chat_ui::finalize_voice_chat_user_message(); + crate::voice_chat_ui::set_voice_chat_sending(true); + send_assistive_with_agent_runtime( + assistive_input, + config.whisper_language, + config.ai_assistive_max_tokens, + ) + .await; + } + // Agent runtime path persists full conversation in ThreadStore. + ( + clean_text.clone(), + crate::state::history::TranscriptKind::Raw, + false, + ) } else if force_raw { // Ctrl Hold: ALWAYS raw transcript (fast dictation mode) // Post-processed clean_text is used (lexicon + cleanup already applied) @@ -1316,84 +2755,49 @@ impl RecordingController { ( crate::ai_formatting::remove_simple_repetitions(&clean_text), crate::state::history::TranscriptKind::Raw, + true, ) } else { info!("Raw mode (Ctrl): using post-processed transcript"); ( clean_text.clone(), crate::state::history::TranscriptKind::Raw, + true, ) } } else if force_ai { // Left double Option: ALWAYS formatting (no augmentation) - let should_use_ai = crate::ai_formatting::has_api_key(); + // Auto-paste like hold mode — formatted text goes where the cursor is. + let should_use_ai = ai_key_available; if should_use_ai { info!("Formatting mode (Left Option): correcting transcript via AI"); - if chat_active { - crate::voice_chat_ui::add_voice_chat_user_message(&clean_text); - crate::voice_chat_ui::set_voice_chat_sending(true); - crate::voice_chat_ui::update_voice_chat_status("Formatting..."); - } - let lang_str = language_opt.map(String::from); - - // Determine streaming mode from config - let transcript_mode = config.transcript_send_mode; - let use_streaming = matches!( - transcript_mode, - crate::config::TranscriptSendMode::Streaming - ); - - // Callback for streaming AI response to overlay - let delta_callback = if use_streaming && chat_active { - Some(Arc::new(|text: &str| { - crate::voice_chat_ui::append_voice_chat_assistant_delta(text); - }) as Arc) - } else { - None - }; - let result = crate::ai_formatting::format_text_with_status( &clean_text, lang_str.as_deref(), false, - delta_callback, + None, ) .await; let kind = match result.status { crate::ai_formatting::AiFormatStatus::Applied => { - if chat_active { - // Display formatted text in overlay - crate::voice_chat_ui::update_voice_chat_status("Formatted:"); - crate::voice_chat_ui::set_voice_chat_text(&result.text); - info!( - "Formatted response displayed in overlay ({} chars)", - result.text.len() - ); - } crate::state::history::TranscriptKind::Ai } crate::ai_formatting::AiFormatStatus::Failed => { - if chat_active { - crate::voice_chat_ui::update_voice_chat_status("Formatting Failed"); - crate::voice_chat_ui::add_voice_chat_error_message("Formatting Failed"); - } crate::state::history::TranscriptKind::AiFailed } crate::ai_formatting::AiFormatStatus::Skipped => { - if chat_active { - crate::voice_chat_ui::set_voice_chat_sending(false); - } crate::state::history::TranscriptKind::Raw } }; - (result.text, kind) + (result.text, kind, true) } else if has_repetition { info!("Formatting mode (Left Option): AI unavailable, cleaning repetitions"); ( crate::ai_formatting::remove_simple_repetitions(&clean_text), crate::state::history::TranscriptKind::Raw, + true, ) } else { info!( @@ -1402,82 +2806,45 @@ impl RecordingController { ( clean_text.clone(), crate::state::history::TranscriptKind::Raw, + true, ) } } else { // Double Option: respects AI Formatting toggle setting let ai_formatting_enabled = config.ai_formatting_enabled; - let should_use_ai = ai_formatting_enabled && crate::ai_formatting::has_api_key(); + let should_use_ai = ai_formatting_enabled && ai_key_available; if should_use_ai { // Toggle ON: formatting only (no augmentation) info!("Formatting mode (Toggle): correcting transcript via AI"); - if chat_active { - crate::voice_chat_ui::add_voice_chat_user_message(&clean_text); - crate::voice_chat_ui::set_voice_chat_sending(true); - crate::voice_chat_ui::update_voice_chat_status("Formatting..."); - } - let lang_str = language_opt.map(String::from); - - // Determine streaming mode from config - let transcript_mode = config.transcript_send_mode; - let use_streaming = matches!( - transcript_mode, - crate::config::TranscriptSendMode::Streaming - ); - - // Callback for streaming AI response to overlay - let delta_callback = if use_streaming && chat_active { - Some(Arc::new(|text: &str| { - crate::voice_chat_ui::append_voice_chat_assistant_delta(text); - }) as Arc) - } else { - None - }; - let result = crate::ai_formatting::format_text_with_status( &clean_text, lang_str.as_deref(), false, - delta_callback, + None, ) .await; let kind = match result.status { crate::ai_formatting::AiFormatStatus::Applied => { - if chat_active { - // Display formatted text in overlay - crate::voice_chat_ui::update_voice_chat_status("Formatted:"); - crate::voice_chat_ui::set_voice_chat_text(&result.text); - info!( - "Formatted response displayed in overlay ({} chars)", - result.text.len() - ); - } crate::state::history::TranscriptKind::Ai } crate::ai_formatting::AiFormatStatus::Failed => { - if chat_active { - crate::voice_chat_ui::update_voice_chat_status("Formatting Failed"); - crate::voice_chat_ui::add_voice_chat_error_message("Formatting Failed"); - } crate::state::history::TranscriptKind::AiFailed } crate::ai_formatting::AiFormatStatus::Skipped => { - if chat_active { - crate::voice_chat_ui::set_voice_chat_sending(false); - } crate::state::history::TranscriptKind::Raw } }; - (result.text, kind) + (result.text, kind, false) } else if has_repetition { // Toggle OFF with repetition: local cleanup only info!("Raw mode (Toggle OFF): applying local repetition cleanup"); ( crate::ai_formatting::remove_simple_repetitions(&clean_text), crate::state::history::TranscriptKind::Raw, + true, ) } else { // Toggle OFF: using post-processed transcript @@ -1485,12 +2852,17 @@ impl RecordingController { ( clean_text.clone(), crate::state::history::TranscriptKind::Raw, + true, ) } }; let mode_label = if assistive { - "assistive" + match effective_hold_mode { + HoldMode::Chat => "chat", + HoldMode::Selection => "selection", + HoldMode::Raw => "assistive", + } } else if force_raw { "raw" } else if force_ai { @@ -1503,6 +2875,90 @@ impl RecordingController { formatted_text.len(), mode_label ); + let quality_probe = + ActionQualityProbe::from_transcripts(&raw_text, &formatted_text, &postprocess_stats); + info!( + "Action quality guardrail: mode={} assistive={} raw_chars={} final_chars={} diff_raw_final={:.3} correction_ratio={:.3} drop_ratio={:.3} route_independent=true", + mode_label, + assistive, + quality_probe.raw_chars, + quality_probe.final_chars, + quality_probe.raw_final_diff_ratio, + quality_probe.correction_ratio, + quality_probe.drop_ratio + ); + let commit_trigger = if !assistive && !live_stream_session { + evaluate_quality_commit_trigger(force_raw, &quality_probe, output_kind) + .map(str::to_string) + } else { + None + }; + if let Some(reason) = commit_trigger.as_deref() { + info!( + "COMMIT decision: trigger={} mode={} diff_raw_final={:.3} correction_ratio={:.3} drop_ratio={:.3}", + reason, + mode_label, + quality_probe.raw_final_diff_ratio, + quality_probe.correction_ratio, + quality_probe.drop_ratio + ); + } else if !assistive && !live_stream_session { + info!("COMMIT decision: not required by quality gate (mode={mode_label})"); + } + + if should_apply_transcription_action_contract(assistive, live_stream_session) { + 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::transcription_overlay::set_transcription_action_contract( + &raw_text, + &formatted_text, + action_contract_mode, + ); + } else if !assistive { + debug!( + "Skipping transcription action contract rewrite during live stream (mode={mode_label})" + ); + } + + // 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(), + ) { + Ok(path) => { + info!("Quick note saved: {}", path.display()); + #[cfg(target_os = "macos")] + crate::os::notifications::notify( + "CodeScribe", + &format!( + "Saved note: {}", + path.file_name().and_then(|s| s.to_str()).unwrap_or("note") + ), + ); + } + Err(e) => { + warn!("Quick note save failed: {}", e); + } + } + + // Optional: make Quick Notes "save-only". + if config.quick_notes_save_only { + should_auto_paste = false; + } + } // Save audio to transcriptions folder if enabled (pair with RAW for reports) if config.dump_audio_logs @@ -1516,15 +2972,21 @@ impl RecordingController { ); } - // Paste the text into the active application - clipboard::paste_text(&formatted_text).context("Failed to paste text")?; - - info!("Text pasted successfully"); + if cfg!(test) { + info!("Skipping paste in tests (mode={})", mode_label); + } else if should_auto_paste { + // Paste the text into the active application + clipboard::paste_text(&formatted_text).context("Failed to paste text")?; + info!("Text pasted successfully"); + } else { + info!("Auto-paste skipped (mode={})", mode_label); + } // Save final transcript (skip duplicate when RAW already stored and unchanged) - let needs_final_save = !raw_save_enabled - || output_kind != crate::state::history::TranscriptKind::Raw - || formatted_text.trim() != raw_text.trim(); + let needs_final_save = !assistive + && (!raw_save_enabled + || output_kind != crate::state::history::TranscriptKind::Raw + || formatted_text.trim() != raw_text.trim()); if needs_final_save { let entry = crate::state::history::save_entry_with_timestamp_and_slug( &formatted_text, @@ -1534,6 +2996,10 @@ impl RecordingController { ); info!("Transcript saved: {}", entry.path.display()); crate::voice_chat_ui::refresh_drawer(); + } else if assistive { + info!( + "Assistive flow: skipping legacy final transcript save (ThreadStore is source of truth)" + ); } else { info!("Final transcript matches RAW; skipping duplicate save"); } @@ -1566,7 +3032,7 @@ impl RecordingController { }); } - Ok(()) + Ok(types::TranscriptProcessOutcome { commit_trigger }) } /// Force reset to IDLE state without stopping recorder. @@ -1580,17 +3046,19 @@ impl RecordingController { /// Internal helper to reset all state variables async fn reset_state(&self) { - *self.state.write().await = State::Idle; + 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_context.write().await = None; // Hide UI indicators hide_hold_badge(); - // Update tray status - let _ = update_tray_status(TrayStatus::Idle); + // Update shared UI status + crate::voice_chat_ui::update_voice_chat_status("Idle"); info!("State reset to IDLE complete"); } diff --git a/app/controller/tests.rs b/app/controller/tests.rs index e1d94107..58f5bad5 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -21,6 +21,8 @@ async fn test_hold_down_schedules_delayed_start() { key_type: HotkeyType::Hold, action: HotkeyAction::Down, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; @@ -48,6 +50,8 @@ async fn test_hold_up_before_delay_cancels() { key_type: HotkeyType::Hold, action: HotkeyAction::Down, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; controller.handle_hotkey_event(down_event).await.unwrap(); @@ -58,6 +62,8 @@ async fn test_hold_up_before_delay_cancels() { key_type: HotkeyType::Hold, action: HotkeyAction::Up, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; controller.handle_hotkey_event(up_event).await.unwrap(); @@ -78,6 +84,8 @@ async fn test_toggle_starts_immediately() { key_type: HotkeyType::Toggle, action: HotkeyAction::Press, assistive: true, + hold_mode: HoldMode::Raw, + force_raw: false, force_ai: false, }; @@ -98,6 +106,8 @@ async fn test_busy_state_ignores_hotkeys() { key_type: HotkeyType::Toggle, action: HotkeyAction::Press, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: false, force_ai: false, }; @@ -172,6 +182,8 @@ async fn test_hold_down_sets_force_raw_mode() { key_type: HotkeyType::Hold, action: HotkeyAction::Down, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; controller.handle_hotkey_event(event).await.unwrap(); @@ -187,6 +199,42 @@ 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::transcription_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::transcription_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::transcription_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::transcription_overlay::TranscriptionActionContractMode::Raw + ); +} + #[tokio::test] async fn test_toggle_press_does_not_set_force_raw_mode() { let controller = RecordingController::new(); @@ -196,6 +244,8 @@ async fn test_toggle_press_does_not_set_force_raw_mode() { key_type: HotkeyType::Toggle, action: HotkeyAction::Press, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: false, force_ai: false, }; controller.handle_hotkey_event(event).await.unwrap(); @@ -219,6 +269,8 @@ async fn test_toggle_press_sets_force_ai_mode() { key_type: HotkeyType::Toggle, action: HotkeyAction::Press, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: false, force_ai: true, }; controller.handle_hotkey_event(event).await.unwrap(); @@ -229,6 +281,35 @@ async fn test_toggle_press_sets_force_ai_mode() { ); } +#[tokio::test] +#[serial] +async fn test_left_double_option_does_not_switch_to_assistive_routing() { + let controller = RecordingController::new(); + + let event = HotkeyInput { + key_type: HotkeyType::Toggle, + action: HotkeyAction::Press, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: false, + force_ai: true, + }; + controller.handle_hotkey_event(event).await.unwrap(); + + assert!( + !*controller.assistive_mode.read().await, + "Left double option must stay non-assistive" + ); + assert!( + *controller.force_ai_mode.read().await, + "Left double option should keep force_ai_mode=true" + ); + assert!( + !is_assistive_session(), + "Global routing flag must stay non-assistive for left double option" + ); +} + #[tokio::test] async fn test_hold_with_shift_sets_assistive_not_force_raw() { let controller = RecordingController::new(); @@ -238,6 +319,8 @@ async fn test_hold_with_shift_sets_assistive_not_force_raw() { key_type: HotkeyType::Hold, action: HotkeyAction::Down, assistive: true, + hold_mode: HoldMode::Chat, + force_raw: false, force_ai: false, // Shift was held from the start (Ctrl+Shift) }; controller.handle_hotkey_event(event).await.unwrap(); @@ -262,6 +345,8 @@ async fn test_shift_upgrade_mid_hold_overrides_force_raw() { key_type: HotkeyType::Hold, action: HotkeyAction::Down, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; controller.handle_hotkey_event(down_event).await.unwrap(); @@ -274,8 +359,10 @@ async fn test_shift_upgrade_mid_hold_overrides_force_raw() { // This comes as another event with assistive=true let upgrade_event = HotkeyInput { key_type: HotkeyType::Hold, - action: HotkeyAction::Down, // Still "down" - modifier flags changed + action: HotkeyAction::Press, // Modifier flags changed while holding assistive: true, + hold_mode: HoldMode::Chat, + force_raw: false, force_ai: false, }; controller.handle_hotkey_event(upgrade_event).await.unwrap(); @@ -305,6 +392,8 @@ async fn test_hold_up_preserves_mode_flags_when_idle() { key_type: HotkeyType::Hold, action: HotkeyAction::Up, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; controller.handle_hotkey_event(up_event).await.unwrap(); @@ -330,6 +419,8 @@ async fn test_hold_up_triggers_finish_recording() { key_type: HotkeyType::Hold, action: HotkeyAction::Up, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; let result = controller.handle_hotkey_event(up_event).await; @@ -395,6 +486,8 @@ async fn test_mode_matrix_coverage() { key_type: HotkeyType::Hold, action: HotkeyAction::Down, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; controller.handle_hotkey_event(ctrl_hold).await.unwrap(); @@ -410,6 +503,8 @@ async fn test_mode_matrix_coverage() { key_type: HotkeyType::Hold, action: HotkeyAction::Down, assistive: true, + hold_mode: HoldMode::Chat, + force_raw: false, force_ai: false, }; controller @@ -428,6 +523,8 @@ async fn test_mode_matrix_coverage() { key_type: HotkeyType::Toggle, action: HotkeyAction::Press, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: false, force_ai: true, }; controller.handle_hotkey_event(double_option).await.unwrap(); @@ -451,6 +548,8 @@ async fn test_finish_recording_resets_unconditionally_force_raw() { key_type: HotkeyType::Hold, action: HotkeyAction::Up, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, force_ai: false, }; let _ = controller.handle_hotkey_event(up_event).await; @@ -475,6 +574,8 @@ async fn test_finish_recording_resets_unconditionally_assistive() { key_type: HotkeyType::Hold, action: HotkeyAction::Up, assistive: true, + hold_mode: HoldMode::Chat, + force_raw: false, force_ai: false, }; let _ = controller.handle_hotkey_event(up_event).await; @@ -515,6 +616,8 @@ async fn test_finish_recording_resets_unconditionally_toggle_mode() { key_type: HotkeyType::Toggle, action: HotkeyAction::Press, assistive: false, + hold_mode: HoldMode::Raw, + force_raw: false, force_ai: true, }; let _ = controller.handle_hotkey_event(stop_event).await; @@ -523,3 +626,259 @@ async fn test_finish_recording_resets_unconditionally_toggle_mode() { assert!(!*controller.force_ai_mode.read().await); assert!(!*controller.force_raw_mode.read().await); } + +#[test] +fn test_action_quality_probe_reports_expected_metrics() { + let raw = "Kubernetes wymoga konfiguracji"; + let final_text = "Kubernetes wymaga konfiguracji."; + let stats = crate::stream_postprocess::StreamPostProcessStats { + input_chunks: 10, + dropped_chunks: 2, + ..Default::default() + }; + let probe = ActionQualityProbe::from_transcripts(raw, final_text, &stats); + + assert_eq!(probe.raw_chars, raw.chars().count()); + assert_eq!(probe.final_chars, final_text.chars().count()); + assert!(probe.raw_final_diff_ratio > 0.0); + assert!(probe.correction_ratio > 0.0); + assert!((probe.drop_ratio - 0.2).abs() < 0.001); +} + +#[test] +fn test_action_quality_probe_is_independent_from_action_routing() { + let stats = crate::stream_postprocess::StreamPostProcessStats { + input_chunks: 8, + dropped_chunks: 1, + ..Default::default() + }; + let save_probe = ActionQualityProbe::from_transcripts("to jest test", "To jest test.", &stats); + let copy_probe = ActionQualityProbe::from_transcripts("to jest test", "To jest test.", &stats); + let augment_probe = + ActionQualityProbe::from_transcripts("to jest test", "To jest test.", &stats); + + assert!((save_probe.raw_final_diff_ratio - copy_probe.raw_final_diff_ratio).abs() < 1e-6); + assert!((save_probe.raw_final_diff_ratio - augment_probe.raw_final_diff_ratio).abs() < 1e-6); + assert!((save_probe.correction_ratio - copy_probe.correction_ratio).abs() < 1e-6); + assert!((save_probe.correction_ratio - augment_probe.correction_ratio).abs() < 1e-6); + assert!((save_probe.drop_ratio - copy_probe.drop_ratio).abs() < 1e-6); + assert!((save_probe.drop_ratio - augment_probe.drop_ratio).abs() < 1e-6); +} + +#[test] +fn test_quality_gate_triggers_commit_for_high_drop_ratio() { + let stats = crate::stream_postprocess::StreamPostProcessStats { + input_chunks: 10, + dropped_chunks: 5, + ..Default::default() + }; + let probe = ActionQualityProbe::from_transcripts( + "to jest bardzo dlugi tekst surowy", + "to jest tekst", + &stats, + ); + let trigger = + evaluate_quality_commit_trigger(false, &probe, crate::state::history::TranscriptKind::Raw); + assert_eq!(trigger, Some("high_drop_ratio")); +} + +#[test] +fn test_quality_gate_skips_commit_for_force_raw_mode() { + let stats = crate::stream_postprocess::StreamPostProcessStats { + input_chunks: 10, + dropped_chunks: 7, + ..Default::default() + }; + let probe = + ActionQualityProbe::from_transcripts("to jest bardzo dlugi tekst surowy", "krótki", &stats); + let trigger = + evaluate_quality_commit_trigger(true, &probe, crate::state::history::TranscriptKind::Raw); + assert!(trigger.is_none()); +} + +#[test] +fn test_quality_gate_triggers_commit_when_ai_failed() { + let stats = crate::stream_postprocess::StreamPostProcessStats { + input_chunks: 2, + dropped_chunks: 0, + ..Default::default() + }; + let probe = ActionQualityProbe::from_transcripts("raw text", "raw text", &stats); + let trigger = evaluate_quality_commit_trigger( + false, + &probe, + crate::state::history::TranscriptKind::AiFailed, + ); + assert_eq!(trigger, Some("ai_failed_fallback")); +} + +#[test] +fn test_delta_first_guards_block_full_rewrite_in_live_stream() { + assert!(!should_allow_full_user_bubble_rewrite(false, false, true)); + assert!(!should_allow_full_assistant_rewrite(false, true)); + assert!(!should_apply_transcription_action_contract(false, true)); +} + +#[test] +fn test_delta_first_guards_allow_full_rewrite_offline() { + assert!(should_allow_full_user_bubble_rewrite(false, false, false)); + assert!(should_allow_full_assistant_rewrite(false, false)); + assert!(should_apply_transcription_action_contract(false, false)); +} + +#[test] +fn test_process_recording_outcome_no_speech_is_soft() { + 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()); +} + +#[tokio::test] +#[serial] +async fn test_rapid_hold_toggle_switch_recovers_to_idle_without_stuck_state() { + let controller = RecordingController::new(); + controller.config.write().await.hold_start_delay_ms = 40; + + let hold_down = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Down, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + let hold_up = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Up, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + let toggle_press = HotkeyInput { + key_type: HotkeyType::Toggle, + action: HotkeyAction::Press, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + + controller.handle_hotkey_event(hold_down).await.unwrap(); + tokio::time::sleep(Duration::from_millis(10)).await; + + // Toggle should take control and prevent stale hold-start from reviving later. + controller + .handle_hotkey_event(toggle_press.clone()) + .await + .unwrap(); + assert_eq!(controller.current_state().await, State::RecToggle); + + controller + .handle_hotkey_event(toggle_press.clone()) + .await + .unwrap(); + controller.handle_hotkey_event(hold_up).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + assert_eq!(controller.current_state().await, State::Idle); + assert!(controller.session_id.read().await.is_none()); + assert!( + !controller + .assistive_loop_active + .load(std::sync::atomic::Ordering::SeqCst) + ); + assert!(!is_assistive_session()); +} + +#[tokio::test] +#[serial] +async fn test_repeated_hold_cancel_near_delay_never_starts_stale_session() { + let controller = RecordingController::new(); + controller.config.write().await.hold_start_delay_ms = 45; + + let hold_down = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Down, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + let hold_up = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Up, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + + for _ in 0..6 { + controller + .handle_hotkey_event(hold_down.clone()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(15)).await; + controller + .handle_hotkey_event(hold_up.clone()) + .await + .unwrap(); + } + + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!(controller.current_state().await, State::Idle); + assert!(!controller.is_recording().await); + assert!(controller.session_id.read().await.is_none()); +} + +#[tokio::test] +async fn test_reset_session_after_start_failure_clears_transient_state() { + let controller = RecordingController::new(); + *controller.state.write().await = State::RecToggle; + *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("failed-start-session".to_string()); + *controller.assistive_context.write().await = Some(AssistiveContext::default()); + 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); + + controller.reset_session_after_start_failure("test").await; + + 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 + .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()); +} diff --git a/app/controller/types.rs b/app/controller/types.rs index 5c109811..9aac0354 100644 --- a/app/controller/types.rs +++ b/app/controller/types.rs @@ -104,6 +104,18 @@ impl std::fmt::Display for State { } } +impl State { + pub fn to_ipc_str(self) -> &'static str { + match self { + State::Idle => "idle", + State::RecHold => "rec_hold", + State::RecToggle => "rec_toggle", + State::Busy => "busy", + State::Conversation => "conversation", + } + } +} + /// Hotkey event types #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HotkeyType { @@ -127,5 +139,43 @@ pub struct HotkeyInput { pub key_type: HotkeyType, pub action: HotkeyAction, pub assistive: bool, + pub hold_mode: crate::os::hotkeys::HoldMode, + pub force_raw: bool, + pub force_ai: bool, +} + +/// Parameters for the transcript text pipeline. +/// +/// Groups all inputs for `process_transcript_text_pipeline` to avoid +/// a 16-argument function signature. +pub struct TranscriptPipelineParams { + pub raw_text: String, + pub recording_timestamp: chrono::DateTime, + pub assistive: bool, + pub hold_mode: crate::os::hotkeys::HoldMode, + pub force_raw: bool, pub force_ai: bool, + pub config: crate::config::Config, + pub language_opt: Option, + pub raw_save_enabled: bool, + pub audio_path: Option, + pub cloud_text_opt: Option, + pub cloud_handle: Option>>, + pub append_mode: bool, + /// True when processing happens while an active stream is still running + /// (e.g., toggle-mode utterance callback). In this mode, prefer delta-only + /// updates and avoid full-text rewrites in overlays. + pub live_stream_session: bool, + pub user_needs_separator: bool, + pub assistant_needs_separator: bool, + /// When true, skip writing to the user bubble in the commit path. + /// Used by event pipeline where Preview already streams into the bubble. + pub skip_user_bubble: bool, +} + +/// Result metadata for transcript post-processing. +#[derive(Debug, Clone, Default)] +pub struct TranscriptProcessOutcome { + /// Why manual commit/decision mode should be shown (if required). + pub commit_trigger: Option, } diff --git a/app/ipc/server.rs b/app/ipc/server.rs index ac0c73e0..3cc9d32c 100644 --- a/app/ipc/server.rs +++ b/app/ipc/server.rs @@ -6,7 +6,7 @@ use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; use std::sync::Arc; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::Semaphore; @@ -14,11 +14,11 @@ use tracing::{info, warn}; use super::{AppStatus, IpcCommand, IpcResponse}; use crate::audio::load_audio_file; -use crate::config::Config; 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; @@ -84,21 +84,60 @@ async fn handle_client(stream: UnixStream, controller: Arc) 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?; + } + } - while reader.read_line(&mut line).await? > 0 { - 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; } - }; - - 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(()) @@ -127,13 +166,18 @@ async fn handle_command(cmd: IpcCommand, controller: &RecordingController) -> Ip return IpcResponse::Error(format!("Failed to save config: {}", e)); } - hotkeys::set_hold_mods(config.hold_mods); - hotkeys::set_toggle_trigger(config.toggle_trigger); - hotkeys::set_exclusive_mode(config.hold_exclusive); + 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()), @@ -244,6 +288,8 @@ async fn handle_command(cmd: IpcCommand, controller: &RecordingController) -> Ip key_type: HotkeyType::Toggle, action: HotkeyAction::Press, assistive, + hold_mode: crate::os::hotkeys::HoldMode::Raw, + force_raw: !assistive, force_ai: false, }; @@ -262,6 +308,9 @@ async fn handle_command(cmd: IpcCommand, controller: &RecordingController) -> Ip 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()) + } } } @@ -291,6 +340,11 @@ fn save_prompt(path: &PathBuf, content: &str) -> std::io::Result<()> { } 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)?; @@ -301,31 +355,41 @@ fn persist_config(config: &Config) -> Result<()> { HashMap::new() }; - let mut updated: Vec<(String, String)> = Vec::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| { - env_vars.insert(key.to_string(), value.clone()); - updated.push((key.to_string(), value)); + 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_MODS", - config.hold_mods.as_str().to_string(), + "HOLD_EXCLUSIVE", + bool_to_env(config.hold_exclusive), &mut env_vars, ); put( - "HOLD_EXCLUSIVE", - bool_to_env(config.hold_exclusive), + "HOLD_START_DELAY_MS", + config.hold_start_delay_ms.to_string(), &mut env_vars, ); put( - "TOGGLE_TRIGGER", - config.toggle_trigger.as_str().to_string(), + "DOUBLE_TAP_INTERVAL_MS", + config.double_tap_interval_ms.to_string(), &mut env_vars, ); put( - "HOLD_START_DELAY_MS", - config.hold_start_delay_ms.to_string(), + "TOGGLE_SILENCE_SEC", + config.toggle_silence_sec.to_string(), &mut env_vars, ); @@ -356,6 +420,11 @@ fn persist_config(config: &Config) -> Result<()> { 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( "HOLD_INDICATOR", bool_to_env(config.hold_indicator), @@ -417,16 +486,6 @@ fn persist_config(config: &Config) -> Result<()> { config.llm_endpoint.clone().unwrap_or_default(), &mut env_vars, ); - put( - "LLM_API_KEY", - config.llm_api_key.clone().unwrap_or_default(), - &mut env_vars, - ); - put( - "STT_API_KEY", - config.stt_api_key.clone().unwrap_or_default(), - &mut env_vars, - ); put( "RESTORE_CLIPBOARD", @@ -450,16 +509,73 @@ fn persist_config(config: &Config) -> Result<()> { &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)?; - for (key, value) in updated { - // SAFETY: This mirrors Config::save_to_env to keep runtime env in sync. - unsafe { std::env::set_var(&key, &value) }; + 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() @@ -468,6 +584,102 @@ fn bool_to_env(value: bool) -> 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()), + "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" + | "BEEP_ON_START" + | "USE_LOCAL_STT" + | "HISTORY_ENABLED" + | "START_AT_LOGIN" + | "QUICK_NOTES_ENABLED" + | "QUICK_NOTES_SAVE_ONLY" + | "AGENT_ENTER_SENDS" + | "SHOW_DOCK_ICON" => { + 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), + "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), + _ => unreachable!(), + } + } + _ => { + 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()); @@ -576,3 +788,122 @@ fn peer_uid(stream: &UnixStream) -> Option { 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 c1a36739..51e9df5d 100644 --- a/app/lib.rs +++ b/app/lib.rs @@ -22,11 +22,15 @@ pub use codescribe_core::{ // App/macOS-specific modules // ═══════════════════════════════════════════════════════════ +pub mod agent; pub mod os; #[cfg(target_os = "macos")] pub mod controller; +#[cfg(target_os = "macos")] +pub mod presentation; + #[cfg(target_os = "macos")] pub mod ipc; @@ -47,22 +51,33 @@ pub mod dev; #[cfg(target_os = "macos")] pub use ui::{ - BadgeMode, HoldBadgeConfig, focused_element_accepts_text, get_caret_position, - get_cursor_position, hide_hold_badge, set_dock_icon, show_badge_for_mode, show_hold_badge, - show_hold_badge_with_config, + 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, }; +#[cfg(target_os = "macos")] +pub use ui::bootstrap::{ + hide_bootstrap_overlay, hide_settings_window, schedule_bootstrap, schedule_settings_window, + should_show_bootstrap, should_show_settings_onboarding, should_show_setup, + show_bootstrap_overlay, show_settings_setup_tab, show_settings_window, +}; + +#[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 voice_chat_ui::{ VoiceChatOverlayConfig, add_voice_chat_error_message, add_voice_chat_user_message, - append_voice_chat_assistant_delta, clear_voice_chat_text, filter_drawer, - hide_voice_chat_overlay, is_auto_send_enabled, is_voice_chat_overlay_visible, refresh_drawer, - reset_voice_chat_activity, send_voice_chat_draft, set_voice_chat_send_callback, - set_voice_chat_sending, set_voice_chat_text, show_agent_tab, show_drawer_tab, - show_voice_chat_overlay, show_voice_chat_overlay_with_config, update_voice_chat_status, + append_voice_chat_assistant_delta, append_voice_chat_user_delta, clear_voice_chat_text, + filter_drawer, hide_voice_chat_overlay, is_auto_send_enabled, is_voice_chat_overlay_visible, + refresh_drawer, reset_voice_chat_activity, send_voice_chat_draft, set_voice_chat_send_callback, + set_voice_chat_sending, set_voice_chat_text, set_voice_chat_user_text, show_agent_tab, + show_drawer_tab, show_settings_tab, show_voice_chat_overlay, + show_voice_chat_overlay_with_config, update_voice_chat_status, }; #[cfg(target_os = "macos")] diff --git a/app/os/clipboard.rs b/app/os/clipboard.rs index 70837cc8..8374b941 100644 --- a/app/os/clipboard.rs +++ b/app/os/clipboard.rs @@ -29,6 +29,8 @@ use tracing::{debug, info, warn}; /// macOS virtual key code for 'V' key const KEYCODE_V: CGKeyCode = 9; +/// macOS virtual key code for 'C' key +const KEYCODE_C: CGKeyCode = 8; /// macOS virtual key code for Right Arrow const KEYCODE_RIGHT_ARROW: CGKeyCode = 124; @@ -231,6 +233,22 @@ fn simulate_cmd_v() -> Result<()> { Ok(()) } +/// Simulates Cmd+C keystroke using CGEvent +/// +/// Used for best-effort selection capture (clipboard snapshot+restore). +pub(crate) fn simulate_cmd_c() -> Result<()> { + let cmd_flag = CGEventFlags::CGEventFlagCommand; + + // Key down: C with Cmd modifier + simulate_key_event(KEYCODE_C, true, cmd_flag)?; + thread::sleep(Duration::from_millis(10)); + + // Key up: C with Cmd modifier + simulate_key_event(KEYCODE_C, false, cmd_flag)?; + + Ok(()) +} + /// Simulates Right Arrow keystroke using CGEvent fn simulate_right_arrow() -> Result<()> { // Key down: Right Arrow (no modifiers) @@ -385,7 +403,7 @@ pub fn paste_text(text: &str) -> Result<()> { /// Returns error if clipboard or keyboard simulation fails /// /// # Example -/// ```no_run +/// ```ignore /// use codescribe::clipboard::paste_and_restore; /// paste_and_restore("Hello, world!").expect("Failed to paste"); /// ``` diff --git a/app/os/hotkeys.rs b/app/os/hotkeys.rs index 8785b9e1..bfdf3ee4 100644 --- a/app/os/hotkeys.rs +++ b/app/os/hotkeys.rs @@ -6,104 +6,211 @@ // - Hold Ctrl (or configured combo): Start recording while held, stop when released // - Double-tap Left Option: Toggle recording on/off (normal, AI formatting) // - Double-tap Right Option: Toggle assistive hands-off (AI augmentation) +// - Double-tap Ctrl: Toggle recording on/off (raw, auto-paste) // // Design: Uses CGEventTap to monitor modifier flag changes only. // We specifically avoid calling TSMGetInputSourceProperty which caused // rdev to crash on macOS 26.2 (Sequoia). We only read CGEventFlags, // not keyboard layout or key translation. // -// HoldMods options: -// - Ctrl: Ctrl key only (default) -// - CtrlAlt: Ctrl+Option together -// - CtrlShift: Ctrl+Shift together -// - CtrlCmd: Ctrl+Command together -// -// ToggleTrigger options: -// - DoubleOption: Left Option (normal) + Right Option (assistive) -// - DoubleRightOption: Right Option only (assistive only) -// - None: Toggle mode completely disabled - -use crate::config::{HoldMods, ToggleTrigger}; +use crate::config::{Config, ShortcutBinding, UserSettings, WorkMode}; use crossbeam_channel::Sender; -use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering}; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering as AtomicOrdering}; use std::time::{Duration, Instant}; -// --- Global HoldMods Configuration --- - -/// Atomic storage for current HoldMods setting -/// Values: 0=Ctrl, 1=CtrlAlt, 2=CtrlShift, 3=CtrlCmd -static HOLD_MODS: AtomicU8 = AtomicU8::new(0); - -/// Set the hold modifier combination for hold-to-talk -pub fn set_hold_mods(mods: HoldMods) { - let value = match mods { - HoldMods::Ctrl => 0, - HoldMods::CtrlAlt => 1, - HoldMods::CtrlShift => 2, - HoldMods::CtrlCmd => 3, - }; - HOLD_MODS.store(value, AtomicOrdering::SeqCst); - tracing::info!("HoldMods set to {:?}", mods); +const BIND_DISABLED: u16 = 0; +const BIND_HOLD_FN: u16 = 1; +const BIND_HOLD_CTRL: u16 = 2; +const BIND_HOLD_CTRL_ALT: u16 = 3; +const BIND_HOLD_CTRL_SHIFT: u16 = 4; +const BIND_HOLD_CTRL_CMD: u16 = 5; +const BIND_DOUBLE_CTRL: u16 = 6; +const BIND_DOUBLE_LEFT_OPTION: u16 = 7; +const BIND_DOUBLE_RIGHT_OPTION: u16 = 8; + +const DEFAULT_MODE_BINDINGS_ENCODED: u16 = + BIND_HOLD_FN | (BIND_DOUBLE_LEFT_OPTION << 4) | (BIND_DOUBLE_RIGHT_OPTION << 8); + +// --- Global Mode Binding Contract --- +// Runtime source of truth: mode -> binding mapping, not legacy hold/toggle fields. +static MODE_HOTKEY_BINDINGS: AtomicU16 = AtomicU16::new(DEFAULT_MODE_BINDINGS_ENCODED); + +fn encode_shortcut_binding(binding: ShortcutBinding) -> u16 { + match binding { + ShortcutBinding::Disabled => BIND_DISABLED, + ShortcutBinding::HoldFn => BIND_HOLD_FN, + ShortcutBinding::HoldCtrl => BIND_HOLD_CTRL, + ShortcutBinding::HoldCtrlAlt => BIND_HOLD_CTRL_ALT, + ShortcutBinding::HoldCtrlShift => BIND_HOLD_CTRL_SHIFT, + ShortcutBinding::HoldCtrlCmd => BIND_HOLD_CTRL_CMD, + ShortcutBinding::DoubleCtrl => BIND_DOUBLE_CTRL, + ShortcutBinding::DoubleLeftOption => BIND_DOUBLE_LEFT_OPTION, + ShortcutBinding::DoubleRightOption => BIND_DOUBLE_RIGHT_OPTION, + } } -/// Get the current hold modifier combination -pub fn get_hold_mods() -> HoldMods { - match HOLD_MODS.load(AtomicOrdering::SeqCst) { - 0 => HoldMods::Ctrl, - 1 => HoldMods::CtrlAlt, - 2 => HoldMods::CtrlShift, - 3 => HoldMods::CtrlCmd, - _ => HoldMods::Ctrl, // fallback +fn decode_shortcut_binding(value: u16) -> ShortcutBinding { + match value { + BIND_DISABLED => ShortcutBinding::Disabled, + BIND_HOLD_FN => ShortcutBinding::HoldFn, + BIND_HOLD_CTRL => ShortcutBinding::HoldCtrl, + BIND_HOLD_CTRL_ALT => ShortcutBinding::HoldCtrlAlt, + BIND_HOLD_CTRL_SHIFT => ShortcutBinding::HoldCtrlShift, + BIND_HOLD_CTRL_CMD => ShortcutBinding::HoldCtrlCmd, + BIND_DOUBLE_CTRL => ShortcutBinding::DoubleCtrl, + BIND_DOUBLE_LEFT_OPTION => ShortcutBinding::DoubleLeftOption, + BIND_DOUBLE_RIGHT_OPTION => ShortcutBinding::DoubleRightOption, + _ => ShortcutBinding::Disabled, } } -// --- Global Toggle Trigger Setting --- +fn encode_mode_hotkey_bindings(bindings: ModeHotkeyBindings) -> u16 { + encode_shortcut_binding(bindings.dictation) + | (encode_shortcut_binding(bindings.formatting) << 4) + | (encode_shortcut_binding(bindings.assistive) << 8) +} -/// Atomic storage for ToggleTrigger (0=DoubleOption, 1=DoubleRightOption, 2=None) -static TOGGLE_TRIGGER: AtomicU8 = AtomicU8::new(0); +fn decode_mode_hotkey_bindings(raw: u16) -> ModeHotkeyBindings { + ModeHotkeyBindings { + dictation: decode_shortcut_binding(raw & 0x0F), + formatting: decode_shortcut_binding((raw >> 4) & 0x0F), + assistive: decode_shortcut_binding((raw >> 8) & 0x0F), + } +} -/// Set the toggle trigger mode (thread-safe) -pub fn set_toggle_trigger(trigger: ToggleTrigger) { - let value = match trigger { - ToggleTrigger::DoubleOption => 0, - ToggleTrigger::DoubleRightOption => 1, - ToggleTrigger::None => 2, - }; - TOGGLE_TRIGGER.store(value, AtomicOrdering::SeqCst); - tracing::info!("Toggle trigger set to: {:?}", trigger); +pub fn set_mode_hotkey_bindings(bindings: ModeHotkeyBindings) { + MODE_HOTKEY_BINDINGS.store( + encode_mode_hotkey_bindings(bindings), + AtomicOrdering::SeqCst, + ); + tracing::info!( + "Mode bindings set: dictation={:?}, formatting={:?}, assistive={:?}", + bindings.dictation, + bindings.formatting, + bindings.assistive + ); } -/// Get the current toggle trigger mode (thread-safe) -pub fn get_toggle_trigger() -> ToggleTrigger { - match TOGGLE_TRIGGER.load(AtomicOrdering::SeqCst) { - 0 => ToggleTrigger::DoubleOption, - 1 => ToggleTrigger::DoubleRightOption, - _ => ToggleTrigger::None, - } +pub fn get_mode_hotkey_bindings() -> ModeHotkeyBindings { + decode_mode_hotkey_bindings(MODE_HOTKEY_BINDINGS.load(AtomicOrdering::SeqCst)) } // --- Global Exclusive Mode Setting --- -// Note: Exclusive mode is now implicitly handled by HoldMods configuration. -// When HoldMods::Ctrl is set, Option key is excluded from hold combo. -// This function is kept for API compatibility with existing code. +// Exclusive mode controls whether Shift/Cmd can act as mode modifiers for hold gestures. +// When enabled, we ignore Shift/Cmd and keep hold mode as RAW. -use std::sync::atomic::AtomicBool; - -/// Atomic storage for exclusive mode (Ctrl and Option mutually exclusive) +/// Atomic storage for exclusive mode (Shift/Cmd mode modifiers disabled) static EXCLUSIVE_MODE: AtomicBool = AtomicBool::new(true); /// Set exclusive mode (thread-safe) -/// When true, Ctrl and Option are mutually exclusive (default behavior) -/// When false, they can be pressed together (legacy behavior) +/// When true, Shift/Cmd modifiers are ignored for hold mode +/// When false, Shift/Cmd can act as mode modifiers (Chat/Selection) pub fn set_exclusive_mode(enabled: bool) { EXCLUSIVE_MODE.store(enabled, AtomicOrdering::SeqCst); tracing::info!("Hotkey exclusive mode set to: {}", enabled); } +pub fn get_exclusive_mode() -> bool { + EXCLUSIVE_MODE.load(AtomicOrdering::SeqCst) +} + +// --- Hold start delay setting --- + +/// Atomic storage for hold-to-talk start delay (milliseconds) +static HOLD_START_DELAY_MS: AtomicU64 = AtomicU64::new(800); + +/// Set hold start delay (ms) used by hotkey gesture detector. +pub fn set_hold_start_delay_ms(ms: u64) { + HOLD_START_DELAY_MS.store(ms, AtomicOrdering::SeqCst); + tracing::info!("Hold start delay set to: {}ms", ms); +} + +/// Get current hold start delay (ms) for hotkey gesture detector. +pub fn get_hold_start_delay_ms() -> u64 { + HOLD_START_DELAY_MS.load(AtomicOrdering::SeqCst) +} + +// --- Double-tap interval setting --- + +/// Atomic storage for double-tap interval (milliseconds) +static DOUBLE_TAP_INTERVAL_MS: AtomicU64 = AtomicU64::new(200); + +/// Set the double-tap interval (ms). Clamped to safe bounds. +pub fn set_double_tap_interval_ms(ms: u64) { + let clamped = ms.clamp(100, 450); + DOUBLE_TAP_INTERVAL_MS.store(clamped, AtomicOrdering::SeqCst); + tracing::info!("Double-tap interval set to: {}ms", clamped); +} + +/// Get the current double-tap interval (ms). +pub fn get_double_tap_interval_ms() -> u64 { + DOUBLE_TAP_INTERVAL_MS.load(AtomicOrdering::SeqCst) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HotkeyRuntimeConfig { + pub mode_bindings: ModeHotkeyBindings, + pub hold_exclusive: bool, + pub hold_start_delay_ms: u64, + pub double_tap_interval_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModeHotkeyBindings { + pub dictation: ShortcutBinding, + pub formatting: ShortcutBinding, + pub assistive: ShortcutBinding, +} + +impl ModeHotkeyBindings { + pub fn from_settings(settings: &UserSettings) -> Self { + Self { + dictation: settings.mode_binding_for(WorkMode::Dictation), + formatting: settings.mode_binding_for(WorkMode::Formatting), + assistive: settings.mode_binding_for(WorkMode::Assistive), + } + } + + pub fn load() -> Self { + Self::from_settings(&UserSettings::load()) + } +} + +impl From<&Config> for HotkeyRuntimeConfig { + fn from(config: &Config) -> Self { + Self { + mode_bindings: ModeHotkeyBindings::load(), + hold_exclusive: config.hold_exclusive, + hold_start_delay_ms: config.hold_start_delay_ms, + double_tap_interval_ms: config.double_tap_interval_ms, + } + } +} + +pub fn get_hotkey_runtime_config() -> HotkeyRuntimeConfig { + HotkeyRuntimeConfig { + mode_bindings: get_mode_hotkey_bindings(), + hold_exclusive: get_exclusive_mode(), + hold_start_delay_ms: get_hold_start_delay_ms(), + double_tap_interval_ms: get_double_tap_interval_ms(), + } +} + +pub fn apply_hotkey_runtime_config(config: HotkeyRuntimeConfig) { + set_mode_hotkey_bindings(config.mode_bindings); + set_exclusive_mode(config.hold_exclusive); + set_hold_start_delay_ms(config.hold_start_delay_ms); + set_double_tap_interval_ms(config.double_tap_interval_ms); +} + +pub fn apply_hotkey_config(config: &Config) { + apply_hotkey_runtime_config(HotkeyRuntimeConfig::from(config)); +} + // --- Constants --- -/// Double-tap interval for toggle detection (milliseconds) -const DOUBLE_TAP_INTERVAL_MS: u64 = 450; +/// Max press duration for a "tap" gesture (milliseconds) +const TAP_MAX_MS: u64 = 220; // --- Types --- @@ -114,14 +221,35 @@ pub enum HoldAction { Up, } +/// High-level hold intent derived from modifier state. +/// +/// UX split: +/// - `Raw`: dictation → auto-paste (fast) +/// - `Chat`: voice chat to AI → response in overlay (no auto-paste) +/// - `Selection`: apply instruction to selected text → response in overlay (no auto-paste) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HoldMode { + #[default] + Raw, + Chat, + Selection, +} + /// Hotkey event emitted by the listener #[derive(Debug, Clone, PartialEq, Eq)] pub enum HotkeyEvent { /// Hold gesture detected (press/release configured modifier combo) - /// The boolean indicates "assistive mode" (Shift was held during the gesture) - Hold { action: HoldAction, assistive: bool }, + Hold { + action: HoldAction, + mode: HoldMode, + force_ai: bool, + }, + /// Modifier change while hold is active (e.g., add/remove Shift/Cmd). + HoldUpdate { mode: HoldMode, force_ai: bool }, /// Normal toggle gesture (double-tap left Option) ToggleNormal, + /// Raw toggle gesture (double-tap Ctrl) + ToggleRaw, /// Assistive toggle gesture (double-tap right Option) ToggleAssistive, } @@ -157,7 +285,10 @@ impl ModifierFlags { /// Check if the current flags match the required flags pub fn matches(&self, required: &ModifierFlags, exclusive: bool) -> bool { if exclusive { - self.ctrl == required.ctrl && self.alt == required.alt && self.cmd == required.cmd + self.ctrl == required.ctrl + && self.alt == required.alt + && self.shift == required.shift + && self.cmd == required.cmd } else { (!required.ctrl || self.ctrl) && (!required.alt || self.alt) @@ -177,14 +308,500 @@ impl Default for ModifierFlags { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct HotkeyModifierSnapshot { + pub ctrl: bool, + pub option: bool, + pub shift: bool, + pub cmd: bool, + pub fn_key: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HotkeyPhysicalKey { + LeftOption, + RightOption, + LeftControl, + RightControl, + Fn, + Other, +} + +impl HotkeyPhysicalKey { + fn is_option(self) -> bool { + matches!(self, Self::LeftOption | Self::RightOption) + } + + fn is_right_option(self) -> bool { + matches!(self, Self::RightOption) + } + + fn is_ctrl(self) -> bool { + matches!(self, Self::LeftControl | Self::RightControl) + } + + fn is_fn(self) -> bool { + matches!(self, Self::Fn) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HotkeyDetectorInput { + KeyDown { + now: Instant, + modifiers: HotkeyModifierSnapshot, + }, + FlagsChanged { + now: Instant, + key: HotkeyPhysicalKey, + modifiers: HotkeyModifierSnapshot, + }, +} + +#[derive(Debug, Clone)] +pub struct HotkeyDetector { + hold_active: bool, + hold_active_ts: Option, + hold_mode: HoldMode, + hold_force_ai: bool, + hold_event_sent: bool, + last_left_tap_ts: Option, + last_right_tap_ts: Option, + last_ctrl_tap_ts: Option, + ctrl_down: bool, + ctrl_down_ts: Option, + option_down: bool, + option_side: Option, + key_pressed_during_modifier: bool, +} + +impl Default for HotkeyDetector { + fn default() -> Self { + Self { + hold_active: false, + hold_active_ts: None, + hold_mode: HoldMode::Raw, + hold_force_ai: false, + hold_event_sent: false, + last_left_tap_ts: None, + last_right_tap_ts: None, + last_ctrl_tap_ts: None, + ctrl_down: false, + ctrl_down_ts: None, + option_down: false, + option_side: None, + key_pressed_during_modifier: false, + } + } +} + +impl HotkeyDetector { + pub fn feed( + &mut self, + input: HotkeyDetectorInput, + config: HotkeyRuntimeConfig, + ) -> Option { + match input { + HotkeyDetectorInput::KeyDown { now, modifiers } => { + self.handle_key_down(now, modifiers, config) + } + HotkeyDetectorInput::FlagsChanged { + now, + key, + modifiers, + } => self.handle_flags_changed(now, key, modifiers, config), + } + } + + pub fn is_combo_active(&self) -> bool { + self.hold_active + } + + fn handle_key_down( + &mut self, + now: Instant, + modifiers: HotkeyModifierSnapshot, + config: HotkeyRuntimeConfig, + ) -> Option { + let dictation_binding = config.mode_bindings.dictation; + let assistive_binding = config.mode_bindings.assistive; + let mut emitted = None; + let base_held = hold_base_pressed(modifiers, dictation_binding) + || assistive_hold_binding(assistive_binding) + .is_some_and(|binding| hold_base_pressed(modifiers, binding)); + if base_held && self.hold_active { + let in_delay_window = self + .hold_active_ts + .map(|ts| { + elapsed_between(now, ts) < Duration::from_millis(config.hold_start_delay_ms) + }) + .unwrap_or(false); + + if in_delay_window { + let mode = self.hold_mode; + let force_ai = self.hold_force_ai; + self.hold_active = false; + self.hold_active_ts = None; + self.hold_force_ai = false; + self.hold_event_sent = false; + self.key_pressed_during_modifier = true; + emitted = Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode, + force_ai, + }); + } + } + + if modifiers.ctrl && (self.ctrl_down || self.hold_active) { + self.key_pressed_during_modifier = true; + self.last_ctrl_tap_ts = None; + } + + if modifiers.option && self.option_down { + self.key_pressed_during_modifier = true; + self.last_left_tap_ts = None; + self.last_right_tap_ts = None; + } + + emitted + } + + fn handle_flags_changed( + &mut self, + now: Instant, + key: HotkeyPhysicalKey, + modifiers: HotkeyModifierSnapshot, + config: HotkeyRuntimeConfig, + ) -> Option { + let dictation_binding = config.mode_bindings.dictation; + let assistive_binding = config.mode_bindings.assistive; + let raw_toggle_enabled = dictation_binding == ShortcutBinding::DoubleCtrl; + let normal_toggle_enabled = + config.mode_bindings.formatting == ShortcutBinding::DoubleLeftOption; + let assistive_toggle_enabled = + config.mode_bindings.assistive == ShortcutBinding::DoubleRightOption; + let assistive_selection_combo_active = assistive_hold_binding(assistive_binding) + .is_some_and(|binding| check_hold_combo(modifiers, binding)); + let dictation_combo_active = check_hold_combo(modifiers, dictation_binding); + let combo_active = assistive_selection_combo_active || dictation_combo_active; + let mode_now = if assistive_selection_combo_active { + HoldMode::Selection + } else { + compute_hold_mode( + modifiers.shift, + modifiers.cmd, + dictation_binding, + config.hold_exclusive, + ) + }; + let force_ai_now = if assistive_selection_combo_active { + false + } else { + compute_hold_force_ai( + modifiers.option, + modifiers.shift, + modifiers.cmd, + dictation_binding, + ) + }; + + let mut emitted = None; + if combo_active && !self.hold_active { + self.hold_active = true; + self.hold_active_ts = Some(now); + self.hold_mode = mode_now; + self.hold_force_ai = force_ai_now; + self.hold_event_sent = true; + emitted = Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: self.hold_mode, + force_ai: self.hold_force_ai, + }); + } else if combo_active + && self.hold_active + && (mode_now != self.hold_mode || force_ai_now != self.hold_force_ai) + { + self.hold_mode = mode_now; + self.hold_force_ai = force_ai_now; + emitted = Some(HotkeyEvent::HoldUpdate { + mode: self.hold_mode, + force_ai: self.hold_force_ai, + }); + } else if !combo_active && self.hold_active { + self.hold_active = false; + if self.hold_event_sent { + emitted = Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode: self.hold_mode, + force_ai: self.hold_force_ai, + }); + } + self.hold_active_ts = None; + self.hold_force_ai = false; + } + + if raw_toggle_enabled { + let mut toggle_event = None; + if key.is_ctrl() && modifiers.ctrl && !self.ctrl_down { + self.ctrl_down = true; + self.ctrl_down_ts = Some(now); + } else if key.is_ctrl() && !modifiers.ctrl && self.ctrl_down { + self.ctrl_down = false; + let held_for = self + .ctrl_down_ts + .take() + .map(|ts| elapsed_between(now, ts)) + .unwrap_or_default(); + + if held_for <= Duration::from_millis(TAP_MAX_MS) + && !modifiers.shift + && !modifiers.option + && !modifiers.cmd + && !self.key_pressed_during_modifier + { + toggle_event = register_double_tap( + &mut self.last_ctrl_tap_ts, + now, + config.double_tap_interval_ms, + HotkeyEvent::ToggleRaw, + ); + } else { + self.last_ctrl_tap_ts = None; + self.key_pressed_during_modifier = false; + } + } + + if !modifiers.ctrl && !modifiers.option && !modifiers.cmd { + self.key_pressed_during_modifier = false; + } + + return emitted.or(toggle_event); + } + + if !normal_toggle_enabled && !assistive_toggle_enabled { + if key.is_option() { + if modifiers.option { + self.option_down = true; + self.option_side = Some(key.is_right_option()); + } else { + self.option_down = false; + self.option_side = None; + } + } else if !modifiers.option { + self.option_down = false; + self.option_side = None; + } + return emitted; + } + + if key.is_option() && modifiers.option && !self.option_down { + self.option_down = true; + self.option_side = Some(key.is_right_option()); + } else if !modifiers.option && self.option_down { + self.option_down = false; + let released_right = key.is_right_option(); + let pressed_side = self.option_side.take(); + + if !key.is_option() { + self.last_left_tap_ts = None; + self.last_right_tap_ts = None; + self.key_pressed_during_modifier = false; + return emitted; + } + + if let Some(pressed_right) = pressed_side + && pressed_right != released_right + { + self.last_left_tap_ts = None; + self.last_right_tap_ts = None; + return emitted; + } + + let hold_binding_blocks_toggle = match dictation_binding { + ShortcutBinding::HoldCtrlAlt => modifiers.ctrl || self.hold_active, + _ => modifiers.ctrl || modifiers.cmd || self.hold_active, + }; + + if self.key_pressed_during_modifier { + self.key_pressed_during_modifier = false; + return emitted; + } + + if !hold_binding_blocks_toggle { + let toggle_event = if released_right { + self.last_left_tap_ts = None; + if assistive_toggle_enabled { + register_double_tap( + &mut self.last_right_tap_ts, + now, + config.double_tap_interval_ms, + HotkeyEvent::ToggleAssistive, + ) + } else { + None + } + } else if normal_toggle_enabled { + self.last_right_tap_ts = None; + register_double_tap( + &mut self.last_left_tap_ts, + now, + config.double_tap_interval_ms, + HotkeyEvent::ToggleNormal, + ) + } else { + None + }; + + emitted = emitted.or(toggle_event); + } + } + + if !modifiers.ctrl && !modifiers.option && !modifiers.cmd && !modifiers.fn_key { + self.key_pressed_during_modifier = false; + } + + emitted + } +} + +fn elapsed_between(now: Instant, previous: Instant) -> Duration { + now.checked_duration_since(previous).unwrap_or_default() +} + +fn register_double_tap( + last_tap: &mut Option, + now: Instant, + interval_ms: u64, + event: HotkeyEvent, +) -> Option { + if let Some(previous) = *last_tap + && elapsed_between(now, previous) <= Duration::from_millis(interval_ms) + { + *last_tap = None; + return Some(event); + } + + *last_tap = Some(now); + None +} + +fn hold_base_pressed( + modifiers: HotkeyModifierSnapshot, + dictation_binding: ShortcutBinding, +) -> bool { + match dictation_binding { + ShortcutBinding::HoldFn => modifiers.fn_key, + ShortcutBinding::HoldCtrl => modifiers.ctrl, + ShortcutBinding::HoldCtrlAlt => modifiers.ctrl && modifiers.option, + ShortcutBinding::HoldCtrlShift => modifiers.ctrl && modifiers.shift, + ShortcutBinding::HoldCtrlCmd => modifiers.ctrl && modifiers.cmd, + ShortcutBinding::Disabled + | ShortcutBinding::DoubleCtrl + | ShortcutBinding::DoubleLeftOption + | ShortcutBinding::DoubleRightOption => false, + } +} + +fn check_hold_combo(modifiers: HotkeyModifierSnapshot, dictation_binding: ShortcutBinding) -> bool { + if modifiers.option + && !matches!( + dictation_binding, + ShortcutBinding::HoldCtrlAlt | ShortcutBinding::HoldFn + ) + { + return false; + } + + match dictation_binding { + ShortcutBinding::HoldFn => modifiers.fn_key, + ShortcutBinding::HoldCtrl => modifiers.ctrl, + ShortcutBinding::HoldCtrlAlt => modifiers.ctrl && modifiers.option, + ShortcutBinding::HoldCtrlShift => modifiers.ctrl && modifiers.shift, + ShortcutBinding::HoldCtrlCmd => modifiers.ctrl && modifiers.cmd, + ShortcutBinding::Disabled + | ShortcutBinding::DoubleCtrl + | ShortcutBinding::DoubleLeftOption + | ShortcutBinding::DoubleRightOption => false, + } +} + +fn assistive_hold_binding(binding: ShortcutBinding) -> Option { + match binding { + ShortcutBinding::HoldFn + | ShortcutBinding::HoldCtrl + | ShortcutBinding::HoldCtrlAlt + | ShortcutBinding::HoldCtrlShift + | ShortcutBinding::HoldCtrlCmd => Some(binding), + ShortcutBinding::Disabled + | ShortcutBinding::DoubleCtrl + | ShortcutBinding::DoubleLeftOption + | ShortcutBinding::DoubleRightOption => None, + } +} + +fn compute_hold_mode( + shift: bool, + cmd: bool, + dictation_binding: ShortcutBinding, + hold_exclusive: bool, +) -> HoldMode { + if hold_exclusive { + return HoldMode::Raw; + } + + match dictation_binding { + ShortcutBinding::Disabled + | ShortcutBinding::HoldCtrl + | ShortcutBinding::HoldCtrlShift + | ShortcutBinding::HoldCtrlCmd + | ShortcutBinding::DoubleCtrl + | ShortcutBinding::DoubleLeftOption + | ShortcutBinding::DoubleRightOption => HoldMode::Raw, + ShortcutBinding::HoldCtrlAlt => { + if cmd { + HoldMode::Selection + } else if shift { + HoldMode::Chat + } else { + HoldMode::Raw + } + } + ShortcutBinding::HoldFn => { + if shift { + HoldMode::Chat + } else if cmd { + HoldMode::Selection + } else { + HoldMode::Raw + } + } + } +} + +fn compute_hold_force_ai( + option: bool, + shift: bool, + cmd: bool, + dictation_binding: ShortcutBinding, +) -> bool { + match dictation_binding { + ShortcutBinding::HoldCtrlAlt => option && !shift && !cmd, + _ => false, + } +} + // --- macOS CGEventTap Implementation using raw bindings --- #[cfg(target_os = "macos")] mod macos { use super::*; use std::ffi::c_void; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::thread; + use std::ptr; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; + use std::sync::mpsc; + use std::thread::{self, JoinHandle}; // CGEvent types and flags type CGEventRef = *mut c_void; @@ -206,6 +823,7 @@ mod macos { const K_CG_EVENT_FLAG_MASK_SHIFT: CGEventFlags = 0x00020000; const K_CG_EVENT_FLAG_MASK_ALTERNATE: CGEventFlags = 0x00080000; // Option key const K_CG_EVENT_FLAG_MASK_COMMAND: CGEventFlags = 0x00100000; + const K_CG_EVENT_FLAG_MASK_SECONDARY_FN: CGEventFlags = 0x00800000; // CGEventField for keycode const K_CG_KEYBOARD_EVENT_KEYCODE: CGEventField = 9; @@ -213,6 +831,10 @@ mod macos { // macOS virtual keycodes for Option keys const K_VK_OPTION: i64 = 58; // Left Option const K_VK_RIGHT_OPTION: i64 = 61; // Right Option + // macOS virtual keycodes for Control keys + const K_VK_CONTROL: i64 = 59; // Left Control + const K_VK_RIGHT_CONTROL: i64 = 62; // Right Control + const K_VK_FUNCTION: i64 = 63; // Fn (Globe) // CGEventTap constants const K_CG_SESSION_EVENT_TAP: u32 = 1; @@ -251,354 +873,355 @@ mod macos { port: CFMachPortRef, order: i64, ) -> CFRunLoopSourceRef; + fn CFMachPortInvalidate(port: CFMachPortRef); fn CFRunLoopGetCurrent() -> CFRunLoopRef; fn CFRunLoopAddSource(rl: CFRunLoopRef, source: CFRunLoopSourceRef, mode: *const c_void); + fn CFRunLoopSourceInvalidate(source: CFRunLoopSourceRef); fn CFRunLoopRun(); + fn CFRunLoopStop(rl: CFRunLoopRef); + fn CFRunLoopWakeUp(rl: CFRunLoopRef); + fn CFRelease(cf: *const c_void); static kCFRunLoopCommonModes: *const c_void; } - /// State for tracking modifier keypresses struct HotkeyState { - /// Hold combo is currently active (all required modifiers held) - hold_active: bool, - /// When hold combo was activated (for hold duration check) - hold_active_ts: Option, - /// Assistive mode detected during this hold - assistive_mode: bool, - /// Hold event already sent (prevent duplicates) - hold_event_sent: bool, - /// Last left Option tap timestamp - last_left_tap_ts: Option, - /// Last right Option tap timestamp - last_right_tap_ts: Option, - /// Option is currently held - option_down: bool, - /// Whether the currently held Option is the RIGHT Option key - right_option_held: bool, - /// A non-modifier key was pressed while modifier(s) held - invalidates gesture - key_pressed_during_modifier: bool, - /// Event sender + detector: HotkeyDetector, tx: Sender, } impl HotkeyState { fn new(tx: Sender) -> Self { Self { - hold_active: false, - hold_active_ts: None, - assistive_mode: false, - hold_event_sent: false, - last_left_tap_ts: None, - last_right_tap_ts: None, - option_down: false, - right_option_held: false, - key_pressed_during_modifier: false, + detector: HotkeyDetector::default(), tx, } } } - fn register_option_tap( - last_tap: &mut Option, - event: HotkeyEvent, - tx: &Sender, - ) { - let now = Instant::now(); - if let Some(previous) = *last_tap - && now.duration_since(previous) <= Duration::from_millis(DOUBLE_TAP_INTERVAL_MS) - { - let _ = tx.send(event); - *last_tap = None; - return; - } - - *last_tap = Some(now); - } + static RUNNING: AtomicBool = AtomicBool::new(false); + static ENABLED: AtomicBool = AtomicBool::new(true); - /// Check if the configured hold combo is currently pressed - /// Returns (combo_active, is_assistive) - fn check_hold_combo( - ctrl: bool, - shift: bool, - option: bool, - cmd: bool, - hold_mods: HoldMods, - ) -> (bool, bool) { - let combo_active = match hold_mods { - // Ctrl alone triggers (Option must NOT be pressed for exclusivity with toggle) - HoldMods::Ctrl => ctrl && !option && !cmd, - // Ctrl+Alt (Option) together trigger - HoldMods::CtrlAlt => ctrl && option && !cmd, - // Ctrl+Shift together trigger (shift is part of combo, not assistive) - HoldMods::CtrlShift => ctrl && shift && !option && !cmd, - // Ctrl+Command together trigger - HoldMods::CtrlCmd => ctrl && cmd && !option, - }; + struct RunningGuard; - // Assistive mode: Shift held DURING the gesture (except for CtrlShift mode where shift is required) - let is_assistive = match hold_mods { - HoldMods::CtrlShift => false, // Shift is part of the combo, not assistive - _ => shift, - }; + impl RunningGuard { + fn acquire() -> Result { + if RUNNING.swap(true, Ordering::SeqCst) { + return Err("Hotkey listener already running".to_string()); + } + Ok(Self) + } + } - (combo_active, is_assistive) + impl Drop for RunningGuard { + fn drop(&mut self) { + RUNNING.store(false, Ordering::SeqCst); + } } - // Global state pointer for callback (must be static for C callback) - static mut GLOBAL_STATE: Option<*mut HotkeyState> = None; - static RUNNING: AtomicBool = AtomicBool::new(false); - static ENABLED: AtomicBool = AtomicBool::new(true); + #[derive(Default)] + struct RuntimeControl { + stop_requested: AtomicBool, + tap: AtomicPtr, + source: AtomicPtr, + run_loop: AtomicPtr, + } - /// CGEventTap callback - processes modifier key events and key presses - extern "C" fn event_callback( - _proxy: CGEventTapProxy, - event_type: CGEventType, - event: CGEventRef, - _user_info: *mut c_void, - ) -> CGEventRef { - // Skip processing if hotkeys are disabled - if !ENABLED.load(Ordering::Relaxed) { - return event; + impl RuntimeControl { + fn is_stop_requested(&self) -> bool { + self.stop_requested.load(Ordering::SeqCst) } - let state = unsafe { - match GLOBAL_STATE { - Some(ptr) => &mut *ptr, - None => return event, + fn request_stop(&self) { + if self.stop_requested.swap(true, Ordering::SeqCst) { + return; } - }; - // Handle KEY_DOWN: cancel pending gestures if non-modifier key pressed - if event_type == K_CG_EVENT_KEY_DOWN { - let flags = unsafe { CGEventGetFlags(event) }; - let ctrl_held = (flags & K_CG_EVENT_FLAG_MASK_CONTROL) != 0; - let option_held = (flags & K_CG_EVENT_FLAG_MASK_ALTERNATE) != 0; - - // If Ctrl is held and hold gesture is in the delay window (~800ms) - // → cancel the hold gesture by sending Hold Up (Ctrl+K, Ctrl+C, etc.) - // After delay, recording has started - don't cancel on key presses - const HOLD_DELAY_MS: u64 = 850; // Slightly longer than controller's 800ms - if ctrl_held && state.hold_active { - let in_delay_window = state - .hold_active_ts - .map(|ts| ts.elapsed() < Duration::from_millis(HOLD_DELAY_MS)) - .unwrap_or(false); - - if in_delay_window { - tracing::info!( - "Key pressed during Ctrl hold delay - canceling (Ctrl+key combo detected)" - ); - // Send Hold Up to cancel the pending hold in controller - let _ = state.tx.send(HotkeyEvent::Hold { - action: HoldAction::Up, - assistive: state.assistive_mode, - }); - state.hold_active = false; - state.hold_active_ts = None; - state.hold_event_sent = false; - state.key_pressed_during_modifier = true; - } else { - tracing::debug!("Key pressed during recording - allowed (past delay window)"); + // Swap each pointer to null BEFORE invalidating. The swap is the + // ownership transfer: whoever gets a non-null value from swap is + // responsible for teardown. This prevents the double-invalidate + // race with `Drop for EventTapResources`. + let tap = self.tap.swap(ptr::null_mut(), Ordering::SeqCst) as CFMachPortRef; + if !tap.is_null() { + unsafe { + CGEventTapEnable(tap, false); + CFMachPortInvalidate(tap); + CFRelease(tap as *const c_void); } } - // If Option is held → invalidate tap sequence (Option+Arrow, etc.) - // This is NOT a tap - it's a modifier combo, so discard the sequence - if option_held && state.option_down { - tracing::debug!("Key pressed while Option held - this is a combo, not a tap"); - state.key_pressed_during_modifier = true; - // Discard any pending tap sequence - do NOT send Toggle - state.last_left_tap_ts = None; - state.last_right_tap_ts = None; + let source = self.source.swap(ptr::null_mut(), Ordering::SeqCst) as CFRunLoopSourceRef; + if !source.is_null() { + unsafe { + CFRunLoopSourceInvalidate(source); + CFRelease(source as *const c_void); + } } - return event; + // run_loop is NOT owned (CFRunLoopGetCurrent doesn't retain) — no CFRelease. + let run_loop = self.run_loop.swap(ptr::null_mut(), Ordering::SeqCst) as CFRunLoopRef; + if !run_loop.is_null() { + unsafe { + CFRunLoopStop(run_loop); + CFRunLoopWakeUp(run_loop); + } + } } + } - // Only process flags changed events from here - if event_type != K_CG_EVENT_FLAGS_CHANGED { - return event; - } + struct EventTapResources { + state: Box, + tap: Option, + source: Option, + run_loop: Option, + control: Arc, + } - let flags = unsafe { CGEventGetFlags(event) }; - let keycode = unsafe { CGEventGetIntegerValueField(event, K_CG_KEYBOARD_EVENT_KEYCODE) }; - - // Debug: log every modifier event - tracing::debug!( - "CGEventTap: flags=0x{:X} keycode={} (ctrl={}, shift={}, opt={}, cmd={})", - flags, - keycode, - (flags & K_CG_EVENT_FLAG_MASK_CONTROL) != 0, - (flags & K_CG_EVENT_FLAG_MASK_SHIFT) != 0, - (flags & K_CG_EVENT_FLAG_MASK_ALTERNATE) != 0, - (flags & K_CG_EVENT_FLAG_MASK_COMMAND) != 0 - ); - - // Check current modifier states - let ctrl_now = (flags & K_CG_EVENT_FLAG_MASK_CONTROL) != 0; - let shift_now = (flags & K_CG_EVENT_FLAG_MASK_SHIFT) != 0; - let option_now = (flags & K_CG_EVENT_FLAG_MASK_ALTERNATE) != 0; - let cmd_now = (flags & K_CG_EVENT_FLAG_MASK_COMMAND) != 0; - - // Reset key_pressed flag when all modifiers released - if !ctrl_now && !option_now && !cmd_now { - state.key_pressed_during_modifier = false; - } - - // Determine if this is specifically the right Option key - let is_right_option = keycode == K_VK_RIGHT_OPTION; - let is_option_key = keycode == K_VK_OPTION || keycode == K_VK_RIGHT_OPTION; - - // Get current settings - let hold_mods = get_hold_mods(); - let toggle_trigger = get_toggle_trigger(); - - // Check if hold combo is active - let (combo_active, is_assistive) = - check_hold_combo(ctrl_now, shift_now, option_now, cmd_now, hold_mods); - - // Detect hold combo activation/deactivation - if combo_active && !state.hold_active { - // Hold combo just activated - state.hold_active = true; - state.hold_active_ts = Some(Instant::now()); - state.assistive_mode = is_assistive; - state.hold_event_sent = false; - - tracing::debug!( - "Hold combo activated ({:?}, assistive={}) - sending Hold Down event", - hold_mods, - is_assistive - ); - // Send Hold Down immediately for responsiveness - let _ = state.tx.send(HotkeyEvent::Hold { - action: HoldAction::Down, - assistive: state.assistive_mode, - }); - state.hold_event_sent = true; - } else if combo_active && state.hold_active && is_assistive && !state.assistive_mode { - // Shift was added while combo active - upgrade to assistive mode - state.assistive_mode = true; - tracing::info!("Upgraded to assistive mode (Shift added during hold)"); - } else if !combo_active && state.hold_active { - // Hold combo just deactivated - state.hold_active = false; - - // ALWAYS send Up event so controller can cancel pending actions - // Controller will decide what to do based on state - if state.hold_event_sent { - if let Some(ts) = state.hold_active_ts { - let elapsed = ts.elapsed(); - tracing::debug!("Hold combo released after {:?}", elapsed); - } - let _ = state.tx.send(HotkeyEvent::Hold { - action: HoldAction::Up, - assistive: state.assistive_mode, - }); + impl EventTapResources { + fn new(tx: Sender, control: Arc) -> Self { + Self { + state: Box::new(HotkeyState::new(tx)), + tap: None, + source: None, + run_loop: None, + control, } - state.hold_active_ts = None; } - let normal_toggle_enabled = matches!(toggle_trigger, ToggleTrigger::DoubleOption); - let assistive_toggle_enabled = matches!( - toggle_trigger, - ToggleTrigger::DoubleOption | ToggleTrigger::DoubleRightOption - ); + fn user_info_ptr(&mut self) -> *mut c_void { + (&mut *self.state as *mut HotkeyState).cast::() + } - // Skip Option processing if toggle is disabled - if matches!(toggle_trigger, ToggleTrigger::None) { - // Still track option_down state but don't process double-tap - if option_now && !state.option_down { - state.option_down = true; - } else if !option_now && state.option_down { - state.option_down = false; - } - return event; + fn set_tap(&mut self, tap: CFMachPortRef) { + self.tap = Some(tap); + self.control + .tap + .store(tap.cast::(), Ordering::SeqCst); } - // Detect Option double-tap for toggle gesture (left/right) - if option_now && !state.option_down { - // Option just pressed - state.option_down = true; - state.right_option_held = is_right_option; - tracing::debug!( - "Option pressed (right={}, keycode={})", - is_right_option, - keycode - ); - } else if !option_now && state.option_down { - // Option just released - let was_right_option = state.right_option_held; - state.option_down = false; - state.right_option_held = false; - - tracing::debug!( - "Option released (right={}, was_right={}, keycode={})", - is_right_option, - was_right_option, - keycode - ); + fn set_source(&mut self, source: CFRunLoopSourceRef) { + self.source = Some(source); + self.control + .source + .store(source.cast::(), Ordering::SeqCst); + } - // Don't trigger toggle if: - // - hold combo was/is active or other modifiers held - // - a key was pressed while Option was held (Option+Arrow is a combo, not a tap) - let hold_mods_block_toggle = match hold_mods { - HoldMods::CtrlAlt => false, // Option is part of hold combo, don't block - _ => ctrl_now || cmd_now || state.hold_active, - }; + fn set_run_loop(&mut self, run_loop: CFRunLoopRef) { + self.run_loop = Some(run_loop); + self.control + .run_loop + .store(run_loop.cast::(), Ordering::SeqCst); + } + } - // Skip if this was a combo (Option+Arrow, etc.) not a pure tap - if state.key_pressed_during_modifier { - tracing::debug!("Option released after combo - not a tap, skipping"); - state.key_pressed_during_modifier = false; - return event; + impl Drop for EventTapResources { + fn drop(&mut self) { + // Use atomic swap to claim ownership of each resource. If + // `request_stop()` already swapped a pointer to null, we get null + // and skip teardown for that resource (it was already cleaned up). + // This eliminates the double-invalidate crash (EXC_BREAKPOINT in + // CFRunLoopSourceInvalidate). + + let tap = self.control.tap.swap(ptr::null_mut(), Ordering::SeqCst) as CFMachPortRef; + if !tap.is_null() { + unsafe { + CGEventTapEnable(tap, false); + CFMachPortInvalidate(tap); + CFRelease(tap as *const c_void); + } } - if !hold_mods_block_toggle { - let current_tap_is_right = was_right_option || (is_option_key && is_right_option); + let source = + self.control.source.swap(ptr::null_mut(), Ordering::SeqCst) as CFRunLoopSourceRef; + if !source.is_null() { + unsafe { + CFRunLoopSourceInvalidate(source); + CFRelease(source as *const c_void); + } + } - if current_tap_is_right { - state.last_left_tap_ts = None; - if assistive_toggle_enabled { - register_option_tap( - &mut state.last_right_tap_ts, - HotkeyEvent::ToggleAssistive, - &state.tx, - ); - } - } else if normal_toggle_enabled { - state.last_right_tap_ts = None; - register_option_tap( - &mut state.last_left_tap_ts, - HotkeyEvent::ToggleNormal, - &state.tx, - ); + // run_loop is NOT owned (CFRunLoopGetCurrent doesn't retain) — no CFRelease. + let run_loop = self + .control + .run_loop + .swap(ptr::null_mut(), Ordering::SeqCst) as CFRunLoopRef; + if !run_loop.is_null() { + unsafe { + CFRunLoopStop(run_loop); + CFRunLoopWakeUp(run_loop); } } + + // Clear Option fields so they don't dangle. + self.tap = None; + self.source = None; + self.run_loop = None; } + } - event + pub struct HotkeyRuntime { + control: Arc, + worker: Option>, + running_guard: Option, + } + + impl HotkeyRuntime { + fn new( + control: Arc, + worker: JoinHandle<()>, + running_guard: RunningGuard, + ) -> Self { + Self { + control, + worker: Some(worker), + running_guard: Some(running_guard), + } + } + + pub fn shutdown(&mut self) { + if self.worker.is_none() && self.running_guard.is_none() { + return; + } + + self.control.request_stop(); + if let Some(worker) = self.worker.take() + && worker.join().is_err() + { + tracing::warn!("Hotkey worker thread panicked during shutdown"); + } + self.running_guard.take(); + } + } + + impl Drop for HotkeyRuntime { + fn drop(&mut self) { + self.shutdown(); + } + } + + #[allow(dead_code)] + fn modifiers_from_flags(flags: CGEventFlags) -> HotkeyModifierSnapshot { + HotkeyModifierSnapshot { + ctrl: (flags & K_CG_EVENT_FLAG_MASK_CONTROL) != 0, + shift: (flags & K_CG_EVENT_FLAG_MASK_SHIFT) != 0, + option: (flags & K_CG_EVENT_FLAG_MASK_ALTERNATE) != 0, + cmd: (flags & K_CG_EVENT_FLAG_MASK_COMMAND) != 0, + fn_key: (flags & K_CG_EVENT_FLAG_MASK_SECONDARY_FN) != 0, + } + } + + #[allow(dead_code)] + fn map_keycode(keycode: i64) -> HotkeyPhysicalKey { + match keycode { + K_VK_OPTION => HotkeyPhysicalKey::LeftOption, + K_VK_RIGHT_OPTION => HotkeyPhysicalKey::RightOption, + K_VK_CONTROL => HotkeyPhysicalKey::LeftControl, + K_VK_RIGHT_CONTROL => HotkeyPhysicalKey::RightControl, + K_VK_FUNCTION => HotkeyPhysicalKey::Fn, + _ => HotkeyPhysicalKey::Other, + } } - /// Start the hotkey listener on a background thread - pub fn start_listener(tx: Sender) -> Result<(), String> { - if RUNNING.swap(true, Ordering::SeqCst) { - return Err("Hotkey listener already running".to_string()); + /// CGEventTap callback - thin adapter from CoreGraphics events to HotkeyDetector input. + extern "C" fn event_callback( + _proxy: CGEventTapProxy, + event_type: CGEventType, + event: CGEventRef, + user_info: *mut c_void, + ) -> CGEventRef { + // Skip processing if hotkeys are disabled + if !ENABLED.load(Ordering::Relaxed) { + return event; } - thread::spawn(move || { - if let Err(e) = run_event_tap(tx) { + let state_ptr = user_info.cast::(); + if state_ptr.is_null() { + return event; + } + let state = unsafe { &mut *state_ptr }; + + let flags = unsafe { CGEventGetFlags(event) }; + let modifiers = modifiers_from_flags(flags); + let now = Instant::now(); + let runtime_config = get_hotkey_runtime_config(); + + let (input, swallow_fn_event) = match event_type { + K_CG_EVENT_KEY_DOWN => (HotkeyDetectorInput::KeyDown { now, modifiers }, false), + K_CG_EVENT_FLAGS_CHANGED => { + let keycode = + unsafe { CGEventGetIntegerValueField(event, K_CG_KEYBOARD_EVENT_KEYCODE) }; + let key = map_keycode(keycode); + + tracing::debug!( + "CGEventTap: flags=0x{:X} keycode={} (ctrl={}, shift={}, opt={}, cmd={}, fn={})", + flags, + keycode, + modifiers.ctrl, + modifiers.shift, + modifiers.option, + modifiers.cmd, + modifiers.fn_key + ); + + ( + HotkeyDetectorInput::FlagsChanged { + now, + key, + modifiers, + }, + runtime_config.mode_bindings.dictation == ShortcutBinding::HoldFn + && key.is_fn(), + ) + } + _ => return event, + }; + + if let Some(hotkey_event) = state.detector.feed(input, runtime_config) { + let _ = state.tx.send(hotkey_event); + } + + if swallow_fn_event { + // Swallow Fn events to avoid the system emoji picker. + return ptr::null_mut(); + } + + event + } + /// Start the hotkey listener on a background thread and return its runtime owner. + pub fn start_listener(tx: Sender) -> Result { + let running_guard = RunningGuard::acquire()?; + let control = Arc::new(RuntimeControl::default()); + let worker_control = Arc::clone(&control); + + let (ready_tx, ready_rx) = mpsc::channel::>(); + let worker = thread::spawn(move || { + if let Err(e) = run_event_tap(tx, worker_control, ready_tx) { tracing::error!("CGEventTap error: {}", e); } - RUNNING.store(false, Ordering::SeqCst); }); - // Give the thread a moment to start - thread::sleep(Duration::from_millis(100)); + let mut runtime = HotkeyRuntime::new(control, worker, running_guard); - Ok(()) + // Wait for startup confirmation so we can surface permission errors. + match ready_rx.recv_timeout(Duration::from_secs(2)) { + Ok(Ok(())) => Ok(runtime), + Ok(Err(e)) => { + runtime.shutdown(); + Err(e) + } + Err(mpsc::RecvTimeoutError::Timeout) => { + runtime.shutdown(); + Err( + "Timed out while starting CGEventTap (hotkeys). Check Accessibility permission." + .to_string(), + ) + } + Err(e) => { + runtime.shutdown(); + Err(format!("Failed to start hotkeys: {}", e)) + } + } } /// Enable hotkey processing (thread-safe) @@ -619,14 +1242,12 @@ mod macos { } /// Run the CGEventTap on the current thread (blocking) - fn run_event_tap(tx: Sender) -> Result<(), String> { - // Create state on heap and store global pointer - let state = Box::new(HotkeyState::new(tx)); - let state_ptr = Box::into_raw(state); - - unsafe { - GLOBAL_STATE = Some(state_ptr); - } + fn run_event_tap( + tx: Sender, + control: Arc, + ready_tx: mpsc::Sender>, + ) -> Result<(), String> { + let mut resources = EventTapResources::new(tx, control); // Event mask: flags changed + key down (to detect Ctrl+K style combos) let event_mask: u64 = (1 << K_CG_EVENT_FLAGS_CHANGED) | (1 << K_CG_EVENT_KEY_DOWN); @@ -639,18 +1260,16 @@ mod macos { K_CG_EVENT_TAP_OPTION_LISTEN_ONLY, event_mask, event_callback, - std::ptr::null_mut(), + resources.user_info_ptr(), ) }; if tap.is_null() { - // Clean up state - unsafe { - let _ = Box::from_raw(state_ptr); - GLOBAL_STATE = None; - } - return Err("Failed to create CGEventTap - check Accessibility permission".to_string()); + let msg = "Failed to create CGEventTap - check Accessibility permission".to_string(); + let _ = ready_tx.send(Err(msg.clone())); + return Err(msg); } + resources.set_tap(tap); // Enable the tap unsafe { @@ -661,55 +1280,182 @@ mod macos { let is_enabled = unsafe { CGEventTapIsEnabled(tap) }; if !is_enabled { tracing::error!("CGEventTap failed to enable! macOS may have denied it."); - unsafe { - let _ = Box::from_raw(state_ptr); - GLOBAL_STATE = None; - } - return Err("CGEventTap not enabled - macOS denied access".to_string()); + let msg = "CGEventTap not enabled - macOS denied access".to_string(); + let _ = ready_tx.send(Err(msg.clone())); + return Err(msg); } tracing::debug!("CGEventTap verified as enabled"); // Create run loop source - let source = unsafe { CFMachPortCreateRunLoopSource(std::ptr::null(), tap, 0) }; + let source = unsafe { CFMachPortCreateRunLoopSource(ptr::null(), tap, 0) }; if source.is_null() { - unsafe { - let _ = Box::from_raw(state_ptr); - GLOBAL_STATE = None; - } - return Err("Failed to create run loop source".to_string()); + let msg = "Failed to create run loop source".to_string(); + let _ = ready_tx.send(Err(msg.clone())); + return Err(msg); } + resources.set_source(source); // Add to run loop + let run_loop = unsafe { CFRunLoopGetCurrent() }; + resources.set_run_loop(run_loop); unsafe { - let run_loop = CFRunLoopGetCurrent(); CFRunLoopAddSource(run_loop, source, kCFRunLoopCommonModes); } - let hold_mods = get_hold_mods(); - let toggle_trigger = get_toggle_trigger(); + let bindings = get_mode_hotkey_bindings(); tracing::info!( - "CGEventTap started, monitoring {:?} hold and Option double-tap (left=normal, right=assistive, trigger={:?})", - hold_mods, - toggle_trigger + "CGEventTap started with mode bindings: dictation={:?}, formatting={:?}, assistive={:?}", + bindings.dictation, + bindings.formatting, + bindings.assistive ); + let _ = ready_tx.send(Ok(())); - // Run the loop (blocking - should never return) - tracing::debug!("Entering CFRunLoopRun (should block forever)..."); - unsafe { - CFRunLoopRun(); + // Run until an explicit shutdown request stops this run loop. + tracing::debug!("Entering CFRunLoopRun (blocks until stop)"); + if resources.control.is_stop_requested() { + unsafe { + CFRunLoopStop(run_loop); + CFRunLoopWakeUp(run_loop); + } + } else { + unsafe { + CFRunLoopRun(); + } } - // If we get here, something went wrong - tracing::error!("CFRunLoopRun returned unexpectedly! Event tap may have died."); + tracing::info!("CGEventTap run loop exited"); - // Clean up (won't reach here normally) - unsafe { - let _ = Box::from_raw(state_ptr); - GLOBAL_STATE = None; + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::sync::Mutex; + + static LIFECYCLE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn spawn_test_runtime() -> HotkeyRuntime { + let running_guard = RunningGuard::acquire().expect("test runtime should acquire guard"); + let control = Arc::new(RuntimeControl::default()); + let worker_control = Arc::clone(&control); + let worker = thread::spawn(move || { + while !worker_control.is_stop_requested() { + thread::sleep(Duration::from_millis(5)); + } + }); + HotkeyRuntime::new(control, worker, running_guard) } - Ok(()) + #[test] + fn compute_hold_mode_respects_modifiers() { + // Fn base with Shift/Cmd modifiers + assert_eq!( + compute_hold_mode(false, false, ShortcutBinding::HoldFn, false), + HoldMode::Raw + ); + assert_eq!( + compute_hold_mode(true, false, ShortcutBinding::HoldFn, false), + HoldMode::Chat + ); + assert_eq!( + compute_hold_mode(false, true, ShortcutBinding::HoldFn, false), + HoldMode::Selection + ); + + // Ctrl-only ignores Shift/Cmd modifiers + assert_eq!( + compute_hold_mode(true, false, ShortcutBinding::HoldCtrl, false), + HoldMode::Raw + ); + assert_eq!( + compute_hold_mode(false, true, ShortcutBinding::HoldCtrl, false), + HoldMode::Raw + ); + + // Ctrl+Option allows modifiers + assert_eq!( + compute_hold_mode(true, false, ShortcutBinding::HoldCtrlAlt, false), + HoldMode::Chat + ); + assert_eq!( + compute_hold_mode(false, true, ShortcutBinding::HoldCtrlAlt, false), + HoldMode::Selection + ); + assert_eq!( + compute_hold_mode(false, false, ShortcutBinding::HoldCtrlAlt, false), + HoldMode::Raw + ); + + // Ctrl+Shift/Cmd are fixed to raw + assert_eq!( + compute_hold_mode(true, false, ShortcutBinding::HoldCtrlShift, false), + HoldMode::Raw + ); + assert_eq!( + compute_hold_mode(false, true, ShortcutBinding::HoldCtrlCmd, false), + HoldMode::Raw + ); + } + + #[test] + fn compute_hold_mode_exclusive_forces_raw() { + assert_eq!( + compute_hold_mode(true, true, ShortcutBinding::HoldFn, true), + HoldMode::Raw + ); + assert_eq!( + compute_hold_mode(true, true, ShortcutBinding::HoldCtrlAlt, true), + HoldMode::Raw + ); + } + + #[test] + fn running_guard_blocks_double_start() { + let _guard = LIFECYCLE_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + RUNNING.store(false, Ordering::SeqCst); + + let first = RunningGuard::acquire().expect("first start must succeed"); + assert!(RunningGuard::acquire().is_err()); + drop(first); + + let second = RunningGuard::acquire().expect("second start after drop must succeed"); + drop(second); + assert!(!RUNNING.load(Ordering::SeqCst)); + } + + #[test] + fn runtime_shutdown_is_idempotent() { + let _guard = LIFECYCLE_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + RUNNING.store(false, Ordering::SeqCst); + + let mut runtime = spawn_test_runtime(); + runtime.shutdown(); + runtime.shutdown(); + + assert!(!RUNNING.load(Ordering::SeqCst)); + } + + #[test] + fn runtime_drop_stops_worker_without_panic() { + let _guard = LIFECYCLE_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + RUNNING.store(false, Ordering::SeqCst); + + { + let _runtime = spawn_test_runtime(); + assert!(RUNNING.load(Ordering::SeqCst)); + } + + assert!(!RUNNING.load(Ordering::SeqCst)); + } } } @@ -719,9 +1465,15 @@ mod macos { mod macos { use super::*; - pub fn start_listener(_tx: Sender) -> Result<(), String> { + pub struct HotkeyRuntime; + + impl HotkeyRuntime { + pub fn shutdown(&mut self) {} + } + + pub fn start_listener(_tx: Sender) -> Result { tracing::warn!("Hotkey listener not supported on this platform"); - Ok(()) + Ok(HotkeyRuntime) } pub fn enable() { @@ -759,10 +1511,14 @@ pub fn are_hotkeys_enabled() -> bool { macos::is_enabled() } -/// Manages global hotkey registration and event handling +/// Manages global hotkey runtime ownership. +/// +/// Owns the macOS event tap worker thread and tears it down on `shutdown()`/`Drop`. +/// Runtime starts in `new`; there is no separate `start`/`process` lifecycle. pub struct HotkeyManager { /// Kept for future use (e.g., manual event injection) _tx: Sender, + runtime: Option, } impl HotkeyManager { @@ -771,40 +1527,71 @@ impl HotkeyManager { /// IMPORTANT: On macOS, starts a background thread for CGEventTap. /// Requires Accessibility permission. pub fn new(tx: Sender) -> Result { - // Start the listener - macos::start_listener(tx.clone())?; + let runtime = macos::start_listener(tx.clone())?; - Ok(Self { _tx: tx }) + Ok(Self { + _tx: tx, + runtime: Some(runtime), + }) } - /// Process pending hotkey events + /// Stop global hotkeys and wait for runtime teardown. /// - /// Note: With CGEventTap implementation, events are sent directly to the channel. - /// This method is kept for API compatibility but does nothing. - pub fn process_events(&self) { - // Events are processed in the background thread - // This is a no-op for API compatibility + /// Safe to call multiple times. + pub fn shutdown(&mut self) { + if let Some(runtime) = self.runtime.as_mut() { + runtime.shutdown(); + } + self.runtime = None; } } -// --- Legacy API (for compatibility) --- - -/// Start the global hotkey listener (legacy API - now just returns success) -/// -/// The actual hotkey handling is now done through HotkeyManager integrated -/// with CGEventTap. -pub fn start( - _tx: Sender, - _required_modifiers: ModifierFlags, - _exclusive_mode: bool, -) -> Result<(), String> { - // This is now a no-op - hotkeys are integrated with HotkeyManager - Ok(()) +impl Drop for HotkeyManager { + fn drop(&mut self) { + self.shutdown(); + } } #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + static HOTKEY_ATOMICS_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn test_config( + dictation: ShortcutBinding, + formatting: ShortcutBinding, + assistive: ShortcutBinding, + ) -> HotkeyRuntimeConfig { + HotkeyRuntimeConfig { + mode_bindings: ModeHotkeyBindings { + dictation, + formatting, + assistive, + }, + hold_exclusive: false, + hold_start_delay_ms: 800, + double_tap_interval_ms: 200, + } + } + + fn mods( + ctrl: bool, + option: bool, + shift: bool, + cmd: bool, + fn_key: bool, + ) -> HotkeyModifierSnapshot { + HotkeyModifierSnapshot { + ctrl, + option, + shift, + cmd, + fn_key, + } + } #[test] fn test_modifier_flags_ctrl_only() { @@ -826,16 +1613,14 @@ mod tests { }; assert!(current.matches(&required, true)); - // With Shift (assistive mode) - should still match in exclusive mode let current_with_shift = ModifierFlags { ctrl: true, alt: false, shift: true, cmd: false, }; - assert!(current_with_shift.matches(&required, true)); + assert!(!current_with_shift.matches(&required, true)); - // Extra modifier (Alt) - should NOT match in exclusive mode let current_with_extra = ModifierFlags { ctrl: true, alt: true, @@ -850,7 +1635,7 @@ mod tests { let required = ModifierFlags::ctrl_only(); let current = ModifierFlags { ctrl: true, - alt: true, // Extra modifier allowed in non-exclusive mode + alt: true, shift: false, cmd: false, }; @@ -877,42 +1662,478 @@ mod tests { } #[test] - fn test_toggle_trigger_get_set() { - // Test default - set_toggle_trigger(ToggleTrigger::DoubleOption); - assert_eq!(get_toggle_trigger(), ToggleTrigger::DoubleOption); + fn test_mode_hotkey_bindings_get_set() { + let _guard = HOTKEY_ATOMICS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + + let bindings = ModeHotkeyBindings { + dictation: ShortcutBinding::HoldCtrlAlt, + formatting: ShortcutBinding::DoubleLeftOption, + assistive: ShortcutBinding::Disabled, + }; + set_mode_hotkey_bindings(bindings); + assert_eq!(get_mode_hotkey_bindings(), bindings); + } - // Test DoubleRightOption - set_toggle_trigger(ToggleTrigger::DoubleRightOption); - assert_eq!(get_toggle_trigger(), ToggleTrigger::DoubleRightOption); + #[test] + fn test_double_tap_interval_get_set() { + let _guard = HOTKEY_ATOMICS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + + set_double_tap_interval_ms(200); + assert_eq!(get_double_tap_interval_ms(), 200); + set_double_tap_interval_ms(50); + assert_eq!(get_double_tap_interval_ms(), 100); + set_double_tap_interval_ms(999); + assert_eq!(get_double_tap_interval_ms(), 450); + } - // Test None (disabled) - set_toggle_trigger(ToggleTrigger::None); - assert_eq!(get_toggle_trigger(), ToggleTrigger::None); + #[test] + fn test_apply_hotkey_runtime_config_updates_all_atomics() { + let _guard = HOTKEY_ATOMICS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + + let runtime = HotkeyRuntimeConfig { + mode_bindings: ModeHotkeyBindings { + dictation: ShortcutBinding::DoubleCtrl, + formatting: ShortcutBinding::Disabled, + assistive: ShortcutBinding::Disabled, + }, + hold_exclusive: true, + hold_start_delay_ms: 1234, + double_tap_interval_ms: 260, + }; + apply_hotkey_runtime_config(runtime); + + assert_eq!(get_mode_hotkey_bindings(), runtime.mode_bindings); + assert_eq!(get_exclusive_mode(), runtime.hold_exclusive); + assert_eq!(get_hold_start_delay_ms(), runtime.hold_start_delay_ms); + assert_eq!( + get_double_tap_interval_ms(), + runtime.double_tap_interval_ms.clamp(100, 450) + ); + } - // Reset to default - set_toggle_trigger(ToggleTrigger::DoubleOption); + #[test] + fn detector_option_double_tap_window_table() { + let table = [(200_u64, true), (201_u64, false)]; + + for (gap_ms, expect_toggle) in table { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, true, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(1), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(gap_ms), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, true, false, false, false), + }, + config, + ), + None + ); + + let second_release = detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(gap_ms + 1), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, false, false, false, false), + }, + config, + ); + assert_eq!( + second_release, + if expect_toggle { + Some(HotkeyEvent::ToggleNormal) + } else { + None + } + ); + } + } + + #[test] + fn detector_cancels_hold_on_keydown_during_delay() { + let mut detector = HotkeyDetector::default(); + let mut config = test_config( + ShortcutBinding::HoldCtrl, + ShortcutBinding::Disabled, + ShortcutBinding::Disabled, + ); + config.hold_start_delay_ms = 800; + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(true, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Raw, + force_ai: false, + }) + ); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::KeyDown { + now: base + Duration::from_millis(200), + modifiers: mods(true, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode: HoldMode::Raw, + force_ai: false, + }) + ); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(260), + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + None + ); + assert!(!detector.is_combo_active()); } #[test] - fn test_hold_mods_get_set() { - // Test default - set_hold_mods(HoldMods::Ctrl); - assert_eq!(get_hold_mods(), HoldMods::Ctrl); + fn detector_hold_ctrl_alt_requires_option_before_starting_hold() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldCtrlAlt, + ShortcutBinding::Disabled, + ShortcutBinding::Disabled, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(true, false, false, false, false), + }, + config, + ), + None + ); + assert!( + !detector.is_combo_active(), + "Ctrl alone must not arm HoldCtrlAlt" + ); - // Test CtrlAlt - set_hold_mods(HoldMods::CtrlAlt); - assert_eq!(get_hold_mods(), HoldMods::CtrlAlt); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(1), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(true, true, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Raw, + force_ai: true, + }) + ); + assert!(detector.is_combo_active()); + } - // Test CtrlShift - set_hold_mods(HoldMods::CtrlShift); - assert_eq!(get_hold_mods(), HoldMods::CtrlShift); + #[test] + fn detector_routes_assistive_hold_binding_to_selection_mode() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::HoldCtrlCmd, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(true, false, false, false, false), + }, + config, + ), + None + ); - // Test CtrlCmd - set_hold_mods(HoldMods::CtrlCmd); - assert_eq!(get_hold_mods(), HoldMods::CtrlCmd); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(1), + key: HotkeyPhysicalKey::Other, + modifiers: mods(true, false, false, true, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Selection, + force_ai: false, + }) + ); - // Reset to default - set_hold_mods(HoldMods::Ctrl); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(2), + key: HotkeyPhysicalKey::Other, + modifiers: mods(true, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode: HoldMode::Selection, + force_ai: false, + }) + ); + } + + #[test] + fn detector_resets_combo_flags_after_option_combo() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, true, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(1), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + None + ); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(40), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, true, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::KeyDown { + now: base + Duration::from_millis(45), + modifiers: mods(false, true, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(50), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + None + ); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(120), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, true, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(121), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(170), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, true, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(171), + key: HotkeyPhysicalKey::LeftOption, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::ToggleNormal) + ); + } + + #[test] + fn detector_raw_toggle_double_ctrl_and_combo_reset() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::DoubleCtrl, + ShortcutBinding::Disabled, + ShortcutBinding::Disabled, + ); + let base = Instant::now(); + + let first_event = detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(true, false, false, false, false), + }, + config, + ); + assert_eq!(first_event, None); + assert_eq!( + detector.feed( + HotkeyDetectorInput::KeyDown { + now: base + Duration::from_millis(10), + modifiers: mods(true, false, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(20), + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + None + ); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(100), + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(true, false, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(110), + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(170), + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(true, false, false, false, false), + }, + config, + ), + None + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(180), + key: HotkeyPhysicalKey::LeftControl, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::ToggleRaw) + ); } } diff --git a/app/os/mod.rs b/app/os/mod.rs index def6f481..6651cc9f 100644 --- a/app/os/mod.rs +++ b/app/os/mod.rs @@ -3,4 +3,10 @@ pub mod clipboard; #[cfg(target_os = "macos")] pub mod hotkeys; #[cfg(target_os = "macos")] +pub mod notifications; +#[cfg(target_os = "macos")] pub mod permissions; +#[cfg(target_os = "macos")] +pub mod selection; +#[cfg(target_os = "macos")] +pub mod shortcut_registry; diff --git a/app/os/notifications.rs b/app/os/notifications.rs new file mode 100644 index 00000000..f488200a --- /dev/null +++ b/app/os/notifications.rs @@ -0,0 +1,189 @@ +//! macOS user notifications via UNUserNotificationCenter. +//! +//! Uses the modern UserNotifications framework (available since macOS 10.14). +//! 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 + +use objc::runtime::{Class, Object}; +use objc::{msg_send, sel, sel_impl}; +use std::ffi::CString; +use std::sync::Once; +use tracing::warn; + +// Ensure UserNotifications.framework is linked +#[link(name = "UserNotifications", kind = "framework")] +unsafe extern "C" {} + +type Id = *mut Object; + +static AUTH_ONCE: Once = Once::new(); + +fn ns_string(s: &str) -> Id { + unsafe { + let cls = Class::get("NSString").unwrap(); + let c_str = CString::new(s).unwrap_or_else(|_| CString::new("").unwrap()); + msg_send![cls, stringWithUTF8String: c_str.as_ptr()] + } +} + +/// Check if we're running inside a proper app bundle. +/// UNUserNotificationCenter crashes with NSInternalInconsistencyException +/// ("bundleProxyForCurrentProcess is nil") when called from a bare binary. +fn has_app_bundle() -> bool { + unsafe { + let cls = match Class::get("NSBundle") { + Some(c) => c, + None => return false, + }; + let bundle: Id = msg_send![cls, mainBundle]; + if bundle.is_null() { + return false; + } + let identifier: Id = msg_send![bundle, bundleIdentifier]; + !identifier.is_null() + } +} + +/// Request notification authorization (once per app lifetime). +/// Non-blocking — fires callback internally, we ignore the result. +fn ensure_authorized() { + AUTH_ONCE.call_once(|| { + unsafe { + let center = notification_center(); + if center.is_null() { + return; + } + // UNAuthorizationOptionAlert | UNAuthorizationOptionSound = (1<<2) | (1<<1) = 6 + let options: usize = 6; + // Pass nil block — we don't care about the result for local notifications + let nil_block: Id = std::ptr::null_mut(); + let _: () = + msg_send![center, requestAuthorizationWithOptions:options completionHandler:nil_block]; + } + }); +} + +fn notification_center() -> Id { + unsafe { + if !has_app_bundle() { + return std::ptr::null_mut(); + } + let cls = match Class::get("UNUserNotificationCenter") { + Some(c) => c, + None => return std::ptr::null_mut(), + }; + msg_send![cls, currentNotificationCenter] + } +} + +/// Show a local notification with title and body. +/// +/// Best-effort: silently does nothing if UserNotifications framework +/// is unavailable or authorization was denied. +pub fn notify(title: &str, body: &str) { + ensure_authorized(); + + unsafe { + let center = notification_center(); + if center.is_null() { + warn!("Notification skipped (no app bundle): {title} — {body}"); + return; + } + + // UNMutableNotificationContent + 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, setBody: ns_string(body)]; + + // Default sound + 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]; + + // UNNotificationRequest with nil trigger = deliver immediately + let request_cls = match Class::get("UNNotificationRequest") { + Some(c) => c, + None => return, + }; + + // Unique ID per notification (timestamp-based) + 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]; + + // nil completion handler + let nil_block: Id = std::ptr::null_mut(); + let _: () = + 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/permissions.rs b/app/os/permissions.rs index 03afe70a..4d4afc79 100644 --- a/app/os/permissions.rs +++ b/app/os/permissions.rs @@ -13,6 +13,14 @@ use core_foundation::base::TCFType; #[cfg(target_os = "macos")] use core_foundation::string::CFString; +#[cfg(target_os = "macos")] +use dispatch::Queue; +#[cfg(target_os = "macos")] +use objc::{msg_send, runtime::Class, sel, sel_impl}; +#[cfg(target_os = "macos")] +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +#[cfg(target_os = "macos")] +use std::time::{Duration, Instant}; /// Permission status #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -51,6 +59,29 @@ pub fn check_accessibility() -> PermissionStatus { PermissionStatus::Granted // Not needed on other platforms } +/// Check if Input Monitoring permission is granted (macOS) +/// +/// This permission gates global key event listening (including CGEventTap in listen-only mode). +#[cfg(target_os = "macos")] +pub fn check_input_monitoring() -> PermissionStatus { + unsafe extern "C" { + fn CGPreflightListenEventAccess() -> bool; + } + + unsafe { + if CGPreflightListenEventAccess() { + PermissionStatus::Granted + } else { + PermissionStatus::Denied + } + } +} + +#[cfg(not(target_os = "macos"))] +pub fn check_input_monitoring() -> PermissionStatus { + PermissionStatus::Granted +} + /// Prompt user to grant Accessibility permission /// /// Opens System Settings > Privacy & Security > Accessibility @@ -62,12 +93,12 @@ pub fn request_accessibility() -> bool { fn AXIsProcessTrustedWithOptions(options: *const std::ffi::c_void) -> bool; } + use core_foundation::boolean::CFBoolean; use core_foundation::dictionary::CFDictionary; - use core_foundation::number::CFNumber; // Create options dictionary with kAXTrustedCheckOptionPrompt = true let key = CFString::new("AXTrustedCheckOptionPrompt"); - let value = CFNumber::from(1i32); // true + let value = CFBoolean::true_value(); let options = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); @@ -79,17 +110,44 @@ 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. -/// Note: On macOS, the permission is checked when cpal first accesses the microphone. -/// We return NotDetermined here to indicate it will be requested on first use. +/// Uses `AVCaptureDevice.authorizationStatusForMediaType("soun")`. #[cfg(target_os = "macos")] pub fn check_microphone() -> PermissionStatus { - // AVCaptureDevice requires AVFoundation framework which isn't linked by default - // Microphone permission will be requested automatically when cpal accesses the mic - // For now, we just return NotDetermined to indicate it hasn't been checked yet - PermissionStatus::NotDetermined + unsafe { + let Some(av_class) = Class::get("AVCaptureDevice") else { + return PermissionStatus::NotDetermined; + }; + + // AVMediaTypeAudio fourcc + let media_type = CFString::new("soun"); + let status: isize = + msg_send![av_class, authorizationStatusForMediaType: media_type.as_concrete_TypeRef()]; + match status { + 3 => PermissionStatus::Granted, // AVAuthorizationStatusAuthorized + 1 | 2 => PermissionStatus::Denied, // Restricted / Denied + _ => PermissionStatus::NotDetermined, + } + } } #[cfg(not(target_os = "macos"))] @@ -97,18 +155,178 @@ pub fn check_microphone() -> PermissionStatus { PermissionStatus::Granted // Not needed on other platforms } +#[cfg(target_os = "macos")] +const MICROPHONE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(target_os = "macos")] +const MICROPHONE_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(250); +#[cfg(target_os = "macos")] +const MAIN_THREAD_DISPATCH_TIMEOUT: Duration = Duration::from_secs(2); + +#[cfg(target_os = "macos")] +fn is_main_thread() -> bool { + unsafe { + if let Some(ns_thread) = Class::get("NSThread") { + msg_send![ns_thread, isMainThread] + } else { + std::thread::current().name() == Some("main") + } + } +} + +#[cfg(target_os = "macos")] +fn start_microphone_request(callback_tx: Sender) -> bool { + use tracing::warn; + + let Some(av_class) = Class::get("AVCaptureDevice") else { + warn!("Microphone request failed: AVCaptureDevice class unavailable."); + return false; + }; + + let media_type = CFString::new("soun"); + unsafe { + let request_block = block::ConcreteBlock::new(move |granted: bool| { + let _ = callback_tx.send(granted); + }) + .copy(); + + let _: () = msg_send![ + av_class, + requestAccessForMediaType: media_type.as_concrete_TypeRef() + completionHandler: &*request_block + ]; + } + + true +} + +#[cfg(target_os = "macos")] +fn start_microphone_request_on_main_thread(callback_tx: Sender) -> bool { + use tracing::warn; + + if is_main_thread() { + return start_microphone_request(callback_tx); + } + + let (started_tx, started_rx) = mpsc::channel(); + Queue::main().exec_async(move || { + let started = start_microphone_request(callback_tx); + let _ = started_tx.send(started); + }); + + match started_rx.recv_timeout(MAIN_THREAD_DISPATCH_TIMEOUT) { + Ok(started) => started, + Err(RecvTimeoutError::Timeout) => { + warn!( + "Microphone request dispatch timed out waiting for main thread (>{:?}).", + MAIN_THREAD_DISPATCH_TIMEOUT + ); + false + } + Err(RecvTimeoutError::Disconnected) => { + warn!("Microphone request dispatch failed: main-thread handoff channel closed."); + false + } + } +} + +#[cfg(target_os = "macos")] +fn wait_for_microphone_resolution(callback_rx: Receiver) -> bool { + use tracing::{info, warn}; + + let started = Instant::now(); + loop { + let elapsed = started.elapsed(); + if elapsed >= MICROPHONE_REQUEST_TIMEOUT { + break; + } + + let remaining = MICROPHONE_REQUEST_TIMEOUT - elapsed; + let wait_for = remaining.min(MICROPHONE_STATUS_POLL_INTERVAL); + + match callback_rx.recv_timeout(wait_for) { + Ok(granted) => { + if granted { + info!("Microphone permission granted by system callback."); + return true; + } + + let status = check_microphone(); + if status == PermissionStatus::Granted { + info!("Microphone callback reported false, but status is now Granted."); + return true; + } + + warn!( + "Microphone permission denied. Enable CodeScribe in System Settings > Privacy & Security > Microphone." + ); + return false; + } + Err(RecvTimeoutError::Timeout) => match check_microphone() { + PermissionStatus::Granted => { + info!("Microphone permission became Granted while waiting for callback."); + return true; + } + PermissionStatus::Denied => { + warn!( + "Microphone permission is denied/restricted. Enable CodeScribe in System Settings > Privacy & Security > Microphone." + ); + return false; + } + PermissionStatus::NotDetermined => {} + }, + Err(RecvTimeoutError::Disconnected) => { + let status = check_microphone(); + warn!( + "Microphone callback channel closed before completion (status: {:?}).", + status + ); + return status == PermissionStatus::Granted; + } + } + } + + let status = check_microphone(); + warn!( + "Timed out waiting {:?} for microphone permission result (status: {:?}). Open System Settings > Privacy & Security > Microphone if needed.", + MICROPHONE_REQUEST_TIMEOUT, status + ); + status == PermissionStatus::Granted +} + /// Request Microphone permission /// /// Shows system dialog asking user to grant microphone access. -/// The callback will be called with the result. +/// Returns true when access is granted. #[cfg(target_os = "macos")] pub fn request_microphone() -> bool { - // For now, we'll just check if we can access the default input device - // The actual permission dialog will be triggered when we first try to use the microphone - // through cpal + use tracing::{info, warn}; - // Return true to indicate we attempted to request (actual permission granted via cpal) - true + match check_microphone() { + PermissionStatus::Granted => return true, + PermissionStatus::Denied => { + warn!( + "Microphone permission already denied/restricted. Grant access in System Settings > Privacy & Security > Microphone." + ); + return false; + } + PermissionStatus::NotDetermined => { + info!("Microphone permission not determined yet; requesting system prompt."); + } + } + + if is_main_thread() { + info!( + "request_microphone() is running on main thread; using bounded polling fallback to avoid hanging on callback delivery." + ); + } + + let (callback_tx, callback_rx) = mpsc::channel(); + if !start_microphone_request_on_main_thread(callback_tx) { + warn!("Microphone permission request could not be started."); + return check_microphone() == PermissionStatus::Granted; + } + + wait_for_microphone_resolution(callback_rx) } #[cfg(not(target_os = "macos"))] @@ -116,6 +334,110 @@ pub fn request_microphone() -> bool { true } +#[cfg(target_os = "macos")] +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + fn CGPreflightScreenCaptureAccess() -> bool; + fn CGRequestScreenCaptureAccess() -> bool; +} + +/// Check screen recording permission status. +#[cfg(target_os = "macos")] +pub fn check_screen_recording() -> PermissionStatus { + if unsafe { CGPreflightScreenCaptureAccess() } { + PermissionStatus::Granted + } else { + // macOS preflight only reports granted/not-granted and does not reliably + // distinguish "never requested" from "denied". Keep this conservative. + PermissionStatus::NotDetermined + } +} + +#[cfg(not(target_os = "macos"))] +pub fn check_screen_recording() -> PermissionStatus { + PermissionStatus::Granted +} + +/// Request screen recording permission. Returns true when granted. +#[cfg(target_os = "macos")] +pub fn request_screen_recording() -> bool { + unsafe { CGRequestScreenCaptureAccess() } +} + +#[cfg(not(target_os = "macos"))] +pub fn request_screen_recording() -> bool { + true +} + +/// Check Full Disk Access permission status. +#[cfg(target_os = "macos")] +pub fn check_full_disk_access() -> PermissionStatus { + full_disk_access_status() +} + +#[cfg(not(target_os = "macos"))] +pub fn check_full_disk_access() -> PermissionStatus { + 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; + + let home = std::env::var("HOME").unwrap_or_default(); + if home.is_empty() { + return PermissionStatus::NotDetermined; + } + + let protected_roots = [ + Path::new(&home).join("Library/Mail"), + Path::new(&home).join("Library/Messages"), + Path::new(&home).join("Library/Safari"), + ]; + + let mut saw_permission_denied = false; + for path in protected_roots { + match std::fs::read_dir(&path) { + Ok(_) => return PermissionStatus::Granted, + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + saw_permission_denied = true; + } + Err(_) => continue, + } + } + + if saw_permission_denied { + PermissionStatus::Denied + } else { + // Could be "not requested yet" or paths absent on this machine. + PermissionStatus::NotDetermined + } +} + +#[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}; @@ -126,7 +448,7 @@ pub fn check_all_permissions() { info!("Accessibility permission: Granted"); } PermissionStatus::Denied => { - warn!("Accessibility permission: DENIED - Global hotkeys may not work!"); + warn!("Accessibility permission: DENIED - Mode bindings may not trigger."); warn!("Grant access in: System Settings > Privacy & Security > Accessibility"); } _ => { @@ -134,17 +456,37 @@ pub fn check_all_permissions() { } } + // 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: Will be requested on first use"); + 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, restart CodeScribe if status does not refresh."); } } } @@ -161,9 +503,174 @@ pub fn request_all_permissions() { request_accessibility(); } - // Microphone permission is requested automatically when we first access the mic - // through cpal, so we just log the status - check_all_permissions(); + // 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 + ); + } + } + + // 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}"); + } + + // 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)] diff --git a/app/os/selection.rs b/app/os/selection.rs new file mode 100644 index 00000000..d63f95f0 --- /dev/null +++ b/app/os/selection.rs @@ -0,0 +1,280 @@ +//! Selection/context capture for assistive mode (macOS) +//! +//! POC goal: +//! - If user has selected text in the frontmost app, include it as context for Assistive mode. +//! - Avoid clipboard pollution by snapshot+restore. +//! - Best-effort only: failure should never break recording/transcription. + +use std::time::Duration; + +use tracing::{debug, warn}; + +use crate::os::clipboard::{self, ClipboardSnapshot}; + +#[derive(Debug, Clone, Default)] +pub struct AssistiveContext { + pub frontmost_app: Option, + pub selected_text: Option, +} + +fn env_flag(key: &str, default: bool) -> bool { + std::env::var(key) + .ok() + .map(|v| { + let v = v.to_lowercase(); + !matches!(v.as_str(), "0" | "false" | "no" | "off") + }) + .unwrap_or(default) +} + +fn env_usize(key: &str, default: usize) -> usize { + std::env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +fn env_u64(key: &str, default: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default) +} + +/// Capture best-effort context for assistive mode. +/// +/// Env knobs (POC): +/// - `ASSISTIVE_CONTEXT_ENABLED` (default: 1) +/// - `ASSISTIVE_CONTEXT_MAX_CHARS` (default: 20000) +/// - `ASSISTIVE_CONTEXT_INCLUDE_APP` (default: 1) +/// - `ASSISTIVE_CONTEXT_COPY_DELAY_MS` (default: 150) +/// - `ASSISTIVE_CONTEXT_COPY_FALLBACK` (default: auto) - enable Cmd+C fallback when AX selection is unavailable +pub fn capture_assistive_context() -> AssistiveContext { + // Unit tests should not trigger osascript / clipboard / event simulation. + if cfg!(test) { + return AssistiveContext::default(); + } + + if !env_flag("ASSISTIVE_CONTEXT_ENABLED", true) { + return AssistiveContext::default(); + } + + let max_chars = env_usize("ASSISTIVE_CONTEXT_MAX_CHARS", 20000); + let include_app = env_flag("ASSISTIVE_CONTEXT_INCLUDE_APP", true); + let copy_delay_ms = env_u64("ASSISTIVE_CONTEXT_COPY_DELAY_MS", 150); + + let frontmost_app = if include_app { + frontmost_app_name() + } else { + None + }; + + // Avoid capturing from ourselves (frontmost can temporarily become CodeScribe) + if matches!( + frontmost_app.as_deref(), + Some("CodeScribe") | Some("codescribe") + ) { + debug!("Assistive context: frontmost is CodeScribe, skipping selection capture"); + return AssistiveContext { + frontmost_app, + selected_text: None, + }; + } + + let selected_text = + selected_text_from_frontmost(max_chars, copy_delay_ms, frontmost_app.as_deref()); + + debug!( + "Assistive context captured (app_present={}, selected_chars={})", + frontmost_app.is_some(), + selected_text + .as_ref() + .map(|s| s.chars().count()) + .unwrap_or(0) + ); + + AssistiveContext { + frontmost_app, + selected_text, + } +} + +/// Capture only the frontmost app name (no selection, no clipboard). +/// +/// This is used to make paste actions (⇲) target the right app even when we're not in Assistive +/// selection mode. +pub fn capture_frontmost_app_only() -> AssistiveContext { + if cfg!(test) { + return AssistiveContext::default(); + } + + if !env_flag("ASSISTIVE_CONTEXT_ENABLED", true) { + return AssistiveContext::default(); + } + + let include_app = env_flag("ASSISTIVE_CONTEXT_INCLUDE_APP", true); + let frontmost_app = if include_app { + frontmost_app_name() + } else { + None + }; + + AssistiveContext { + frontmost_app, + selected_text: None, + } +} + +/// Build the LLM input for assistive mode, including optional selection context. +pub fn build_assistive_input(user_voice_text: &str, ctx: &AssistiveContext) -> String { + let instruction = user_voice_text.trim(); + let selected_text = ctx.selected_text.as_deref().unwrap_or("").trim(); + let frontmost_app = ctx.frontmost_app.as_deref().unwrap_or("").trim(); + + let mut out = String::new(); + + out.push_str("INSTRUKCJA_UŻYTKOWNIKA:\n<<<\n"); + out.push_str(instruction); + out.push_str("\n>\n\n"); + + out.push_str("ZAZNACZONY_TEKST:\n<<<\n"); + out.push_str(selected_text); + out.push_str("\n>\n"); + + if !frontmost_app.is_empty() { + out.push_str("\nKONTEKST:\n- frontmost_app: "); + out.push_str(frontmost_app); + out.push('\n'); + } + + out +} + +#[cfg(target_os = "macos")] +fn frontmost_app_name() -> Option { + use std::process::Command; + + // This is best-effort. It may fail if System Events is restricted. + let output = Command::new("osascript") + .args([ + "-e", + r#"tell application "System Events" to name of first application process whose frontmost is true"#, + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!s.is_empty()).then_some(s) +} + +#[cfg(not(target_os = "macos"))] +fn frontmost_app_name() -> Option { + None +} + +#[cfg(target_os = "macos")] +fn prefer_copy_fallback_for_app(frontmost_app: Option<&str>) -> bool { + let app = frontmost_app.unwrap_or("").trim().to_lowercase(); + matches!( + app.as_str(), + "safari" + | "google chrome" + | "google chrome beta" + | "arc" + | "brave browser" + | "firefox" + | "microsoft edge" + | "orion" + | "vivaldi" + ) +} + +#[cfg(target_os = "macos")] +fn selected_text_from_frontmost( + max_chars: usize, + copy_delay_ms: u64, + frontmost_app: Option<&str>, +) -> Option { + // Prefer Accessibility selection if available (doesn't depend on clipboard). + // + // 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) { + return Some(selected); + } + + // Cmd+C fallback is enabled by default for web browsers where AX selection is unreliable + // (notably Safari). The explicit env flag still overrides this behavior. + // We snapshot+restore to avoid clipboard pollution and treat "unchanged clipboard" as no selection. + let fallback_default = prefer_copy_fallback_for_app(frontmost_app); + if !env_flag("ASSISTIVE_CONTEXT_COPY_FALLBACK", fallback_default) { + if matches!(sel_len, Some(0)) { + debug!("Assistive context: selection length is 0; Cmd+C fallback disabled"); + } + return None; + } + if matches!(sel_len, Some(0)) { + debug!("Assistive context: AX range length=0; trying Cmd+C fallback"); + } + + // Fallback: snapshot clipboard + Cmd+C + restore. + // This can fail in some apps and can mis-detect "no selection" when clipboard doesn't change. + let snapshot = ClipboardSnapshot::capture().ok(); + let prev_text = snapshot.as_ref().and_then(|s| s.text.clone()); + + if let Err(e) = clipboard::simulate_cmd_c() { + warn!("Assistive context: failed to simulate Cmd+C: {}", e); + return None; + } + + std::thread::sleep(Duration::from_millis(copy_delay_ms)); + + let mut copied = match clipboard::get_clipboard() { + Ok(t) => t, + Err(e) => { + debug!("Assistive context: clipboard read failed: {}", e); + String::new() + } + }; + + if let Some(snapshot) = snapshot + && let Err(e) = snapshot.restore() + { + debug!("Assistive context: clipboard restore failed: {}", e); + } + + copied = copied.trim().to_string(); + if copied.is_empty() { + return None; + } + + // If clipboard didn't change, treat as "no selection" to avoid leaking arbitrary clipboard data. + if let Some(prev) = prev_text + && copied == prev.trim() + { + debug!("Assistive context: clipboard unchanged; treating as no selection"); + return None; + } + + let copied_chars = copied.chars().count(); + if copied_chars > max_chars { + copied = copied.chars().take(max_chars).collect(); + copied.push('…'); + } + + Some(copied) +} + +#[cfg(not(target_os = "macos"))] +fn selected_text_from_frontmost( + _max_chars: usize, + _copy_delay_ms: u64, + _frontmost_app: Option<&str>, +) -> Option { + None +} diff --git a/app/os/shortcut_registry.rs b/app/os/shortcut_registry.rs new file mode 100644 index 00000000..252683b4 --- /dev/null +++ b/app/os/shortcut_registry.rs @@ -0,0 +1,434 @@ +//! 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). + +use crate::config::{ShortcutBinding, UserSettings}; +use crate::os::hotkeys::ModeHotkeyBindings; +#[cfg(target_os = "macos")] +use std::collections::HashSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HotkeyGesture { + HoldFn, + HoldCtrl, + HoldCtrlAlt, + HoldCtrlShift, + HoldCtrlCmd, + ToggleDoubleCtrl, + ToggleDoubleLeftOption, + ToggleDoubleRightOption, +} + +impl HotkeyGesture { + pub fn label(self) -> &'static str { + match self { + Self::HoldFn => "Hold Fn/Globe", + Self::HoldCtrl => "Hold Ctrl", + Self::HoldCtrlAlt => "Hold Ctrl+Option", + Self::HoldCtrlShift => "Hold Ctrl+Shift", + Self::HoldCtrlCmd => "Hold Ctrl+Command", + Self::ToggleDoubleCtrl => "Double-tap Ctrl", + Self::ToggleDoubleLeftOption => "Double-tap Left Option", + Self::ToggleDoubleRightOption => "Double-tap Right Option", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HotkeyConflict { + pub gesture: HotkeyGesture, + pub message: String, +} + +pub fn detect_hotkey_conflicts(settings: &UserSettings) -> Vec { + let bindings = ModeHotkeyBindings::from_settings(settings); + let mut conflicts = detect_internal_conflicts(bindings); + conflicts.extend(detect_macos_symbolic_conflicts(bindings)); + conflicts +} + +fn active_gestures(bindings: ModeHotkeyBindings) -> Vec { + let mut gestures = Vec::new(); + + if bindings.dictation == ShortcutBinding::DoubleCtrl { + gestures.push(HotkeyGesture::ToggleDoubleCtrl); + } + + match bindings.dictation { + ShortcutBinding::HoldFn => gestures.push(HotkeyGesture::HoldFn), + ShortcutBinding::HoldCtrl => gestures.push(HotkeyGesture::HoldCtrl), + ShortcutBinding::HoldCtrlAlt => gestures.push(HotkeyGesture::HoldCtrlAlt), + ShortcutBinding::HoldCtrlShift => gestures.push(HotkeyGesture::HoldCtrlShift), + ShortcutBinding::HoldCtrlCmd => gestures.push(HotkeyGesture::HoldCtrlCmd), + ShortcutBinding::Disabled + | ShortcutBinding::DoubleCtrl + | ShortcutBinding::DoubleLeftOption + | ShortcutBinding::DoubleRightOption => {} + } + if bindings.formatting == ShortcutBinding::DoubleLeftOption { + gestures.push(HotkeyGesture::ToggleDoubleLeftOption); + } + match bindings.assistive { + ShortcutBinding::DoubleRightOption => gestures.push(HotkeyGesture::ToggleDoubleRightOption), + ShortcutBinding::HoldFn => gestures.push(HotkeyGesture::HoldFn), + ShortcutBinding::HoldCtrl => gestures.push(HotkeyGesture::HoldCtrl), + ShortcutBinding::HoldCtrlAlt => gestures.push(HotkeyGesture::HoldCtrlAlt), + ShortcutBinding::HoldCtrlShift => gestures.push(HotkeyGesture::HoldCtrlShift), + ShortcutBinding::HoldCtrlCmd => gestures.push(HotkeyGesture::HoldCtrlCmd), + ShortcutBinding::Disabled + | ShortcutBinding::DoubleCtrl + | ShortcutBinding::DoubleLeftOption => {} + } + + gestures +} + +fn detect_internal_conflicts(bindings: ModeHotkeyBindings) -> Vec { + let mut conflicts = Vec::new(); + + if bindings.dictation == ShortcutBinding::DoubleCtrl + && bindings.formatting == ShortcutBinding::DoubleLeftOption + { + conflicts.push(HotkeyConflict { + gesture: HotkeyGesture::ToggleDoubleLeftOption, + message: "Dictation is set to Double Ctrl, so Left Option toggle is disabled." + .to_string(), + }); + } + + if bindings.dictation == ShortcutBinding::DoubleCtrl + && bindings.assistive == ShortcutBinding::DoubleRightOption + { + conflicts.push(HotkeyConflict { + gesture: HotkeyGesture::ToggleDoubleRightOption, + message: "Dictation is set to Double Ctrl, so Right Option toggle is disabled." + .to_string(), + }); + } + + if bindings.assistive != ShortcutBinding::Disabled && bindings.dictation == bindings.assistive { + let gesture = match bindings.assistive { + ShortcutBinding::HoldFn => HotkeyGesture::HoldFn, + ShortcutBinding::HoldCtrl => HotkeyGesture::HoldCtrl, + ShortcutBinding::HoldCtrlAlt => HotkeyGesture::HoldCtrlAlt, + ShortcutBinding::HoldCtrlShift => HotkeyGesture::HoldCtrlShift, + ShortcutBinding::HoldCtrlCmd => HotkeyGesture::HoldCtrlCmd, + ShortcutBinding::DoubleCtrl => HotkeyGesture::ToggleDoubleCtrl, + ShortcutBinding::DoubleLeftOption => HotkeyGesture::ToggleDoubleLeftOption, + ShortcutBinding::DoubleRightOption => HotkeyGesture::ToggleDoubleRightOption, + ShortcutBinding::Disabled => HotkeyGesture::HoldFn, + }; + conflicts.push(HotkeyConflict { + gesture, + message: + "Dictation and Assistive use the same binding; Assistive selection shortcut may not be reachable." + .to_string(), + }); + } + + conflicts +} + +#[cfg(target_os = "macos")] +fn detect_macos_symbolic_conflicts(bindings: ModeHotkeyBindings) -> Vec { + let signatures = load_symbolic_signatures(); + if signatures.is_empty() { + return Vec::new(); + } + + collect_symbolic_conflicts(bindings, &signatures) +} + +#[cfg(not(target_os = "macos"))] +fn detect_macos_symbolic_conflicts(_bindings: ModeHotkeyBindings) -> Vec { + Vec::new() +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct SymbolicSignature { + id: u32, + keycode: i64, + modifiers: i64, +} + +#[cfg(target_os = "macos")] +fn symbolic_hotkey_name(id: u32) -> &'static str { + match id { + 160 => "Show Emoji & Symbols (Globe/Fn)", + 164 => "Start Dictation (double Globe/Fn)", + _ => "macOS system shortcut", + } +} + +#[cfg(target_os = "macos")] +fn collect_symbolic_conflicts( + bindings: ModeHotkeyBindings, + signatures: &[SymbolicSignature], +) -> Vec { + let mut conflicts = Vec::new(); + let mut dedup = HashSet::new(); + + for gesture in active_gestures(bindings) { + for signature in signatures { + if !gesture_conflicts_with_symbolic(gesture, *signature) { + continue; + } + if !dedup.insert((gesture, signature.id)) { + continue; + } + conflicts.push(HotkeyConflict { + gesture, + message: format!( + "Conflicts with {} (macOS #{}).", + symbolic_hotkey_name(signature.id), + signature.id + ), + }); + } + } + + conflicts +} + +#[cfg(target_os = "macos")] +fn gesture_conflicts_with_symbolic(gesture: HotkeyGesture, signature: SymbolicSignature) -> bool { + const LEFT_OPTION_KEYCODE: i64 = 58; + const RIGHT_OPTION_KEYCODE: i64 = 61; + const LEFT_CONTROL_KEYCODE: i64 = 59; + const RIGHT_CONTROL_KEYCODE: i64 = 62; + const FN_KEYCODE: i64 = 63; + + match gesture { + HotkeyGesture::HoldFn => { + signature.id == 160 + || signature.id == 164 + || ((signature.keycode == FN_KEYCODE || signature.keycode == 65535) + && signature.modifiers == 0) + } + HotkeyGesture::ToggleDoubleCtrl => { + (signature.keycode == LEFT_CONTROL_KEYCODE + || signature.keycode == RIGHT_CONTROL_KEYCODE) + && signature.modifiers == 0 + } + HotkeyGesture::ToggleDoubleLeftOption => { + signature.keycode == LEFT_OPTION_KEYCODE && signature.modifiers == 0 + } + HotkeyGesture::ToggleDoubleRightOption => { + signature.keycode == RIGHT_OPTION_KEYCODE && signature.modifiers == 0 + } + HotkeyGesture::HoldCtrl + | HotkeyGesture::HoldCtrlAlt + | HotkeyGesture::HoldCtrlShift + | HotkeyGesture::HoldCtrlCmd => false, + } +} + +#[cfg(target_os = "macos")] +fn load_symbolic_signatures() -> Vec { + use directories::BaseDirs; + use serde::Deserialize; + use std::collections::HashMap; + use std::process::Command; + use tracing::debug; + + #[derive(Debug, Deserialize)] + struct SymbolicRegistry { + #[serde(rename = "AppleSymbolicHotKeys")] + hotkeys: HashMap, + } + + #[derive(Debug, Deserialize)] + struct SymbolicEntry { + #[serde(default)] + enabled: bool, + #[serde(default)] + value: Option, + } + + #[derive(Debug, Deserialize)] + struct SymbolicValue { + #[serde(default)] + parameters: Vec, + } + + let Some(base_dirs) = BaseDirs::new() else { + return Vec::new(); + }; + let plist_path = base_dirs + .home_dir() + .join("Library/Preferences/com.apple.symbolichotkeys.plist"); + if !plist_path.exists() { + return Vec::new(); + } + + let output = match Command::new("/usr/bin/plutil") + .arg("-convert") + .arg("json") + .arg("-o") + .arg("-") + .arg(&plist_path) + .output() + { + Ok(output) => output, + Err(e) => { + debug!( + "Shortcut registry check skipped; failed to run plutil on {}: {e}", + plist_path.display() + ); + return Vec::new(); + } + }; + + if !output.status.success() { + debug!( + "Shortcut registry check skipped; plutil failed for {} (status={})", + plist_path.display(), + output.status + ); + return Vec::new(); + } + + let registry: SymbolicRegistry = match serde_json::from_slice(&output.stdout) { + Ok(registry) => registry, + Err(e) => { + debug!( + "Shortcut registry check skipped; invalid symbolic hotkeys JSON from {}: {e}", + plist_path.display() + ); + return Vec::new(); + } + }; + + let mut signatures = Vec::new(); + for (id_raw, entry) in registry.hotkeys { + if !entry.enabled { + continue; + } + let Some(value) = entry.value else { + continue; + }; + if value.parameters.len() < 3 { + continue; + } + + let Ok(id) = id_raw.parse::() else { + continue; + }; + signatures.push(SymbolicSignature { + id, + keycode: value.parameters[1], + modifiers: value.parameters[2], + }); + } + + signatures +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ModeBinding, WorkMode}; + + fn settings_for( + dictation: ShortcutBinding, + formatting: ShortcutBinding, + assistive: ShortcutBinding, + ) -> UserSettings { + UserSettings { + mode_bindings: Some(vec![ + ModeBinding { + mode: WorkMode::Dictation, + binding: dictation, + }, + ModeBinding { + mode: WorkMode::Formatting, + binding: formatting, + }, + ModeBinding { + mode: WorkMode::Assistive, + binding: assistive, + }, + ]), + ..Default::default() + } + } + + #[test] + fn internal_conflict_detects_double_ctrl_vs_left_option() { + let settings = settings_for( + ShortcutBinding::DoubleCtrl, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::Disabled, + ); + let conflicts = detect_internal_conflicts(ModeHotkeyBindings::from_settings(&settings)); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].gesture, HotkeyGesture::ToggleDoubleLeftOption); + } + + #[test] + fn internal_conflict_empty_for_safe_combo() { + let settings = settings_for( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, + ); + let conflicts = detect_internal_conflicts(ModeHotkeyBindings::from_settings(&settings)); + assert!(conflicts.is_empty()); + } + + #[test] + fn internal_conflict_detects_assistive_dictation_binding_collision() { + let settings = settings_for( + ShortcutBinding::HoldCtrlCmd, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::HoldCtrlCmd, + ); + let conflicts = detect_internal_conflicts(ModeHotkeyBindings::from_settings(&settings)); + assert!( + conflicts + .iter() + .any(|c| c.gesture == HotkeyGesture::HoldCtrlCmd), + "shared dictation/assistive hold binding should be reported as conflict" + ); + } + + #[test] + fn active_gestures_keeps_assistive_hold_visible_with_double_ctrl_dictation() { + let bindings = ModeHotkeyBindings { + dictation: ShortcutBinding::DoubleCtrl, + formatting: ShortcutBinding::Disabled, + assistive: ShortcutBinding::HoldCtrlCmd, + }; + let gestures = active_gestures(bindings); + assert!( + gestures.contains(&HotkeyGesture::ToggleDoubleCtrl), + "dictation double-ctrl toggle must remain visible" + ); + assert!( + gestures.contains(&HotkeyGesture::HoldCtrlCmd), + "assistive hold binding must remain visible for conflict checks" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn symbolic_conflict_detects_fn_registry_collision() { + let settings = settings_for( + ShortcutBinding::HoldFn, + ShortcutBinding::Disabled, + ShortcutBinding::Disabled, + ); + let signatures = vec![SymbolicSignature { + id: 160, + keycode: 65535, + modifiers: 0, + }]; + + let conflicts = + collect_symbolic_conflicts(ModeHotkeyBindings::from_settings(&settings), &signatures); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].gesture, HotkeyGesture::HoldFn); + } +} diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs new file mode 100644 index 00000000..c8223609 --- /dev/null +++ b/app/presentation/emitter.rs @@ -0,0 +1,476 @@ +//! Event-driven presentation emitter. +//! +//! Converts `EngineEvent`s into user-facing output by delegating to +//! `BufferedEmitter` (typing animation, delta encoding) from core. +//! +//! Uses an ordered mpsc channel to guarantee that push_segment, +//! push_correction and finish arrive in the exact order they were emitted, +//! eliminating the fire-and-forget tokio::spawn ordering race. +//! +//! Created by M&K (c)2026 VetCoders + +use std::sync::Arc; + +use codescribe_core::pipeline::contracts::{DeltaSink, EngineEvent, EventSink}; +use codescribe_core::pipeline::streaming::BufferedEmitter; +use tokio::sync::Mutex; +use tracing::{debug, info}; + +/// Commands sent through the ordered channel to the emitter worker. +enum EmitterCmd { + PushSegment(String), + PushCorrection(String), + Finish, +} + +#[derive(Debug, PartialEq, Eq)] +enum PreviewUpdate { + Noop, + Segment(String), + Correction, +} + +fn preview_update(last_preview: &str, incoming: &str) -> PreviewUpdate { + if let Some(stripped) = incoming.strip_prefix(last_preview) { + let suffix = stripped.to_string(); + if suffix.trim().is_empty() { + PreviewUpdate::Noop + } else { + PreviewUpdate::Segment(suffix) + } + } else { + PreviewUpdate::Correction + } +} + +/// Presentation emitter — bridges `EngineEvent`s to `BufferedEmitter`. +/// +/// Implements `EventSink` so it can be plugged directly into `transcription_session`. +/// Internally manages the `BufferedEmitter` tick loop for typing animation. +/// +/// All mutations to `BufferedEmitter` are serialized through an mpsc channel, +/// guaranteeing in-order delivery (no fire-and-forget spawn races). +pub struct PresentationEmitter { + cmd_tx: std::sync::Mutex>>, + emitter_handle: Option>, + cmd_handle: Option>, + /// Optional callback for completed utterances (used by Toggle mode). + utterance_callback: Option>, + /// Optional callback for VAD stop detection. + vad_start_callback: Option>, + vad_start_emitted: std::sync::atomic::AtomicBool, + /// Last preview text — used to compute incremental segment for push_segment. + last_preview: std::sync::Mutex, + /// Last non-empty preview text for boundary fallback when final text is empty. + last_non_empty_preview: std::sync::Mutex, + /// Last utterance id delivered to callback (guards duplicate boundary commits). + last_dispatched_utterance_id: std::sync::atomic::AtomicU64, +} + +impl PresentationEmitter { + pub fn new( + transcript_buffer: Arc>, + delta_callback: Option>, + stream_log_path: Option, + ) -> Self { + let emitter = Arc::new(Mutex::new(BufferedEmitter::new( + transcript_buffer, + delta_callback, + stream_log_path, + ))); + + let emitter_clone = emitter.clone(); + let emitter_handle = Some(tokio::spawn( + codescribe_core::pipeline::streaming::emitter_tick_loop(emitter_clone), + )); + + // Ordered command channel: on_event sends commands, worker processes in FIFO order. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let emitter_for_cmd = emitter.clone(); + let cmd_handle = Some(tokio::spawn(async move { + while let Some(cmd) = rx.recv().await { + let mut guard = emitter_for_cmd.lock().await; + let should_break = matches!(&cmd, EmitterCmd::Finish); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match cmd { + EmitterCmd::PushSegment(text) => guard.push_segment(text), + EmitterCmd::PushCorrection(text) => guard.push_correction(text), + EmitterCmd::Finish => { + guard.finish(); + } + })); + if result.is_err() { + tracing::error!("Emitter command worker panicked; forcing emitter finish"); + guard.finish(); + break; + } + if should_break { + break; + } + } + // Ensure tick loop exits even when channel closes unexpectedly. + let mut guard = emitter_for_cmd.lock().await; + guard.finish(); + })); + + Self { + cmd_tx: std::sync::Mutex::new(Some(tx)), + emitter_handle, + cmd_handle, + utterance_callback: None, + vad_start_callback: None, + vad_start_emitted: std::sync::atomic::AtomicBool::new(false), + last_preview: std::sync::Mutex::new(String::new()), + last_non_empty_preview: std::sync::Mutex::new(String::new()), + last_dispatched_utterance_id: std::sync::atomic::AtomicU64::new(0), + } + } + + pub fn set_utterance_callback(&mut self, cb: Option>) { + self.utterance_callback = cb; + } + + pub fn set_vad_start_callback(&mut self, cb: Option>) { + self.vad_start_callback = cb; + } + + /// Signal the emitter to finish and wait for both the command worker + /// and the tick loop to complete. + pub async fn finish(&mut self) { + // Send Finish through channel (ordered after all pending pushes). + if let Ok(guard) = self.cmd_tx.lock() + && let Some(tx) = guard.as_ref() + { + let _ = tx.send(EmitterCmd::Finish); + } + + // Wait for command worker to drain and exit. + if let Some(handle) = self.cmd_handle.take() + && let Err(e) = handle.await + { + tracing::error!("Emitter cmd worker failed: {}", e); + } + + // Wait for tick loop to finish. + if let Some(handle) = self.emitter_handle.take() + && let Err(e) = handle.await + { + tracing::error!("Emitter tick loop failed: {}", e); + } + } + + /// Send a command to the emitter worker (non-blocking, ordered). + fn send_cmd(&self, cmd: EmitterCmd) { + if let Ok(guard) = self.cmd_tx.lock() + && let Some(tx) = guard.as_ref() + && tx.send(cmd).is_err() + { + debug!("Emitter channel closed, dropping command"); + } + } +} + +impl Drop for PresentationEmitter { + fn drop(&mut self) { + // Close command channel first (lets cmd worker exit naturally). + if let Ok(mut guard) = self.cmd_tx.lock() { + let _ = guard.take(); + } + // Abort detached tasks as a hard stop fallback to avoid leaks. + if let Some(handle) = self.cmd_handle.take() { + handle.abort(); + } + if let Some(handle) = self.emitter_handle.take() { + handle.abort(); + } + } +} + +impl EventSink for PresentationEmitter { + fn on_event(&self, event: &EngineEvent) { + match event { + EngineEvent::VadStart { .. } => { + if !self + .vad_start_emitted + .swap(true, std::sync::atomic::Ordering::SeqCst) + && let Some(cb) = &self.vad_start_callback + { + cb(); + } + } + EngineEvent::Preview { text, .. } => { + // Compute only the new suffix since last preview and push + // that as incremental segment to the buffered emitter. + // + // If Preview diverges (not a prefix extension), treat it as a + // replacement path instead of appending the whole preview. + // This prevents duplicated/garbled overlay text when partial + // passes rewrite earlier tokens. + let mut last = self.last_preview.lock().unwrap_or_else(|e| e.into_inner()); + let previous_len = last.chars().count(); + let update = preview_update(last.as_str(), text); + *last = text.clone(); + if !text.trim().is_empty() { + let mut last_non_empty = self + .last_non_empty_preview + .lock() + .unwrap_or_else(|e| e.into_inner()); + *last_non_empty = text.clone(); + } + drop(last); + + match update { + PreviewUpdate::Noop => {} + PreviewUpdate::Segment(new_suffix) => { + self.send_cmd(EmitterCmd::PushSegment(new_suffix)); + } + PreviewUpdate::Correction => { + debug!( + previous_len, + incoming_len = text.chars().count(), + "Preview diverged from last preview; routing as correction to avoid append corruption" + ); + self.send_cmd(EmitterCmd::PushCorrection(text.clone())); + } + } + } + EngineEvent::Correction { + text, + previous_text, + .. + } => { + if text.trim().is_empty() { + return; + } + let mut last = self.last_preview.lock().unwrap_or_else(|e| e.into_inner()); + let mut last_non_empty = self + .last_non_empty_preview + .lock() + .unwrap_or_else(|e| e.into_inner()); + let baseline = if !last.trim().is_empty() { + last.clone() + } else if !previous_text.trim().is_empty() { + previous_text.clone() + } else { + last_non_empty.clone() + }; + *last = text.clone(); + *last_non_empty = text.clone(); + drop(last_non_empty); + drop(last); + if baseline.trim().is_empty() { + debug!( + "Correction arrived without preview baseline; routing as segment bootstrap" + ); + self.send_cmd(EmitterCmd::PushSegment(text.clone())); + } else { + self.send_cmd(EmitterCmd::PushCorrection(text.clone())); + } + } + EngineEvent::UtteranceFinal { + utterance_id, text, .. + } => { + // Reset last_preview — engine clears accumulated_text on utterance boundary. + { + let mut last = self.last_preview.lock().unwrap_or_else(|e| e.into_inner()); + last.clear(); + } + let fallback_preview = { + let mut last_non_empty = self + .last_non_empty_preview + .lock() + .unwrap_or_else(|e| e.into_inner()); + let value = last_non_empty.trim().to_string(); + last_non_empty.clear(); + value + }; + let duplicate = self + .last_dispatched_utterance_id + .swap(*utterance_id, std::sync::atomic::Ordering::SeqCst) + == *utterance_id; + if duplicate { + debug!( + utterance_id = *utterance_id, + "Ignoring duplicate UtteranceFinal callback dispatch" + ); + return; + } + if let Some(cb) = &self.utterance_callback { + let payload = if text.trim().is_empty() { + fallback_preview.as_str() + } else { + text.trim() + }; + if !payload.is_empty() { + cb(payload.to_string()); + } + } + } + EngineEvent::NoSpeech { reason } => { + { + let mut last = self.last_preview.lock().unwrap_or_else(|e| e.into_inner()); + last.clear(); + } + { + let mut last_non_empty = self + .last_non_empty_preview + .lock() + .unwrap_or_else(|e| e.into_inner()); + last_non_empty.clear(); + } + info!("Engine reported no speech: {}", reason); + } + EngineEvent::Drop { kind, text, reason } => { + debug!( + "Engine dropped: {:?} — {} (text: '{}')", + kind, + reason, + text.chars().take(50).collect::() + ); + } + EngineEvent::Stats { + hallucination_drops, + semantic_gate_drops, + filtered_empty_drops, + corrections_applied, + total_utterances, + dropped_audio_chunks, + partial_runs_total, + trigger_utterance_count, + trigger_speech_count, + trigger_watchdog_count, + partial_stale_count, + partial_coalesced_count, + partial_dropped_count, + } => { + info!( + "Session stats: utterances={}, hallucinations={}, semantic_gate={}, filtered_empty={}, corrections={}, dropped_chunks={}, partial_runs={} (utterance={}, speech={}, watchdog={}, stale={}, coalesced={}, dropped={})", + total_utterances, + hallucination_drops, + semantic_gate_drops, + filtered_empty_drops, + corrections_applied, + dropped_audio_chunks, + partial_runs_total, + trigger_utterance_count, + trigger_speech_count, + trigger_watchdog_count, + partial_stale_count, + partial_coalesced_count, + partial_dropped_count, + ); + // Stats is the last event from transcription_session. + // Signal BufferedEmitter to finish through the ordered channel, + // ensuring all pending pushes are processed first. + self.send_cmd(EmitterCmd::Finish); + } + EngineEvent::Warning { code, message } => { + tracing::warn!("Engine warning [{}]: {}", code, message); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::{PresentationEmitter, PreviewUpdate, preview_update}; + use codescribe_core::pipeline::contracts::{EngineEvent, EventSink}; + use std::sync::{Arc, Mutex as StdMutex}; + use tokio::sync::Mutex; + + #[test] + fn preview_update_emits_only_new_suffix_for_prefix_growth() { + assert_eq!( + preview_update("No dobra", "No dobra ziomeczku"), + PreviewUpdate::Segment(" ziomeczku".to_string()) + ); + } + + #[test] + fn preview_update_routes_divergence_to_correction() { + assert_eq!( + preview_update("No dobra ziomeczku", "No dobra, ziomeczku"), + PreviewUpdate::Correction + ); + } + + #[test] + fn preview_update_ignores_whitespace_only_suffix() { + assert_eq!(preview_update("tekst", "tekst "), PreviewUpdate::Noop); + } + + #[tokio::test] + async fn correction_after_final_still_updates_live_buffer() { + let transcript = Arc::new(Mutex::new(String::new())); + let emitter = PresentationEmitter::new(transcript.clone(), None, None); + + emitter.on_event(&EngineEvent::Preview { + rev: 1, + text: "Ala ma".to_string(), + }); + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "Ala ma".to_string(), + raw_text: "Ala ma".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + }); + emitter.on_event(&EngineEvent::Correction { + rev: 2, + text: "Ala ma kota".to_string(), + previous_text: "Ala ma".to_string(), + }); + + tokio::time::sleep(std::time::Duration::from_millis(220)).await; + let snapshot = transcript.lock().await.clone(); + assert!( + snapshot.contains("Ala ma kota"), + "expected correction to survive utterance boundary, got: {snapshot:?}" + ); + } + + #[tokio::test] + async fn utterance_callback_falls_back_to_last_preview_and_dedupes() { + let transcript = Arc::new(Mutex::new(String::new())); + let mut emitter = PresentationEmitter::new(transcript, None, None); + let delivered = Arc::new(StdMutex::new(Vec::::new())); + let delivered_ref = Arc::clone(&delivered); + emitter.set_utterance_callback(Some(Arc::new(move |text: String| { + delivered_ref + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(text); + }))); + + emitter.on_event(&EngineEvent::Preview { + rev: 1, + text: "ostatni sensowny preview".to_string(), + }); + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 7, + text: " ".to_string(), + raw_text: String::new(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + }); + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 7, + text: "duplikat".to_string(), + raw_text: "duplikat".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + }); + + let delivered = delivered.lock().unwrap_or_else(|e| e.into_inner()).clone(); + assert_eq!( + delivered, + vec!["ostatni sensowny preview".to_string()], + "empty final should fallback to preview and duplicate utterance must be ignored" + ); + } +} diff --git a/app/presentation/mod.rs b/app/presentation/mod.rs new file mode 100644 index 00000000..32880a70 --- /dev/null +++ b/app/presentation/mod.rs @@ -0,0 +1,11 @@ +//! Presentation layer — converts engine events to user-facing output. +//! +//! This module owns all presentation decisions: typing animation, buffer delays, +//! delta encoding for overlays, etc. The engine emits `EngineEvent`s (what happened), +//! and this module decides how to show them. +//! +//! Created by M&K (c)2026 VetCoders + +pub mod emitter; + +pub use emitter::PresentationEmitter; diff --git a/app/ui/README.md b/app/ui/README.md index cfa01cd5..4f601008 100644 --- a/app/ui/README.md +++ b/app/ui/README.md @@ -5,6 +5,7 @@ This module provides native macOS functionality for displaying a floating badge ## 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) @@ -14,6 +15,7 @@ A small, floating circular window that follows the cursor/caret position during - **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 @@ -22,6 +24,7 @@ Uses macOS Accessibility API to find the text insertion point: - Falls back to cursor position if caret unavailable ### 3. Cursor Position + Simple mouse cursor tracking via NSEvent. ## API @@ -98,6 +101,7 @@ pub struct HoldBadgeConfig { ``` **Defaults:** + - `diameter`: 12.0 pixels - `offset`: (10.0, -10.0) - right and above - `update_interval_ms`: 150ms @@ -106,18 +110,22 @@ pub struct HoldBadgeConfig { ## 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 @@ -126,7 +134,9 @@ The badge position is updated in a background thread: 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 @@ -135,6 +145,7 @@ The module queries: ## Requirements ### Dependencies + ```toml [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.25" @@ -145,11 +156,14 @@ 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 @@ -157,6 +171,7 @@ Without these permissions: ## Examples ### Recording Indicator + ```rust // Show badge when recording starts fn on_recording_start() { @@ -170,6 +185,7 @@ fn on_recording_stop() { ``` ### Smart Positioning + ```rust // Use different offsets based on text field presence let config = if focused_element_accepts_text() { @@ -188,6 +204,7 @@ show_hold_badge_with_config(config); ``` ### Visual Feedback States + ```rust // Different colors for different states fn show_recording_badge() { @@ -210,11 +227,13 @@ fn show_processing_badge() { ## 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 @@ -231,6 +250,7 @@ This will: ## Future Enhancements Potential improvements: + - [ ] Multiple simultaneous badges - [ ] Animation support (pulse, fade, etc.) - [ ] Custom shapes (not just circles) diff --git a/app/ui/bootstrap/handlers.rs b/app/ui/bootstrap/handlers.rs new file mode 100644 index 00000000..09e359bd --- /dev/null +++ b/app/ui/bootstrap/handlers.rs @@ -0,0 +1,401 @@ +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_ADVANCED, TAB_AI_PROMPTS, TAB_AUDIO_INPUT, TAB_DIAGNOSTICS, TAB_MODES_SHORTCUTS, + TAB_QUALITY, TAB_SETUP, handle_bootstrap_window_closed, handle_hotkey_done, + 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_quality_report, on_open_system_settings, on_permission_action, on_prompt_load, + on_prompt_reset, on_prompt_save, on_prompt_type_changed, on_quality_daemon_toggled, + on_quality_refresh, on_refresh_permissions, on_save_api_settings, on_show_dock_icon_toggled, + on_show_hotkey_conflicts, on_ultra_quality_toggled, on_voice_lab_field_changed, + on_voice_lab_toggle_changed, on_volume_changed, switch_tab, +}; + +pub type Id = *mut Object; + +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 { + unsafe { + ACTION_HANDLER_INIT.call_once(|| { + let superclass = Class::get("NSObject").expect("NSObject not found"); + let mut decl = ClassDecl::new("BootstrapOverlayActionHandler", superclass) + .expect("Failed to declare handler class"); + + // Setup 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!(onTabSetup:), + on_tab_setup as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onTabModesShortcuts:), + on_tab_modes_shortcuts as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onTabAiPrompts:), + on_tab_ai_prompts as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onTabAudioInput:), + on_tab_audio_input as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onTabQuality:), + on_tab_quality as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onTabDiagnostics:), + on_tab_diagnostics as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onTabAdvanced:), + on_tab_advanced 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!(onFormattingLevelChanged:), + on_formatting_level_changed as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onVoiceLabToggleChanged:), + on_voice_lab_toggle_changed as extern "C" fn(&Object, Sel, Id), + ); + decl.add_method( + sel!(onVoiceLabFieldChanged:), + on_voice_lab_field_changed as extern "C" fn(&Object, Sel, Id), + ); + + // Setup tab: LLM 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!(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!(onQualityDaemonToggled:), + on_quality_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_quality_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 { + unsafe { + WINDOW_DELEGATE_INIT.call_once(|| { + let superclass = Class::get("NSObject").expect("NSObject not found"); + let mut decl = ClassDecl::new("BootstrapWindowDelegate", 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 { + unsafe { + TOOLBAR_DELEGATE_INIT.call_once(|| { + let superclass = Class::get("NSObject").expect("NSObject not found"); + let mut decl = ClassDecl::new("BootstrapToolbarDelegate", 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 + } +} + +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_setup(_this: &Object, _sel: Sel, _sender: Id) { + switch_tab(TAB_SETUP); +} + +extern "C" fn on_tab_modes_shortcuts(_this: &Object, _sel: Sel, _sender: Id) { + switch_tab(TAB_MODES_SHORTCUTS); +} + +extern "C" fn on_tab_ai_prompts(_this: &Object, _sel: Sel, _sender: Id) { + switch_tab(TAB_AI_PROMPTS); +} + +extern "C" fn on_tab_audio_input(_this: &Object, _sel: Sel, _sender: Id) { + switch_tab(TAB_AUDIO_INPUT); +} + +extern "C" fn on_tab_quality(_this: &Object, _sel: Sel, _sender: Id) { + switch_tab(TAB_QUALITY); +} + +extern "C" fn on_tab_diagnostics(_this: &Object, _sel: Sel, _sender: Id) { + switch_tab(TAB_DIAGNOSTICS); +} + +extern "C" fn on_tab_advanced(_this: &Object, _sel: Sel, _sender: Id) { + switch_tab(TAB_ADVANCED); +} + +extern "C" fn on_window_will_close(_this: &Object, _sel: Sel, _notification: Id) { + handle_bootstrap_window_closed(); +} + +extern "C" fn toolbar_allowed_item_identifiers(_this: &Object, _sel: Sel, _toolbar: Id) -> Id { + 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 toolbar_default_item_identifiers(_this: &Object, _sel: Sel, _toolbar: Id) -> Id { + unsafe { + let ns_mutable_array = Class::get("NSMutableArray").unwrap(); + let ids: Id = msg_send![ns_mutable_array, array]; + let flexible_space: Id = ns_string(NSTOOLBAR_FLEXIBLE_SPACE_ITEM_IDENTIFIER); + let _: () = msg_send![ids, addObject: flexible_space]; + ids + } +} + +extern "C" fn toolbar_item_for_identifier( + _this: &Object, + _sel: Sel, + _toolbar: Id, + item_identifier: Id, + _will_be_inserted: bool, +) -> Id { + 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) { + let responds: bool = unsafe { msg_send![class, instancesRespondToSelector: selector] }; + assert!( + responds, + "BootstrapOverlayActionHandler missing selector `{label}`" + ); + } + + #[test] + fn action_handler_registers_core_settings_selectors() { + let class = action_handler_class(); + assert!( + !class.is_null(), + "BootstrapOverlayActionHandler class should be registered" + ); + + assert_selector_registered(class, sel!(onTabSetup:), "onTabSetup:"); + assert_selector_registered(class, sel!(onTabModesShortcuts:), "onTabModesShortcuts:"); + assert_selector_registered(class, sel!(onTabAiPrompts:), "onTabAiPrompts:"); + assert_selector_registered(class, sel!(onTabAudioInput:), "onTabAudioInput:"); + assert_selector_registered(class, sel!(onTabQuality:), "onTabQuality:"); + assert_selector_registered(class, sel!(onTabDiagnostics:), "onTabDiagnostics:"); + assert_selector_registered(class, sel!(onTabAdvanced:), "onTabAdvanced:"); + 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:"); + } +} diff --git a/app/ui/bootstrap/mod.rs b/app/ui/bootstrap/mod.rs new file mode 100644 index 00000000..59bc53c7 --- /dev/null +++ b/app/ui/bootstrap/mod.rs @@ -0,0 +1,4982 @@ +use std::collections::HashMap; +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, NSWindowCollectionBehavior}; +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::bootstrap::handlers::{ + action_handler_class, toolbar_delegate_class, window_delegate_class, +}; +use crate::ui::onboarding::{ + PERMISSION_ORDER, PermissionKind, open_permission_settings, permission_status, + request_permission, +}; +use crate::ui_helpers::{ + LabelConfig, add_subview, button, button_set_action, button_style, create_button, + create_floating_window, create_glass_effect_container_view, create_glass_effect_view_with, + create_label, create_scrollable_text_view, create_secure_text_input, create_slider, + create_text_input, create_toggle, get_text_view_string, layout_region_frame_for_view, + ns_string, set_glass_container_content_view, set_glass_effect_content_view, + set_text_field_string, set_text_view_string, ui_colors, ui_tokens, window_close, + window_content_view, window_show, +}; + +mod handlers; + +// Type alias for Objective-C object pointers +type Id = *mut Object; + +const SIDEBAR_WIDTH: f64 = 216.0; +const SETTINGS_WINDOW_WIDTH: f64 = 840.0; +const SETTINGS_WINDOW_HEIGHT: f64 = 700.0; +// Keep Settings readable while restoring stronger system glass. +const SETTINGS_MAX_OPACITY: f64 = ui_tokens::SETTINGS_WINDOW_OPACITY; +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 PERMISSION_ROW_HEIGHT: f64 = 24.0 + ui_tokens::DENSITY_COMFORTABLE; +const PERMISSION_BUTTON_WIDTH: f64 = 118.0; +const STEP_ROW_HEIGHT: f64 = 24.0 + ui_tokens::DENSITY_COMFORTABLE; +const SETUP_TOP_OFFSET: f64 = 20.0; +const SETUP_POST_STEPS_GAP: f64 = 8.0; +const SETUP_SAVE_MIN_ANCHOR_Y: f64 = 52.0; +const SETUP_HINT_MIN_Y: f64 = 52.0; +const TAB_SETUP: usize = 0; +const TAB_MODES_SHORTCUTS: usize = 1; +const TAB_AI_PROMPTS: usize = 2; +const TAB_AUDIO_INPUT: usize = 3; +const TAB_QUALITY: usize = 4; +const TAB_DIAGNOSTICS: usize = 5; +const TAB_ADVANCED: usize = 6; +const TAB_COUNT: usize = 7; + +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 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; + +#[derive(Default)] +struct ModeBindingRecorderState { + monitor_installed: bool, + target_mode: Option, +} + +lazy_static! { + static ref MODE_BINDING_RECORDER_STATE: Mutex = + Mutex::new(ModeBindingRecorderState::default()); +} + +#[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; + } + + 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 +} + +#[derive(Clone, Copy)] +struct ToggleRowSpec<'a> { + title: &'a str, + checked: bool, + action: objc::runtime::Sel, + description: Option<&'a str>, + tag: Option, + gap: f64, +} + +unsafe fn intensify_settings_glass(view: Id) { + let supports_emphasized: bool = msg_send![view, respondsToSelector: sel!(setEmphasized:)]; + if supports_emphasized { + let _: () = msg_send![view, setEmphasized: true]; + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum VoiceLabFieldKind { + Bool, + Value, +} + +#[derive(Clone, Copy)] +struct VoiceLabFieldSpec { + key: &'static str, + label: &'static str, + default_value: &'static str, + description: &'static str, + kind: VoiceLabFieldKind, +} + +// Voice Lab: only fields where user choice actually improves UX. +// Tuned pipeline internals (chunk_sec, similarity, correction thresholds etc.) +// stay as env-var escape hatches — their defaults are proven optimal. +const VOICE_LAB_FIELDS: [VoiceLabFieldSpec; 6] = [ + VoiceLabFieldSpec { + key: "CODESCRIBE_BUFFER_DELAY_MS", + label: "Buffer delay (ms)", + default_value: "280", + description: "Delay before buffered emission starts.", + kind: VoiceLabFieldKind::Value, + }, + VoiceLabFieldSpec { + key: "CODESCRIBE_TYPING_CPS", + label: "Typing speed (CPS)", + default_value: "90", + description: "Characters-per-second animation speed.", + kind: VoiceLabFieldKind::Value, + }, + VoiceLabFieldSpec { + key: "CODESCRIBE_EMIT_WORDS_MAX", + label: "Emit words max", + default_value: "2", + description: "Max words emitted per tick in buffered mode.", + kind: VoiceLabFieldKind::Value, + }, + VoiceLabFieldSpec { + key: "CODESCRIBE_BUFFERED_INTERIM_SEC", + label: "Interim cadence (sec)", + default_value: "1.2", + description: "How often partial results are shown.", + kind: VoiceLabFieldKind::Value, + }, + VoiceLabFieldSpec { + key: "WHISPER_MODEL", + label: "Whisper cloud model", + default_value: "mlx-community/whisper-large-v3-mlx", + description: "Cloud/multipart STT model id.", + kind: VoiceLabFieldKind::Value, + }, + VoiceLabFieldSpec { + key: "BACKEND_MAX_UPLOAD_MB", + label: "Cloud upload cap (MB)", + default_value: "20", + description: "Max upload size for cloud STT multipart.", + kind: VoiceLabFieldKind::Value, + }, +]; + +fn parse_env_bool(v: &str) -> bool { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +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 + } +} + +fn setup_content_height(min_visible_height: f64, gap: f64) -> f64 { + let permission_rows = PERMISSION_ORDER.len() as f64; + let quick_start_steps = (STEP_PRESS_HOTKEY + 1) as f64; + let flow_before_save = (24.0 + gap) // setup title + + (1.0 + gap) // header separator + + (16.0 + gap) // optional/non-blocking note + + (20.0 + gap) // permissions header + + permission_rows * PERMISSION_ROW_HEIGHT + + gap // permissions divider + + (20.0 + gap) // quick start header + + quick_start_steps * STEP_ROW_HEIGHT + + SETUP_POST_STEPS_GAP + + (16.0 + gap) // checklist hint + + gap // setup divider + + (16.0 + gap); // tab routing hint + min_visible_height.max((SETUP_TOP_OFFSET + flow_before_save + SETUP_SAVE_MIN_ANCHOR_Y).ceil()) +} + +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]; +} + +unsafe fn settings_input_paper_bg() -> Id { + let base = ui_colors::surface_paper_warm(); + msg_send![base, colorWithAlphaComponent: 0.84f64] +} + +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]; + unsafe { + add_subview(container, separator); + } + y - 1.0 +} + +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 +} + +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 +} + +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() + }); + 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]; + } + 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() + }); + unsafe { + add_subview(container, desc_label); + } + } + + *y -= toggle_row_step(spec.description.is_some(), spec.gap); + toggle +} + +fn voice_lab_value(spec: &VoiceLabFieldSpec) -> String { + std::env::var(spec.key).unwrap_or_else(|_| spec.default_value.to_string()) +} + +fn voice_lab_value_from_snapshot( + spec: &VoiceLabFieldSpec, + env_snapshot: &HashMap, +) -> String { + env_snapshot + .get(spec.key) + .cloned() + .unwrap_or_else(|| spec.default_value.to_string()) +} + +fn parse_ranged_f32(raw: &str, min: f32, max: f32) -> Option { + let parsed = raw.parse::().ok()?; + if !parsed.is_finite() || parsed < min || parsed > max { + return None; + } + Some(parsed) +} + +fn parse_ranged_u64(raw: &str, min: u64, max: u64) -> Option { + let parsed = raw.parse::().ok()?; + if parsed < min || parsed > max { + return None; + } + Some(parsed) +} + +fn parse_ranged_usize(raw: &str, min: usize, max: usize) -> Option { + let parsed = raw.parse::().ok()?; + if parsed < min || parsed > max { + return None; + } + Some(parsed) +} + +fn validate_voice_lab_value(spec: &VoiceLabFieldSpec, raw_value: &str) -> Option { + let trimmed = raw_value.trim(); + if trimmed.is_empty() { + return if spec.default_value.is_empty() { + Some(String::new()) + } else { + None + }; + } + + let valid = match spec.key { + "CODESCRIBE_BUFFER_DELAY_MS" => parse_ranged_u64(trimmed, 0, 60_000).is_some(), + "CODESCRIBE_TYPING_CPS" => parse_ranged_f32(trimmed, 5.0, 120.0).is_some(), + "CODESCRIBE_EMIT_WORDS_MAX" => parse_ranged_usize(trimmed, 1, 12).is_some(), + "CODESCRIBE_BUFFERED_INTERIM_SEC" => parse_ranged_f32(trimmed, 1.0, 30.0).is_some(), + "BACKEND_MAX_UPLOAD_MB" => parse_ranged_u64(trimmed, 1, 200).is_some(), + "WHISPER_MODEL" => true, + _ => true, + }; + + if valid { + Some(trimmed.to_string()) + } else { + None + } +} + +fn voice_lab_spec_from_tag(tag: isize) -> Option<&'static VoiceLabFieldSpec> { + if tag < 0 { + return None; + } + VOICE_LAB_FIELDS.get(tag as usize) +} + +#[derive(Default)] +struct BootstrapState { + window: Option, + window_delegate: 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, + config_cache: Option, + // Onboarding additions + permission_labels: [Option; 5], + permission_action_buttons: [Option; 5], + permission_requested: [bool; 5], + permission_polling: bool, + quality_daemon_checkbox: Option, + ultra_quality_checkbox: Option, + quality_available_label: Option, + quality_pending_label: Option, + quality_last_check_label: Option, + quality_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 BOOTSTRAP_STATE: Mutex = Mutex::new(BootstrapState::default()); +} + +fn clear_bootstrap_ui_state(state: &mut BootstrapState) { + state.step_labels = [None, None, None]; + state.tab_buttons = [None; TAB_COUNT]; + state.content_views = [None; TAB_COUNT]; + state.active_tab = TAB_SETUP; + 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.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.quality_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.quality_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; +} + +fn setup_done_path() -> PathBuf { + Config::config_dir().join("setup_done") +} + +fn onboarding_done_path() -> PathBuf { + Config::config_dir().join("onboarding_done") +} + +fn bootstrap_done_path() -> PathBuf { + Config::config_dir().join("bootstrap_done") +} + +fn migrate_legacy_setup_sentinel() { + let setup_done = setup_done_path(); + if setup_done.exists() { + return; + } + + if onboarding_done_path().exists() && 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_setup() -> bool { + migrate_legacy_setup_sentinel(); + !setup_done_path().exists() +} + +pub fn should_show_bootstrap() -> bool { + should_show_setup() +} + +fn mark_setup_done() { + let path = setup_done_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(path, "done"); +} + +pub fn schedule_bootstrap() { + if !should_show_setup() { + return; + } + + thread::spawn(|| { + thread::sleep(Duration::from_millis(800)); + show_settings_setup_tab(); + }); +} + +static SHOW_OVERLAY_IN_FLIGHT: AtomicBool = AtomicBool::new(false); + +pub fn show_bootstrap_overlay() { + // Fast path: if window already exists, just show it on main thread. + { + let state = BOOTSTRAP_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; + window_show(window); + refresh_permission_indicators(); + start_permission_polling(); + }); + return; + } + } + + // Slow path: need to create window — guard against concurrent thread spawns. + if SHOW_OVERLAY_IN_FLIGHT.swap(true, Ordering::SeqCst) { + return; + } + std::thread::spawn(|| { + let config = Config::load(); + Queue::main().exec_async(move || { + SHOW_OVERLAY_IN_FLIGHT.store(false, Ordering::SeqCst); + let mut state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.config_cache = Some(config); + drop(state); + show_bootstrap_overlay_impl(); + }); + }); +} + +/// Alias: Settings window (bootstrap is now a standalone Settings window). +pub fn show_settings_window() { + show_bootstrap_overlay(); +} + +fn show_bootstrap_overlay_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.) + unsafe { + let reuse_window = { + let mut state = BOOTSTRAP_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 { + window_show(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), + ); + + // Settings window should be fixed-size (no resize / fullscreen), to avoid AppKit + // fullscreen transition crashes with our custom content setup. + let window = create_floating_window(frame, "CodeScribe Settings", true, false); + // Keep Settings glass/opacity aligned with chat + transcription overlays. + let _: () = msg_send![window, setAlphaValue: SETTINGS_MAX_OPACITY]; + let _: () = msg_send![window, setLevel: crate::ui_helpers::NS_NORMAL_WINDOW_LEVEL]; + let _: () = msg_send![window, setTitle: ns_string("CodeScribe Settings")]; + let supports_subtitle: bool = msg_send![window, respondsToSelector: sel!(setSubtitle:)]; + if supports_subtitle { + let _: () = msg_send![ + window, + setSubtitle: ns_string("Native macOS speech-to-text setup and runtime tuning") + ]; + } + 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: 3_isize]; // NSWindowToolbarStyleUnified + } + let supports_toolbar_button: bool = + msg_send![window, respondsToSelector: sel!(setShowsToolbarButton:)]; + if supports_toolbar_button { + let _: () = msg_send![window, setShowsToolbarButton: false]; + } + // Disallow fullscreen/zoom to avoid triggering AppKit fullscreen snapshots that can crash. + let _: () = + msg_send![window, setCollectionBehavior: NSWindowCollectionBehavior::FullScreenNone]; + // Hard lock the size (no resize handles, no zoom). + let fixed_size = CGSize::new(window_width, window_height); + let _: () = msg_send![window, setContentMinSize: fixed_size]; + let _: () = msg_send![window, setContentMaxSize: fixed_size]; + 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 = BOOTSTRAP_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. + + window_show(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 { + unsafe { + let (config, existing_root) = { + let state = BOOTSTRAP_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 + ]; + let _: () = msg_send![root, setWantsLayer: true]; + let root_layer: Id = msg_send![root, layer]; + if !root_layer.is_null() { + let _: () = msg_send![root_layer, setCornerRadius: ui_tokens::SURFACE_RADIUS]; + let _: () = msg_send![root_layer, setMasksToBounds: true]; + let _: () = msg_send![root_layer, setBorderWidth: 0.0f64]; + } + 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 = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.root_view = Some(root as usize); + state.window = None; + 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.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.quality_daemon_checkbox = built_state.quality_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.quality_report_label = built_state.quality_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_permission_indicators(); + start_permission_polling(); + Some(root) + } +} + +// ============================================================================ +// Permission checks / setup readiness +// ============================================================================ + +fn permissions_all_granted() -> bool { + PERMISSION_ORDER + .iter() + .all(|kind| permission_status(*kind) == PermissionStatus::Granted) +} + +fn permission_color(granted: bool) -> Id { + if granted { + ui_colors::status_granted() + } else { + ui_colors::status_denied() + } +} + +fn permission_row_label(kind: PermissionKind) -> &'static str { + kind.title() +} + +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") + } +} + +fn permission_kind_from_tag(tag: isize) -> Option { + if tag < 0 { + return None; + } + PERMISSION_ORDER.get(tag as usize).copied() +} + +fn open_system_settings_security() { + let _ = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security") + .spawn(); +} + +fn handle_permission_action(kind: PermissionKind) { + let idx = kind.index(); + let already_requested = { + let mut state = BOOTSTRAP_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); + refresh_permission_indicators(); + }); + refresh_permission_indicators(); + return; + } + + let granted = request_permission(kind); + if !granted + && matches!( + kind, + PermissionKind::Accessibility | PermissionKind::InputMonitoring + ) + { + open_permission_settings(kind); + } + + refresh_permission_indicators(); +} + +fn keychain_key_is_set(account: &str) -> bool { + std::env::var(account) + .ok() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +fn key_status_text(is_set: bool) -> &'static str { + if is_set { + "Stored in Keychain" + } else { + "Not set" + } +} + +fn key_status_color(is_set: bool) -> Id { + if is_set { + ui_colors::status_granted() + } else { + ui_colors::secondary_label() + } +} + +fn key_status_symbol_name(is_set: bool) -> &'static str { + if is_set { + "checkmark.seal.fill" + } else { + "circle" + } +} + +fn formatting_key_is_set() -> bool { + keychain_key_is_set("LLM_FORMATTING_API_KEY") +} + +unsafe fn update_key_status_indicator(indicator: Id, is_set: bool) { + let _ = + 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)]; + } +} + +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("")]; + unsafe { + update_key_status_indicator(indicator, is_set); + } + indicator +} + +fn update_keychain_status_labels() { + let (llm_icon, llm_label, assist_icon, assist_label) = { + let state = BOOTSTRAP_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, + ) + }; + 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)]; + } + } +} + +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"); + } + unsafe { std::env::remove_var(account) }; + if let Some(ptr) = field_ptr { + unsafe { set_text_field_string(ptr as Id, "") }; + } + update_keychain_status_labels(); +} + +fn start_permission_polling() { + let should_start = { + let mut state = BOOTSTRAP_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 = BOOTSTRAP_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 = BOOTSTRAP_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]; + } + } + } + + if permissions_all_granted() { + mark_setup_done(); + } + + refresh_diagnostics_dashboard(); + }); +} + +unsafe fn build_settings_ui( + root_view: Id, + settings_width: f64, + settings_height: f64, + action_handler: Id, + config: &Config, +) -> BootstrapState { + unsafe { + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + let ns_view = objc_class("NSView"); + let mut state = BootstrapState::default(); + + let settings_width = settings_width.max(SIDEBAR_WIDTH + 240.0); + let settings_height = settings_height.max(280.0); + let body_h = settings_height; + + // Group adjacent glass panes so Tahoe can merge boundaries naturally. + let root_frame = CGRect::new( + &CGPoint::new(0.0, 0.0), + &CGSize::new(settings_width, settings_height), + ); + let root_glass_group = create_glass_effect_container_view(root_frame, 0.0); + let _: () = msg_send![ + root_glass_group, + setAutoresizingMask: 2_isize | 16_isize // Width | Height + ]; + add_subview(root_view, root_glass_group); + + let root_content: Id = msg_send![ns_view, alloc]; + let root_content: Id = msg_send![root_content, initWithFrame: root_frame]; + let _: () = msg_send![ + root_content, + setAutoresizingMask: 2_isize | 16_isize // Width | Height + ]; + let _: bool = set_glass_container_content_view(root_glass_group, 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::BehindWindow, + objc2_app_kit::NSVisualEffectState::Active, + ); + let _: () = msg_send![sidebar_glass, setAlphaValue: SETTINGS_MAX_OPACITY]; + intensify_settings_glass(sidebar_glass); + 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::FullScreenUI, + objc2_app_kit::NSVisualEffectBlendingMode::BehindWindow, + objc2_app_kit::NSVisualEffectState::Active, + ); + let _: () = msg_send![content_glass, setAlphaValue: SETTINGS_MAX_OPACITY]; + intensify_settings_glass(content_glass); + 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 = [ + "Setup", + "Modes & Shortcuts", + "AI & Prompts", + "Audio & Input", + "Quality", + "Diagnostics", + "Advanced", + ]; + let tab_sels = [ + sel!(onTabSetup:), + sel!(onTabModesShortcuts:), + sel!(onTabAiPrompts:), + sel!(onTabAudioInput:), + sel!(onTabQuality:), + sel!(onTabDiagnostics:), + sel!(onTabAdvanced:), + ]; + 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() { + // Group separators: after Setup (index 0) and after Audio & Input (index 3) + 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_SETUP); + 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 * 2.0).max(220.0), + ), + ); + + // --- Setup tab (index 0) --- + let content_width = tab_content_frame.size.width; + let tab_document_frame = CGRect::new( + &CGPoint::new(0.0, 0.0), + &CGSize::new(tab_content_frame.size.width, tab_content_frame.size.height), + ); + let setup_gap = ui_tokens::DENSITY_COMFORTABLE; + let content_h = setup_content_height(tab_content_frame.size.height, setup_gap); + let setup_document_frame = CGRect::new( + &CGPoint::new(0.0, 0.0), + &CGSize::new(content_width, content_h), + ); + + let setup_view: Id = msg_send![ns_view, alloc]; + let setup_view: Id = msg_send![setup_view, initWithFrame: setup_document_frame]; + let setup_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, setup_view); + add_subview(content_container, setup_scroll); + + let pad = ui_tokens::EDGE_PADDING; + let field_w = content_width - pad * 2.0; + let primary = crate::ui_helpers::color_label(); + let secondary = crate::ui_helpers::color_secondary_label(); + let mut y = content_h - SETUP_TOP_OFFSET; + + // ── Permissions ─────────────────────────────────────────────── + let mut perm_labels: [Option; 5] = [None; 5]; + let mut perm_action_buttons: [Option; 5] = [None; 5]; + + let setup_title = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 24.0)), + text: "Setup".to_string(), + font_size: ui_tokens::TITLE_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(setup_view, setup_title); + y -= 24.0 + setup_gap; + + y = add_tafla_header_separator(setup_view, pad, y, field_w); + y -= setup_gap; + + let setup_hint_top = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 16.0)), + text: "Checklist is optional. You can switch tabs any time.".to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(setup_view, setup_hint_top); + y -= 16.0 + setup_gap; + + let permissions_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 20.0)), + text: "Permissions Checklist".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(setup_view, permissions_header); + y -= 20.0 + setup_gap; + + // Let action buttons expand with available width to reduce label clipping + // and keep localized copy readable across larger settings windows. + let permission_button_w = (field_w * 0.30).clamp(PERMISSION_BUTTON_WIDTH, 176.0); + let permission_label_w = (field_w - permission_button_w - 12.0).max(180.0); + + 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)); + + let label = create_label(LabelConfig { + frame: CGRect::new( + &CGPoint::new(pad, y), + &CGSize::new(permission_label_w, 20.0), + ), + text, + font_size: ui_tokens::BODY_FONT_SIZE, + bold: true, + text_color: permission_color(granted), + ..Default::default() + }); + add_subview(setup_view, label); + perm_labels[idx] = Some(label as usize); + + let initial_button_title = + permission_action_title(kind, status, false).unwrap_or("Grant"); + let action_btn = button( + CGRect::new( + &CGPoint::new(content_width - pad - permission_button_w, y - 2.0), + &CGSize::new(permission_button_w, 24.0), + ), + initial_button_title, + ); + let _: () = msg_send![action_btn, setTag: idx as isize]; + button_set_action(action_btn, action_handler, sel!(onPermissionAction:)); + if permission_action_title(kind, status, false).is_none() { + let _: () = msg_send![action_btn, setHidden: true]; + } + add_subview(setup_view, action_btn); + perm_action_buttons[idx] = Some(action_btn as usize); + y -= PERMISSION_ROW_HEIGHT; + } + + let permissions_divider = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 1.0)), + text: String::new(), + background_color: Some(ui_colors::surface_border()), + ..Default::default() + }); + let _: () = msg_send![permissions_divider, setAlphaValue: 0.9f64]; + add_subview(setup_view, permissions_divider); + y -= setup_gap; + + let quick_start_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 20.0)), + text: "Quick Start".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(setup_view, quick_start_header); + y -= 20.0 + setup_gap; + + // ── Quick-start steps ──────────────────────────────────────── + // Make hotkey discovery explicit by showing the current Assistive binding + // (the most "agent-like" mode). + let settings_snapshot = UserSettings::load(); + let assistive_binding = settings_snapshot.mode_binding_for(WorkMode::Assistive); + let step_defs: [(String, objc::runtime::Sel, &str); 3] = [ + ("1) Test mic".to_string(), sel!(onTestMic:), "Test"), + ( + "2) Show agent overlay".to_string(), + sel!(onShowOverlay:), + "Show", + ), + ( + "3) Trigger Assistive".to_string(), + sel!(onHotkeyDone:), + "Done", + ), + ]; + let mut step_status_labels: [Option; 3] = [None; 3]; + + for (i, (label_text, sel, btn_text)) in step_defs.iter().enumerate() { + let step_label = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(240.0, 20.0)), + text: label_text.to_string(), + font_size: ui_tokens::BODY_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(setup_view, step_label); + + let status_lbl = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad + 250.0, y), &CGSize::new(120.0, 20.0)), + text: "pending".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(setup_view, status_lbl); + step_status_labels[i] = Some(status_lbl as usize); + + let step_btn = button( + CGRect::new( + &CGPoint::new(content_width - 100.0, y - 2.0), + &CGSize::new(80.0, 24.0), + ), + btn_text, + ); + button_set_action(step_btn, action_handler, *sel); + add_subview(setup_view, step_btn); + y -= STEP_ROW_HEIGHT; + } + y -= SETUP_POST_STEPS_GAP; + + let checklist_hint = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 16.0)), + text: format!( + "Assistive hotkey: {} (Modes & Shortcuts). Permissions update live as macOS grants access.", + assistive_binding.label() + ), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(setup_view, checklist_hint); + y -= 16.0 + setup_gap; + + let setup_divider = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 1.0)), + text: String::new(), + background_color: Some(ui_colors::surface_border()), + ..Default::default() + }); + let _: () = msg_send![setup_divider, setAlphaValue: 0.9f64]; + add_subview(setup_view, setup_divider); + y -= setup_gap; + + let setup_hint = create_label(LabelConfig { + frame: CGRect::new( + &CGPoint::new(pad, (y - 6.0).max(SETUP_HINT_MIN_Y)), + &CGSize::new(field_w, 16.0), + ), + text: "Hotkeys live in Modes & Shortcuts. Agent provider + prompts live in AI & Prompts. Timing lives in Advanced.".to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(setup_view, setup_hint); + + // --- Modes & Shortcuts 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); + + // --- AI & Prompts tab (index 2) --- + let api_view = build_ai_prompts_tab(action_handler, tab_document_frame, config, &mut state); + let api_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, api_view); + let _: () = msg_send![api_scroll, setHidden: true]; + add_subview(content_container, api_scroll); + + // --- Audio & Input tab (index 3) --- + 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); + + // --- Quality tab (index 4) --- + let quality_view = + build_quality_tab(action_handler, tab_document_frame, config, &mut state); + let quality_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, quality_view); + let _: () = msg_send![quality_scroll, setHidden: true]; + add_subview(content_container, quality_scroll); + + // --- Diagnostics tab (index 5) --- + let diagnostics_view = + build_diagnostics_tab(action_handler, tab_document_frame, config, &mut state); + let diagnostics_scroll = + wrap_tab_content_in_scroll_view(tab_content_frame, diagnostics_view); + let _: () = msg_send![diagnostics_scroll, setHidden: true]; + add_subview(content_container, diagnostics_scroll); + + // --- Advanced tab (index 6) --- + let advanced_view = + build_advanced_tab(action_handler, tab_document_frame, config, &mut state); + let advanced_scroll = wrap_tab_content_in_scroll_view(tab_content_frame, advanced_view); + let _: () = msg_send![advanced_scroll, setHidden: true]; + add_subview(content_container, advanced_scroll); + + // ==================================================================== + // Store state + // ==================================================================== + state.step_labels = step_status_labels; + state.tab_buttons = tab_buttons; + state.content_views = [ + Some(setup_scroll as usize), + Some(keys_scroll as usize), + Some(api_scroll as usize), + Some(audio_scroll as usize), + Some(quality_scroll as usize), + Some(diagnostics_scroll as usize), + Some(advanced_scroll as usize), + ]; + state.active_tab = TAB_SETUP; + state.permission_labels = perm_labels; + state.permission_action_buttons = perm_action_buttons; + state.permission_requested = [false; 5]; + state.permission_polling = false; + 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 { + 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 { + "Setup" => "gearshape", + "Modes & Shortcuts" => "keyboard", + "AI & Prompts" => "text.bubble", + "Audio & Input" => "waveform", + "Quality" => "checkmark.seal", + "Diagnostics" => "stethoscope", + "Advanced" => "slider.horizontal.3", + _ => "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 = BOOTSTRAP_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_QUALITY { + refresh_quality_dashboard(); + } else if index == TAB_DIAGNOSTICS { + refresh_diagnostics_dashboard(); + } else if index == TAB_AI_PROMPTS { + 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!("Bootstrap 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::show_voice_chat_overlay(); + crate::show_agent_tab(); + crate::voice_chat_ui::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_bootstrap_window_closed() { + let mut state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.window = None; + state.window_delegate = None; + state.root_view = None; + clear_bootstrap_ui_state(&mut state); + state.config_cache = None; +} + +pub fn hide_bootstrap_overlay() { + Queue::main().exec_async(|| unsafe { + let (window_ptr, root_ptr) = { + let mut state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.permission_polling = false; + let window_ptr = state.window.take(); + if window_ptr.is_some() { + state.window_delegate = None; + state.root_view = None; + clear_bootstrap_ui_state(&mut state); + (window_ptr, None) + } else { + (None, 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_bootstrap_overlay(); +} + +/// Alias: schedule Settings onboarding window. +pub fn schedule_settings_window() { + schedule_bootstrap(); +} + +/// Show Settings and force-focus the Setup tab. +pub fn show_settings_setup_tab() { + show_bootstrap_overlay(); + switch_tab(TAB_SETUP); +} + +/// Alias: should show Settings onboarding window. +pub fn should_show_settings_onboarding() -> bool { + should_show_setup() +} + +/// Reset embedded Settings view state when the overlay is destroyed. +pub fn reset_embedded_bootstrap_state() { + let mut state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + if state.window.is_some() { + return; + } + state.root_view = None; + state.window_delegate = None; + state.config_cache = None; + clear_bootstrap_ui_state(&mut state); +} + +fn update_step_status(index: usize, text: &str) { + let text = text.to_string(); + Queue::main().exec_async(move || unsafe { + let state = BOOTSTRAP_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 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, + } +} + +fn mode_from_disable_tag(tag: isize) -> Option { + mode_from_tag(tag - MODE_DISABLE_TAG_OFFSET) +} + +fn mode_from_double_ctrl_tag(tag: isize) -> bool { + tag == MODE_DICTATION_DOUBLE_CTRL_TAG +} + +fn mode_label_slot(mode: WorkMode) -> usize { + match mode { + WorkMode::Dictation => 0, + WorkMode::Formatting => 1, + WorkMode::Assistive => 2, + } +} + +fn set_mode_recorder_hint(text: &str, is_error: bool) { + let hint_ptr = { + let state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.keys_recorder_hint_label + }; + let Some(hint_ptr) = hint_ptr else { + return; + }; + 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]; + } +} + +fn refresh_mode_binding_labels() { + let settings = UserSettings::load(); + let state = BOOTSTRAP_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(); + unsafe { + set_text_field_string(label_ptr as Id, &text); + } + } + } +} + +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, + }, + } +} + +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 + ) + } + } +} + +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 +} + +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, + ); +} + +fn apply_recorded_mode_binding(mode: WorkMode, binding: ShortcutBinding) { + apply_mode_binding(mode, binding); +} + +fn recorder_capture_mode() -> Option { + let recorder = MODE_BINDING_RECORDER_STATE + .lock() + .unwrap_or_else(|e| e.into_inner()); + recorder.target_mode +} + +fn recorder_clear_target_mode() { + let mut recorder = MODE_BINDING_RECORDER_STATE + .lock() + .unwrap_or_else(|e| e.into_inner()); + recorder.target_mode = None; +} + +fn handle_mode_binding_recorder_event(event: Id) -> Id { + let Some(mode) = recorder_capture_mode() else { + return event; + }; + + 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() +} + +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; + } + + 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 +} + +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, + ); +} + +fn hotkey_conflicts(_config: &Config) -> Vec { + let settings = UserSettings::load(); + shortcut_registry::detect_hotkey_conflicts(&settings) +} + +fn hotkey_conflict_status_from(conflicts: &[shortcut_registry::HotkeyConflict]) -> (String, bool) { + if conflicts.is_empty() { + return ( + "Mode shortcuts: no conflicts in macOS Keyboard Shortcuts registry.".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!( + "Conflict: {} -> {}{}", + first.gesture.label(), + first.message, + suffix + ), + true, + ) +} + +fn hotkey_conflict_status(config: &Config) -> (String, bool) { + let conflicts = hotkey_conflicts(config); + hotkey_conflict_status_from(&conflicts) +} + +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 conflicts with macOS/global shortcuts:".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 the conflicting mode binding or disable that mode shortcut in Settings.".to_string()); + lines.join("\n") +} + +fn set_hotkey_conflict_details_button_enabled(button_ptr: Option, enabled: bool) { + let Some(button_ptr) = button_ptr else { + return; + }; + unsafe { + let button = button_ptr as Id; + let _: () = msg_send![button, setEnabled: enabled]; + } +} + +fn refresh_hotkey_conflict_indicator() { + let config = Config::load(); + let (label_ptr, button_ptr) = { + let state = BOOTSTRAP_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); +} + +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; + }; + 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]; + } +} + +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 = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.window + }; + + 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]; + } +} + +fn prompt_type_from_index(index: isize) -> &'static str { + if index == 1 { + "assistive" + } else { + "formatting" + } +} + +fn prompt_display_name(prompt_type: &str) -> &'static str { + if prompt_type == "assistive" { + "Assistive" + } else { + "Formatting" + } +} + +fn selected_prompt_type() -> &'static str { + let popup_ptr = { + let state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.prompt_type_popup + }; + let Some(popup_ptr) = popup_ptr else { + return "formatting"; + }; + unsafe { + let popup = popup_ptr as Id; + let idx: isize = msg_send![popup, indexOfSelectedItem]; + prompt_type_from_index(idx) + } +} + +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() + } +} + +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() + }) + } + } +} + +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()) + } + } +} + +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()) + } + } +} + +fn set_prompt_editor_content(text: &str) { + let text_view_ptr = { + let state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.prompt_editor_text_view + }; + let Some(text_view_ptr) = text_view_ptr else { + return; + }; + unsafe { + set_text_view_string(text_view_ptr as Id, text); + } +} + +fn read_prompt_editor_content() -> String { + let text_view_ptr = { + let state = BOOTSTRAP_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(); + }; + unsafe { get_text_view_string(text_view_ptr as Id) } +} + +fn set_prompt_editor_status(text: &str, is_error: bool) { + let status_ptr = { + let state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.prompt_status_label + }; + let Some(status_ptr) = status_ptr else { + return; + }; + 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]; + } +} + +fn refresh_prompt_editor_labels() { + Queue::main().exec_async(move || unsafe { + let (path_ptr, status_ptr) = { + let state = BOOTSTRAP_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()]; + } + }); +} + +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 = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + ( + state.quality_available_label, + state.quality_pending_label, + state.quality_last_check_label, + state.quality_report_label, + state.quality_open_report_button, + ) + }; + + let daemon_state = crate::quality_loop::read_daemon_state(); + + if let Some(ptr) = available_label { + let label = ptr as Id; + set_text_field_string( + label, + if daemon_state.available { + "Available" + } else { + "Unavailable" + }, + ); + let _: () = msg_send![ + label, + setTextColor: if daemon_state.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, &quality_report_text(&daemon_state)); + } + + if let Some(ptr) = open_report_button { + let _: () = msg_send![ptr as Id, setEnabled: quality_report_exists(&daemon_state)]; + } + }); +} + +fn refresh_diagnostics_dashboard() { + Queue::main().exec_async(move || unsafe { + let (permission_labels, conflict_label, conflict_button, status_label) = { + let state = BOOTSTRAP_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 +// ============================================================================ + +unsafe fn build_modes_shortcuts_tab( + action_handler: Id, + frame: core_graphics::geometry::CGRect, + config: &Config, + state: &mut BootstrapState, +) -> Id { + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + 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: "Modes & Shortcuts".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-first shortcut model. Each mode has one binding you can customize or disable." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, subtitle); + y -= 16.0 + gap; + + let usage_hint = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 28.0)), + text: "Usage: hold bindings record while held (release to send). Double-tap bindings are hands-free: pause to auto-send an utterance, double-tap again 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 change_button_w = 96.0; + let disable_button_w = 72.0; + let button_gap = 8.0; + let change_x = pad + content_w - change_button_w; + let disable_x = change_x - button_gap - 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); + + let change_button = create_button( + CGRect::new( + &CGPoint::new(change_x, y - 2.0), + &CGSize::new(change_button_w, 24.0), + ), + "\u{2328} Customize", + button_style::GLASS, + ); + let _: () = msg_send![change_button, setTag: tag]; + button_set_action(change_button, action_handler, sel!(onModeBindingChange:)); + add_subview(container, change_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: "Shortcut recorder: click [⌨ Customize], press Fn/Ctrl/Option. Esc cancels." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, recorder_hint); + y -= 16.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: "Capture timing controls are available in Advanced.".to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, api_hint); + + 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 +// ============================================================================ + +unsafe fn build_ai_prompts_tab( + action_handler: Id, + frame: core_graphics::geometry::CGRect, + _config: &Config, + state: &mut BootstrapState, +) -> Id { + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + 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 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: "AI & Prompts".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: "Runtime AI endpoints plus an in-app prompt editor for formatting + assistive (agent) modes." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, subtitle); + y -= 16.0 + gap; + + 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 llm_endpoint_val = std::env::var("LLM_FORMATTING_ENDPOINT") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_default(); + let llm_endpoint_field = create_text_input( + CGRect::new( + &CGPoint::new(pad, y), + &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), + ), + "Endpoint (e.g. https://api.libraxis.cloud/v1/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_default(); + let llm_model_field = create_text_input( + CGRect::new( + &CGPoint::new(pad, y), + &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), + ), + "Model (e.g. programmer)", + &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), + ), + "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; + + // Section divider + extra gap before Assistive AI section + y = add_tafla_header_separator(container, pad, y, content_w); + y -= ui_tokens::SECTION_GAP; + + let assist_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), + text: "Assistive AI (agent chat)".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: true, + text_color: secondary, + ..Default::default() + }); + add_subview(container, assist_header); + y -= 18.0 + gap; + + let assist_endpoint_val = std::env::var("LLM_ASSISTIVE_ENDPOINT").unwrap_or_default(); + let assist_endpoint_field = create_text_input( + CGRect::new( + &CGPoint::new(pad, y), + &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), + ), + "Endpoint (e.g. https://api.libraxis.cloud/v1/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").unwrap_or_default(); + let assist_model_field = create_text_input( + CGRect::new( + &CGPoint::new(pad, y), + &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), + ), + "Model (e.g. programmer)", + &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), + ), + "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(frame.size.width - pad - 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); + y -= 24.0 + gap; + + let runtime_hint = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w - 98.0, 16.0)), + text: "Save AI persists endpoint/model/key values. Prompt content is edited below." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, runtime_hint); + y -= 16.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; + + 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 editor_height = 220.0; + let editor_y = (y - editor_height).max(28.0); + 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); + + y = editor_y - gap; + let prompt_status = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), + 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 +// ============================================================================ + +unsafe fn build_audio_input_tab( + action_handler: Id, + frame: core_graphics::geometry::CGRect, + config: &Config, +) -> Id { + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + 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(); + + // Section title + let title = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 24.0)), + text: "Audio & Input".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, formatting, and interaction behavior.".to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, subtitle); + 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; + + // AI Formatting toggle + 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("Use LLM to clean up transcriptions"), + tag: None, + gap, + }, + ); + + // Formatting level dropdown + 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")]; + // Pre-select based on current setting + 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, // default to Medium + }; + 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; + + // 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, + }, + ); + // 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 +} + +unsafe fn build_advanced_tab( + action_handler: Id, + frame: core_graphics::geometry::CGRect, + config: &Config, + state: &mut BootstrapState, +) -> Id { + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + unsafe { + let ns_view = objc_class("NSView"); + let env_snapshot: HashMap = std::env::vars().collect(); + + 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: "Advanced".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: "Low-level tuning for shortcut timing and transcription pipeline experiments." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, subtitle); + y -= 16.0 + gap; + + let capture_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), + text: "Capture Timing".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 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 + gap; + + let advanced_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![advanced_divider, setAlphaValue: 0.9f64]; + add_subview(container, advanced_divider); + y -= ui_tokens::SECTION_GAP; + + let voice_lab_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), + text: "Voice Lab Overrides".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(container, voice_lab_header); + y -= 18.0 + gap; + + let apply_hint = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 14.0)), + text: "Apply: press Enter or click outside the field.".to_string(), + font_size: 10.0, + text_color: secondary, + ..Default::default() + }); + add_subview(container, apply_hint); + y -= 14.0 + gap; + + for (idx, spec) in VOICE_LAB_FIELDS.iter().enumerate() { + match spec.kind { + VoiceLabFieldKind::Bool => { + let checked = + parse_env_bool(&voice_lab_value_from_snapshot(spec, &env_snapshot)); + let title = format!("{} ({})", spec.label, spec.key); + let _check = add_toggle_row( + container, + action_handler, + pad, + &mut y, + content_w, + secondary, + ToggleRowSpec { + title: &title, + checked, + action: sel!(onVoiceLabToggleChanged:), + description: Some(spec.description), + tag: Some(idx as isize), + gap, + }, + ); + } + VoiceLabFieldKind::Value => { + let label = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), + text: format!("{} ({})", spec.label, spec.key), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, label); + y -= 16.0 + gap; + + let current = voice_lab_value_from_snapshot(spec, &env_snapshot); + let field = create_text_input( + CGRect::new( + &CGPoint::new(pad, y), + &CGSize::new(content_w, SETTINGS_INPUT_HEIGHT), + ), + spec.default_value, + ¤t, + ); + style_paper_input(field); + let _: () = msg_send![field, setTag: idx as isize]; + button_set_action(field, action_handler, sel!(onVoiceLabFieldChanged:)); + add_subview(container, field); + y -= SETTINGS_INPUT_HEIGHT + gap; + + let desc = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), + text: spec.description.to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, desc); + y -= 16.0 + gap; + } + } + } + + container + } +} + +// ============================================================================ +// Quality tab +// ============================================================================ + +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() + } +} + +fn quality_report_exists(state: &crate::quality_loop::QualityDaemonState) -> bool { + state + .latest_report + .as_ref() + .map(|dir| PathBuf::from(dir).join("index.html").exists()) + .unwrap_or(false) +} + +fn quality_report_text(state: &crate::quality_loop::QualityDaemonState) -> 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(), + } +} + +unsafe fn build_quality_tab( + action_handler: Id, + frame: core_graphics::geometry::CGRect, + _config: &Config, + state: &mut BootstrapState, +) -> Id { + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + 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 field_w = content_w; + 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: "Quality".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: "Quality daemon dashboard with live status, pending mismatch signal, and report actions." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, subtitle); + y -= 16.0 + gap; + + let quality_on = UserSettings::load() + .quality_daemon_autostart + .unwrap_or_else(|| { + std::env::var("CODESCRIBE_AUTOSTART_QUALITY_DAEMON") + .map(|v| parse_env_bool(&v)) + .unwrap_or(false) + }); + let quality_check = add_toggle_row( + container, + action_handler, + pad, + &mut y, + field_w, + secondary, + ToggleRowSpec { + title: "Auto-tune transcription quality (recommended)", + checked: quality_on, + action: sel!(onQualityDaemonToggled:), + description: Some("Runs quality analysis every 30 minutes in the background."), + tag: None, + gap, + }, + ); + state.quality_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, + field_w, + secondary, + ToggleRowSpec { + title: "Ultra Quality (slow final pass)", + checked: ultra_on, + action: sel!(onUltraQualityToggled:), + description: Some("Runs a slower explicit final pass for maximum text quality."), + tag: None, + gap, + }, + ); + state.ultra_quality_checkbox = Some(ultra_check as usize); + + let divider = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(field_w, 1.0)), + text: String::new(), + background_color: Some(ui_colors::surface_border()), + ..Default::default() + }); + let _: () = msg_send![divider, setAlphaValue: 0.9f64]; + add_subview(container, divider); + y -= ui_tokens::SECTION_GAP; + + let dashboard_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), + text: "Daemon State".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(container, dashboard_header); + y -= 18.0 + gap; + + let daemon_state = crate::quality_loop::read_daemon_state(); + + let add_metric_row = |container: Id, + y: &mut f64, + label: &str, + value: &str, + value_color: Id, + width: f64, + pad: f64, + gap: f64| + -> usize { + let label_view = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, *y), &CGSize::new(124.0, 18.0)), + text: label.to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + text_color: crate::ui_helpers::color_secondary_label(), + ..Default::default() + }); + add_subview(container, label_view); + + let value_view = create_label(LabelConfig { + frame: CGRect::new( + &CGPoint::new(pad + 126.0, *y), + &CGSize::new((width - 126.0).max(120.0), 18.0), + ), + text: value.to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + text_color: value_color, + ..Default::default() + }); + add_subview(container, value_view); + *y -= 18.0 + gap; + value_view as usize + }; + + let available_text = if daemon_state.available { + "Available" + } else { + "Unavailable" + }; + let available_color = if daemon_state.available { + ui_colors::status_granted() + } else { + ui_colors::status_warning() + }; + state.quality_available_label = Some(add_metric_row( + container, + &mut y, + "Availability:", + available_text, + available_color, + content_w, + pad, + gap, + )); + state.quality_pending_label = Some(add_metric_row( + container, + &mut y, + "Pending:", + &daemon_state.pending_mismatches.to_string(), + if daemon_state.pending_mismatches > 0 { + ui_colors::status_warning() + } else { + secondary + }, + content_w, + pad, + gap, + )); + state.quality_last_check_label = Some(add_metric_row( + container, + &mut y, + "Last check:", + &quality_last_check_text(&daemon_state.last_check), + secondary, + content_w, + pad, + gap, + )); + state.quality_report_label = Some(add_metric_row( + container, + &mut y, + "Latest report:", + &quality_report_text(&daemon_state), + secondary, + content_w, + pad, + gap, + )); + + 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: quality_report_exists(&daemon_state)]; + add_subview(container, open_report_btn); + state.quality_open_report_button = Some(open_report_btn as usize); + + container + } +} + +// ============================================================================ +// Diagnostics tab +// ============================================================================ + +fn permission_status_text(status: PermissionStatus) -> &'static str { + match status { + PermissionStatus::Granted => "Granted", + PermissionStatus::Denied => "Denied", + PermissionStatus::NotDetermined => "Not determined", + } +} + +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(), + } +} + +unsafe fn build_diagnostics_tab( + action_handler: Id, + frame: core_graphics::geometry::CGRect, + config: &Config, + state: &mut BootstrapState, +) -> Id { + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + 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: "Diagnostics".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: "Permission matrix, hotkey conflict clarity, and one-click diagnostics copy." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, subtitle); + y -= 16.0 + gap; + + let matrix_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), + text: "Permission Matrix".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(container, matrix_header); + y -= 18.0 + gap; + + let mut diagnostics_permission_labels: [Option; 5] = [None; 5]; + for kind in PERMISSION_ORDER { + let idx = kind.index(); + let status = permission_status(kind); + + let name_label = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(188.0, 20.0)), + text: permission_row_label(kind).to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, name_label); + + let value_label = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad + 192.0, y), &CGSize::new(200.0, 20.0)), + text: permission_status_text(status).to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + text_color: permission_status_color(status), + ..Default::default() + }); + add_subview(container, value_label); + diagnostics_permission_labels[idx] = Some(value_label as usize); + + y -= 20.0 + gap; + } + state.diagnostics_permission_labels = diagnostics_permission_labels; + + let matrix_actions_y = y - 2.0; + let refresh_matrix_btn = create_button( + CGRect::new( + &CGPoint::new(pad, matrix_actions_y), + &CGSize::new(124.0, 24.0), + ), + "Refresh matrix", + button_style::GLASS, + ); + button_set_action( + refresh_matrix_btn, + action_handler, + sel!(onDiagnosticsRefresh:), + ); + add_subview(container, refresh_matrix_btn); + + let open_settings_btn = create_button( + CGRect::new( + &CGPoint::new(pad + 134.0, matrix_actions_y), + &CGSize::new(154.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); + + let copy_diag_btn = create_button( + CGRect::new( + &CGPoint::new(pad + 298.0, matrix_actions_y), + &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); + y -= 24.0 + gap; + + let 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![divider, setAlphaValue: 0.9f64]; + add_subview(container, divider); + y -= ui_tokens::SECTION_GAP; + + let conflicts_header = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 18.0)), + text: "Hotkey Conflicts".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: true, + text_color: primary, + ..Default::default() + }); + add_subview(container, conflicts_header); + y -= 18.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); + 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 + 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); + state.diagnostics_conflict_details_button = Some(conflict_details_button as usize); + y -= 28.0 + gap; + + let status_label = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(pad, y), &CGSize::new(content_w, 16.0)), + text: "Use Copy diagnostics to capture a full environment + permission report." + .to_string(), + font_size: ui_tokens::MICRO_FONT_SIZE, + text_color: secondary, + ..Default::default() + }); + add_subview(container, status_label); + state.diagnostics_status_label = Some(status_label as usize); + + container + } +} + +// ============================================================================ +// Settings handlers +// ============================================================================ + +pub(super) extern "C" fn on_mode_binding_change( + _this: &Object, + _cmd: objc::runtime::Sel, + sender: Id, +) { + 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) { + 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, +) { + 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_formatting_level_changed( + _this: &Object, + _cmd: objc::runtime::Sel, + sender: Id, +) { + 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, +) { + 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, +) { + 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) { + 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 = BOOTSTRAP_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 = BOOTSTRAP_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(); + 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); + } + 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_quality_report( + _this: &Object, + _cmd: objc::runtime::Sel, + _sender: Id, +) { + if !crate::quality_loop::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 = BOOTSTRAP_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 { + 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 { + 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) { + 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 = BOOTSTRAP_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, +) { + 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 = BOOTSTRAP_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) { + 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, +) { + 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, +) { + 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_volume_changed(_this: &Object, _cmd: objc::runtime::Sel, sender: Id) { + 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(); + } +} + +pub(super) extern "C" fn on_voice_lab_toggle_changed( + _this: &Object, + _cmd: objc::runtime::Sel, + sender: Id, +) { + unsafe { + let tag: isize = msg_send![sender, tag]; + let Some(spec) = voice_lab_spec_from_tag(tag) else { + return; + }; + if spec.kind != VoiceLabFieldKind::Bool { + return; + } + let checked_state: isize = msg_send![sender, state]; + let enabled = checked_state == 1; + info!("Settings: {} -> {}", spec.key, enabled); + let config = Config::load(); + let _ = config.save_to_env(spec.key, if enabled { "1" } else { "0" }); + } +} + +pub(super) extern "C" fn on_voice_lab_field_changed( + _this: &Object, + _cmd: objc::runtime::Sel, + sender: Id, +) { + unsafe { + let tag: isize = msg_send![sender, tag]; + let Some(spec) = voice_lab_spec_from_tag(tag) else { + return; + }; + if spec.kind != VoiceLabFieldKind::Value { + return; + } + + let ns_val: Id = msg_send![sender, stringValue]; + let cstr: *const std::ffi::c_char = msg_send![ns_val, UTF8String]; + if cstr.is_null() { + return; + } + let value = std::ffi::CStr::from_ptr(cstr) + .to_string_lossy() + .trim() + .to_string(); + let Some(validated) = validate_voice_lab_value(spec, &value) else { + warn!( + "Settings: rejected invalid value for {} -> {:?}", + spec.key, value + ); + set_text_field_string(sender, &voice_lab_value(spec)); + return; + }; + info!("Settings: {} -> {}", spec.key, validated); + let config = Config::load(); + let _ = config.save_to_env(spec.key, &validated); + set_text_field_string(sender, &validated); + } +} + +// ============================================================================ +// Assistive AI + Quality daemon + Permissions handlers +// ============================================================================ + +pub(super) extern "C" fn on_assistive_endpoint_changed( + _this: &Object, + _cmd: objc::runtime::Sel, + sender: Id, +) { + 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, +) { + 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, +) { + 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 = BOOTSTRAP_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_quality_daemon_toggled( + _this: &Object, + _cmd: objc::runtime::Sel, + sender: Id, +) { + 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( + "CODESCRIBE_AUTOSTART_QUALITY_DAEMON", + if enabled { "1" } else { "0" }, + ); + refresh_quality_dashboard(); + } +} + +pub(super) extern "C" fn on_ultra_quality_toggled( + _this: &Object, + _cmd: objc::runtime::Sel, + sender: Id, +) { + 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, +) { + 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(); +} + +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 voice_lab_validation_rejects_invalid_numeric() { + let spec = VoiceLabFieldSpec { + key: "CODESCRIBE_BUFFERED_INTERIM_SEC", + label: "Interim cadence", + default_value: "1.2", + description: "", + kind: VoiceLabFieldKind::Value, + }; + assert!(validate_voice_lab_value(&spec, "abc").is_none()); + assert!(validate_voice_lab_value(&spec, "0.1").is_none()); + assert_eq!( + validate_voice_lab_value(&spec, "4.5"), + Some("4.5".to_string()) + ); + } + + #[test] + fn voice_lab_validation_handles_empty_by_default_policy() { + let non_empty = VoiceLabFieldSpec { + key: "WHISPER_MODEL", + label: "Whisper cloud model", + default_value: "mlx-community/whisper-large-v3-mlx", + description: "", + kind: VoiceLabFieldKind::Value, + }; + let allow_empty = VoiceLabFieldSpec { + key: "CUSTOM_EMPTY_KEY", + label: "Custom empty key", + default_value: "", + description: "", + kind: VoiceLabFieldKind::Value, + }; + assert!(validate_voice_lab_value(&non_empty, " ").is_none()); + assert_eq!( + validate_voice_lab_value(&allow_empty, " "), + Some(String::new()) + ); + } + + #[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] + #[serial] + #[cfg(target_os = "macos")] + fn attach_settings_view_builds_root_view() { + if std::env::var("CODESCRIBE_UI_TESTS").is_err() { + return; + } + 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_bootstrap_state(); + let state = BOOTSTRAP_STATE.lock().unwrap_or_else(|e| e.into_inner()); + assert!(state.root_view.is_none()); + } + } +} diff --git a/app/ui/mod.rs b/app/ui/mod.rs index b4d24aea..49f77f8a 100644 --- a/app/ui/mod.rs +++ b/app/ui/mod.rs @@ -1,3 +1,5 @@ +pub mod bootstrap; +pub mod onboarding; pub mod overlay; pub mod shared; pub mod tray; @@ -11,6 +13,7 @@ use core_foundation::base::TCFType; use core_foundation::string::CFString; use core_graphics::geometry::{CGPoint, CGRect, CGSize}; use dispatch::Queue; +use objc::runtime::Sel; use objc::runtime::{Class, Object}; use objc::{msg_send, sel, sel_impl}; use objc2_app_kit::{ @@ -19,6 +22,7 @@ use objc2_app_kit::{ 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; @@ -35,13 +39,14 @@ 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: AXId); + 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"; @@ -63,7 +68,7 @@ pub enum BadgeMode { Toggle, /// Processing: Orange - "transkrybuję/formatuję" Processing, - /// Assistive mode (Ctrl+Shift): Purple with glow - "AI słucha" + /// AI mode (Chat/Selection): Purple with glow - "AI słucha" Assistive, } @@ -142,6 +147,7 @@ struct HoldBadgeState { window: Option, // Store as usize to make it Send timer_running: bool, config: HoldBadgeConfig, + last_position: (f64, f64), } lazy_static::lazy_static! { @@ -149,6 +155,7 @@ lazy_static::lazy_static! { window: None, timer_running: false, config: HoldBadgeConfig::default(), + last_position: (f64::NAN, f64::NAN), })); } @@ -329,6 +336,132 @@ pub fn get_caret_position() -> Option<(f64, f64)> { } } +/// 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(); @@ -384,7 +517,9 @@ unsafe fn create_badge_window(config: &HoldBadgeConfig) -> Id { let _: () = msg_send![window, setBackgroundColor: clear_color_ptr]; let _: () = msg_send![window, setIgnoresMouseEvents: true]; let _: () = msg_send![window, setLevel: NS_STATUS_WINDOW_LEVEL]; - let collection_behavior = NSWindowCollectionBehavior::CanJoinAllSpaces; + // 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 @@ -476,19 +611,6 @@ unsafe fn create_cg_color(r: f64, g: f64, b: f64, a: f64) -> *const std::ffi::c_ } } -/// Update the badge window position -unsafe fn update_badge_position(window: Id, config: &HoldBadgeConfig) { - let (x, y) = get_badge_position(); - let adjusted_x = x + config.offset.0; - let adjusted_y = y + config.offset.1; - - let new_origin = CGPoint { - x: adjusted_x, - y: adjusted_y, - }; - let _: () = msg_send![window, setFrameOrigin: new_origin]; -} - /// Show the hold badge and start position tracking (default: Hold mode) pub fn show_hold_badge() { show_hold_badge_with_config(HoldBadgeConfig::default()); @@ -503,12 +625,15 @@ pub fn show_badge_for_mode(mode: BadgeMode) { fn show_hold_badge_impl(config: HoldBadgeConfig) { debug!("Showing hold badge (diameter={})", config.diameter); unsafe { - let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - - // Hide existing badge if any - if let Some(window_ptr) = state.window { + // 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); - state.window = None; } // Create new badge window (MUST be on main thread) @@ -521,31 +646,52 @@ fn show_hold_badge_impl(config: HoldBadgeConfig) { let content_view: Id = msg_send![window, contentView]; let _: () = msg_send![content_view, setNeedsDisplay: true]; - state.window = Some(window as usize); - state.config = config.clone(); - state.timer_running = true; - - // Start position update timer (and pulse animation if needed) - let update_interval = config.update_interval_ms; - let should_pulse = config.mode.should_pulse(); - - thread::spawn(move || { - let mut pulse_phase: f64 = 0.0; - let pulse_speed = 0.15; // Radians per update cycle + // 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) + }; - while BADGE_STATE - .lock() - .unwrap_or_else(|e| e.into_inner()) - .timer_running - { - thread::sleep(Duration::from_millis(update_interval)); + // 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 state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if !state.timer_running { - break; - } + let Some(window_ptr) = window_ptr else { + continue; + }; - if let Some(window_ptr) = state.window { // Calculate pulse opacity (sine wave from 0.4 to 1.0) let pulse_opacity = if should_pulse { pulse_phase += pulse_speed; @@ -554,13 +700,45 @@ fn show_hold_badge_impl(config: HoldBadgeConfig) { 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; - let state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - update_badge_position(window, &state.config); + if position_changed { + let new_origin = CGPoint { + x: adjusted_x, + y: adjusted_y, + }; + let _: () = msg_send![window, setFrameOrigin: new_origin]; + } - // Update opacity for pulsing effect if should_pulse { let content_view: Id = msg_send![window, contentView]; if !content_view.is_null() { @@ -578,8 +756,8 @@ fn show_hold_badge_impl(config: HoldBadgeConfig) { } }); } - } - }); + }); + } } } @@ -607,19 +785,18 @@ 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 - Queue::main().exec_async(|| { - let mut state = BADGE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(window_ptr) = state.window { + // 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); } - state.window = None; } }); } @@ -627,6 +804,62 @@ pub fn hide_hold_badge() { /// 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 @@ -635,44 +868,158 @@ pub fn set_dock_icon() { debug!("Setting Dock icon programmatically"); Queue::main().exec_async(|| unsafe { - // Get NSApplication shared instance - let ns_app_class = Class::get("NSApplication").expect("NSApplication class not found"); + 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!("Failed to get NSApplication sharedApplication"); + warn!("set_dock_icon: NSApplication sharedApplication is null"); return; } - // Create NSData from embedded bytes - let ns_data_class = Class::get("NSData").expect("NSData class not found"); - let ns_data: Id = msg_send![ - ns_data_class, - dataWithBytes: DOCK_ICON_BYTES.as_ptr() - length: DOCK_ICON_BYTES.len() - ]; + set_dock_icon_for_app(shared_app); + }); +} - if ns_data.is_null() { - warn!("Failed to create NSData from icon bytes"); +/// 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; } - // Create NSImage from NSData - let ns_image_class = Class::get("NSImage").expect("NSImage class not found"); - let ns_image: Id = msg_send![ns_image_class, alloc]; - let ns_image: Id = msg_send![ns_image, initWithData: ns_data]; + 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; + } - if ns_image.is_null() { - warn!("Failed to create NSImage from icon data"); - return; + debug!( + "Dock activation policy changed: {} -> {}", + dock_activation_policy_name(current_policy), + dock_activation_policy_name(target_policy), + ); } - // Set as application icon - let _: () = msg_send![shared_app, setApplicationIconImage: ns_image]; - debug!("Dock icon set successfully"); + 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::*; @@ -699,4 +1046,23 @@ mod tests { 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/mod_decls.rs b/app/ui/mod_decls.rs index 61920fd1..54399514 100644 --- a/app/ui/mod_decls.rs +++ b/app/ui/mod_decls.rs @@ -1,3 +1,4 @@ +pub mod onboarding; pub mod overlay; pub mod shared; pub mod tray; diff --git a/app/ui/onboarding/mod.rs b/app/ui/onboarding/mod.rs new file mode 100644 index 00000000..1db560c4 --- /dev/null +++ b/app/ui/onboarding/mod.rs @@ -0,0 +1,1946 @@ +mod steps; + +pub(crate) use self::steps::PermissionKind; +use self::steps::{TOTAL_STEPS, WizardStep, step_for_index}; +use crate::config::{Config, ShortcutBinding, UserSettings, WorkMode, keychain}; +use crate::os::hotkeys; +use crate::os::permissions::{self, PermissionStatus}; +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, set_hidden, set_text_field_string, window_close, window_show, +}; +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 objc2_app_kit::{ + NSBackingStoreType, NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, + NSWindowButton, NSWindowCollectionBehavior, NSWindowStyleMask, +}; +use std::fs; +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; +use std::path::PathBuf; +use std::sync::{LazyLock, Mutex, OnceLock}; +use std::thread; +use std::time::Duration; +use tracing::{info, warn}; + +// Type alias for Objective-C object pointers +pub type Id = *mut Object; + +const WINDOW_WIDTH: f64 = 720.0; +const WINDOW_HEIGHT: f64 = 540.0; +const FULL_DISK_STEP_INDEX: usize = 5; +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_RECHECK_READY: &str = "\u{25CB} Ready To Recheck"; +const STATUS_RECHECK_PENDING: &str = "\u{2715} Not Granted Yet"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum LanguageChoice { + #[default] + English, + Polish, +} + +impl LanguageChoice { + fn label(self) -> &'static str { + match self { + Self::English => "English", + Self::Polish => "Polish", + } + } + + fn value(self) -> &'static str { + match self { + Self::English => "en", + Self::Polish => "pl", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum HotkeyModeChoice { + HoldToTalk, + Toggle, + #[default] + Both, +} + +impl HotkeyModeChoice { + fn label(self) -> &'static str { + match self { + Self::HoldToTalk => "Dictation (Hold)", + Self::Toggle => "Hands-off (Toggle)", + Self::Both => "Hybrid", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum PermissionUiStatus { + #[default] + NotDetermined, + Granted, + Denied, +} + +#[derive(Clone, Copy, Default)] +struct UiRefs { + sidebar_step_labels: [Option; TOTAL_STEPS], + icon_label: Option, + title_label: Option, + description_label: Option, + status_label: Option, + instruction_label: Option, + step_counter_label: Option, + primary_button: Option, + back_button: Option, + skip_button: Option, + language_view: Option, + language_en_radio: Option, + language_pl_radio: Option, + api_view: Option, + api_key_field: Option, + api_hint_label: Option, + hotkey_view: Option, + hotkey_hold_radio: Option, + hotkey_toggle_radio: Option, + hotkey_both_radio: Option, + summary_view: Option, + summary_permission_labels: [Option; 5], + summary_config_label: Option, +} + +struct OnboardingState { + window: Option, + window_delegate: Option, + action_handler: Option, + step_index: usize, + language: LanguageChoice, + hotkey_mode: HotkeyModeChoice, + requested_permissions: [bool; 5], + permission_states: [PermissionUiStatus; 5], + scheduled_auto_advance_step: Option, + full_disk_polling: bool, + closing_via_finish: bool, + api_key_configured: bool, + 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(), + } + } +} + +static ONBOARDING_STATE: LazyLock> = + LazyLock::new(|| Mutex::new(OnboardingState::default())); + +static ACTION_HANDLER_CLASS: OnceLock<&'static Class> = OnceLock::new(); +static WINDOW_DELEGATE_CLASS: OnceLock<&'static Class> = OnceLock::new(); + +pub(crate) const PERMISSION_ORDER: [PermissionKind; 5] = [ + PermissionKind::Microphone, + PermissionKind::Accessibility, + PermissionKind::InputMonitoring, + PermissionKind::ScreenRecording, + PermissionKind::FullDiskAccess, +]; + +fn onboarding_done_path() -> PathBuf { + Config::config_dir().join("onboarding_done") +} + +fn onboarding_progress_path() -> PathBuf { + Config::config_dir().join("onboarding_progress") +} + +fn onboarding_lock_path() -> PathBuf { + Config::config_dir().join("onboarding_session.lock") +} + +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)) +} + +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()); +} + +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) + }) +} + +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) +} + +fn acquire_onboarding_lock() -> bool { + let path = onboarding_lock_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + + let pid = std::process::id(); + + let try_create = || -> std::io::Result<()> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&path)?; + let _ = write!(file, "{pid}"); + Ok(()) + }; + + match try_create() { + Ok(()) => return true, + Err(e) if e.kind() != ErrorKind::AlreadyExists => { + warn!("Onboarding: failed to acquire lock: {e}"); + return false; + } + Err(_) => {} + } + + let existing_pid = fs::read_to_string(&path) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .unwrap_or(0); + + if existing_pid > 0 && existing_pid != pid && process_is_alive(existing_pid) { + warn!( + "Onboarding: lock is held by live process pid={existing_pid}, skipping duplicate wizard" + ); + return false; + } + + match fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => { + warn!("Onboarding: failed to remove stale lock: {e}"); + return false; + } + } + + match try_create() { + Ok(()) => true, + Err(e) => { + warn!("Onboarding: failed to acquire lock: {e}"); + false + } + } +} + +fn release_onboarding_lock() { + let _ = fs::remove_file(onboarding_lock_path()); +} + +pub fn should_show_onboarding() -> bool { + crate::ui::bootstrap::should_show_setup() +} + +fn mark_onboarding_done() { + clear_onboarding_progress(); + let path = onboarding_done_path(); + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(path, "done"); +} + +pub fn show_onboarding_wizard() { + if !should_show_onboarding() { + return; + } + if !acquire_onboarding_lock() { + return; + } + + if is_main_thread() { + launch_onboarding_window(); + } else { + Queue::main().exec_async(move || { + launch_onboarding_window(); + }); + } +} + +fn launch_onboarding_window() { + 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(); + } + } +} + +fn is_main_thread() -> bool { + unsafe { + let ns_thread = Class::get("NSThread").unwrap(); + msg_send![ns_thread, isMainThread] + } +} + +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() + }) +} + +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]; + } + } +} + +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 { + window_show(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: [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(); + window_show(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)), + "Enter your LLM 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.".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 + } +} + +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 guide you through permissions and setup so CodeScribe works perfectly 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 { + 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, + if requested { + "Recheck" + } else { + "Open Settings" + }, + ); + set_hidden_if_present(ui.skip_button, false); + set_button_title_if_present( + ui.skip_button, + if requested { "Continue Anyway" } else { "Skip" }, + ); + } else { + set_button_title_if_present( + ui.primary_button, + 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 transcription language. You can 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 API Key (Optional)"); + set_text_if_present( + ui.description_label, + "Use your LLM API key for AI formatting and assistant features.", + ); + 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 (Offline)"); + } + WizardStep::HotkeyMode => { + set_text_if_present(ui.icon_label, "HOTKEY"); + set_text_if_present(ui.title_label, "Customize Mode Shortcuts"); + set_text_if_present( + ui.description_label, + "Mode first, keys second. Pick a starter profile now; you can refine each mode shortcut later in Settings.", + ); + 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"); + set_text_if_present( + ui.description_label, + "Review your setup. You can always adjust these settings later.", + ); + 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 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), + } +} + +fn handle_back_action() { + retreat_step(); +} + +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()); + if state.permission_states[idx] == PermissionUiStatus::Granted { + drop(state); + advance_step(); + return; + } + + already_requested = state.requested_permissions[idx]; + state.requested_permissions[idx] = true; + step_to_persist = state.step_index; + } + + if kind == PermissionKind::FullDiskAccess && already_requested { + 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 { + 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); + } + + 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(); + } +} + +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_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 granted = 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); + granted = state.permission_states[idx] == PermissionUiStatus::Granted; + if granted { + state.full_disk_polling = false; + } + should_render = true; + } else { + state.full_disk_polling = false; + } + } + + if should_render { + render_current_step(); + } + if granted { + 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, "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()); +} + +fn finish_onboarding(completed: bool) { + if completed { + 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(); + } +} + +fn initial_language_choice() -> LanguageChoice { + let settings = UserSettings::load(); + match settings.whisper_language.as_deref() { + Some("pl") => LanguageChoice::Polish, + _ => LanguageChoice::English, + } +} + +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, + } +} + +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(), + } +} + +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 + } + } + } +} + +pub(crate) 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(crate) fn request_permission(kind: PermissionKind) -> bool { + match kind { + PermissionKind::Microphone => { + let result = permissions::request_microphone(); + if !result { + open_permission_settings(kind); + } + result + } + PermissionKind::Accessibility => permissions::request_accessibility(), + PermissionKind::InputMonitoring => permissions::request_input_monitoring(), + PermissionKind::ScreenRecording => { + let result = permissions::request_screen_recording(); + if !result { + open_permission_settings(kind); + } + result + } + PermissionKind::FullDiskAccess => permissions::request_full_disk_access(), + } +} + +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]; + } + } + } +} + +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 + } +} + +fn set_text_if_present(ptr: Option, text: &str) { + unsafe { + if let Some(value) = ptr { + set_text_field_string(value as Id, text); + } + } +} + +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)]; + } + } +} + +fn set_hidden_if_present(ptr: Option, hidden: bool) { + unsafe { + if let Some(value) = ptr { + set_hidden(value as Id, hidden); + } + } +} + +fn set_label_color_if_present(ptr: Option, color: Id) { + unsafe { + if let Some(value) = ptr { + let _: () = msg_send![value as Id, setTextColor: color]; + } + } +} + +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 }]; + } + } +} + +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 }]; + } + } +} + +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 { + "Configured" + } else { + "Skipped (Offline mode)" + }; + + set_text_if_present( + ui.summary_config_label, + &format!( + "Language: {}\nAPI key: {}\nMode profile: {}", + language.label(), + api_status, + hotkey_mode.label() + ), + ); +} + +fn permission_instruction_text( + kind: PermissionKind, + status: PermissionUiStatus, + requested: bool, +) -> Option<&'static str> { + match kind { + PermissionKind::FullDiskAccess => { + if status == PermissionUiStatus::Granted { + None + } else if requested { + Some( + "After enabling CodeScribe in System Settings > Privacy & Security > Full Disk Access, return here and click Recheck. Status also refreshes automatically every few seconds.", + ) + } else { + Some( + "Click Open Settings, enable CodeScribe in Full Disk Access, then return here.", + ) + } + } + _ => { + if status == PermissionUiStatus::Denied { + Some( + "This permission is required to continue onboarding. Enable it in System Settings, then click Try Again.", + ) + } else { + None + } + } + } +} + +fn permission_status_text( + kind: PermissionKind, + status: PermissionUiStatus, + requested: bool, +) -> &'static str { + match (kind, status, requested) { + (PermissionKind::FullDiskAccess, PermissionUiStatus::NotDetermined, true) => { + STATUS_RECHECK_READY + } + (PermissionKind::FullDiskAccess, PermissionUiStatus::Denied, true) => { + STATUS_RECHECK_PENDING + } + (_, PermissionUiStatus::NotDetermined, _) => STATUS_NOT_DETERMINED, + (_, PermissionUiStatus::Granted, _) => STATUS_GRANTED, + (_, PermissionUiStatus::Denied, _) => STATUS_DENIED, + } +} + +fn permission_status_color(status: PermissionUiStatus) -> Id { + match status { + PermissionUiStatus::NotDetermined => system_secondary_color(), + PermissionUiStatus::Granted => system_green_color(), + PermissionUiStatus::Denied => system_red_color(), + } +} + +fn system_green_color() -> Id { + unsafe { + let ns_color = Class::get("NSColor").unwrap(); + msg_send![ns_color, systemGreenColor] + } +} + +fn system_red_color() -> Id { + unsafe { + let ns_color = Class::get("NSColor").unwrap(); + msg_send![ns_color, systemRedColor] + } +} + +fn system_secondary_color() -> Id { + unsafe { + let ns_color = Class::get("NSColor").unwrap(); + msg_send![ns_color, secondaryLabelColor] + } +} + +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/steps.rs b/app/ui/onboarding/steps.rs new file mode 100644 index 00000000..77decbff --- /dev/null +++ b/app/ui/onboarding/steps.rs @@ -0,0 +1,89 @@ +//! Step metadata for first-run onboarding wizard. + +#[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", + } + } +} + +#[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/overlay/mod.rs b/app/ui/overlay/mod.rs index b62182c2..97a53c45 100644 --- a/app/ui/overlay/mod.rs +++ b/app/ui/overlay/mod.rs @@ -3,13 +3,13 @@ //! This module provides a minimal floating overlay window that: //! - Shows status during recording (Recording..., Processing...) //! - Displays live streaming transcription text -//! - Result goes to clipboard (no chat, no conversation) +//! - Supports explicit decision actions (Save/Copy/Augment) after recording //! - Auto-hides after recording completion //! //! Use this for: Ctrl hold (raw), Left ⌥⌥ toggle (normal) -//! For assistive modes (Ctrl+Shift, Right ⌥⌥), use voice_chat_ui instead. +//! For agent chat conversations, use voice_chat_ui overlay. //! -//! Design: macOS Tahoe-style with NSVisualEffectView (HudWindow material) +//! Design: macOS Tahoe Liquid Glass (NSVisualEffectView, HudWindow material) // Allow unexpected cfgs from objc crate's msg_send! macro // Allow unused API methods - they're part of the public interface for future use @@ -26,12 +26,15 @@ use objc2_app_kit::{ }; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; +use crate::ui::shared::status::{UiStatus, status_from_detail}; use crate::ui_helpers::{ add_subview, animate_fade, button_set_action, button_style, clamp_overlay_position, - color_white, create_button, set_hidden, set_text, window_close, window_set_alpha, window_show, + create_button, create_glass_effect_view_with, create_label, create_scrollable_text_view, + ns_string, set_glass_effect_content_view, set_hidden, set_text, set_text_view_string, + set_tooltip, ui_colors, ui_tokens, window_close, window_set_alpha, window_show, }; use objc::declare::ClassDecl; use objc::runtime::Sel; @@ -51,15 +54,21 @@ const OVERLAY_WINDOW_WIDTH: f64 = 420.0; const OVERLAY_WINDOW_MIN_HEIGHT: f64 = 180.0; const OVERLAY_WINDOW_MAX_HEIGHT_RATIO: f64 = 0.5; const OVERLAY_PADDING: f64 = 16.0; -const OVERLAY_STATUS_HEIGHT: f64 = 28.0; +const OVERLAY_HEADER_HEIGHT: f64 = 20.0; +const OVERLAY_STATUS_HEIGHT: f64 = 20.0; +const OVERLAY_INFO_HEIGHT: f64 = 12.0; +const OVERLAY_STATUS_WIDTH: f64 = 100.0; +const OVERLAY_HEADER_GAP: f64 = 4.0; +const OVERLAY_CONTENT_GAP: f64 = 8.0; +const OVERLAY_TEXT_MIN_HEIGHT: f64 = 44.0; const OVERLAY_BUTTON_HEIGHT: f64 = 28.0; const OVERLAY_BUTTON_MARGIN: f64 = 8.0; -const OVERLAY_CORNER_RADIUS: f64 = 16.0; -const MAX_TAIL_LINES: usize = 16; -const MAX_TAIL_CHARS: usize = 2000; +const OVERLAY_HEADER_LABEL: &str = "CodeScribe - Dictation Overlay"; // Auto-hide delay after recording completes const AUTO_HIDE_DELAY_SECS: u64 = 5; +const OVERLAY_LAYOUT_THROTTLE_MS: u64 = 80; +const OVERLAY_LAYOUT_HYSTERESIS_PX: f64 = 1.0; /// Configuration for the transcription overlay #[derive(Debug, Clone)] @@ -79,49 +88,133 @@ impl Default for TranscriptionOverlayConfig { } } +/// 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, +} + /// Transcription overlay state struct TranscriptionOverlayState { window: Option, - text_field: Option, + header_label: Option, + text_scroll_view: Option, + text_view: Option, status_field: Option, + auto_hide_label: Option, blur_view: Option, copy_button: Option, augment_button: Option, - archive_button: Option, + save_button: Option, commit_button: Option, progress_indicator: Option, tracking_area: Option, decision_mode: bool, hover_active: bool, action_handler: Option, + action_contract_mode: TranscriptionActionContractMode, + raw_text: String, + last_pass_text: String, accumulated_text: String, window_width: f64, min_height: f64, max_height: f64, + last_applied_height: f64, + last_layout_resize_at: Instant, + pending_layout_resize: bool, } lazy_static::lazy_static! { static ref OVERLAY_STATE: Mutex = Mutex::new(TranscriptionOverlayState { window: None, - text_field: 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, - archive_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, + raw_text: String::new(), + last_pass_text: String::new(), accumulated_text: String::new(), window_width: OVERLAY_WINDOW_WIDTH, 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, }); } +#[repr(C)] +#[derive(Clone, Copy)] +struct NSRange { + location: usize, + length: usize, +} + +/// 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)] +struct OverlaySnapshot { + window: Option, + header_label: Option, + text_scroll_view: Option, + text_view: Option, + status_field: Option, + auto_hide_label: Option, + blur_view: Option, + copy_button: Option, + augment_button: Option, + save_button: Option, + commit_button: Option, + progress_indicator: Option, + window_width: f64, + min_height: f64, + max_height: f64, + last_applied_height: f64, +} + +impl OverlaySnapshot { + 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, + window_width: state.window_width, + 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 static AUTO_HIDE_PENDING: AtomicBool = AtomicBool::new(false); /// Counter to invalidate old timers @@ -148,8 +241,8 @@ fn action_handler_class() -> *const Class { on_augment_transcript as extern "C" fn(&Object, Sel, Id), ); decl.add_method( - sel!(onArchiveTranscript:), - on_archive_transcript as extern "C" fn(&Object, Sel, Id), + sel!(onSaveTranscript:), + on_save_transcript as extern "C" fn(&Object, Sel, Id), ); decl.add_method( sel!(onCommitRecording:), @@ -169,26 +262,52 @@ fn action_handler_class() -> *const Class { unsafe { ACTION_HANDLER_CLASS } } -/// Handler: Copy (AI format) to clipboard +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(), + } +} + +/// Handler: Copy transcript using contract source of truth. extern "C" fn on_copy_transcript(_this: &Object, _cmd: Sel, _sender: Id) { - let text = get_transcription_text(); + let (text, snap) = { + let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + ( + action_text_for_contract(&state), + OverlaySnapshot::from_state(&state), + ) + }; if text.is_empty() { return; } - run_ai_copy(text, false); + 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()); + hide_transcription_overlay(); } -/// Handler: Augment (AI assist) to clipboard +/// Handler: Augment transcript via explicit chat handoff. extern "C" fn on_augment_transcript(_this: &Object, _cmd: Sel, _sender: Id) { - let text = get_transcription_text(); + let text = { + let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + action_text_for_contract(&state) + }; if text.is_empty() { return; } - run_ai_copy(text, true); + crate::show_voice_chat_overlay(); + crate::show_agent_tab(); + crate::voice_chat_ui::handoff_transcript_to_chat(&text); + hide_transcription_overlay(); } -/// Handler: Archive (no-op save already happened; just close overlay) -extern "C" fn on_archive_transcript(_this: &Object, _cmd: Sel, _sender: Id) { +/// Handler: Save (save already happened in controller; just close overlay) +extern "C" fn on_save_transcript(_this: &Object, _cmd: Sel, _sender: Id) { hide_transcription_overlay(); } @@ -198,255 +317,383 @@ extern "C" fn on_commit_recording(_this: &Object, _cmd: Sel, _sender: Id) { } extern "C" fn on_mouse_entered(_this: &Object, _cmd: Sel, _sender: Id) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hover_active = true; - if state.decision_mode { - set_action_buttons_visible(&state, true); + 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 mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.hover_active = false; - set_action_buttons_visible(&state, false); + let (decision_mode, snap) = { + let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.hover_active = false; + (state.decision_mode, OverlaySnapshot::from_state(&state)) + }; // Lock dropped before AppKit calls. + if decision_mode { + set_action_buttons_visible_unlocked(&snap, true); + schedule_auto_hide(); + } else { + set_action_buttons_visible_unlocked(&snap, false); + } } -fn set_action_buttons_visible(state: &TranscriptionOverlayState, visible: bool) { - if let Some(copy_ptr) = state.copy_button { +/// Show/hide action buttons. Call ONLY outside the `OVERLAY_STATE` lock. +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) = state.augment_button { + if let Some(augment_ptr) = snap.augment_button { unsafe { set_hidden(augment_ptr as Id, !visible); } } - if let Some(archive_ptr) = state.archive_button { + if let Some(save_ptr) = snap.save_button { unsafe { - set_hidden(archive_ptr as Id, !visible); + set_hidden(save_ptr as Id, !visible); } } } -fn set_recording_button_visible(state: &TranscriptionOverlayState, visible: bool) { - if let Some(commit_ptr) = state.commit_button { +/// Show/hide commit button. Call ONLY outside the `OVERLAY_STATE` lock. +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 set_buttons_enabled(state: &TranscriptionOverlayState, enabled: bool) { - if let Some(copy_ptr) = state.copy_button { - unsafe { - crate::ui_helpers::set_enabled(copy_ptr as Id, enabled); +fn action_contract_source_label(mode: TranscriptionActionContractMode) -> &'static str { + match mode { + TranscriptionActionContractMode::Raw => "RAW", + TranscriptionActionContractMode::AiFormat => "AI-FORMAT", + } +} + +fn copy_action_tooltip(mode: TranscriptionActionContractMode) -> &'static str { + match mode { + TranscriptionActionContractMode::Raw => "Copy RAW transcript", + TranscriptionActionContractMode::AiFormat => "Copy last-pass/formatted transcript", + } +} + +fn augment_action_tooltip(mode: TranscriptionActionContractMode) -> &'static str { + match mode { + TranscriptionActionContractMode::Raw => "Open Agent overlay and hand off RAW transcript", + TranscriptionActionContractMode::AiFormat => { + "Open Agent overlay and hand off last-pass/formatted transcript" } } - if let Some(augment_ptr) = state.augment_button { +} + +fn decision_hint_text(mode: TranscriptionActionContractMode, include_auto_hide: bool) -> String { + let base = format!( + "Dictation overlay | Source: {} | Save closes | Augment -> Agent", + action_contract_source_label(mode) + ); + if include_auto_hide { + format!("{base} | Auto-hide {}s", AUTO_HIDE_DELAY_SECS) + } else { + base + } +} + +/// Refresh action contract tooltips/hints. Call ONLY outside the `OVERLAY_STATE` lock. +fn refresh_action_contract_ui_unlocked( + snap: &OverlaySnapshot, + mode: TranscriptionActionContractMode, + include_auto_hide_hint: bool, +) { + if let Some(copy_ptr) = snap.copy_button { unsafe { - crate::ui_helpers::set_enabled(augment_ptr as Id, enabled); + set_tooltip(copy_ptr as Id, copy_action_tooltip(mode)); } } - if let Some(archive_ptr) = state.archive_button { + if let Some(augment_ptr) = snap.augment_button { unsafe { - crate::ui_helpers::set_enabled(archive_ptr as Id, enabled); + set_tooltip(augment_ptr as Id, augment_action_tooltip(mode)); } } -} - -fn set_status_message(state: &TranscriptionOverlayState, msg: &str, show: bool) { - if let Some(status_ptr) = state.status_field { + if let Some(save_ptr) = snap.save_button { unsafe { - set_text(status_ptr as Id, msg); - set_hidden(status_ptr as Id, !show); + set_tooltip( + save_ptr as Id, + "Close dictation overlay (transcript already saved)", + ); } } - if let Some(spinner_ptr) = state.progress_indicator { + if let Some(label_ptr) = snap.auto_hide_label { unsafe { - set_hidden(spinner_ptr as Id, !show); - } - if show { - unsafe { - let _: () = - msg_send![spinner_ptr as Id, startAnimation: std::ptr::null::()]; - } - } else { - unsafe { - let _: () = msg_send![spinner_ptr as Id, stopAnimation: std::ptr::null::()]; + if include_auto_hide_hint { + let hint = decision_hint_text(mode, 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 run_ai_copy(text: String, augment: bool) { - let label = if augment { - "Augmentowanie…" - } else { - "Formatowanie…" - }; - { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - set_status_message(&state, label, true); - set_buttons_enabled(&state, false); +/// Show/hide auto-hide hint. Call ONLY outside the `OVERLAY_STATE` lock. +fn set_auto_hide_hint_visible_unlocked( + snap: &OverlaySnapshot, + mode: TranscriptionActionContractMode, + visible: bool, +) { + refresh_action_contract_ui_unlocked(snap, mode, visible); +} + +fn overlay_status_label(kind: UiStatus) -> &'static str { + match kind { + UiStatus::Idle => "Idle", + UiStatus::Listening => "Listening", + UiStatus::Processing => "Thinking", + UiStatus::Error => "Error", } - let lang = Config::load().whisper_language.as_str(); - tokio::spawn(async move { - let result = - crate::ai_formatting::format_text_with_status(&text, Some(lang), augment, None).await; +} - Queue::main().exec_async(move || { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - match result.status { - crate::ai_formatting::AiFormatStatus::Applied => { - if let Err(e) = clipboard::set_clipboard(&result.text) { - warn!("Failed to copy formatted text: {}", e); - set_status_message(&state, "Błąd kopiowania", true); - set_buttons_enabled(&state, true); - } else { - info!("Copied formatted transcript ({} chars)", result.text.len()); - hide_transcription_overlay(); - } - } - crate::ai_formatting::AiFormatStatus::Failed => { - set_status_message(&state, "AI Failed", true); - set_buttons_enabled(&state, true); - } - crate::ai_formatting::AiFormatStatus::Skipped => { - set_status_message(&state, "AI Skipped", true); - set_buttons_enabled(&state, true); - } - } - }); - }); +fn overlay_top_reserved_height() -> f64 { + OVERLAY_PADDING + + OVERLAY_HEADER_HEIGHT + + OVERLAY_HEADER_GAP + + OVERLAY_INFO_HEIGHT + + OVERLAY_CONTENT_GAP } -fn measure_text_height(text_field: Id, width: f64) -> f64 { - unsafe { - let cell: Id = msg_send![text_field, cell]; - if cell.is_null() { - return 0.0; - } - let bounds = CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width, - height: f64::MAX, - }, - }; - let size: CGSize = msg_send![cell, cellSizeForBounds: bounds]; - size.height - } +fn overlay_bottom_reserved_height() -> f64 { + OVERLAY_PADDING + OVERLAY_BUTTON_HEIGHT + OVERLAY_BUTTON_MARGIN } -fn trim_text_to_tail(text: &mut String) { - if text.is_empty() { - return; +#[derive(Debug, Clone, Copy)] +struct OverlayLayoutMetrics { + target_height: f64, + text_viewport_height: f64, + text_document_height: f64, + needs_scroll: bool, +} + +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, } +} - let mut trimmed = false; - let lines: Vec<&str> = text.lines().collect(); - let mut tail_text = if lines.len() > MAX_TAIL_LINES { - trimmed = true; - lines[lines.len() - MAX_TAIL_LINES..].join("\n") - } else { - text.clone() - }; +/// Update status label + spinner. Call ONLY outside the `OVERLAY_STATE` lock. +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 tail_text.chars().count() > MAX_TAIL_CHARS { - trimmed = true; - let tail_chars: String = tail_text - .chars() - .rev() - .take(MAX_TAIL_CHARS) - .collect::>() - .into_iter() - .rev() - .collect(); - tail_text = tail_chars; + 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); + } } - if trimmed { - *text = tail_text; + 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::()]; + } + } } } -fn resize_overlay_to_fit_text(state: &mut TranscriptionOverlayState) { - let (window_ptr, text_field_ptr) = match (state.window, state.text_field) { - (Some(window_ptr), Some(text_field_ptr)) => (window_ptr as Id, text_field_ptr as Id), - _ => return, - }; - - let text_width = state.window_width - OVERLAY_PADDING * 2.0; - let mut text_height = measure_text_height(text_field_ptr, text_width); - let mut required_window_height = - text_height + OVERLAY_PADDING * 3.0 + OVERLAY_BUTTON_HEIGHT + OVERLAY_BUTTON_MARGIN; +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) + } +} - if required_window_height > state.max_height { - trim_text_to_tail(&mut state.accumulated_text); - unsafe { - set_text(text_field_ptr, &state.accumulated_text); +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; } - text_height = measure_text_height(text_field_ptr, text_width); - required_window_height = - text_height + OVERLAY_PADDING * 3.0 + OVERLAY_BUTTON_HEIGHT + OVERLAY_BUTTON_MARGIN; + let range = NSRange { + location: len, + length: 0, + }; + let _: () = msg_send![text_view, scrollRangeToVisible: range]; } +} - let target_height = required_window_height - .max(state.min_height) - .min(state.max_height); +/// 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. +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, + }; + + let text_width = (snap.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); unsafe { let current_frame: CGRect = msg_send![window_ptr, frame]; let top_y = current_frame.origin.y + current_frame.size.height; - let new_frame = CGRect { - origin: CGPoint { - x: current_frame.origin.x, - y: top_y - target_height, - }, - size: CGSize { - width: state.window_width, - height: target_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: snap.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, setFrame: new_frame display: true]; let _: () = msg_send![window_ptr, setLevel: NS_FLOATING_WINDOW_LEVEL]; let text_frame = CGRect { origin: CGPoint { x: OVERLAY_PADDING, - y: OVERLAY_PADDING + OVERLAY_BUTTON_HEIGHT + OVERLAY_BUTTON_MARGIN, + y: overlay_bottom_reserved_height(), }, size: CGSize { - width: state.window_width - OVERLAY_PADDING * 2.0, - height: target_height - - OVERLAY_PADDING * 3.0 - - OVERLAY_BUTTON_HEIGHT - - OVERLAY_BUTTON_MARGIN, + width: text_width, + height: metrics.text_viewport_height, }, }; - let _: () = msg_send![text_field_ptr, setFrame: text_frame]; + let _: () = msg_send![text_scroll_ptr, setFrame: text_frame]; - if let Some(status_ptr) = state.status_field { - let status_frame = CGRect { + 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 = snap.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: target_height - OVERLAY_STATUS_HEIGHT - OVERLAY_PADDING, + y: header_y, }, size: CGSize { - width: state.window_width - OVERLAY_PADDING * 2.0, + 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(spinner_ptr) = state.progress_indicator { - let spinner_size = 14.0; + 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: snap.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: state.window_width - OVERLAY_PADDING - spinner_size, - y: target_height - OVERLAY_STATUS_HEIGHT - OVERLAY_PADDING + 7.0, + x: spinner_x, + y: header_y + ((OVERLAY_HEADER_HEIGHT - spinner_size) / 2.0).max(0.0), }, size: CGSize { width: spinner_size, @@ -456,12 +703,12 @@ fn resize_overlay_to_fit_text(state: &mut TranscriptionOverlayState) { let _: () = msg_send![spinner_ptr as Id, setFrame: spinner_frame]; } - if let Some(blur_ptr) = state.blur_view { + if let Some(blur_ptr) = snap.blur_view { let blur_frame = CGRect { origin: CGPoint { x: 0.0, y: 0.0 }, size: CGSize { - width: state.window_width, - height: target_height, + width: snap.window_width, + height: applied_height, }, }; let _: () = msg_send![blur_ptr as Id, setFrame: blur_frame]; @@ -470,8 +717,8 @@ fn resize_overlay_to_fit_text(state: &mut TranscriptionOverlayState) { let button_width = 100.0; let button_gap = 10.0; let row_width = button_width * 3.0 + button_gap * 2.0; - let row_x = (state.window_width - row_width) / 2.0; - let archive_frame = CGRect { + let row_x = (snap.window_width - row_width) / 2.0; + let save_frame = CGRect { origin: CGPoint { x: row_x, y: OVERLAY_PADDING, @@ -502,27 +749,119 @@ fn resize_overlay_to_fit_text(state: &mut TranscriptionOverlayState) { }, }; - if let Some(archive_ptr) = state.archive_button { - let _: () = msg_send![archive_ptr as Id, setFrame: archive_frame]; + if let Some(save_ptr) = snap.save_button { + let _: () = msg_send![save_ptr as Id, setFrame: save_frame]; } - if let Some(copy_ptr) = state.copy_button { + if let Some(copy_ptr) = snap.copy_button { let _: () = msg_send![copy_ptr as Id, setFrame: copy_frame]; } - if let Some(augment_ptr) = state.augment_button { + if let Some(augment_ptr) = snap.augment_button { let _: () = msg_send![augment_ptr as Id, setFrame: augment_frame]; } + + applied_height } } -fn update_overlay_text_and_layout(state: &mut TranscriptionOverlayState) { - if let Some(text_field_ptr) = state.text_field { +/// Update the overlay text content. Call ONLY outside the `OVERLAY_STATE` lock. +fn update_overlay_text_unlocked(text_view_ptr: Option, visible_text: &str) { + if let Some(tv_ptr) = text_view_ptr { unsafe { - set_text(text_field_ptr as Id, &state.accumulated_text); + set_text_view_string(tv_ptr as Id, visible_text); } - resize_overlay_to_fit_text(state); } } +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) +} + +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, + '.' | ',' + | ';' + | ':' + | '!' + | '?' + | ')' + | '(' + | ']' + | '[' + | '}' + | '{' + | '"' + | '\'' + | '…' + | '—' + | '-' + ) +} + +// 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. +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. +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); +} + /// Show the transcription overlay window pub fn show_transcription_overlay() { // Cancel any pending auto-hide @@ -540,34 +879,40 @@ fn show_transcription_overlay_impl() { // 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 nil: *mut Object = std::ptr::null_mut(); - let _: () = msg_send![window, makeKeyAndOrderFront: nil]; - resize_overlay_to_fit_text(&mut state); + 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.action_contract_mode = TranscriptionActionContractMode::Raw; + drop(state); // Release lock BEFORE heavy AppKit widget creation. // Get classes let ns_window_class = Class::get("NSWindow"); - let ns_text_field_class = Class::get("NSTextField"); let ns_screen_class = Class::get("NSScreen"); - let ns_string_class = Class::get("NSString"); - let ns_visual_effect_view_class = Class::get("NSVisualEffectView"); 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_window_class.is_none() - || ns_text_field_class.is_none() || ns_screen_class.is_none() - || ns_string_class.is_none() - || ns_visual_effect_view_class.is_none() || ns_color_class.is_none() || ns_progress_class.is_none() || ns_tracking_area_class.is_none() @@ -577,10 +922,7 @@ fn show_transcription_overlay_impl() { } let ns_window = ns_window_class.unwrap(); - let ns_text_field = ns_text_field_class.unwrap(); let ns_screen = ns_screen_class.unwrap(); - let ns_string = ns_string_class.unwrap(); - let ns_visual_effect_view = ns_visual_effect_view_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(); @@ -600,7 +942,7 @@ fn show_transcription_overlay_impl() { let window_width = OVERLAY_WINDOW_WIDTH; let window_height = OVERLAY_WINDOW_MIN_HEIGHT; let margin = 20.0; - let corner_radius = OVERLAY_CORNER_RADIUS; + let corner_radius = ui_tokens::SURFACE_RADIUS; let max_height = (visible_frame.size.height * OVERLAY_WINDOW_MAX_HEIGHT_RATIO).max(window_height); @@ -672,102 +1014,115 @@ fn show_transcription_overlay_impl() { let _: () = msg_send![window, setHasShadow: true]; // Join all spaces (follow focus) - let collection_behavior = NSWindowCollectionBehavior::CanJoinAllSpaces; + // 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 content_view: Id = msg_send![window, contentView]; - if content_view.is_null() { + let window_content_view: Id = msg_send![window, contentView]; + if window_content_view.is_null() { warn!("Failed to get content view"); return; } - // === NSVisualEffectView for macOS Tahoe-style blur === - let blur_frame = CGRect { - origin: CGPoint { x: 0.0, y: 0.0 }, - size: CGSize { - width: window_width, - height: window_height, - }, - }; - - let blur_view: Id = msg_send![ns_visual_effect_view, alloc]; - let blur_view: Id = msg_send![blur_view, initWithFrame: blur_frame]; - - // HudWindow material (13) - perfect for floating overlays - let material = NSVisualEffectMaterial::HUDWindow; - let _: () = msg_send![blur_view, setMaterial: material]; - - // BehindWindow blending for true vibrancy - let blending = NSVisualEffectBlendingMode::BehindWindow; - let _: () = msg_send![blur_view, setBlendingMode: blending]; - - // Always active (don't dim when window loses focus) - let effect_state = NSVisualEffectState::Active; - let _: () = msg_send![blur_view, setState: effect_state]; - - // Enable layer-backed view for corner radius - let _: () = msg_send![blur_view, setWantsLayer: true]; - - // Set corner radius on the layer + // === 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]; } - // Add blur view as background - add_subview(content_view, blur_view); - - // === Status indicator (top) === (hidden by default) - let status_height = OVERLAY_STATUS_HEIGHT; - let padding = OVERLAY_PADDING; - let status_frame = CGRect { - origin: CGPoint { - x: padding, - y: window_height - status_height - padding, - }, - size: CGSize { - width: window_width - padding * 2.0, - height: status_height, - }, - }; - - let status_field: Id = msg_send![ns_text_field, alloc]; - let status_field: Id = msg_send![status_field, initWithFrame: status_frame]; - let _: () = msg_send![status_field, setBezeled: false]; - let _: () = msg_send![status_field, setDrawsBackground: false]; - let _: () = msg_send![status_field, setEditable: false]; - let _: () = msg_send![status_field, setSelectable: false]; - - // White text for contrast on dark blur - let white_color: Id = msg_send![ns_color, whiteColor]; - let _: () = msg_send![status_field, setTextColor: white_color]; - - // Bold system font for status - let ns_font_class = Class::get("NSFont").unwrap(); - let bold_font: Id = msg_send![ns_font_class, boldSystemFontOfSize: 13.0f64]; - let _: () = msg_send![status_field, setFont: bold_font]; + // 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: 2_isize | 16_isize]; // Width | Height + let _: bool = set_glass_effect_content_view(blur_view, content_view); - // Recording indicator with emoji - let initial_status: Id = msg_send![ns_string, stringWithUTF8String: c"".as_ptr()]; - let _: () = msg_send![status_field, setStringValue: initial_status]; + 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, + }); + 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]; add_subview(content_view, status_field); - set_hidden(status_field, true); - // Spinner (hidden by default) - let spinner_size = 14.0; - let spinner_frame = CGRect { - origin: CGPoint { - x: window_width - padding - spinner_size, - y: window_height - status_height - padding + 7.0, - }, - size: CGSize { - width: spinner_size, - height: spinner_size, - }, - }; + 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, true), + font_size: ui_tokens::MICRO_FONT_SIZE, + bold: false, + text_color: ui_colors::overlay_hint_text(), + background_color: None, + selectable: false, + editable: false, + }); + 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, setStyle: NS_PROGRESS_INDICATOR_STYLE_SPINNING]; @@ -776,48 +1131,33 @@ fn show_transcription_overlay_impl() { add_subview(content_view, spinner); set_hidden(spinner, true); - // === Text field for transcription (main area) === - let button_height = OVERLAY_BUTTON_HEIGHT; - let button_margin = OVERLAY_BUTTON_MARGIN; - let text_frame = CGRect { - origin: CGPoint { - x: padding, - y: padding + button_height + button_margin, - }, - size: CGSize { - width: window_width - padding * 2.0, - height: window_height - padding * 3.0 - button_height - button_margin, - }, - }; - - let text_field: Id = msg_send![ns_text_field, alloc]; - let text_field: Id = msg_send![text_field, initWithFrame: text_frame]; - let _: () = msg_send![text_field, setBezeled: false]; - let _: () = msg_send![text_field, setDrawsBackground: false]; - let _: () = msg_send![text_field, setEditable: false]; - let _: () = msg_send![text_field, setSelectable: true]; - - // Semi-transparent white for text - let text_color = color_white(0.9); - let _: () = msg_send![text_field, setTextColor: text_color]; - - // System font for content + // === 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 ns_font_class = Class::get("NSFont").unwrap(); let system_font: Id = msg_send![ns_font_class, systemFontOfSize: 14.0f64]; - let _: () = msg_send![text_field, setFont: system_font]; - - // Enable word wrapping - let cell: Id = msg_send![text_field, cell]; - if !cell.is_null() { - let _: () = msg_send![cell, setWraps: true]; - let _: () = msg_send![cell, setScrollable: false]; - // Top-left alignment - let _: () = msg_send![cell, setUsesSingleLineMode: false]; + 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]; } - - let empty_str: Id = msg_send![ns_string, stringWithUTF8String: c"".as_ptr()]; - let _: () = msg_send![text_field, setStringValue: empty_str]; - - add_subview(content_view, text_field); + set_text_view_string(text_view, ""); + add_subview(content_view, text_scroll_view); // Create action handler instance let handler_class = action_handler_class(); @@ -845,7 +1185,7 @@ fn show_transcription_overlay_impl() { let row_x = (window_width - row_width) / 2.0; let commit_x = (window_width - button_width) / 2.0; - let archive_frame = CGRect { + let save_frame = CGRect { origin: CGPoint { x: row_x, y: padding, @@ -886,22 +1226,35 @@ fn show_transcription_overlay_impl() { }, }; - let archive_button = create_button(archive_frame, "Archiwizuj", button_style::ROUNDED); - let copy_button = create_button(copy_frame, "Kopiuj", button_style::ROUNDED); - let augment_button = create_button(augment_frame, "Augmentuj", button_style::ROUNDED); - let commit_button = create_button(commit_frame, "Zakończ", button_style::ROUNDED); + let save_button = create_button(save_frame, "Save", button_style::GLASS); + let copy_button = create_button(copy_frame, "Copy", button_style::ROUNDED); + let augment_button = create_button(augment_frame, "Augment", button_style::ROUNDED); + let commit_button = create_button(commit_frame, "Finish", button_style::GLASS); + set_tooltip( + copy_button, + copy_action_tooltip(TranscriptionActionContractMode::Raw), + ); + set_tooltip( + augment_button, + augment_action_tooltip(TranscriptionActionContractMode::Raw), + ); + set_tooltip( + save_button, + "Close dictation overlay (transcript already saved)", + ); + set_tooltip(commit_button, "Stop recording and enter decision mode"); - button_set_action(archive_button, action_handler, sel!(onArchiveTranscript:)); + button_set_action(save_button, action_handler, sel!(onSaveTranscript:)); button_set_action(copy_button, action_handler, sel!(onCopyTranscript:)); button_set_action(augment_button, action_handler, sel!(onAugmentTranscript:)); button_set_action(commit_button, action_handler, sel!(onCommitRecording:)); - add_subview(content_view, archive_button); + add_subview(content_view, save_button); add_subview(content_view, copy_button); add_subview(content_view, augment_button); add_subview(content_view, commit_button); - set_hidden(archive_button, true); + set_hidden(save_button, true); set_hidden(copy_button, true); set_hidden(augment_button, true); set_hidden(commit_button, true); @@ -909,17 +1262,27 @@ fn show_transcription_overlay_impl() { // Show the window with fade-in animation window_set_alpha(window, 0.0); window_show(window); - let nil: *mut Object = std::ptr::null_mut(); - let _: () = msg_send![window, makeKeyAndOrderFront: nil]; 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_close(window); + return; + } state.window = Some(window as usize); - state.text_field = Some(text_field 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(augment_button as usize); - state.archive_button = Some(archive_button as usize); + state.save_button = Some(save_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); @@ -929,6 +1292,23 @@ fn show_transcription_overlay_impl() { state.window_width = window_width; 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)"); } @@ -943,12 +1323,11 @@ pub fn update_transcription_status(status: &str) { } fn update_transcription_status_impl(status: &str) { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(status_field_ptr) = state.status_field { - unsafe { - set_text(status_field_ptr as Id, status); - } - } + 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 @@ -960,9 +1339,39 @@ pub fn append_transcription_delta(delta: &str) { } fn append_transcription_delta_impl(delta: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text.push_str(delta); - update_overlay_text_and_layout(&mut state); + // 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()); + 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 = + overlay_visible_text(&state.accumulated_text, state.decision_mode).to_string(); + 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 @@ -974,9 +1383,61 @@ pub fn set_transcription_text(text: &str) { } fn set_transcription_text_impl(text: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text = text.to_string(); - update_overlay_text_and_layout(&mut state); + 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(); + let visible = + overlay_visible_text(&state.accumulated_text, state.decision_mode).to_string(); + 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, +) { + 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.raw_text = raw_text_owned; + state.last_pass_text = last_pass_owned; + state.action_contract_mode = mode_copy; + state.accumulated_text = action_text_for_contract(&state); + let visible = + overlay_visible_text(&state.accumulated_text, state.decision_mode).to_string(); + 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); + 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 @@ -993,47 +1454,35 @@ pub fn clear_transcription_text() { } fn clear_transcription_text_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.accumulated_text.clear(); - - if let Some(text_field_ptr) = state.text_field { - unsafe { - set_text(text_field_ptr as Id, ""); - } - } - resize_overlay_to_fit_text(&mut state); - if let Some(copy_ptr) = state.copy_button { - unsafe { - set_hidden(copy_ptr as Id, true); - } - } - if let Some(augment_ptr) = state.augment_button { - unsafe { - set_hidden(augment_ptr as Id, true); - } - } - if let Some(archive_ptr) = state.archive_button { - unsafe { - set_hidden(archive_ptr as Id, true); - } - } - if let Some(commit_ptr) = state.commit_button { - unsafe { - set_hidden(commit_ptr as Id, true); - } - } - if let Some(status_ptr) = state.status_field { - unsafe { - set_hidden(status_ptr as Id, true); - } - } - if let Some(spinner_ptr) = state.progress_indicator { + 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.action_contract_mode = TranscriptionActionContractMode::Raw; + 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); + if let Some(spinner_ptr) = snap.progress_indicator { unsafe { set_hidden(spinner_ptr as Id, true); } } - state.decision_mode = false; - state.hover_active = false; + 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 @@ -1047,6 +1496,17 @@ 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)); @@ -1080,23 +1540,38 @@ fn should_auto_hide(expected_generation: u64) -> bool { /// Enter decision mode: show actions on hover for the current transcript pub fn enter_decision_mode() { Queue::main().exec_async(|| { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = true; - let show = state.hover_active; - set_action_buttons_visible(&state, show); - set_recording_button_visible(&state, false); + let (snap, mode) = { + let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.decision_mode = true; + ( + 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_recording_button_visible_unlocked(&snap, false); + set_recording_status_unlocked(&snap, false); }); } -/// Enter recording mode: hide actions and status +/// Enter recording mode: hide actions, show recording indicator pub fn enter_recording_mode() { Queue::main().exec_async(|| { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.decision_mode = false; - state.hover_active = false; - set_action_buttons_visible(&state, false); - set_recording_button_visible(&state, true); - set_status_message(&state, "", false); + let (snap, mode) = { + let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.decision_mode = false; + state.hover_active = 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_recording_button_visible_unlocked(&snap, true); + // Show recording indicator (red dot + text), no spinner + set_recording_status_unlocked(&snap, true); }); } @@ -1118,8 +1593,35 @@ fn close_window_by_ptr(window_ptr: usize) { } fn hide_transcription_overlay_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(window_ptr) = state.window.take() { + // DEADLOCK PREVENTION: extract window_ptr and clear state under lock, + // then drop lock before the animate_fade AppKit call. + let window_ptr = { + let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + let wp = state.window.take(); + 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.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 + }; // Lock dropped. + + if let Some(window_ptr) = window_ptr { let window = window_ptr as Id; // Fade out animation (0.15s) @@ -1137,24 +1639,12 @@ fn hide_transcription_overlay_impl() { debug!("Transcription overlay hidden"); } - state.text_field = None; - state.status_field = None; - state.blur_view = None; - state.copy_button = None; - state.augment_button = None; - state.archive_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; - // Note: accumulated_text is NOT cleared here - it's needed for clipboard copy } #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] fn test_transcription_text() { @@ -1177,6 +1667,7 @@ mod tests { } #[test] + #[serial] fn test_auto_hide_generation() { // Test that generation counter increments let gen1 = AUTO_HIDE_GENERATION.load(Ordering::SeqCst); @@ -1191,6 +1682,7 @@ mod tests { } #[test] + #[serial] fn test_auto_hide_hover_guard() { AUTO_HIDE_GENERATION.store(42, Ordering::SeqCst); AUTO_HIDE_PENDING.store(true, Ordering::SeqCst); @@ -1207,4 +1699,120 @@ mod tests { } assert!(should_auto_hide(42)); } + + #[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_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] + 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"); + } + + #[test] + fn test_overlay_visible_text_decision_mode_uses_exact_text() { + let text = "pełny tekst kontraktu bez trimowania"; + assert_eq!(overlay_visible_text(text, true), text); + } + + #[test] + fn test_overlay_visible_text_live_mode_defaults_to_exact_text() { + let text = "To jest stabilne zda"; + assert_eq!(overlay_visible_text(text, false), text); + } } diff --git a/app/ui/shared/helpers.rs b/app/ui/shared/helpers.rs index 74c6aefa..6c98d11c 100644 --- a/app/ui/shared/helpers.rs +++ b/app/ui/shared/helpers.rs @@ -11,10 +11,26 @@ use crate::os::clipboard; use core_graphics::geometry::{CGPoint, CGRect, CGSize}; -use objc::runtime::{Class, Object}; +use objc::declare::ClassDecl; +use objc::runtime::Sel; +use objc::runtime::{Class, Object, class_getInstanceMethod, object_getClass}; use objc::{msg_send, sel, sel_impl}; -use objc2_app_kit::{NSBackingStoreType, NSWindowCollectionBehavior, NSWindowStyleMask}; +#[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::{ + NSBackingStoreType, NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, + NSWindowCollectionBehavior, NSWindowStyleMask, +}; +#[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; @@ -22,33 +38,1002 @@ 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; + +// ============================================================================ +// 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; + 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; + + // ─── 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 // ============================================================================ -/// 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] +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) + } +} + +#[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, + } +} + +/// 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)); + let view: Id = Retained::into_raw(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`. + #[allow(clippy::transmute_ptr_to_ptr)] + 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]; } -/// Create white color with optional alpha -pub fn color_white(alpha: f64) -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, colorWithWhite: 1.0f64 alpha: alpha] +/// # 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]; } -/// Create clear (transparent) color -pub fn color_clear() -> Id { - unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, clearColor] +/// # 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]; } // ============================================================================ @@ -99,6 +1084,16 @@ pub unsafe fn set_text_field_string(field: Id, text: &str) { } } +/// 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 // ============================================================================ @@ -122,10 +1117,7 @@ impl Default for LabelConfig { text: String::new(), font_size: 13.0, bold: false, - text_color: unsafe { - let ns_color = Class::get("NSColor").unwrap(); - msg_send![ns_color, whiteColor] - }, + text_color: color_label(), background_color: None, selectable: false, editable: false, @@ -232,6 +1224,7 @@ pub mod button_style { pub const SHADOWLESS_SQUARE: isize = 6; pub const SMALL_SQUARE: isize = 10; pub const INLINE: isize = 15; + pub const GLASS: isize = 16; } /// Create a button with title and action @@ -250,6 +1243,119 @@ pub fn create_button(frame: CGRect, title: &str, style: isize) -> Id { } } +/// 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. @@ -266,26 +1372,18 @@ pub fn button(frame: CGRect, title: &str) -> Id { } // ============================================================================ -// Checkbox Helpers +// Toggle Helpers // ============================================================================ -/// Create a checkbox (switch style button) -pub fn create_checkbox(frame: CGRect, title: &str, checked: bool) -> Id { +/// Create a native NSSwitch toggle. +pub fn create_toggle(frame: CGRect, checked: bool) -> 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 _: () = msg_send![btn, setButtonType: 3_isize]; // NSSwitchButton - - let title_str = ns_string(title); - let _: () = msg_send![btn, setTitle: title_str]; - + 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![btn, setState: state]; - - btn + let _: () = msg_send![toggle, setState: state]; + toggle } } @@ -298,7 +1396,6 @@ pub fn create_card_view(frame: CGRect, title: &str, subtitle: &str, preview: &st unsafe { let ns_view = Class::get("NSView").unwrap(); let ns_text_field = Class::get("NSTextField").unwrap(); - let ns_color = Class::get("NSColor").unwrap(); let ns_font = Class::get("NSFont").unwrap(); let card: Id = msg_send![ns_view, alloc]; @@ -306,11 +1403,14 @@ pub fn create_card_view(frame: CGRect, title: &str, subtitle: &str, preview: &st let _: () = msg_send![card, setWantsLayer: true]; let layer: Id = msg_send![card, layer]; if !layer.is_null() { - let bg_color: Id = - msg_send![ns_color, colorWithRed: 0.2 green: 0.2 blue: 0.2 alpha: 0.4]; + let bg_color: Id = ui_colors::card_bg(); let cg_color: Id = msg_send![bg_color, CGColor]; let _: () = msg_send![layer, setBackgroundColor: cg_color]; - let _: () = msg_send![layer, setCornerRadius: 12.0f64]; + 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( @@ -325,9 +1425,9 @@ pub fn create_card_view(frame: CGRect, title: &str, subtitle: &str, preview: &st 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: 12.0f64]; + 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 = msg_send![ns_color, colorWithWhite: 1.0 alpha: 0.9]; + let title_color: Id = color_label(); let _: () = msg_send![title_field, setTextColor: title_color]; let _: () = msg_send![card, addSubview: title_field]; @@ -343,15 +1443,21 @@ pub fn create_card_view(frame: CGRect, title: &str, subtitle: &str, preview: &st 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: 11.0f64]; + 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 = msg_send![ns_color, colorWithWhite: 1.0 alpha: 0.5]; + 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, 12.0), - &CGSize::new(frame.size.width - 24.0, frame.size.height - 56.0), + &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]; @@ -362,9 +1468,9 @@ pub fn create_card_view(frame: CGRect, title: &str, subtitle: &str, preview: &st 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: 12.0f64]; + 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 = msg_send![ns_color, colorWithWhite: 1.0 alpha: 0.85]; + let preview_color: Id = color_secondary_label(); let _: () = msg_send![preview_field, setTextColor: preview_color]; let _: () = msg_send![card, addSubview: preview_field]; @@ -386,10 +1492,14 @@ pub fn create_scrollable_text_view(frame: CGRect, editable: bool) -> (Id, Id) { // 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( @@ -401,14 +1511,28 @@ pub fn create_scrollable_text_view(frame: CGRect, editable: bool) -> (Id, Id) { 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]; - // White text - let white: Id = msg_send![ns_color, whiteColor]; - let _: () = msg_send![text_view, setTextColor: white]; + // 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)]; @@ -432,14 +1556,24 @@ pub fn create_scrollable_text_view(frame: CGRect, editable: bool) -> (Id, Id) { // ============================================================================ /// Create a floating overlay window -pub fn create_floating_window(frame: CGRect, title: &str, transparent_titlebar: bool) -> Id { +pub fn create_floating_window( + frame: CGRect, + title: &str, + transparent_titlebar: bool, + resizable: bool, +) -> Id { unsafe { let ns_window = Class::get("NSWindow").unwrap(); - let style = NSWindowStyleMask::Titled + let mut style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable - | NSWindowStyleMask::Miniaturizable - | NSWindowStyleMask::Resizable; + | NSWindowStyleMask::Miniaturizable; + if transparent_titlebar { + style |= NSWindowStyleMask::FullSizeContentView; + } + if resizable { + style |= NSWindowStyleMask::Resizable; + } let window: Id = msg_send![ns_window, alloc]; let window: Id = msg_send![ @@ -451,12 +1585,23 @@ pub fn create_floating_window(frame: CGRect, title: &str, transparent_titlebar: ]; if transparent_titlebar { + // Re-apply FullSizeContentView post-init to avoid AppKit falling back to + // separate titlebar/content regions (which visually looks like a duplicate top bar). + let current_style: NSWindowStyleMask = msg_send![window, styleMask]; + let full_style = current_style | NSWindowStyleMask::FullSizeContentView; + let _: () = msg_send![window, setStyleMask: full_style]; let _: () = msg_send![window, setTitleVisibility: 1_isize]; // NSWindowTitleHidden let _: () = msg_send![window, setTitlebarAppearsTransparent: true]; + let _: () = msg_send![window, setOpaque: false]; + let ns_color = Class::get("NSColor").unwrap(); + let clear: Id = msg_send![ns_color, clearColor]; + let _: () = msg_send![window, setBackgroundColor: clear]; } let _: () = msg_send![window, setMovableByWindowBackground: true]; let _: () = msg_send![window, setLevel: NS_FLOATING_WINDOW_LEVEL]; + // Keep the window instance alive even after close; we manage lifecycle explicitly. + let _: () = msg_send![window, setReleasedWhenClosed: false]; // Can join all spaces let collection = NSWindowCollectionBehavior::CanJoinAllSpaces @@ -497,6 +1642,16 @@ pub unsafe fn window_show(window: Id) { } } +/// 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. @@ -517,7 +1672,17 @@ pub unsafe fn window_set_alpha(window: Id, alpha: f64) { #[cfg(test)] mod tests { - use super::{CGPoint, CGRect, CGSize, clamp_overlay_position}; + use super::*; + 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 clamp_overlay_position_keeps_window_inside_frame() { @@ -526,6 +1691,139 @@ mod tests { assert_eq!(x, 30.0); assert_eq!(y, 10.0); } + + #[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")); + } + } } /// Clamp overlay position to visible frame with margin. @@ -681,23 +1979,46 @@ pub unsafe fn set_enabled(view: Id, enabled: bool) { } } +/// 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] + } + } +} + // ============================================================================ -// Chat Bubble Helpers (loct.io dark theme) +// Chat Bubble Helpers (GlyphPulse / Quantum style) // ============================================================================ -/// loct.io brand colors for dark theme -pub mod bubble_colors { - /// User bubble background - muted violet/purple accent - pub const USER_BG: (f64, f64, f64, f64) = (0.35, 0.28, 0.55, 0.9); - /// Assistant bubble background - dark gray - pub const ASSISTANT_BG: (f64, f64, f64, f64) = (0.22, 0.22, 0.24, 0.9); - /// System/error bubble background - dark red tint - pub const ERROR_BG: (f64, f64, f64, f64) = (0.4, 0.2, 0.2, 0.9); - /// Text color - white - pub const TEXT: (f64, f64, f64, f64) = (1.0, 1.0, 1.0, 1.0); - /// Streaming indicator color - subtle pulse - pub const STREAMING: (f64, f64, f64, f64) = (0.6, 0.6, 0.6, 1.0); -} +const NSTRACKING_MOUSE_ENTERED_AND_EXITED: u64 = 1 << 0; +const NSTRACKING_ACTIVE_ALWAYS: u64 = 1 << 7; +const NSTRACKING_IN_VISIBLE_RECT: u64 = 1 << 9; /// Role for chat bubble styling #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -707,13 +2028,257 @@ pub enum BubbleRole { System, } +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, '-' | ':' | ' ')) + }) +} + +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) + }) +} + +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]; + } + // Use full markdown mode when table syntax is detected; otherwise keep the + // inline-preserving mode to avoid whitespace regressions in regular bubbles. + let responds_syntax: bool = + msg_send![options, respondsToSelector: sel!(setInterpretedSyntax:)]; + if responds_syntax { + // 0 = .full, 1 = .inlineOnly, 2 = .inlineOnlyPreservingWhitespace + let syntax: isize = if looks_like_markdown_table(text) { + 0 + } else { + 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 +} + +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 metadata: Option, /// Optional message index for Copy button (None = no button) pub message_index: Option, /// Optional action target for Copy button @@ -725,61 +2290,203 @@ pub struct BubbleConfig { /// Returns (container_view, text_label) tuple for later updates pub fn create_bubble_view(config: BubbleConfig) -> (Id, Id) { unsafe { - let ns_view = Class::get("NSView").unwrap(); - let ns_text_field = Class::get("NSTextField").unwrap(); - let ns_color = Class::get("NSColor").unwrap(); + 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 rect_max: CGRect = msg_send![ + text_str, + boundingRectWithSize: CGSize::new(text_max_width, 10_000.0) + options: opts + attributes: attrs + ]; + + // 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 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 bubble_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) + }; + + // 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]; - // Calculate text dimensions (approximate) - let font_size = 13.0; - let padding = 12.0; - let copy_button_height = if config.message_index.is_some() { - 20.0 + let _: () = msg_send![text_label, setFont: font]; + let allow_markdown = matches!(config.role, BubbleRole::Assistant | BubbleRole::System); + if !(allow_markdown && 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 + + // 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 { - 0.0 + msg_send![cell, cellSizeForBounds: measure_bounds] }; - let chars_per_line = (config.max_width - padding * 2.0) / (font_size * 0.6); - let text_len = config.text.len() as f64; - let estimated_lines = (text_len / chars_per_line).ceil().max(1.0); - let line_height = font_size * 1.4; - let text_height = estimated_lines * line_height; - let bubble_height = text_height + padding * 2.0 + copy_button_height; - - // Bubble width: content-aware but capped - let content_width = (text_len * font_size * 0.6).min(config.max_width - padding * 2.0); - let bubble_width = content_width + padding * 2.0; + let text_height = measured.height.ceil().max(line_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, bubble_height), + &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, // Right-aligned - BubbleRole::Assistant | BubbleRole::System => 8.0, // Left-aligned + 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, 0.0), + &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 (r, g, b, a) = if config.is_error { - bubble_colors::ERROR_BG + let bg_color: Id = if config.is_error { + ui_colors::bubble_error_bg() } else { match config.role { - BubbleRole::User => bubble_colors::USER_BG, - BubbleRole::Assistant => bubble_colors::ASSISTANT_BG, - BubbleRole::System => bubble_colors::ERROR_BG, + BubbleRole::User => ui_colors::bubble_user_bg(), + BubbleRole::Assistant => ui_colors::bubble_assistant_bg(), + BubbleRole::System => ui_colors::bubble_system_bg(), } }; - let bg_color: Id = msg_send![ns_color, colorWithRed: r green: g blue: b alpha: a]; // Set background via layer (for rounded corners) let _: () = msg_send![bubble, setWantsLayer: true]; @@ -788,60 +2495,81 @@ pub fn create_bubble_view(config: BubbleConfig) -> (Id, Id) { // CGColor from NSColor let cg_color: Id = msg_send![bg_color, CGColor]; let _: () = msg_send![layer, setBackgroundColor: cg_color]; - let _: () = msg_send![layer, setCornerRadius: 12.0f64]; + 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]; + } } - // Text label inside bubble + // Update label frame to the final measured height. let text_frame = CGRect::new( - &CGPoint::new(padding, padding / 2.0), - &CGSize::new(bubble_width - padding * 2.0, text_height), + &CGPoint::new(padding_x, padding_top), + &CGSize::new(text_layout_width.max(1.0), text_height), ); - let text_label: Id = msg_send![ns_text_field, alloc]; - let text_label: Id = msg_send![text_label, initWithFrame: text_frame]; - - 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]; - - // Text color - let (tr, tg, tb, ta) = if config.is_streaming { - bubble_colors::STREAMING - } else { - bubble_colors::TEXT - }; - let text_color: Id = msg_send![ns_color, colorWithRed: tr green: tg blue: tb alpha: ta]; - let _: () = msg_send![text_label, setTextColor: text_color]; - - // Font - let font: Id = msg_send![ns_font, systemFontOfSize: font_size]; - let _: () = msg_send![text_label, setFont: 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() - }; - let text_str = ns_string(&display_text); - let _: () = msg_send![text_label, setStringValue: text_str]; - - // Word wrap - let _: () = msg_send![text_label, setLineBreakMode: 0_isize]; // NSLineBreakByWordWrapping + 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 - let _: () = msg_send![bubble, addSubview: text_label]; - + // (text_label already added to bubble above — directly or via scroll wrapper) // Add Copy button if message_index is provided if let (Some(msg_index), Some(target)) = (config.message_index, config.copy_action_target) { let ns_button = Class::get("NSButton").unwrap(); let button_width = 40.0; - let button_height = 16.0; - let button_x = bubble_width - button_width - padding / 2.0; - let button_y = 2.0; // Bottom of bubble + let button_height = copy_button_height; + let button_x = bubble_width - button_width - padding_x; + // 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(button_x, button_y), @@ -859,26 +2587,53 @@ pub fn create_bubble_view(config: BubbleConfig) -> (Id, Id) { let title = ns_string("Copy"); let _: () = msg_send![copy_button, setTitle: title]; - let small_font: Id = msg_send![ns_font, systemFontOfSize: 10.0f64]; + 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]; - // Subtle text color - let button_color: Id = - msg_send![ns_color, colorWithRed: 0.7f64 green: 0.7f64 blue: 0.7f64 alpha: 1.0f64]; + // 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") + ]; // Set action 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]; } 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) } } @@ -886,10 +2641,14 @@ pub fn create_bubble_view(config: BubbleConfig) -> (Id, Id) { /// 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, is_streaming: bool) { +pub unsafe fn update_bubble_text( + text_label: Id, + text: &str, + role: BubbleRole, + is_streaming: bool, + is_error: bool, +) { unsafe { - let ns_color = Class::get("NSColor").unwrap(); - let display_text = if is_streaming && text.is_empty() { "• • •".to_string() } else if is_streaming { @@ -898,26 +2657,477 @@ pub unsafe fn update_bubble_text(text_label: Id, text: &str, is_streaming: bool) text.to_string() }; - let text_str = ns_string(&display_text); - let _: () = msg_send![text_label, setStringValue: text_str]; + let allow_markdown = matches!(role, BubbleRole::Assistant | BubbleRole::System); + // 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 !(allow_markdown && 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]; + } - // Update text color based on streaming state - let (tr, tg, tb, ta) = if is_streaming { - bubble_colors::STREAMING + let text_color: Id = if is_error { + ui_colors::bubble_error_text() } else { - bubble_colors::TEXT + 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 text_color: Id = msg_send![ns_color, colorWithRed: tr green: tg blue: tb alpha: ta]; 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 the Copy button to stay anchored near the bottom edge (flipped coords). + 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]; + 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 btn_frame: CGRect = msg_send![v, 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(btn_frame.origin.x, new_y), + &CGSize::new(btn_frame.size.width, btn_frame.size.height), + ); + let _: () = msg_send![v, 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 // ============================================================================ +/// Pick one or more files via native macOS open panel. +/// +/// Returns absolute paths. Intended for "attach as context" flows (Agent chat). +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]; @@ -925,9 +3135,26 @@ pub fn open_file_in_editor(path: &std::path::Path) -> bool { let path_str = path.to_string_lossy(); let ns_path = ns_string(&path_str); - let result: bool = msg_send![workspace, openFile: ns_path]; - result + 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) @@ -980,12 +3207,225 @@ pub fn create_vertical_stack_view(frame: CGRect) -> Id { } } +/// 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" { + 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]; } } @@ -999,7 +3439,86 @@ pub unsafe fn stack_view_clear(stack: Id) { 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]; } } } + +// ============================================================================ +// Editable Text Input Helpers +// ============================================================================ + +/// 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/mod.rs b/app/ui/shared/mod.rs index 1630fabc..e9095042 100644 --- a/app/ui/shared/mod.rs +++ b/app/ui/shared/mod.rs @@ -1 +1,2 @@ pub mod helpers; +pub mod status; diff --git a/app/ui/shared/status.rs b/app/ui/shared/status.rs new file mode 100644 index 00000000..491f307a --- /dev/null +++ b/app/ui/shared/status.rs @@ -0,0 +1,149 @@ +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("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("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 index f133eccd..86ef3559 100644 --- a/app/ui/tray/handlers.rs +++ b/app/ui/tray/handlers.rs @@ -1,14 +1,14 @@ //! 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, HoldMods, ToggleTrigger}; +use crate::config::Config; use crate::os::clipboard; -use crate::tray::state::{HOLD_MENU_ITEMS, TOGGLE_MENU_ITEMS, send_menu_event}; +use crate::os::permissions; +use crate::tray::state::{NOTES_MENU_ITEMS, send_menu_event}; use crate::tray::types::{MenuIds, TrayMenuEvent}; /// Handle menu item click and send appropriate event @@ -19,8 +19,18 @@ pub fn handle_menu_event(event_id: &MenuId, menu_ids: &MenuIds) { handle_copy_last(); } else if event_id == &menu_ids.show_overlay { crate::show_voice_chat_overlay(); + } else if menu_ids + .complete_setup + .as_ref() + .is_some_and(|id| event_id == id) + { + crate::show_settings_setup_tab(); + } else if event_id == &menu_ids.open_settings { + crate::show_settings_tab(); } else if event_id == &menu_ids.open_history { handle_open_history_folder(); + } else if event_id == &menu_ids.copy_diagnostics { + handle_copy_diagnostics(); } else if event_id == &menu_ids.help { handle_open_help(); } else if event_id == &menu_ids.about { @@ -28,29 +38,21 @@ pub fn handle_menu_event(event_id: &MenuId, menu_ids: &MenuIds) { } else if event_id == &menu_ids.quit { send_menu_event(TrayMenuEvent::Quit); } - // Hold Hotkeys submenu - else if event_id == &menu_ids.hold_ctrl { - handle_set_hold_mods(HoldMods::Ctrl); - } else if event_id == &menu_ids.hold_ctrl_opt { - handle_set_hold_mods(HoldMods::CtrlAlt); - } else if event_id == &menu_ids.hold_ctrl_shift { - handle_set_hold_mods(HoldMods::CtrlShift); - } else if event_id == &menu_ids.hold_ctrl_cmd { - handle_set_hold_mods(HoldMods::CtrlCmd); - } else if event_id == &menu_ids.hold_exclusive { - handle_toggle_hold_exclusive(); - } - // Toggle trigger submenu - else if event_id == &menu_ids.toggle_double_opt { - handle_set_toggle_trigger(ToggleTrigger::DoubleOption); - } else if event_id == &menu_ids.toggle_double_ralt { - handle_set_toggle_trigger(ToggleTrigger::DoubleRightOption); - } else if event_id == &menu_ids.toggle_disabled { - handle_set_toggle_trigger(ToggleTrigger::None); + // Notes + else if event_id == &menu_ids.notes_toggle_quick_notes { + handle_toggle_quick_notes(); + } else if event_id == &menu_ids.notes_toggle_save_only { + handle_toggle_quick_notes_save_only(); + } else if event_id == &menu_ids.notes_open_folder { + handle_open_notes_folder(); + } else if event_id == &menu_ids.notes_open_today { + handle_open_today_note(); } // Quality - Open Report else if event_id == &menu_ids.quality_open_report { handle_open_quality_report(); + } else if event_id == &menu_ids.silero_vad_install { + handle_install_silero_vad(); } else { debug!("Unknown menu event id: {:?}", event_id); } @@ -74,66 +76,98 @@ fn handle_copy_last() { } } -// ============================================================================ -// Hold Hotkeys Handlers -// ============================================================================ +fn handle_copy_diagnostics() { + send_menu_event(TrayMenuEvent::CopyDiagnostics); -/// Set hold modifier keys and update menu checkmarks -fn handle_set_hold_mods(mods: HoldMods) { - info!("Setting hold mods to: {:?}", mods); - send_menu_event(TrayMenuEvent::SetHoldMods(mods)); + let report = permissions::diagnostics_report(); + if let Err(e) = clipboard::set_clipboard(&report) { + info!("Failed to copy diagnostics to clipboard: {}", e); + return; + } - // Update menu checkmarks (radio behavior) - HOLD_MENU_ITEMS.with(|items_cell| { - if let Some(ref items) = *items_cell.borrow() { - items.ctrl.set_checked(mods == HoldMods::Ctrl); - items.ctrl_opt.set_checked(mods == HoldMods::CtrlAlt); - items.ctrl_shift.set_checked(mods == HoldMods::CtrlShift); - items.ctrl_cmd.set_checked(mods == HoldMods::CtrlCmd); - items.label.set_text(format!("Current: {}", mods.label())); - } - }); + #[cfg(target_os = "macos")] + { + let _ = Command::new("osascript") + .arg("-e") + .arg( + r#"display notification "Copied diagnostics to clipboard" with title "CodeScribe""#, + ) + .spawn(); + } +} - // Persist to config - let config = Config::load(); - let _ = config.save_to_env("HOLD_MODS", mods.as_str()); +fn handle_install_silero_vad() { + send_menu_event(TrayMenuEvent::InstallSileroVad); } -/// Toggle hold exclusive mode -fn handle_toggle_hold_exclusive() { - send_menu_event(TrayMenuEvent::ToggleHoldExclusive); +// ============================================================================ +// Notes Handlers +// ============================================================================ +fn handle_toggle_quick_notes() { let config = Config::load(); - let new_state = !config.hold_exclusive; - let _ = config.save_to_env("HOLD_EXCLUSIVE", if new_state { "1" } else { "0" }); + let new_state = !config.quick_notes_enabled; + info!( - "Hold exclusive toggled: {}", + "Toggling Quick Notes: {}", if new_state { "ON" } else { "OFF" } ); -} -/// Set toggle trigger and update menu checkmarks -fn handle_set_toggle_trigger(trigger: ToggleTrigger) { - info!("Setting toggle trigger to: {:?}", trigger); - send_menu_event(TrayMenuEvent::SetToggleTrigger(trigger)); + let _ = config.save_to_env("QUICK_NOTES_ENABLED", if new_state { "1" } else { "0" }); + send_menu_event(TrayMenuEvent::SetQuickNotesEnabled(new_state)); - // Update menu checkmarks (radio behavior) - TOGGLE_MENU_ITEMS.with(|items_cell| { + NOTES_MENU_ITEMS.with(|items_cell| { if let Some(ref items) = *items_cell.borrow() { - items - .double_opt - .set_checked(trigger == ToggleTrigger::DoubleOption); - items - .double_ralt - .set_checked(trigger == ToggleTrigger::DoubleRightOption); - items.disabled.set_checked(trigger == ToggleTrigger::None); - items.label.set_text(format!("Toggle: {}", trigger.label())); + 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); + } } }); - // Persist to config + #[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 _ = config.save_to_env("TOGGLE_TRIGGER", trigger.as_str()); + 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 @@ -172,7 +206,7 @@ fn handle_show_about() { { let version = env!("CARGO_PKG_VERSION"); let message = format!( - "CodeScribe v{}\\n\\nSpeech-to-text for macOS\\n\\nCreated by M&K (c)2026 VetCoders", + "CodeScribe v{}\n\nSpeech-to-text for macOS\n\nCreated by M&K (c)2026 VetCoders", version ); diff --git a/app/ui/tray/icons.rs b/app/ui/tray/icons.rs index a295c7fd..1331e8c3 100644 --- a/app/ui/tray/icons.rs +++ b/app/ui/tray/icons.rs @@ -28,7 +28,7 @@ const ICON_SIZE: u32 = 44; static SHOW_STATUS_GLYPH: AtomicBool = AtomicBool::new(true); /// Get whether the status glyph is currently enabled -pub fn is_status_glyph_enabled() -> bool { +fn is_status_glyph_enabled() -> bool { SHOW_STATUS_GLYPH.load(Ordering::SeqCst) } diff --git a/app/ui/tray/menu.rs b/app/ui/tray/menu.rs index a1ed8ae0..cbca93c0 100644 --- a/app/ui/tray/menu.rs +++ b/app/ui/tray/menu.rs @@ -1,13 +1,10 @@ //! Main menu building logic for the tray menu //! -//! Menu structure: +//! Menu structure (flat): //! - Status line (dynamic) -//! - Copy Last to Clipboard -//! - Show Chat Overlay -//! - Hold Hotkeys submenu (root level) -//! - Open history folder -//! - Quality status -//! - Help/About +//! - Show Agent / Open history / Copy last +//! - Notes ▸ / Diagnostics ▸ +//! - Quick Start / Help / About //! - Quit //! //! Note: Settings options moved to Settings tab in Chat Overlay @@ -16,39 +13,32 @@ use std::cell::RefCell; use anyhow::Result; use muda::accelerator::{Accelerator, Code, Modifiers}; -use muda::{Menu, MenuItem, PredefinedMenuItem}; +use muda::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tracing::debug; + +use codescribe_core::vad; use crate::config::Config; -use crate::tray::submenus::build_hold_hotkeys_submenu; -use crate::tray::types::MenuIds; +use crate::tray::state::NOTES_MENU_ITEMS; +use crate::tray::types::{MenuIds, NotesMenuItems}; // 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 COMPLETE_SETUP_MENU_ITEM: RefCell> = const { RefCell::new(None) }; } /// Build the tray menu /// -/// Menu structure: -/// ```text -/// Status: Idle -/// Copy Last to Clipboard -/// Show Chat Overlay -/// ───────────── -/// Hold Hotkeys ▸ -/// Open history... -/// Quality: OK -/// ───────────── -/// Help -/// About -/// ───────────── -/// Quit -/// ``` -/// /// Note: Settings moved to Settings tab in Chat Overlay pub fn build_menu() -> 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("Status: Idle", false, None); @@ -59,29 +49,72 @@ pub fn build_menu() -> Result<(Menu, MenuIds)> { *cell.borrow_mut() = Some(status_item); }); - // 2. Copy last to clipboard (quick action) - let copy_last_item = MenuItem::new("Copy Last to Clipboard", true, None); - let copy_last_id = copy_last_item.id().clone(); - menu.append(©_last_item)?; - - // 2a. Show Chat Overlay - let show_overlay_item = MenuItem::new("Show Chat Overlay", true, None); + // 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)?; - // 4. Separator - menu.append(&PredefinedMenuItem::separator())?; - - // 5. Hold Hotkeys submenu (root level) - let (hold_hotkeys_menu, hold_ids) = build_hold_hotkeys_submenu()?; - menu.append(&hold_hotkeys_menu)?; - - // 6. Open history folder + // 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)?; - // 6b. Quality menu item (shows pending mismatches from daemon) + // 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 state = crate::quality_loop::read_daemon_state(); let quality_label = if !state.available { "Quality: unavailable".to_string() @@ -92,16 +125,49 @@ pub fn build_menu() -> Result<(Menu, MenuIds)> { }; let quality_item = MenuItem::new(&quality_label, true, None); let quality_open_report_id = quality_item.id().clone(); - menu.append(&quality_item)?; + diagnostics_menu.append(&quality_item)?; // Store for dynamic updates QUALITY_MENU_ITEM.with(|cell| { *cell.borrow_mut() = Some(quality_item); }); - // 7. Separator + 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 show_complete_setup = crate::should_show_onboarding() || crate::should_show_bootstrap(); + let complete_setup_id = if show_complete_setup { + let complete_setup_item = MenuItem::new("Complete Setup...", true, None); + let id = complete_setup_item.id().clone(); + menu.append(&complete_setup_item)?; + COMPLETE_SETUP_MENU_ITEM.with(|cell| { + *cell.borrow_mut() = Some(complete_setup_item); + }); + Some(id) + } else { + COMPLETE_SETUP_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(); @@ -121,38 +187,27 @@ pub fn build_menu() -> Result<(Menu, MenuIds)> { let quit_id = quit_item.id().clone(); menu.append(&quit_item)?; - // Destructure hold_ids tuple - let ( - hold_ctrl_id, - hold_ctrl_opt_id, - hold_ctrl_shift_id, - hold_ctrl_cmd_id, - hold_exclusive_id, - toggle_double_opt_id, - toggle_double_ralt_id, - toggle_disabled_id, - ) = hold_ids; - Ok(( menu, MenuIds { copy_last: copy_last_id, show_overlay: show_overlay_id, + open_settings: settings_id, + complete_setup: complete_setup_id, open_history: open_history_id, + copy_diagnostics: copy_diag_id, help: help_id, about: about_id, quit: quit_id, - // Hold Hotkeys submenu - hold_ctrl: hold_ctrl_id, - hold_ctrl_opt: hold_ctrl_opt_id, - hold_ctrl_shift: hold_ctrl_shift_id, - hold_ctrl_cmd: hold_ctrl_cmd_id, - hold_exclusive: hold_exclusive_id, - toggle_double_opt: toggle_double_opt_id, - toggle_double_ralt: toggle_double_ralt_id, - toggle_disabled: toggle_disabled_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, }, )) } @@ -167,20 +222,6 @@ pub fn update_status_label(label: &str) { }); } -/// Toggle AI Formatting and persist to config -/// Note: Tray menu checkbox removed - settings now in Chat Overlay Settings tab -pub fn toggle_ai_formatting() -> bool { - // Read current state from Config (source of truth) - let current_state = Config::load().ai_formatting_enabled; - let new_state = !current_state; - - // Persist to config - let config = Config::load(); - let _ = config.save_to_env("AI_FORMATTING_ENABLED", if new_state { "1" } else { "0" }); - - new_state -} - /// Update the quality label in the menu /// Call this periodically to reflect daemon state changes pub fn update_quality_label() { @@ -200,25 +241,50 @@ pub fn update_quality_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_complete_setup_item() { + if crate::should_show_setup() { + return; + } + + let menu = ROOT_MENU.with(|cell| cell.borrow().clone()); + let complete_setup_item = COMPLETE_SETUP_MENU_ITEM.with(|cell| cell.borrow_mut().take()); + if let (Some(menu), Some(item)) = (menu, complete_setup_item) + && let Err(err) = menu.remove(&item) + { + debug!("Failed to remove Complete Setup menu item: {}", err); + COMPLETE_SETUP_MENU_ITEM.with(|cell| { + *cell.borrow_mut() = Some(item); + }); + } +} + #[cfg(test)] mod tests { fn menu_labels_for_test() -> Vec { - let state = crate::quality_loop::read_daemon_state(); - let quality_label = if !state.available { - "Quality: unavailable".to_string() - } else if state.pending_mismatches > 0 { - format!("Quality: {} pending", state.pending_mismatches) - } else { - "Quality: OK".to_string() - }; - vec![ "Status: Idle".to_string(), - "Copy Last to Clipboard".to_string(), - "Show Chat Overlay".to_string(), - "Hold Hotkeys".to_string(), + "Show Agent".to_string(), "Open history...".to_string(), - quality_label, + "Copy last transcript".to_string(), + "Settings".to_string(), "Help".to_string(), "About".to_string(), "Quit".to_string(), @@ -227,9 +293,9 @@ mod tests { #[test] #[cfg(target_os = "macos")] - fn tray_menu_includes_show_chat_overlay() { + fn tray_menu_includes_show_agent() { let labels = menu_labels_for_test(); - let found = labels.iter().any(|label| label == "Show Chat Overlay"); - assert!(found, "Show Chat Overlay menu item missing"); + let found = labels.iter().any(|label| label == "Show Agent"); + assert!(found, "Show Agent menu item missing"); } } diff --git a/app/ui/tray/mod.rs b/app/ui/tray/mod.rs index 98817eb7..38850633 100644 --- a/app/ui/tray/mod.rs +++ b/app/ui/tray/mod.rs @@ -9,31 +9,21 @@ //! - `icons` - Icon rendering and status glyph management //! - `state` - Cross-thread channels for status updates //! - `menu` - Menu building logic -//! - `submenus` - Submenu building functions //! - `handlers` - Menu action handlers //! //! ## Menu Structure //! //! ```text -//! Status: Done! -//! ───────────── -//! [✓] AI Formatting -//! Copy Last to Clipboard -//! ───────────── -//! Settings ▸ -//! ├── Hold Hotkeys ▸ -//! │ ├── Ctrl only -//! │ ├── Ctrl+Option -//! │ ├── Ctrl+Shift -//! │ └── Ctrl+Command -//! ├── Recent Transcripts ▸ -//! │ ├── [5 entries] -//! │ └── Open Folder -//! └── Edit Config File -//! ───────────── +//! Status: Idle +//! Show Agent +//! Open history... +//! Copy last transcript +//! Notes ▸ +//! Diagnostics ▸ +//! Complete Setup... (when setup is incomplete) +//! Settings //! Help //! About -//! ───────────── //! Quit //! ``` @@ -41,7 +31,6 @@ mod handlers; mod icons; mod menu; mod state; -mod submenus; mod types; use std::sync::OnceLock; @@ -57,7 +46,9 @@ use tracing::{debug, info}; use tray_icon::{TrayIconBuilder, menu::MenuEvent}; // Re-export public API -pub use menu::{toggle_ai_formatting, update_quality_label}; +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}; @@ -94,11 +85,17 @@ pub fn is_shutdown_requested() -> bool { /// Run the tray application (blocking) /// /// Uses tao event loop for proper macOS integration. -/// Optionally accepts a HotkeyManager to process hotkey events in the same loop. pub fn run() -> Result<()> { run_with_hotkeys(None) } +fn shutdown_hotkeys(hotkey_manager: &mut Option) { + if let Some(hk_manager) = hotkey_manager.as_mut() { + hk_manager.shutdown(); + } + *hotkey_manager = None; +} + /// Run the tray application with optional hotkey manager /// /// The hotkey manager must be created on main thread before calling this. @@ -111,12 +108,16 @@ pub fn run() -> Result<()> { /// - Status channel is disconnected /// /// On exit, cleanup is performed: -/// - Hotkey manager is dropped (unregisters hotkeys) +/// - 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<()> { 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)); @@ -154,6 +155,8 @@ pub fn run_with_hotkeys(hotkey_manager: Option) -> Resul // Poll interval for checking channels let poll_interval = Duration::from_millis(100); + let mut last_menu_refresh = Instant::now(); + let mut hotkey_manager = hotkey_manager; // Run the event loop event_loop.run(move |event, _, control_flow| { @@ -161,22 +164,26 @@ pub fn run_with_hotkeys(hotkey_manager: Option) -> Resul *control_flow = ControlFlow::WaitUntil(Instant::now() + poll_interval); // Handle dock icon click (macOS Reopen event) - // Note: GUI was removed, dock click now just logs the event if let Event::Reopen { .. } = event { - debug!("Dock icon clicked (no GUI available)"); + debug!("Dock icon clicked → opening Settings window"); + crate::show_bootstrap_overlay(); return; } // Check for programmatic shutdown request if is_shutdown_requested() { info!("Shutdown flag detected, performing cleanup..."); + shutdown_hotkeys(&mut hotkey_manager); *control_flow = ControlFlow::Exit; return; } - // Process hotkey events (integrated with main event loop for macOS) - if let Some(ref hk_manager) = hotkey_manager { - hk_manager.process_events(); + // 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_complete_setup_item(); + last_menu_refresh = Instant::now(); } // Check for status updates (non-blocking) @@ -204,6 +211,7 @@ pub fn run_with_hotkeys(hotkey_manager: Option) -> Resul Err(TryRecvError::Empty) => {} Err(TryRecvError::Disconnected) => { info!("Status channel closed, exiting"); + shutdown_hotkeys(&mut hotkey_manager); *control_flow = ControlFlow::Exit; } } @@ -217,13 +225,14 @@ pub fn run_with_hotkeys(hotkey_manager: Option) -> Resul // Handle Quit specially to exit event loop if event.id == menu_ids.quit { info!("Quit requested via menu, exiting..."); + shutdown_hotkeys(&mut hotkey_manager); *control_flow = ControlFlow::Exit; } } }); - // Note: This code is unreachable because event_loop.run() never returns - // on macOS. Cleanup happens when the closures are dropped. + // Note: This code is unreachable because event_loop.run() never returns on macOS. + // Hotkeys are shut down in-loop before requesting exit. } // ============================================================================ diff --git a/app/ui/tray/state.rs b/app/ui/tray/state.rs index d63ea190..8376f392 100644 --- a/app/ui/tray/state.rs +++ b/app/ui/tray/state.rs @@ -9,15 +9,14 @@ use crossbeam_channel::{Receiver, Sender, unbounded}; use tracing::debug; use crate::tray::menu::update_status_label; -use crate::tray::types::{HoldMenuItems, ToggleMenuItems, TrayMenuEvent, TrayStatus}; +use crate::tray::types::{NotesMenuItems, TrayMenuEvent, TrayStatus}; // ============================================================================ // Thread-local Menu Item Storage // ============================================================================ thread_local! { - pub static HOLD_MENU_ITEMS: RefCell> = const { RefCell::new(None) }; - pub static TOGGLE_MENU_ITEMS: RefCell> = const { RefCell::new(None) }; + pub static NOTES_MENU_ITEMS: RefCell> = const { RefCell::new(None) }; } // ============================================================================ diff --git a/app/ui/tray/submenus.rs b/app/ui/tray/submenus.rs deleted file mode 100644 index d9a1e8b2..00000000 --- a/app/ui/tray/submenus.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Submenu building functions for the tray menu -//! -//! Each function builds a specific submenu and returns its IDs. - -use anyhow::Result; -use muda::{CheckMenuItem, MenuId, MenuItem, PredefinedMenuItem, Submenu}; - -use crate::tray::state::{HOLD_MENU_ITEMS, TOGGLE_MENU_ITEMS}; -use crate::tray::types::{HoldMenuItems, HoldMods, ToggleMenuItems}; - -// Type aliases -pub type HoldMenuIds = ( - MenuId, - MenuId, - MenuId, - MenuId, - MenuId, - MenuId, - MenuId, - MenuId, -); - -/// Build the Hold Hotkeys submenu -pub fn build_hold_hotkeys_submenu() -> Result<(Submenu, HoldMenuIds)> { - let hold_menu = Submenu::new("Hold Hotkeys", true); - - // Read from Config (source of truth for initial state) - let config = crate::config::Config::load(); - let current_mods = config.hold_mods; - let current_trigger = config.toggle_trigger; - - let hold_current_label = - MenuItem::new(format!("Current: {}", current_mods.label()), false, None); - hold_menu.append(&hold_current_label)?; - hold_menu.append(&PredefinedMenuItem::separator())?; - - let hold_ctrl = CheckMenuItem::new( - format!("Hold: {}", HoldMods::Ctrl.label()), - true, - current_mods == HoldMods::Ctrl, - None, - ); - let hold_ctrl_id = hold_ctrl.id().clone(); - let hold_ctrl_opt = CheckMenuItem::new( - format!("Hold: {}", HoldMods::CtrlAlt.label()), - true, - current_mods == HoldMods::CtrlAlt, - None, - ); - let hold_ctrl_opt_id = hold_ctrl_opt.id().clone(); - let hold_ctrl_shift = CheckMenuItem::new( - format!("Hold: {}", HoldMods::CtrlShift.label()), - true, - current_mods == HoldMods::CtrlShift, - None, - ); - let hold_ctrl_shift_id = hold_ctrl_shift.id().clone(); - let hold_ctrl_cmd = CheckMenuItem::new( - format!("Hold: {}", HoldMods::CtrlCmd.label()), - true, - current_mods == HoldMods::CtrlCmd, - None, - ); - let hold_ctrl_cmd_id = hold_ctrl_cmd.id().clone(); - - hold_menu.append(&hold_ctrl)?; - hold_menu.append(&hold_ctrl_opt)?; - hold_menu.append(&hold_ctrl_shift)?; - hold_menu.append(&hold_ctrl_cmd)?; - hold_menu.append(&PredefinedMenuItem::separator())?; - - let hold_exclusive = CheckMenuItem::new( - "Exclusive (ignore extra modifiers)", - true, - config.hold_exclusive, - None, - ); - let hold_exclusive_id = hold_exclusive.id().clone(); - hold_menu.append(&hold_exclusive)?; - hold_menu.append(&PredefinedMenuItem::separator())?; - - let toggle_label = MenuItem::new(format!("Toggle: {}", current_trigger.label()), false, None); - hold_menu.append(&toggle_label)?; - let toggle_double_opt = CheckMenuItem::new( - "Enable left Option (normal) + right Option (assistive)", - true, - current_trigger == crate::config::ToggleTrigger::DoubleOption, - None, - ); - let toggle_double_opt_id = toggle_double_opt.id().clone(); - let toggle_double_ralt = CheckMenuItem::new( - "Enable right Option (assistive only)", - true, - current_trigger == crate::config::ToggleTrigger::DoubleRightOption, - None, - ); - let toggle_double_ralt_id = toggle_double_ralt.id().clone(); - let toggle_disabled = CheckMenuItem::new( - "Disable toggles", - true, - current_trigger == crate::config::ToggleTrigger::None, - None, - ); - let toggle_disabled_id = toggle_disabled.id().clone(); - - hold_menu.append(&toggle_double_opt)?; - hold_menu.append(&toggle_double_ralt)?; - hold_menu.append(&toggle_disabled)?; - - HOLD_MENU_ITEMS.with(|items_cell| { - *items_cell.borrow_mut() = Some(HoldMenuItems { - ctrl: hold_ctrl, - ctrl_opt: hold_ctrl_opt, - ctrl_shift: hold_ctrl_shift, - ctrl_cmd: hold_ctrl_cmd, - label: hold_current_label, - }); - }); - - TOGGLE_MENU_ITEMS.with(|items_cell| { - *items_cell.borrow_mut() = Some(ToggleMenuItems { - double_opt: toggle_double_opt, - double_ralt: toggle_double_ralt, - disabled: toggle_disabled, - label: toggle_label, - }); - }); - - Ok(( - hold_menu, - ( - hold_ctrl_id, - hold_ctrl_opt_id, - hold_ctrl_shift_id, - hold_ctrl_cmd_id, - hold_exclusive_id, - toggle_double_opt_id, - toggle_double_ralt_id, - toggle_disabled_id, - ), - )) -} diff --git a/app/ui/tray/types.rs b/app/ui/tray/types.rs index 4fc7b0f6..fec6998a 100644 --- a/app/ui/tray/types.rs +++ b/app/ui/tray/types.rs @@ -3,15 +3,12 @@ //! Contains all enums and structs used by the tray system. use anyhow::Result; -use muda::{CheckMenuItem, MenuId, MenuItem}; +use muda::{CheckMenuItem, MenuId}; use tracing::debug; use tray_icon::Icon; use crate::tray::icons::{create_fallback_icon, load_custom_icon}; -// Re-export config enums for menu use (single source of truth) -pub use crate::config::{HoldMods, ToggleTrigger}; - /// Status of the CodeScribe system, reflected in tray icon #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TrayStatus { @@ -66,8 +63,6 @@ impl TrayStatus { pub enum TrayMenuEvent { /// Copy last transcript to clipboard CopyLast, - /// Open settings file in editor - OpenSettings, /// Open help/documentation in browser OpenHelp, /// Show about dialog @@ -75,34 +70,26 @@ pub enum TrayMenuEvent { /// User clicked Quit - clean shutdown Quit, - // Hold Hotkeys submenu - SetHoldMods(HoldMods), - ToggleHoldExclusive, - SetToggleTrigger(ToggleTrigger), - // History (open folder) OpenHistoryFolder, + + // Diagnostics + CopyDiagnostics, + InstallSileroVad, + + // Notes + SetQuickNotesEnabled(bool), + SetQuickNotesSaveOnly(bool), } // ============================================================================ // Menu Item Storage Structs // ============================================================================ -/// Hold Hotkeys menu items for radio-button behavior -pub struct HoldMenuItems { - pub ctrl: CheckMenuItem, - pub ctrl_opt: CheckMenuItem, - pub ctrl_shift: CheckMenuItem, - pub ctrl_cmd: CheckMenuItem, - pub label: MenuItem, -} - -/// Toggle Trigger menu items for radio-button behavior -pub struct ToggleMenuItems { - pub double_opt: CheckMenuItem, - pub double_ralt: CheckMenuItem, - pub disabled: CheckMenuItem, - pub label: MenuItem, +/// Notes menu items that need runtime updates. +pub struct NotesMenuItems { + pub quick_notes_toggle: CheckMenuItem, + pub quick_notes_save_only: CheckMenuItem, } // ============================================================================ @@ -115,21 +102,23 @@ pub struct MenuIds { // Top-level pub copy_last: MenuId, pub show_overlay: MenuId, + pub open_settings: MenuId, + pub complete_setup: Option, pub open_history: MenuId, + pub copy_diagnostics: MenuId, pub help: MenuId, pub about: MenuId, pub quit: MenuId, - // Hold Hotkeys submenu - pub hold_ctrl: MenuId, - pub hold_ctrl_opt: MenuId, - pub hold_ctrl_shift: MenuId, - pub hold_ctrl_cmd: MenuId, - pub hold_exclusive: MenuId, - pub toggle_double_opt: MenuId, - pub toggle_double_ralt: MenuId, - pub toggle_disabled: 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.rs b/app/ui/voice_chat/api.rs index 9a5f6fca..364362c9 100644 --- a/app/ui/voice_chat/api.rs +++ b/app/ui/voice_chat/api.rs @@ -3,23 +3,35 @@ //! Contains all the public functions for controlling the overlay and //! internal helper functions for state updates. -use core_graphics::geometry::CGPoint; +use core_graphics::geometry::{CGPoint, CGRect, CGSize}; use dispatch::Queue; use objc::runtime::{Class, Object}; use objc::{msg_send, sel, sel_impl}; -use std::time::SystemTime; +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tracing::{debug, info, warn}; +use chrono::{DateTime, Local}; + +use codescribe_core::agent::{Thread, ThreadIndex, ThreadStore}; +use codescribe_core::attachment::Attachment; + +use crate::ui::shared::status::{UiStatus, status_from_detail}; use crate::ui_helpers::{ - BubbleConfig, BubbleRole, create_bubble_view, create_card_view, get_text_field_string, - list_draft_files, ns_string, open_file_in_editor, set_text_field_string, stack_view_add, - stack_view_clear, + BubbleConfig, BubbleRole, LabelConfig, add_subview, apply_tafla_surface, button_set_action, + button_style, chat_header_layout, color_label, color_rgba, color_secondary_label, + create_bubble_view, create_button, create_card_view, create_label, get_text_field_string, + get_text_view_string, layout_region_frame_for_view, 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, ui_colors, ui_tokens, + update_bubble_text, window_set_alpha, window_show, }; use super::handlers::{clear_search_field, copy_to_clipboard}; use super::state::{ - ChatMessage, ChatRole, ConversationModeState, DrawerEntry, OVERLAY_STATE, SEND_CALLBACK, Tab, - TranscriptionMode, VoiceChatOverlayState, + ChatMessage, ChatRole, ConversationModeState, DrawerEntry, DrawerEntrySource, OVERLAY_STATE, + SEND_CALLBACK, Tab, TranscriptionMode, VoiceChatOverlayState, }; // Type alias for Objective-C object pointers @@ -37,6 +49,49 @@ pub fn update_voice_chat_status(status: &str) { }); } +/// 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); + }); +} + +/// 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 || { + append_voice_chat_user_delta_impl(&delta_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(); @@ -53,16 +108,27 @@ pub fn set_voice_chat_text(text: &str) { }); } +/// 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 || { let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.active_assistant_stream_index = None; + let mode = message_mode_label(&state); state.messages.push(ChatMessage { role: ChatRole::System, text: text_owned.clone(), is_streaming: false, is_error: true, + timestamp: SystemTime::now(), + mode: Some(mode), }); state.is_sending = false; update_chat_view_with_state(&mut state, true); @@ -70,21 +136,79 @@ pub fn add_voice_chat_error_message(text: &str) { }); } +/// 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 || { + 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_owned.clone(), + is_streaming: false, + is_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + 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 || { 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_owned, is_streaming: false, is_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), }); 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); + }); +} + +/// 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(|| { @@ -119,6 +243,45 @@ pub fn clear_voice_chat_text() { }); } +/// Start a fresh Agent thread by rotating backend runtime first, then clearing UI state. +pub(super) 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}" + )); + } + }); + }); +} + /// 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()); @@ -150,12 +313,54 @@ pub fn refresh_drawer() { }); } +/// 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 + } +} + /// Filter drawer entries by query (reloads from disk) pub fn filter_drawer(query: &str) { let query_owned = query.to_string(); Queue::main().exec_async(move || { let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.drawer_entries = load_drawer_entries(); + state.drawer_entries = load_drawer_entries_with_query(&query_owned); render_drawer_entries(&mut state, &query_owned); }); } @@ -163,6 +368,14 @@ pub fn filter_drawer(query: &str) { /// 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); }); } @@ -170,10 +383,41 @@ pub fn show_agent_tab() { /// 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); }); } +/// Switch to Settings tab programmatically +pub fn show_settings_tab() { + Queue::main().exec_async(|| { + update_active_tab_impl(Tab::Settings); + }); +} + +/// Request Settings tab to be shown the next time the overlay is created. +/// This is used when routing tray "Settings" to the overlay before it exists. +pub fn request_settings_tab_on_open() { + show_settings_tab(); +} + +/// 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 || { @@ -204,401 +448,3121 @@ pub fn is_conversation_active() -> bool { // ═══════════════════════════════════════════════════════════ pub fn update_active_tab_impl(tab: Tab) { - unsafe { + if tab == Tab::Settings { + // Settings lives in a separate NSWindow; keep chat overlay alive. + crate::show_bootstrap_overlay(); + return; + } + + // 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, + 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.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); + } + + // 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); + } + + // 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]; + } + } + } +} + +fn update_active_tab_locked(state: &mut VoiceChatOverlayState, tab: Tab) { + unsafe { + if tab == Tab::Settings { + return; + } + let prev_tab = state.active_tab; state.active_tab = tab; - if let Some(tab_control) = state.tab_control { - let _: () = msg_send![tab_control as Id, setSelectedSegment: if tab == Tab::Drawer { 0_isize } else { 1_isize }]; + 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_drawer); + crate::ui_helpers::set_hidden(agent_view as Id, !show_agent); } - if let Some(agent_input) = state.agent_input_field { - let superview: Id = msg_send![agent_input as Id, superview]; - if !superview.is_null() { - crate::ui_helpers::set_hidden(superview, show_drawer); - } else { - crate::ui_helpers::set_hidden(agent_input as Id, show_drawer); - } + 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_drawer); + crate::ui_helpers::set_hidden(agent_send as Id, !show_agent); } - } -} -fn update_voice_chat_status_impl(status: &str) { - unsafe { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(title_label) = state.title_label { - let title = format!("CodeScribe — {}", status); - let ns_str = ns_string(&title); - let _: () = msg_send![title_label as Id, setStringValue: ns_str]; + 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); + } + + // 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]; + } } } } -fn append_voice_chat_assistant_delta_impl(delta: &str) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_streaming_assistant_message(&mut state); - if let Some(last) = state.messages.last_mut() { - last.text.push_str(delta); - last.is_streaming = true; +/// Reflow Agent layout after the overlay window was resized. +/// +/// Without this, long messages can look clipped until the next message arrives. +pub(super) 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); } -fn finalize_assistant_message_impl(text: &str, is_error: bool) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - ensure_streaming_assistant_message(&mut state); - if let Some(last) = state.messages.last_mut() { - last.text = text.to_string(); - last.is_streaming = false; - last.is_error = is_error; - } - state.is_sending = false; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); +/// Lightweight layout pass for window resizing (keeps inputs/footers aligned). +pub(super) 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(super) fn clear_voice_chat_text_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.messages.clear(); - state.manual_draft.clear(); - state.is_sending = false; +fn reflow_header_controls_locked(state: &mut VoiceChatOverlayState) { + unsafe { + let ( + Some(title_ptr), + Some(drawer_ptr), + Some(agent_ptr), + Some(settings_ptr), + Some(favorites_ptr), + Some(status_ptr), + ) = ( + state.title_label, + state.tab_drawer_button, + state.tab_agent_button, + state.tab_settings_button, + state.favorites_button, + state.status_pill, + ) + else { + return; + }; - if let Some(input_field) = state.agent_input_field { - unsafe { - set_text_field_string(input_field as Id, ""); - } - } + let title_label = title_ptr as Id; + 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; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); -} + 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); -/// 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 Some(input_field) = state.agent_input_field else { - return; + let title_frame: CGRect = msg_send![title_label, frame]; + let header_controls: Id = msg_send![title_label, superview]; + let header_width = if header_controls.is_null() { + 0.0 + } else { + let bounds: CGRect = msg_send![header_controls, bounds]; + bounds.size.width }; - let draft = unsafe { get_text_field_string(input_field as Id) }; - let draft = draft.trim().to_string(); - if draft.is_empty() { - return; + let title_x = if header_width >= 620.0 { + ui_tokens::TRAFFIC_LIGHTS_SPACER_WIDTH + 6.0 + } else { + ui_tokens::EDGE_PADDING_TIGHT + }; + let title_max_w = + (right_cluster_start_x - title_x - ui_tokens::CHAT_HEADER_GROUP_GAP * 2.0).max(56.0); + let title_w = ui_tokens::CHAT_TITLE_LABEL_WIDTH.min(title_max_w); + if (title_w - title_frame.size.width).abs() > 0.5 + || (title_x - title_frame.origin.x).abs() > 0.5 + { + let resized_title = CGRect::new( + &CGPoint::new(title_x, title_frame.origin.y), + &CGSize::new(title_w, title_frame.size.height), + ); + let _: () = msg_send![title_label, setFrame: resized_title]; } - state.messages.push(ChatMessage { - role: ChatRole::User, - text: draft.clone(), - is_streaming: false, - is_error: false, - }); - state.manual_draft.clear(); - state.is_sending = true; - unsafe { - set_text_field_string(input_field as Id, ""); + let title_frame: CGRect = msg_send![title_label, frame]; + + let layout = chat_header_layout( + title_frame.origin.x, + title_frame.size.width, + 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]; } - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - let handler = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - (handler.clone(), draft) - }; - if let (Some(handler), draft) = callback { - handler(draft); - } 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); + 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(super) 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 { +fn reflow_footer_controls_locked(state: &mut VoiceChatOverlayState) { + unsafe { + let Some(blur_ptr) = state.blur_view else { return; }; - if last_message.role != ChatRole::User { - 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]; } - let text = last_message.text.clone(); - state.is_sending = true; - update_chat_view_with_state(&mut state, true); - update_send_button_with_state(&mut state); - let handler = SEND_CALLBACK.lock().unwrap_or_else(|e| e.into_inner()); - (handler.clone(), text) - }; - if let (Some(handler), text) = callback { - handler(text); - } 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); + 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]; + } + + let header_height = ui_tokens::HEADER_HEIGHT_COMPACT; + 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 - header_height - footer_height - content_gap * 2.0) + .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]; + } + } } } -pub(super) fn discard_last_message_impl() { +fn update_voice_chat_status_impl(status: &str) { let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if state.messages.pop().is_some() { + 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()); +} + +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()); +} + +fn status_kind_for_runtime(base_status: &str, runtime_degraded: bool) -> UiStatus { + if runtime_degraded { + UiStatus::Error + } else { + status_from_detail(base_status) + } +} + +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)") + } +} + +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); +} + +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); + } +} + +/// Minimum interval between layout passes during streaming (prevents main-thread saturation). +const DELTA_LAYOUT_THROTTLE: Duration = Duration::from_millis(50); + +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 +} + +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(|| { + 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); + } + } + }); + }); + } + } +} + +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 = 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; + } + apply_delta_and_layout(&mut state, Some(idx)); +} + +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); + 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)); +} + +fn display_text_for_message(message: &ChatMessage) -> String { + if message.is_streaming && message.text.is_empty() { + "• • •".to_string() + } else if message.is_streaming { + format!("{} …", message.text) + } else { + message.text.clone() + } +} + +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() + } +} + +fn message_role_label(role: ChatRole) -> &'static str { + match role { + ChatRole::User => "You", + ChatRole::Assistant => "Assistant", + ChatRole::System => "System", + } +} + +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); + if let Some(mode) = message.mode.as_ref() { + format!("{role} · {time} · {mode}") + } else { + format!("{role} · {time}") + } +} + +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) +} + +unsafe fn sync_agent_document_view_size(state: &VoiceChatOverlayState, max_width: f64) { + let Some(container_ptr) = state.agent_container else { + return; + }; + let container = container_ptr as Id; + + // 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)); + } + + let _: () = msg_send![container, setFrameSize: CGSize::new(max_width, total_h)]; + 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]; + } + } +} + +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 container = bubble_ptr as Id; + let label = label_ptr as Id; + let bubble_role = match message.role { + ChatRole::User => BubbleRole::User, + ChatRole::Assistant => BubbleRole::Assistant, + ChatRole::System => BubbleRole::System, + }; + update_bubble_text( + label, + &message.text, + bubble_role, + message.is_streaming, + message.is_error, + ); + let display_text = display_text_for_message(message); + resize_bubble_container_for_text(container, label, &display_text); + let max_width = agent_max_width(state); + sync_agent_document_view_size(state, max_width); + + // Keep the latest message in view while streaming. + if index + 1 == state.agent_bubble_views.len() + && let Some(scroll_view_ptr) = state.agent_scroll_view + { + let _ = scroll_view_ptr; + let bounds: CGRect = msg_send![container, bounds]; + // Scroll to the bottom edge of the bubble; if the bubble is taller than the viewport, + // scrolling the whole bounds can keep the top visible and never reach the bottom. + let y = (bounds.size.height - 2.0).max(0.0); + let rect = CGRect::new(&CGPoint::new(0.0, y), &CGSize::new(bounds.size.width, 2.0)); + let _: () = msg_send![container, scrollRectToVisible: rect]; + } + true + } +} + +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 = if let Some(idx) = state.active_user_stream_index.take() { + if is_valid_stream_message(&state, idx, ChatRole::User) { + idx + } else { + let mode = message_mode_label(&state); + state.messages.push(ChatMessage { + role: ChatRole::User, + text: String::new(), + is_streaming: false, + is_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + state.messages.len() - 1 + } + } else { + let mode = message_mode_label(&state); + state.messages.push(ChatMessage { + role: ChatRole::User, + text: String::new(), + is_streaming: false, + is_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + 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 = false; + } + update_chat_view_with_state(&mut state, true); +} + +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); +} + +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_error, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + 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_error, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + 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; + } + state.is_sending = false; + update_chat_view_with_state(&mut state, true); + update_send_button_with_state(&mut state); +} + +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; + } + state.is_sending = false; + update_chat_view_with_state(&mut state, true); + update_send_button_with_state(&mut state); +} + +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); + } + } +} + +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_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + 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(super) 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.manual_draft.clear(); + 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_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + } + + let mode = message_mode_label(&state); + state.messages.push(ChatMessage { + role: ChatRole::User, + text: draft.clone(), + is_streaming: false, + is_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + state.manual_draft.clear(); + 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(super) 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_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + } + 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(super) 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; + } + update_chat_view_with_state(&mut state, true); + } +} + +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::System => None, + } +} + +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::System => None, + } +} + +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) +} + +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_error: false, + timestamp: SystemTime::now(), + mode: Some(mode), + }); + let idx = state.messages.len() - 1; + if let Some(active_idx) = active_stream_index_mut(state, role) { + *active_idx = Some(idx); + } + idx +} + +pub(super) 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; + stack_view_clear(container); + state.agent_bubble_views.clear(); + + // 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 last_bubble: Option = None; + for (index, message) in state.messages.iter().enumerate() { + let role = match message.role { + ChatRole::User => BubbleRole::User, + ChatRole::Assistant => BubbleRole::Assistant, + ChatRole::System => BubbleRole::System, + }; + let (bubble, text_label) = create_bubble_view(BubbleConfig { + text: message.text.clone(), + role, + max_width, + font_size: base_font * zoom, + is_streaming: message.is_streaming, + is_error: message.is_error, + metadata: Some(message_metadata(message)), + message_index: Some(index), + copy_action_target: state.action_handler.map(|p| p as Id), + }); + 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 == state.messages.len() - 1 + && !state.auto_send_enabled + && !state.is_sending + { + let action_bar = create_commit_action_bar(state.action_handler); + stack_view_add(container, action_bar); + } + } + + // 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); + + if scroll_to_bottom { + if let Some(bubble) = last_bubble { + 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, 2.0)); + let _: () = msg_send![bubble, scrollRectToVisible: rect]; + } else if let Some(scroll_view_ptr) = state.agent_scroll_view { + let scroll_view = scroll_view_ptr as Id; + let content_view: Id = msg_send![scroll_view, contentView]; + let _: () = msg_send![content_view, scrollToPoint: CGPoint::new(0.0, 0.0)]; + let _: () = msg_send![scroll_view, reflectScrolledClipView: content_view]; + } + } + } +} + +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(super) 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)]; + } + } +} + +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)) +} + +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) + } +} + +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 +// ═══════════════════════════════════════════════════════════ + +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(super) fn render_attachment_chips(state: &mut VoiceChatOverlayState) { + render_attachment_chips_locked(state); +} + +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. +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 +} + +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); +} + +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 agent scroll frame needs updating (e.g. chip strip toggled). + // We compare the actual frame origin against the expected bottom rather + // than checking 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 scroll frame 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_frame: CGRect = msg_send![agent_scroll, frame]; + 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_frame.origin.y - 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 + }; + + // Resize Agent scroll view so it doesn't overlap with input + chips. + if let Some(agent_scroll_ptr) = state.agent_scroll_view { + let agent_scroll = agent_scroll_ptr as Id; + let bottom = footer_inset + desired_h + input_gap + chip_strip_extra; + let top = content_height - gap; + let new_agent_frame = CGRect::new( + &CGPoint::new(pad, bottom), + &CGSize::new( + (content_width - pad * 2.0).max(0.0), + (top - bottom).max(0.0), + ), + ); + let _: () = msg_send![agent_scroll, setFrame: new_agent_frame]; + + if let Some(container_ptr) = state.agent_container { + let container = container_ptr as Id; + let container_frame: CGRect = msg_send![container, frame]; + // 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, container_frame.size.height); + let _: () = msg_send![container, setFrameSize: new_size]; + } + } + } +} + +fn create_commit_action_bar(action_handler: Option) -> Id { + unsafe { + let ns_view = Class::get("NSView").unwrap(); + 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 + } +} + +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 window_ptr = { + let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + let window_ptr = state.window.take(); + clear_overlay_state(&mut state); + window_ptr + }; + + if let Some(window_ptr) = window_ptr { + unsafe { + let window = window_ptr as Id; + crate::ui_helpers::animate_fade(window, 0.0, 0.15); + crate::ui_helpers::window_close(window); + } + } + + clear_search_field(); +} + +pub fn clear_overlay_state(state: &mut VoiceChatOverlayState) { + state.window = None; + state.window_delegate = 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(); + 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.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.is_sending = false; + state.manual_draft.clear(); + state.conversation_state = ConversationModeState::Inactive; +} + +fn refresh_drawer_impl() { + let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + state.favorites = load_favorites_from_disk(); + let query = drawer_query_from_state(&state); + state.drawer_entries = load_drawer_entries(); + render_drawer_entries(&mut state, &query); +} + +pub fn handle_card_copy(index: usize) { + let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(entry) = state.drawer_entries.get(index) { + if is_drawer_unavailable_placeholder(entry) { + return; + } + match &entry.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)); + return; + } + if let Ok(raw) = std::fs::read_to_string(&entry.path) { + copy_to_clipboard(&raw); + } + } + } + DrawerEntrySource::LegacyFile => { + if let Ok(contents) = std::fs::read_to_string(&entry.path) { + copy_to_clipboard(&contents); + } + } + } } } -fn ensure_streaming_assistant_message(state: &mut VoiceChatOverlayState) { - let needs_new = state - .messages - .last() - .is_none_or(|msg| msg.role != ChatRole::Assistant || !msg.is_streaming); - if needs_new { - state.messages.push(ChatMessage { - role: ChatRole::Assistant, - text: String::new(), - is_streaming: true, - is_error: false, +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(super) fn update_chat_view_with_state( - state: &mut VoiceChatOverlayState, - scroll_to_bottom: bool, -) { +pub fn handle_card_delete(index: usize) { + let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(entry) = state.drawer_entries.get(index) { + if is_drawer_unavailable_placeholder(entry) { + return; + } + let favorite_key = drawer_entry_favorite_key(entry); + match &entry.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(&entry.path) { + warn!( + "Failed to delete thread fallback {}: {}", + entry.path.display(), + err + ); + } + } + DrawerEntrySource::LegacyFile => { + if let Err(err) = std::fs::remove_file(&entry.path) { + warn!("Failed to delete {}: {}", entry.path.display(), err); + } + } + } + state.favorites.remove(&favorite_key); + save_favorites_to_disk(&state.favorites); + } + state.favorites = load_favorites_from_disk(); + let query = drawer_query_from_state(&state); + state.drawer_entries = load_drawer_entries_with_query(&query); + render_drawer_entries(&mut state, &query); +} + +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); + } + save_favorites_to_disk(&state.favorites); + + 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}"); + } + update_favorites_button_with_state(&mut state); + let query = drawer_query_from_state(&state); + render_drawer_entries(&mut state, &query); +} + +pub(super) 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); +} + +fn update_favorites_button_with_state(state: &mut VoiceChatOverlayState) { unsafe { - let Some(container_ptr) = state.agent_container else { + 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]; + } + } +} + +fn drawer_query_from_state(state: &VoiceChatOverlayState) -> String { + state + .search_field + .map(|field| unsafe { get_text_field_string(field as Id) }) + .unwrap_or_default() +} + +fn drawer_entry_matches_query(entry: &DrawerEntry, query_lower: &str) -> bool { + if query_lower.is_empty() { + return true; + } + let path = entry.path.to_string_lossy(); + let mut haystack = + String::with_capacity(path.len() + entry.preview.len() + entry.search_corpus.len() + 96); + haystack.push_str(entry_type_label(entry)); + haystack.push(' '); + haystack.push_str(mode_label(entry.mode)); + haystack.push(' '); + haystack.push_str(&path); + haystack.push(' '); + if let Some(file_name) = entry.path.file_name().and_then(|name| name.to_str()) { + haystack.push_str(file_name); + 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) +} + +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 +} + +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); - state.agent_bubble_views.clear(); - for (index, message) in state.messages.iter().enumerate() { - let role = match message.role { - ChatRole::User => BubbleRole::User, - ChatRole::Assistant => BubbleRole::Assistant, - ChatRole::System => BubbleRole::System, - }; - let (bubble, text_label) = create_bubble_view(BubbleConfig { - text: message.text.clone(), - role, - max_width: 390.0, - is_streaming: message.is_streaming, - is_error: message.is_error, - message_index: if message.role == ChatRole::Assistant { - Some(index) - } else { - None - }, - copy_action_target: state.action_handler.map(|p| p as Id), - }); - stack_view_add(container, bubble); - state - .agent_bubble_views - .push((bubble as usize, text_label as usize)); + let visible = filtered_drawer_entries(state, query); + for (index, entry) in visible.iter() { + let card = create_drawer_card(entry, *index, state.action_handler, query); + stack_view_add(container, card); + } - // Add commit/discard action bar for draft user messages - if message.role == ChatRole::User - && index == state.messages.len() - 1 - && !state.auto_send_enabled - && !state.is_sending - { - let action_bar = create_commit_action_bar(state.action_handler); - stack_view_add(container, action_bar); - } + 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); } - if scroll_to_bottom && let Some(scroll_view_ptr) = state.agent_scroll_view { + // 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 fitting: CGSize = msg_send![container, fittingSize]; + let frame: CGRect = msg_send![container, frame]; + let new_size = CGSize::new(frame.size.width, fitting.height.max(frame.size.height)); + let _: () = msg_send![container, setFrameSize: new_size]; + + // Scroll to top on refresh/filter. let scroll_view = scroll_view_ptr as Id; let content_view: Id = msg_send![scroll_view, contentView]; - let _: () = msg_send![content_view, scrollToPoint: CGPoint::new(0.0, f64::MAX)]; + let _: () = msg_send![content_view, scrollToPoint: CGPoint::new(0.0, 0.0)]; + let _: () = msg_send![scroll_view, reflectScrolledClipView: content_view]; } } } -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 title = if state.is_sending { "…" } else { ">" }; - let title = ns_string(title); - let _: () = msg_send![btn, setTitle: title]; - } +fn create_drawer_empty_state(width: f64, handler: Option) -> Id { + fn overlay_hotkey_shortcuts_tooltip() -> String { + let (hold, toggle) = super::shortcuts_lines(crate::os::hotkeys::ModeHotkeyBindings::load()); + format!("{hold}\n{toggle}") } -} -fn create_commit_action_bar(action_handler: Option) -> Id { unsafe { let ns_view = Class::get("NSView").unwrap(); - 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( + let frame = CGRect::new( &CGPoint::new(0.0, 0.0), - &core_graphics::geometry::CGSize::new(max_width, bar_height), + &CGSize::new(width.max(240.0), ui_tokens::EMPTY_STATE_HEIGHT), ); - let bar: Id = msg_send![bar, initWithFrame: bar_frame]; + 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 btn_width = 64.0; - let btn_height = 22.0; - let gap = 8.0; - let right_edge = max_width - 8.0; + 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 + } +} + +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", + }; + 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" +} + +#[cfg(test)] +mod tests { + use super::*; + 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, + 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 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, + 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] + #[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(); + } - // 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, + set_voice_chat_runtime_degraded_impl( + true, + Some("Legacy formatter fallback is active.".to_string()), ); - if let Some(handler) = action_handler { - crate::ui_helpers::button_set_action( - discard_btn, - handler as Id, - sel!(onDiscardMessage:), - ); + 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")); } - 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:)); + 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); } - let _: () = msg_send![bar, addSubview: commit_btn]; - bar + 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); } -} -fn hide_voice_chat_overlay_impl() { - unsafe { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(window_ptr) = state.window { - let window = window_ptr as Id; - crate::ui_helpers::animate_fade(window, 0.0, 0.15); - crate::ui_helpers::window_close(window); - clear_overlay_state(&mut state); - } + #[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, + preview: "summary".to_string(), + search_corpus: "summary".to_string(), + is_ai_formatted: true, + is_favorite: false, + }; + + let subtitle = drawer_entry_subtitle(&entry); + assert!(subtitle.contains("ThreadStore (index-only)")); + assert!(subtitle.contains("thread:t_2026-02-23_missing")); } - clear_search_field(); -} -pub fn clear_overlay_state(state: &mut VoiceChatOverlayState) { - state.window = None; - state.window_delegate = None; - state.blur_view = None; - state.title_label = None; - state.tab_control = None; - state.close_button = None; - state.settings_button = None; - state.drawer_scroll_view = None; - state.drawer_container = None; - state.search_field = None; - state.agent_scroll_view = None; - state.agent_container = None; - state.agent_bubble_views.clear(); - state.agent_input_field = None; - state.agent_send_button = None; - state.active_tab = Tab::Drawer; - state.is_sending = false; - state.manual_draft.clear(); - state.conversation_state = ConversationModeState::Inactive; -} + #[test] + fn display_text_for_message_handles_streaming() { + let streaming_empty = ChatMessage { + role: ChatRole::Assistant, + text: String::new(), + is_streaming: true, + is_error: false, + timestamp: SystemTime::now(), + mode: None, + }; + assert_eq!(display_text_for_message(&streaming_empty), "• • •"); -fn refresh_drawer_impl() { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - state.drawer_entries = load_drawer_entries(); - let query = state - .search_field - .map(|field| unsafe { get_text_field_string(field as Id) }) - .unwrap_or_default(); - render_drawer_entries(&mut state, &query); -} + let streaming = ChatMessage { + text: "hello".to_string(), + ..streaming_empty + }; + assert_eq!(display_text_for_message(&streaming), "hello …"); -pub fn handle_card_copy(index: usize) { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(entry) = state.drawer_entries.get(index) - && let Ok(contents) = std::fs::read_to_string(&entry.path) - { - copy_to_clipboard(&contents); + let finished = ChatMessage { + is_streaming: false, + ..streaming + }; + assert_eq!(display_text_for_message(&finished), "hello"); } -} -pub fn handle_card_edit(index: usize) { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(entry) = state.drawer_entries.get(index) { - let _ = open_file_in_editor(&entry.path); + #[test] + fn update_active_tab_handles_settings_without_views() { + let mut state = VoiceChatOverlayState::default(); + update_active_tab_locked(&mut state, Tab::Settings); + assert_eq!(state.active_tab, Tab::Drawer); + + update_active_tab_locked(&mut state, Tab::Drawer); + assert_eq!(state.active_tab, Tab::Drawer); } -} -pub fn handle_card_delete(index: usize) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(entry) = state.drawer_entries.get(index) - && let Err(err) = std::fs::remove_file(&entry.path) - { - warn!("Failed to delete {}: {}", entry.path.display(), err); + #[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" + ); } - state.drawer_entries = load_drawer_entries(); - render_drawer_entries(&mut state, ""); -} -pub fn handle_card_favorite(index: usize) { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(entry) = state.drawer_entries.get_mut(index) { - entry.is_favorite = !entry.is_favorite; + #[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; } - render_drawer_entries(&mut state, ""); -} -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); + #[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); - let filter = query.trim().to_lowercase(); - for (index, entry) in state.drawer_entries.iter().enumerate() { - if !filter.is_empty() { - let hay = entry.preview.to_lowercase(); - if !hay.contains(&filter) { - continue; - } - } - let card = create_drawer_card(entry, index, state.action_handler); - stack_view_add(container, card); + 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; } } -fn create_drawer_card(entry: &DrawerEntry, index: usize, handler: Option) -> Id { +fn create_drawer_card( + entry: &DrawerEntry, + index: usize, + handler: Option, + query: &str, +) -> Id { unsafe { let ns_view = Class::get("NSView").unwrap(); let frame = core_graphics::geometry::CGRect::new( @@ -610,9 +3574,25 @@ fn create_drawer_card(entry: &DrawerEntry, index: usize, handler: Option) entry_type_label(entry), format_relative_time(entry.timestamp) ); - let subtitle = format!("{} • {}", mode_label(entry.mode), entry.path.display()); + let subtitle = drawer_entry_subtitle(entry); let preview = entry.preview.clone(); let card = create_card_view(frame, &title, &subtitle, &preview); + // Highlight matching query text in the preview field (last NSTextField subview). + if !query.is_empty() { + let subviews: Id = msg_send![card, subviews]; + let count: usize = msg_send![subviews, count]; + // The preview field is typically the 3rd text field added (index 2). + // Walk subviews in reverse to find it (last NSTextField before action buttons). + for i in (0..count).rev() { + let subview: Id = msg_send![subviews, objectAtIndex: i]; + let ns_text_field = Class::get("NSTextField").unwrap(); + let is_text_field: bool = msg_send![subview, isKindOfClass: ns_text_field]; + if is_text_field { + apply_search_highlight(subview, &preview, query); + break; + } + } + } let actions_container: Id = msg_send![ns_view, alloc]; let actions_frame = core_graphics::geometry::CGRect::new( @@ -627,11 +3607,16 @@ fn create_drawer_card(entry: &DrawerEntry, index: usize, handler: Option) let button = crate::ui_helpers::create_button( core_graphics::geometry::CGRect::new( &CGPoint::new((idx as f64) * 70.0, 0.0), - &core_graphics::geometry::CGSize::new(64.0, 18.0), + &core_graphics::geometry::CGSize::new(64.0, 20.0), ), title, - crate::ui_helpers::button_style::SMALL_SQUARE, + crate::ui_helpers::button_style::ROUNDED, ); + 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]); } @@ -642,14 +3627,27 @@ fn create_drawer_card(entry: &DrawerEntry, index: usize, handler: Option) let favorite = crate::ui_helpers::create_button( core_graphics::geometry::CGRect::new( &CGPoint::new(230.0, 0.0), - &core_graphics::geometry::CGSize::new(28.0, 18.0), + &core_graphics::geometry::CGSize::new(28.0, 20.0), ), - if entry.is_favorite { "♥" } else { "♡" }, - crate::ui_helpers::button_style::SMALL_SQUARE, + "", + crate::ui_helpers::button_style::INLINE, ); + let fav_symbol = if entry.is_favorite { + "heart.fill" + } else { + "heart" + }; + let _ = set_button_symbol(favorite, fav_symbol); + crate::ui_helpers::style_toolbar_icon_button(favorite); + let supports_control_size: bool = + msg_send![favorite, respondsToSelector: sel!(setControlSize:)]; + if supports_control_size { + let _: () = msg_send![favorite, setControlSize: 1_isize]; + } if let Some(handler) = handler { crate::ui_helpers::button_set_action(favorite, handler as Id, sel!(onCardFavorite:)); } + set_tooltip(favorite, "Favorite"); let _: () = msg_send![favorite, setTag: index as isize]; let _: () = msg_send![actions_container, addSubview: favorite]; @@ -658,14 +3656,128 @@ fn create_drawer_card(entry: &DrawerEntry, index: usize, handler: Option) } } +/// NSRange for Objective-C attributed string APIs. +#[repr(C)] +#[derive(Copy, Clone)] +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). +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]; +} fn entry_type_label(entry: &DrawerEntry) -> &'static str { - if entry.is_ai_formatted { "AI" } else { "Tt" } + 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" + } + } + } +} + +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(), + } +} + +fn drawer_entry_subtitle(entry: &DrawerEntry) -> String { + if is_drawer_unavailable_placeholder(entry) { + return "Shift/Cmd • ThreadStore • unavailable".to_string(); + } + let source_label = drawer_entry_source_label(entry); + match &entry.source { + DrawerEntrySource::Thread { id } => { + format!( + "{} • {} • thread:{id}", + mode_label(entry.mode), + source_label + ) + } + DrawerEntrySource::LegacyFile => { + format!( + "{} • {} • {}", + mode_label(entry.mode), + source_label, + entry.path.display() + ) + } + } } fn mode_label(mode: TranscriptionMode) -> &'static str { match mode { TranscriptionMode::Hold => "Ctrl+Hold", - TranscriptionMode::Assistive => "Ctrl+Shift", + TranscriptionMode::Assistive => "Shift/Cmd", TranscriptionMode::Toggle => "Toggle", TranscriptionMode::Conversation => "Moshi", } @@ -689,48 +3801,321 @@ fn format_relative_time(timestamp: SystemTime) -> String { } pub fn load_drawer_entries() -> Vec { - let config_dir = codescribe_core::config::Config::config_dir(); - let today = chrono::Local::now().format("%Y-%m-%d").to_string(); - let dir = config_dir.join("transcriptions").join(today); + load_drawer_entries_with_query("") +} - let files = list_draft_files(&dir); - files +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(|a, b| b.timestamp.cmp(&a.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 +} + +fn thread_history_unavailable_drawer_entry() -> DrawerEntry { + DrawerEntry { + source: DrawerEntrySource::LegacyFile, + path: PathBuf::from(""), + timestamp: SystemTime::now(), + mode: TranscriptionMode::Assistive, + preview: "Thread history unavailable — storage error".to_string(), + search_corpus: "thread history unavailable storage error".to_string(), + is_ai_formatted: false, + is_favorite: false, + } +} + +fn is_drawer_unavailable_placeholder(entry: &DrawerEntry) -> bool { + matches!(entry.source, DrawerEntrySource::LegacyFile) && entry.path.as_os_str().is_empty() +} + +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(|path| { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_lowercase(); - let metadata = path.metadata().ok(); - let timestamp = metadata - .as_ref() - .and_then(|m| m.modified().ok()) - .unwrap_or_else(SystemTime::now); - let content = std::fs::read_to_string(&path).unwrap_or_default(); - let preview = content.chars().take(120).collect::(); - let mode = if name.contains("conversation") || name.contains("moshi") { - TranscriptionMode::Conversation - } else if name.contains("assistive") { - TranscriptionMode::Assistive - } else if name.contains("_raw") || name.contains("raw") { - TranscriptionMode::Hold - } else { - TranscriptionMode::Toggle - }; - let is_ai_formatted = name.contains("_ai") && !name.contains("ai-failed"); + .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(); + DrawerEntry { + source, path, timestamp, mode, preview, - is_ai_formatted, - is_favorite: false, + search_corpus, + is_ai_formatted: true, + is_favorite: summary.is_favorite || favorites.contains(&favorite_key), } }) .collect() } +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) +} + +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 + } +} + +fn normalize_preview(text: &str, max_chars: usize) -> String { + text.split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max_chars) + .collect::() +} + +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) +} + +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() +} + +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(), + } +} + +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() +} + +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 + } +} + +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)] +struct FavoritesFile { + version: u32, + paths: Vec, +} + +fn favorites_path() -> std::path::PathBuf { + let dir = codescribe_core::config::Config::config_dir(); + dir.join("voice_chat_favorites.json") +} + +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() +} + +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/handlers.rs b/app/ui/voice_chat/handlers.rs index cd784ec1..654eabcc 100644 --- a/app/ui/voice_chat/handlers.rs +++ b/app/ui/voice_chat/handlers.rs @@ -2,20 +2,33 @@ //! //! Contains Objective-C class registration and action handler functions. +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 crate::ui_helpers::{get_text_field_string, ns_string, set_hidden, set_text_field_string}; +use codescribe_core::attachment::{Attachment, AttachmentSource, AttachmentStore}; +use codescribe_core::config::UserSettings; + +use crate::config::Config; +use crate::ui::bootstrap; +use crate::ui_helpers::{ + clamp_overlay_position, get_text_field_string, ns_string, set_hidden, set_text_field_string, +}; use super::api::{ - clear_overlay_state, clear_voice_chat_text_impl, commit_last_user_message_impl, - discard_last_message_impl, filter_drawer, handle_card_copy, handle_card_delete, - handle_card_edit, handle_card_favorite, send_draft_message_impl, update_active_tab_impl, + clear_overlay_state, commit_last_user_message_impl, discard_last_message_impl, filter_drawer, + handle_card_copy, handle_card_delete, handle_card_edit, handle_card_favorite, + 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, + update_active_tab_impl, update_attach_button_ui, }; -use super::state::{ChatRole, OVERLAY_STATE, Tab}; +use super::state::{ChatRole, OVERLAY_STATE, Tab, VoiceChatOverlayState}; // Type alias for Objective-C object pointers pub type Id = *mut Object; @@ -24,6 +37,92 @@ 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 DROP_TARGET_INIT: Once = Once::new(); +static mut DROP_TARGET_CLASS: *const Class = std::ptr::null(); + +const NS_DRAG_OP_COPY: u64 = 1; +const MAX_ATTACHMENT_BYTES_UI: u64 = 50 * 1024 * 1024; +static RESIZE_SETTLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +#[repr(C)] +#[derive(Clone, Copy)] +struct ObjcCGPoint { + x: CGFloat, + y: CGFloat, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct ObjcCGSize { + width: CGFloat, + height: CGFloat, +} + +#[repr(C)] +#[derive(Clone, Copy)] +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 { @@ -38,14 +137,38 @@ pub fn action_handler_class() -> *const Class { on_send as extern "C" fn(&Object, Sel, Id), ); decl.add_method( - sel!(onTabChanged:), - on_tab_changed as extern "C" fn(&Object, Sel, Id), + 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), @@ -70,10 +193,31 @@ pub fn action_handler_class() -> *const Class { 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), @@ -82,6 +226,74 @@ pub fn action_handler_class() -> *const Class { 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; }); @@ -100,6 +312,18 @@ pub fn window_delegate_class() -> *const Class { 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; }); @@ -107,6 +331,416 @@ pub fn window_delegate_class() -> *const 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 + } +} + +/// 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 + } +} + +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)) +} + +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); +} + +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); + } +} + +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 } + } +} + +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 + } +} + +extern "C" fn can_become_key_window(_this: &Object, _cmd: Sel) -> bool { + true +} + +extern "C" fn can_become_main_window(_this: &Object, _cmd: Sel) -> bool { + true +} + +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)) + } + } +} + +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); + +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"); + } + }); +} + // ═══════════════════════════════════════════════════════════ // Action Handlers // ═══════════════════════════════════════════════════════════ @@ -115,8 +749,124 @@ extern "C" fn on_send(_this: &Object, _cmd: Sel, _sender: Id) { send_draft_message_impl(); } +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 + ]; + } +} + +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); +} + +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); +} + extern "C" fn on_close(_this: &Object, _cmd: Sel, _sender: Id) { super::api::hide_voice_chat_overlay(); + if bootstrap::should_show_bootstrap() { + bootstrap::handle_hotkey_done(); + } } extern "C" fn on_window_will_close(_this: &Object, _cmd: Sel, _notification: Id) { @@ -125,19 +875,174 @@ extern "C" fn on_window_will_close(_this: &Object, _cmd: Sel, _notification: Id) debug!("Voice chat overlay closed by user"); } -extern "C" fn on_tab_changed(_this: &Object, _cmd: Sel, sender: Id) { +fn window_visible_frame(window: Id) -> Option { unsafe { - let selected: isize = msg_send![sender, selectedSegment]; - let tab = if selected == 0 { - Tab::Drawer + 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 { - Tab::Agent - }; - update_active_tab_impl(tab); - info!("Tab changed to: {:?}", tab); + 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(); + }); + }); +} + +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(); + }); + } +} + +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); } } +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(); + }); +} + +extern "C" fn on_tab_drawer(_this: &Object, _cmd: Sel, _sender: Id) { + update_active_tab_impl(Tab::Drawer); + info!("Tab changed to: {:?}", Tab::Drawer); +} + +extern "C" fn on_tab_agent(_this: &Object, _cmd: Sel, _sender: Id) { + update_active_tab_impl(Tab::Agent); + info!("Tab changed to: {:?}", Tab::Agent); +} + +extern "C" fn on_tab_settings(_this: &Object, _cmd: Sel, _sender: Id) { + update_active_tab_impl(Tab::Settings); + info!("Settings window opened from chat overlay"); +} + 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 @@ -153,6 +1058,83 @@ extern "C" fn on_copy_last_response(_this: &Object, _cmd: Sel, _sender: Id) { } } +#[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); + } +} + +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); + } +} + 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; @@ -186,14 +1168,58 @@ extern "C" fn on_card_favorite(_this: &Object, _cmd: Sel, sender: Id) { handle_card_favorite(index); } -extern "C" fn on_search_changed(_this: &Object, _cmd: Sel, sender: Id) { - let query = unsafe { get_text_field_string(sender) }; +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); } +extern "C" fn on_search_changed(_this: &Object, _cmd: Sel, sender: Id) { + filter_drawer_for_search_field(sender); +} + +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); +} + extern "C" fn on_new_thread(_this: &Object, _cmd: Sel, _sender: Id) { - clear_voice_chat_text_impl(); - info!("New thread started"); + start_new_thread_impl(); + info!("New thread requested (backend reset + UI clear)"); +} + +extern "C" fn on_toggle_favorites_only(_this: &Object, _cmd: Sel, _sender: Id) { + toggle_drawer_favorites_only_impl(); + info!("Toggled Drawer favorites-only filter"); +} + +extern "C" fn on_start_recording(_this: &Object, _cmd: Sel, _sender: Id) { + crate::controller::request_toggle_recording_start(false); + info!("CTA: start recording"); +} + +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::hide_transcription_overlay(); + crate::controller::request_toggle_recording_start(true); + info!("Header CTA: toggle assistive recording"); +} + +extern "C" fn on_show_overlay(_this: &Object, _cmd: Sel, _sender: Id) { + if !super::api::is_voice_chat_overlay_visible() { + crate::show_voice_chat_overlay(); + } + crate::voice_chat_ui::show_agent_tab(); + info!("CTA: show/focus overlay"); } extern "C" fn on_commit_message(_this: &Object, _cmd: Sel, _sender: Id) { @@ -206,6 +1232,836 @@ extern "C" fn on_discard_message(_this: &Object, _cmd: Sel, _sender: Id) { info!("Draft message discarded"); } +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 + ]; + } +} + +extern "C" fn on_export_all_copy(_this: &Object, _cmd: Sel, _sender: Id) { + let md = super::api::export_chat_markdown(false); + copy_to_clipboard(&md); + info!("Exported chat (all) to clipboard as Markdown"); +} + +extern "C" fn on_export_all_save(_this: &Object, _cmd: Sel, _sender: Id) { + if let Some(path) = super::api::save_chat_markdown_to_history(false) { + info!("Saved chat (all) export to {}", path.display()); + super::api::refresh_drawer(); + } else { + info!("Failed to save chat (all) export"); + } +} + +extern "C" fn on_export_assistant_copy(_this: &Object, _cmd: Sel, _sender: Id) { + let md = super::api::export_chat_markdown(true); + copy_to_clipboard(&md); + info!("Exported chat (assistant-only) to clipboard as Markdown"); +} + +extern "C" fn on_export_assistant_save(_this: &Object, _cmd: Sel, _sender: Id) { + if let Some(path) = super::api::save_chat_markdown_to_history(true) { + info!("Saved chat (assistant-only) export to {}", path.display()); + super::api::refresh_drawer(); + } else { + info!("Failed to save chat (assistant-only) export"); + } +} + +extern "C" fn on_show_shortcuts(_this: &Object, _cmd: Sel, _sender: Id) { + let (hold, toggle) = super::shortcuts_lines(crate::os::hotkeys::ModeHotkeyBindings::load()); + if !super::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::voice_chat_ui::show_agent_tab(); + crate::voice_chat_ui::add_voice_chat_system_message(&format!( + "Keyboard shortcuts:\n{}\n{}", + hold, toggle + )); + crate::voice_chat_ui::update_voice_chat_status("Shortcuts"); + info!("Displayed keyboard shortcuts inline (non-modal)"); +} + +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 + ]; + } +} + +// ═══════════════════════════════════════════════════════════ +// Connector Handlers (GitHub, URL) +// ═══════════════════════════════════════════════════════════ + +/// Show an input dialog and fetch a file from GitHub, adding it as an attachment. +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. + 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; + 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. +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. + 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; + 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)); + } + } + }); +} + +/// Show a modal text input dialog using NSAlert with an accessory NSTextField. +/// Returns the entered text, or None if the user cancelled. +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. +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]; + } +} + +// ═══════════════════════════════════════════════════════════ +// Attachment Chip Handlers +// ═══════════════════════════════════════════════════════════ + +/// Chip body click → show context menu with Preview / Remove / Reveal / Copy Path. +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::() + ]; + } +} + +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; + super::api::remove_attachment_at(index); +} + +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(); + }); + } +} + +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(); + } +} + +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); + } +} + +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. + super::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) +extern "C" fn on_do_command_by_selector( + _this: &Object, + _cmd: Sel, + _text_view: Id, + selector: Sel, +) -> bool { + // ── Cmd+V paste interception ── + // Intercept paste: to handle file URLs and standalone images as attachments. + // Text paste (with or without accompanying image) falls through to default. + if selector == sel!(paste:) { + let handled = unsafe { try_paste_as_attachment() }; + if handled { + return true; + } + return false; // default NSTextView paste + } + + 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. +} + +// ═══════════════════════════════════════════════════════════ +// Paste-as-attachment +// ═══════════════════════════════════════════════════════════ + +/// 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. +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 !file_paths.is_empty() { + 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 has_image && !has_text { + // 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()) +} + // ═══════════════════════════════════════════════════════════ // Utilities // ═══════════════════════════════════════════════════════════ @@ -228,13 +2084,29 @@ pub fn copy_to_clipboard(text: &str) { } pub fn clear_search_field() { - let state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(search_field) = state.search_field { - unsafe { - set_text_field_string(search_field as Id, ""); - } + // 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_hidden(search_field as Id, false); + set_text_field_string(sf as Id, ""); + set_hidden(sf as Id, false); } } } + +#[cfg(test)] +mod tests { + use super::canonical_zoom_level; + + #[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); + } +} diff --git a/app/ui/voice_chat/mod.rs b/app/ui/voice_chat/mod.rs index ca1ebd8f..6a3ddc8f 100644 --- a/app/ui/voice_chat/mod.rs +++ b/app/ui/voice_chat/mod.rs @@ -3,6 +3,7 @@ //! This module provides a floating overlay window with: //! - Drawer tab: clipboard-style transcription cards //! - Agent tab: chat bubbles with streaming LLM responses +//! - Settings: routes to settings window/onboarding mod api; mod handlers; @@ -10,12 +11,16 @@ mod state; // Re-export public API pub use api::{ - add_voice_chat_error_message, add_voice_chat_user_message, append_voice_chat_assistant_delta, - clear_voice_chat_text, filter_drawer, 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_send_callback, - set_voice_chat_sending, set_voice_chat_text, show_agent_tab, show_drawer_tab, - update_conversation_state, update_drawer_after_save, update_voice_chat_status, + add_voice_chat_error_message, add_voice_chat_system_message, add_voice_chat_user_message, + append_voice_chat_assistant_delta, append_voice_chat_user_delta, clear_voice_chat_text, + dispatch_voice_chat_send, 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, + request_settings_tab_on_open, reset_voice_chat_activity, send_voice_chat_draft, + 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, show_settings_tab, update_conversation_state, update_drawer_after_save, + update_voice_chat_context_summary, update_voice_chat_status, }; pub use state::{ConversationModeState, VoiceChatOverlayConfig}; @@ -28,21 +33,71 @@ use objc2_app_kit::{ NSBackingStoreType, NSVisualEffectBlendingMode, NSVisualEffectMaterial, NSVisualEffectState, NSWindowCollectionBehavior, NSWindowStyleMask, }; -use tracing::{info, warn}; +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::{ - NS_FLOATING_WINDOW_LEVEL, add_subview, button_set_action, button_style, color_clear, - create_button, create_segmented_control, create_vertical_stack_view, ns_string, set_hidden, - window_set_alpha, window_show, + LabelConfig, NS_FLOATING_WINDOW_LEVEL, add_subview, apply_tafla_surface, button_set_action, + button_style, chat_header_layout, chat_input_row_layout, color_clear, color_label, + color_secondary_label, create_button, create_flipped_vertical_stack_view, + create_glass_effect_view_with, create_label, create_scrollable_text_view, + create_vertical_stack_view, layout_region_frame_for_view, ns_string, 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, window_show, }; use api::update_active_tab_impl; -use handlers::{action_handler_class, window_delegate_class}; +use handlers::{ + action_handler_class, drop_target_view_class, overlay_window_class, window_delegate_class, +}; use state::{OVERLAY_STATE, Tab}; // Type alias for Objective-C object pointers pub type Id = *mut Object; +// 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; + +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(|| { @@ -59,23 +114,50 @@ pub fn show_voice_chat_overlay_with_config(_config: VoiceChatOverlayConfig) { fn show_voice_chat_overlay_impl() { unsafe { - let mut state = OVERLAY_STATE.lock().unwrap_or_else(|e| e.into_inner()); + // 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.) - let ns_window = Class::get("NSWindow").unwrap(); - let ns_screen = Class::get("NSScreen").unwrap(); + // 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(window_ptr) = state.window { - let window = window_ptr as Id; - let is_window: bool = msg_send![window, isKindOfClass: ns_window]; - if is_window { - let _: () = msg_send![window, orderFrontRegardless]; - info!("Voice chat overlay reused"); - return; + let _: () = msg_send![window, orderFrontRegardless]; + let _: () = msg_send![window, setAlphaValue: 1.0f64]; + let collection_behavior = NSWindowCollectionBehavior::CanJoinAllSpaces + | NSWindowCollectionBehavior::FullScreenAuxiliary; + let _: () = msg_send![window, setCollectionBehavior: collection_behavior]; + + 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); } - 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 ns_screen = Class::get("NSScreen").unwrap(); let config = VoiceChatOverlayConfig::default(); let window_width = config.width; let window_height = config.height; @@ -128,8 +210,11 @@ fn show_voice_chat_overlay_impl() { }, }; - let window: Id = msg_send![ns_window, alloc]; - let style_mask = NSWindowStyleMask::Borderless | NSWindowStyleMask::FullSizeContentView; + let overlay_window_class = overlay_window_class(); + let window: Id = msg_send![overlay_window_class, alloc]; + let style_mask = NSWindowStyleMask::Borderless + | NSWindowStyleMask::FullSizeContentView + | NSWindowStyleMask::Resizable; let backing = NSBackingStoreType::Buffered; let window: Id = msg_send![ window, @@ -145,249 +230,1006 @@ fn show_voice_chat_overlay_impl() { let _: () = msg_send![window, setOpaque: false]; let _: () = msg_send![window, setBackgroundColor: color_clear()]; let _: () = msg_send![window, setLevel: NS_FLOATING_WINDOW_LEVEL]; - let collection_behavior = NSWindowCollectionBehavior::CanJoinAllSpaces; + // Keep the window instance alive even after close; we manage lifecycle explicitly. + let _: () = msg_send![window, setReleasedWhenClosed: false]; + let _: () = msg_send![window, setContentMinSize: CGSize::new(380.0, 360.0)]; + // Cap at sensible max: width 1000px (chat bubbles don't need more), + // height = screen visible height (scrolling handles overflow). + let ns_screen = Class::get("NSScreen").unwrap(); + let screen: Id = msg_send![ns_screen, mainScreen]; + if !screen.is_null() { + let visible: CGRect = msg_send![screen, visibleFrame]; + let max_w = visible.size.width.min(1000.0); + let _: () = + msg_send![window, setContentMaxSize: CGSize::new(max_w, visible.size.height)]; + } + // 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]; 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 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 ns_visual = Class::get("NSVisualEffectView").unwrap(); - let blur_view: Id = msg_send![ns_visual, alloc]; - let blur_view: Id = msg_send![blur_view, initWithFrame: frame]; - let _: () = msg_send![blur_view, setMaterial: NSVisualEffectMaterial::HUDWindow]; - let _: () = msg_send![blur_view, setBlendingMode: NSVisualEffectBlendingMode::BehindWindow]; - let _: () = msg_send![blur_view, setState: NSVisualEffectState::Active]; - let _: () = msg_send![blur_view, setWantsLayer: true]; + 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 _: () = msg_send![layer, setCornerRadius: 16.0f64]; + 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(content_view, blur_view); + 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 header_height = 44.0; - let footer_height = 44.0; - let agent_input_height = 52.0; + let content_pad = ui_tokens::EDGE_PADDING; + + let header_height = ui_tokens::HEADER_HEIGHT_COMPACT; + let footer_height = ui_tokens::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 + // Header container on the single root glass (no glass-on-glass dome). let header_frame = CGRect::new( - &CGPoint::new(0.0, window_height - header_height), - &CGSize::new(window_width, header_height), + &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_view: Id = msg_send![Class::get("NSView").unwrap(), alloc]; - let header_view: Id = msg_send![header_view, initWithFrame: header_frame]; - let _: () = msg_send![header_view, setWantsLayer: true]; - let header_layer: Id = msg_send![header_view, layer]; + let header_bg: Id = msg_send![Class::get("NSView").unwrap(), alloc]; + let header_bg: Id = msg_send![header_bg, initWithFrame: header_frame]; + let _: () = msg_send![header_bg, setWantsLayer: true]; + 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 color: Id = msg_send![Class::get("NSColor").unwrap(), colorWithRed: 0.15 green: 0.15 blue: 0.15 alpha: 0.6]; - let cg_color: Id = msg_send![color, CGColor]; - let _: () = msg_send![header_layer, setBackgroundColor: cg_color]; + let clear_cg: Id = msg_send![color_clear(), CGColor]; + let _: () = msg_send![header_layer, setBackgroundColor: clear_cg]; + } + 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]; } - add_subview(blur_view, header_view); + 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 + ]; + let title_x = if header_frame.size.width >= 620.0 { + ui_tokens::TRAFFIC_LIGHTS_SPACER_WIDTH + 6.0 + } else { + ui_tokens::EDGE_PADDING_TIGHT + }; + let title_y = ((header_height - 20.0) / 2.0).max(0.0); + // Keep enough width so "CodeScribe" is not clipped at default window sizes. + let title_w = ui_tokens::CHAT_TITLE_LABEL_WIDTH; + let title_label = create_label(LabelConfig { + frame: CGRect::new(&CGPoint::new(title_x, title_y), &CGSize::new(title_w, 20.0)), + text: "CodeScribe".to_string(), + font_size: ui_tokens::TITLE_FONT_SIZE, + bold: true, + text_color: color_label(), + background_color: None, + selectable: false, + editable: false, + }); + let _: () = msg_send![ + title_label, + setAutoresizingMask: NSVIEW_MAX_X_MARGIN | NSVIEW_MIN_Y_MARGIN + ]; + add_subview(header_controls, title_label); + + // 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 close_button_x = x; + x -= gap + 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 between the title and the right-side icon cluster. + let right_cluster_start_x = record_button_x; + let header_layout = chat_header_layout(title_x, title_w, 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 title_label = crate::ui_helpers::create_label(crate::ui_helpers::LabelConfig { + 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(16.0, window_height - 30.0), - &CGSize::new(160.0, 20.0), + &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: "CodeScribe".to_string(), - font_size: 13.0, - bold: true, - text_color: crate::ui_helpers::color_white(0.9), + 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, }); - add_subview(blur_view, title_label); + 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); - let tab_control = create_segmented_control( + // Drawer favorites filter (hearts on/off) + let favorites_button = create_button( CGRect::new( - &CGPoint::new(170.0, window_height - 34.0), - &CGSize::new(160.0, 24.0), + &CGPoint::new(favorites_button_x, header_btn_y), + &CGSize::new(btn_w, btn_h), ), - &["Drawer", "Agent"], + "", + 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:), ); - button_set_action(tab_control, action_handler, sel!(onTabChanged:)); - add_subview(blur_view, tab_control); + 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 new_thread_button = create_button( + let help_button = create_button( CGRect::new( - &CGPoint::new(window_width - 96.0, window_height - 34.0), - &CGSize::new(28.0, 24.0), + &CGPoint::new(help_button_x, header_btn_y), + &CGSize::new(btn_w, btn_h), ), - "↻", - button_style::SMALL_SQUARE, + "", + button_style::INLINE, ); - button_set_action(new_thread_button, action_handler, sel!(onNewThread:)); - add_subview(blur_view, new_thread_button); + 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 settings_button = create_button( + let more_button = create_button( CGRect::new( - &CGPoint::new(window_width - 64.0, window_height - 34.0), - &CGSize::new(24.0, 24.0), + &CGPoint::new(more_button_x, header_btn_y), + &CGSize::new(btn_w, btn_h), ), - "⚙", - button_style::SMALL_SQUARE, + "", + button_style::INLINE, ); - add_subview(blur_view, settings_button); + 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(window_width - 34.0, window_height - 34.0), - &CGSize::new(24.0, 24.0), + &CGPoint::new(close_button_x, header_btn_y), + &CGSize::new(btn_w, btn_h), ), - "✕", - button_style::SMALL_SQUARE, + "", + 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:)); - add_subview(blur_view, close_button); + set_tooltip(close_button, "Close window"); + let _: () = msg_send![ + close_button, + setAutoresizingMask: NSVIEW_MIN_X_MARGIN | NSVIEW_MIN_Y_MARGIN + ]; + add_subview(header_controls, close_button); - // Drawer scroll view + stack - let drawer_frame = CGRect::new( - &CGPoint::new(16.0, footer_height + 10.0), + // 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( - window_width - 32.0, - window_height - header_height - footer_height - 20.0, + (content_bounds.size.width - content_pad * 2.0).max(0.0), + (content_bounds.size.height - header_height - footer_height - content_gap * 2.0) + .max(0.0), ), ); + 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; + let drawer_frame = CGRect::new( + &CGPoint::new(inner_pad, inner_pad), + &CGSize::new( + (content_frame.size.width - inner_pad * 2.0).max(0.0), + (content_frame.size.height - inner_pad * 2.0).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(blur_view, drawer_scroll); + 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 + let agent_scroll_frame_bottom = 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, agent_scroll_frame_bottom), + &CGSize::new( + (content_frame.size.width - inner_pad * 2.0).max(0.0), + (agent_scroll_frame_top - agent_scroll_frame_bottom).max(0.0), + ), + ); let agent_scroll: Id = msg_send![ns_scroll, alloc]; - let agent_scroll: Id = msg_send![agent_scroll, initWithFrame: drawer_frame]; + 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 agent_container = create_vertical_stack_view(CGRect::new( + let _: () = msg_send![agent_scroll, setAutohidesScrollers: true]; + // NSScrollerStyleOverlay == 1 + let _: () = msg_send![agent_scroll, setScrollerStyle: 1_isize]; + 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(drawer_frame.size.width, drawer_frame.size.height), + &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]; - add_subview(blur_view, agent_scroll); + add_subview(content_view, agent_scroll); + + // Drawer footer (search) + let search_x = content_frame.origin.x; + let search_w = content_frame.size.width.max(160.0); + let footer_base_y = content_bounds.origin.y; + + let search_label = create_label(LabelConfig { + frame: CGRect::new( + &CGPoint::new(search_x, footer_base_y + footer_height - 20.0), + &CGSize::new(search_w, 16.0), + ), + text: "Filter transcripts".to_string(), + font_size: ui_tokens::SMALL_FONT_SIZE, + bold: false, + text_color: color_secondary_label(), + background_color: None, + selectable: false, + editable: false, + }); + let _: () = msg_send![ + search_label, + setAutoresizingMask: NSVIEW_WIDTH_SIZABLE | NSVIEW_MAX_Y_MARGIN + ]; + add_subview(glass_content_view, search_label); - // Drawer footer (search + dropdowns) let ns_search = Class::get("NSSearchField").unwrap(); let search_field: Id = msg_send![ns_search, alloc]; let search_frame = CGRect::new( - &CGPoint::new(16.0, 12.0), - &CGSize::new(window_width - 200.0, 24.0), + &CGPoint::new(search_x, footer_base_y + 12.0), + &CGSize::new(search_w, 24.0), ); let search_field: Id = msg_send![search_field, initWithFrame: search_frame]; - let placeholder = ns_string("Search..."); + 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:)]; - add_subview(blur_view, search_field); - - let type_button = create_button( - CGRect::new( - &CGPoint::new(window_width - 176.0, 12.0), - &CGSize::new(72.0, 24.0), - ), - "Type ▾", - button_style::SMALL_SQUARE, - ); - add_subview(blur_view, type_button); - - let mode_button = create_button( - CGRect::new( - &CGPoint::new(window_width - 96.0, 12.0), - &CGSize::new(72.0, 24.0), - ), - "Mode ▾", - button_style::SMALL_SQUARE, - ); - add_subview(blur_view, mode_button); + 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(glass_content_view, search_field); // Agent input bar - let input_bar: Id = msg_send![Class::get("NSView").unwrap(), alloc]; + 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(16.0, 8.0), - &CGSize::new(window_width - 32.0, agent_input_height), + &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]; + 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: Id = msg_send![Class::get("NSColor").unwrap(), colorWithRed: 0.15 green: 0.15 blue: 0.15 alpha: 0.6]; + let color = ui_colors::input_bar_bg(); let cg_color: Id = msg_send![color, CGColor]; let _: () = msg_send![input_layer, setBackgroundColor: cg_color]; - let _: () = msg_send![input_layer, setCornerRadius: 18.0f64]; + 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(blur_view, input_bar); + 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(text_area_frame, true); + 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]; - let ns_text_field = Class::get("NSTextField").unwrap(); - let agent_input: Id = msg_send![ns_text_field, alloc]; - let agent_input: Id = msg_send![agent_input, initWithFrame: CGRect::new(&CGPoint::new(12.0, 12.0), &CGSize::new(window_width - 90.0, 28.0))]; - let _: () = msg_send![agent_input, setBezeled: true]; - let _: () = msg_send![agent_input, setPlaceholderString: ns_string("Napisz polecenie...")]; - let _: () = msg_send![agent_input, setTarget: action_handler]; - let _: () = msg_send![agent_input, setAction: sel!(onInputSubmit:)]; - let _: () = msg_send![input_bar, addSubview: agent_input]; + // 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(window_width - 76.0, 10.0), - &CGSize::new(36.0, 32.0), + &CGPoint::new(input_row.send_x, input_row.send_y), + &CGSize::new(input_row.button_width, input_row.button_height), ), - ">", - button_style::ROUNDED, + "", + 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]; + // 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); - state.window = Some(window as usize); - state.window_delegate = Some(window_delegate as usize); - state.blur_view = Some(blur_view as usize); - state.title_label = Some(title_label as usize); - state.tab_control = Some(tab_control as usize); - state.close_button = Some(close_button as usize); - state.settings_button = Some(settings_button as usize); - state.drawer_scroll_view = Some(drawer_scroll as usize); - state.drawer_container = Some(drawer_container 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_field = Some(agent_input as usize); - state.agent_send_button = Some(agent_send_button as usize); - state.action_handler = Some(action_handler as usize); - state.active_tab = Tab::Drawer; + // Phase 3 — store widget pointers into state (short lock scope). + let (has_messages, desired_tab, status_base_text, open_settings) = { + 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.search_label = Some(search_label 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.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(); + state.active_tab = pending_tab.unwrap_or(Tab::Drawer); + + let has_messages = !state.messages.is_empty(); + let mut open_settings = false; + let desired_tab = if let Some(tab) = pending_tab { + if tab == Tab::Settings { + open_settings = true; + if has_messages { + Tab::Agent + } else { + Tab::Drawer + } + } else { + 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, open_settings) + }; // 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); window_show(window); - let nil: *mut Object = std::ptr::null_mut(); - let _: () = msg_send![window, makeKeyAndOrderFront: nil]; 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]; + let _: () = msg_send![window, orderFrontRegardless]; + }); + }); - let has_messages = !state.messages.is_empty(); - drop(state); + // Phase 5 — post-show updates. api::refresh_drawer(); - if has_messages { - update_active_tab_impl(Tab::Agent); + api::update_voice_chat_status(&status_base_text); + update_active_tab_impl(desired_tab); + if open_settings { + crate::show_bootstrap_overlay(); + } + 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); - } else { - update_active_tab_impl(Tab::Drawer); } } } + +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")); + } +} diff --git a/app/ui/voice_chat/state.rs b/app/ui/voice_chat/state.rs index 895b8094..9ae85720 100644 --- a/app/ui/voice_chat/state.rs +++ b/app/ui/voice_chat/state.rs @@ -2,9 +2,14 @@ //! //! Contains overlay state, configuration, and message types. +use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; + +use codescribe_core::attachment::Attachment; + +use crate::ui::shared::status::UiStatus; /// Type alias for voice chat send callback pub type VoiceChatSendCallback = Arc; @@ -45,12 +50,15 @@ pub struct ChatMessage { pub text: String, pub is_streaming: bool, pub is_error: bool, + pub timestamp: SystemTime, + pub mode: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { Drawer, Agent, + Settings, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -80,11 +88,19 @@ pub enum ConversationModeState { 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 preview: String, + pub search_corpus: String, pub is_ai_formatted: bool, pub is_favorite: bool, } @@ -95,40 +111,92 @@ pub struct VoiceChatOverlayState { 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 tab_control: 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, - pub settings_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_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, + /// 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 (used for routing Settings from tray). + 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, pub manual_draft: String, pub is_sending: 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 { @@ -137,26 +205,63 @@ impl Default for VoiceChatOverlayState { 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, - tab_control: 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, - settings_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_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, + 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, manual_draft: String::new(), is_sending: false, 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, } } } diff --git a/assets/lab/README.md b/assets/lab/README.md deleted file mode 100644 index 7ac64a16..00000000 --- a/assets/lab/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# CodeScribe Voice & Chat Lab - -**URL**: `http://127.0.0.1:8237/lab` - -## Zakładki - -| Tab | Opis | -|-----|------| -| **Voice Lab** | Spektrogram + streaming WebSocket + testy endpointów | -| **Chat** | Testowy chat z AI (Harmony/OpenAI) | -| **Teacher** | Aktywne uczenie się - kalibracja słownika | - ---- - -## 🎓 Teacher - Instrukcja obsługi - -### Cel -Whisper źle transkrybuje specjalistyczne słowa? Teacher pozwala nauczyć go poprawnej wymowy przez porównanie tego co **powiedziałeś** z tym co **usłyszał**. - -### Flow w 5 krokach - -``` -1. Wybierz kategorię (topic) - ↓ -2. AI generuje zdania do przeczytania - ↓ -3. Czytasz zdanie na głos (nagrywasz) - ↓ -4. Poprawiasz transkrypt jeśli Whisper się pomylił - ↓ -5. Klikasz "Learn" → różnice trafiają do słownika -``` - -### Szczegółowy przepis - -#### Krok 1: Wybierz kategorię -Wpisz temat w pole **Topic**, np: -- `veterinary` - terminologia weterynaryjna -- `programming` - nazwy funkcji, bibliotek -- `liturgia` - terminy kościelne -- `cooking` - kulinaria - -#### Krok 2: Wygeneruj zdania -Kliknij jeden z przycisków: -- **Generate Set** - 5 zdań do ręcznego wyboru -- **Wizard (10)** - tryb krok-po-kroku, 10 zdań - -#### Krok 3: Nagraj wymowę -1. Przejdź do zakładki **Voice Lab** -2. Kliknij **Start Stream** (mikrofon) -3. Przeczytaj zdanie wyświetlone w Reference -4. Kliknij **Stop Stream** -5. Wróć do **Teacher** - -#### Krok 4: Porównaj i popraw -- **Reference Text** = co powiedziałeś (oryginalne zdanie) -- **Transcript** = co Whisper usłyszał - -Jeśli Whisper źle zrozumiał, **popraw Transcript** ręcznie na to co naprawdę mówiłeś. - -#### Krok 5: Zapisz do słownika -Kliknij: -- **🧠 Fix & Learn** - zapisz różnice i zostań -- **Learn & Next ▶** - zapisz i przejdź do następnego zdania (Wizard) - ---- - -## Gdzie trafiają poprawki? - -Różnice zapisują się do pliku lexicon w `~/.codescribe/lexicon/{topic}.jsonl`: - -```json -{"term": "ketoprofen", "mispronunciations": ["keto profen", "ketoprafen"]} -{"term": "amoksycylina", "mispronunciations": ["amoksycyliny", "amoxicilina"]} -``` - -Te reguły są używane przez Light Plus do poprawiania transkrypcji w locie. - ---- - -## Przyciski w Teacher - -| Przycisk | Funkcja | -|----------|---------| -| **Generate Set** | Wygeneruj 5 zdań AI | -| **Wizard (10)** | Tryb krok-po-kroku (10 zdań) | -| **Preview** | Podgląd aktualnego słownika | -| **Reload** | Przeładuj słownik z dysku | -| **Clear** | Wyczyść słownik dla tematu | -| **Export** | Eksportuj słownik do JSON | -| **📋** (przy zdaniu) | Skopiuj zdanie do Reference | -| **Next ▶** | Następne zdanie w Wizard | -| **⬇ Pull Last Transcript** | Pobierz ostatni transkrypt | - ---- - -## Metryki - -Po każdym Learn widzisz: -- **WER** (Word Error Rate) - % błędnych słów -- **Avg WER** - średnia ze wszystkich prób -- **Export report** - zapisz całą sesję kalibracji - ---- - -## Troubleshooting - -| Problem | Rozwiązanie | -|---------|-------------| -| Brak transkryptu | Upewnij się że kliknąłeś Start Stream w Voice Lab | -| "Generate failed" | Sprawdź czy backend ma dostęp do AI (Harmony/OpenAI) | -| Słownik pusty | Kliknij Reload lub sprawdź `~/.codescribe/lexicon/` | - ---- - -Created by M&K (c)2025 The LibraxisAI Team diff --git a/assets/lab/app.js b/assets/lab/app.js deleted file mode 100644 index 128bf3ab..00000000 --- a/assets/lab/app.js +++ /dev/null @@ -1,7 +0,0 @@ -import { React, html } from "./lib/react.js"; -import { createRoot } from "react-dom/client"; -import { LabApp } from "./components/LabApp.js"; - -const rootEl = document.getElementById("lab-root"); -const root = createRoot(rootEl); -root.render(html`<${LabApp} />`); diff --git a/assets/lab/components/ChatPanel.js b/assets/lab/components/ChatPanel.js deleted file mode 100644 index c8670128..00000000 --- a/assets/lab/components/ChatPanel.js +++ /dev/null @@ -1,65 +0,0 @@ -import { React, html } from "../lib/react.js"; - -const { useState } = React; - -export function ChatPanel({ controller, state }) { - const [draft, setDraft] = useState(""); - - const handleSend = () => { - const text = draft.trim(); - if (!text || state.chatBusy) return; - controller.sendChatMessage(text); - setDraft(""); - }; - - const handleKeyDown = (event) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleSend(); - } - }; - - return html` -
-
-

Assistant conversation

-
- ${state.chatHistory - .filter((msg) => msg.role !== "system") - .map( - (msg, idx) => html`
${msg.content}
`, - )} -
-
- -
-
- ${state.chatStatus || (state.chatThreadId ? `Thread: ${state.chatThreadId}` : "")} -
-
- - -
-
-
-
-
- How it works -

- Messages are sent through the configured Harmony / Libraxis endpoint. The backend proxies the - request so your keys stay local. Responses live in this session only – reset chat to start fresh. -

-

- Tip: run the streaming tab while chatting to verify transcription + formatter parity. -

-
-
- `; -} diff --git a/assets/lab/components/EndpointPanel.js b/assets/lab/components/EndpointPanel.js deleted file mode 100644 index 23e86c61..00000000 --- a/assets/lab/components/EndpointPanel.js +++ /dev/null @@ -1,59 +0,0 @@ -import { html } from "../lib/react.js"; - -const FIELD_DEFS = [ - { key: "transcribeUrl", label: "Upload (STT) endpoint", placeholder: "/transcribe" }, - { key: "sttAndFormatUrl", label: "STT + Format endpoint", placeholder: "/stt_and_format" }, - { key: "ndjsonUrl", label: "NDJSON stream endpoint", placeholder: "/stream/transcribe" }, - { key: "wsUrl", label: "WebSocket stream", placeholder: "wss://…/ws/transcribe" }, - { key: "responsesUrl", label: "Harmony /v1/responses endpoint", placeholder: "/demo/chat" }, - { key: "apiKey", label: "Bearer token", placeholder: "sk-…" }, - { key: "model", label: "Model", placeholder: "gpt-4o-mini" }, -]; - -export function EndpointPanel({ controller, state }) { - return html` -
-

Endpoint & Capture Controls

-
- - { - const file = event.target.files?.[0] || null; - controller.setFile(file); - }} - /> - - ${state.selectedFileName ? `Selected: ${state.selectedFileName}` : "Select a clip to upload"} - -
-
- - - - -
-
- ${FIELD_DEFS.map( - ({ key, label, placeholder }) => html` -
- - controller.setEndpointField(key, event.target.value)} - /> -
- `, - )} -
-
${state.endpointOutput}
-
- `; -} diff --git a/assets/lab/components/LabApp.js b/assets/lab/components/LabApp.js deleted file mode 100644 index a8aa0aee..00000000 --- a/assets/lab/components/LabApp.js +++ /dev/null @@ -1,63 +0,0 @@ -import { React, html } from "../lib/react.js"; -import { useLabController } from "../hooks/useLabController.js"; -import { SpectrogramPanel } from "./SpectrogramPanel.js"; -import { TranscriptPanel } from "./TranscriptPanel.js"; -import { EndpointPanel } from "./EndpointPanel.js"; -import { LogPanel } from "./LogPanel.js"; -import { ChatPanel } from "./ChatPanel.js"; -import { TeacherPanel } from "./TeacherPanel.js"; - -const { useMemo } = React; - -export function LabApp() { - const { controller, state } = useLabController(); - const bufferPct = useMemo(() => { - const denom = 10 * 1024 * 1024; - return Math.min(1, Math.max(0, state.bufferBytes / denom)); - }, [state.bufferBytes]); - - return html` -
-
-

CodeScribe Voice & Chat Lab

- Backend: ${state.provider} -
-
- ${["lab", "chat", "teacher"].map( - (tab) => html` - - `, - )} -
- ${state.activeSection === "lab" - ? html`<${LabSurface} controller=${controller} state=${state} bufferPct=${bufferPct} />` - : state.activeSection === "chat" - ? html`<${ChatPanel} controller=${controller} state=${state} />` - : html`<${TeacherPanel} controller=${controller} />`} -
- `; -} - -function LabSurface({ controller, state, bufferPct }) { - return html` -
-
- <${SpectrogramPanel} - controller=${controller} - state=${state} - bufferPct=${bufferPct} - /> - <${TranscriptPanel} controller=${controller} state=${state} /> -
-
- <${EndpointPanel} controller=${controller} state=${state} /> - <${LogPanel} controller=${controller} state=${state} /> -
-
- `; -} diff --git a/assets/lab/components/LogPanel.js b/assets/lab/components/LogPanel.js deleted file mode 100644 index 488b0492..00000000 --- a/assets/lab/components/LogPanel.js +++ /dev/null @@ -1,44 +0,0 @@ -import { html } from "../lib/react.js"; - -export function LogPanel({ controller, state }) { - const summary = state.streamSummary || { acks: 0, finals: [] }; - return html` -
-
-

Stream Inspector

-
- - -
-
-
-acks: ${summary.acks}
-finals (${summary.finals.length})
-${summary.finals.map((t, idx) => `  ${idx + 1}. ${t}`).join("\n")}
-
-${state.streamResponse || "—"}
-      
- ${state.logView === "log" - ? html`
- ${state.logEntries.map( - (entry) => html`
- ${entry.type || "event"} - ${entry.text || entry.message || ""} - ${entry.at} -
`, - )} -
` - : html`
${state.rawStream || "(no data)"}
`} -
- `; -} diff --git a/assets/lab/components/SpectrogramPanel.js b/assets/lab/components/SpectrogramPanel.js deleted file mode 100644 index 45530672..00000000 --- a/assets/lab/components/SpectrogramPanel.js +++ /dev/null @@ -1,39 +0,0 @@ -import { React, html } from "../lib/react.js"; - -const { useEffect, useRef } = React; - -export function SpectrogramPanel({ controller, state, bufferPct }) { - const canvasRef = useRef(null); - - useEffect(() => { - if (!canvasRef.current) return undefined; - const detach = controller.attachCanvas(canvasRef.current); - return () => { - if (detach) detach(); - }; - }, [controller]); - - return html` -
-
-

Streaming Spectrogram

- - ${state.status.text} - -
- -
- - -
-
- - ${(state.bufferBytes / 1024).toFixed(1)} KB buffered -
-
- `; -} diff --git a/assets/lab/components/TeacherPanel.js b/assets/lab/components/TeacherPanel.js deleted file mode 100644 index 9d9f5d3e..00000000 --- a/assets/lab/components/TeacherPanel.js +++ /dev/null @@ -1,423 +0,0 @@ -import { React, html } from "../lib/react.js"; - -const { useState, useEffect } = React; - -export function TeacherPanel({ controller }) { - const [topic, setTopic] = useState("general"); - const [reference, setReference] = useState(""); - const [transcript, setTranscript] = useState(""); - const [isLearning, setIsLearning] = useState(false); - const [isRecording, setIsRecording] = useState(false); - const [isGenerating, setIsGenerating] = useState(false); - const [status, setStatus] = useState(""); - const [sentences, setSentences] = useState([]); - const [wizardIndex, setWizardIndex] = useState(0); - const [lexiconPreview, setLexiconPreview] = useState([]); - const [lexiconCount, setLexiconCount] = useState(0); - const [isFetchingLexicon, setIsFetchingLexicon] = useState(false); - const [statusLog, setStatusLog] = useState([]); - const [calibrationRuns, setCalibrationRuns] = useState([]); - - const pushStatus = (msg, kind = "info") => { - const line = `[${kind}] ${msg}`; - setStatus(line); - setStatusLog((prev) => [line, ...prev].slice(0, 12)); - }; - - const refreshLexicon = async () => { - setIsFetchingLexicon(true); - try { - const resp = await fetch( - `/lab/lexicon?topic=${encodeURIComponent(topic)}&limit=30` - ); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data.error || resp.statusText || "Lexicon fetch failed"); - setLexiconPreview(data.entries || []); - setLexiconCount(data.count || 0); - pushStatus(`Lexicon: ${data.count || 0} entries for ${data.topic}`); - } catch (e) { - pushStatus(`Lexicon error: ${e.message}`, "err"); - } finally { - setIsFetchingLexicon(false); - } - }; - - useEffect(() => { - refreshLexicon(); - }, [topic]); - - const handleLearn = async () => { - setIsLearning(true); - pushStatus("Analyzing errors..."); - let success = false; - try { - // Use current transcript from controller if not overridden - const currentTranscript = transcript || controller.state.transcript; - - const resp = await fetch("/lab/learn", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - topic: topic, - reference: reference, - transcript: currentTranscript - }) - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok || !data.ok) { - throw new Error(data.error || resp.statusText); - } - if (data.ai_error) { - pushStatus(`LLM warning: ${data.ai_error}`, "warn"); - } - const src = data.source || "diff"; - pushStatus( - `Learned ${data.learned} new terms (source: ${src}${data.truncated ? ", truncated" : ""}).` - ); - if (data.metrics) { - const { wer, distance, ref_tokens, hyp_tokens } = data.metrics; - const pct = wer != null ? (wer * 100).toFixed(1) : "n/a"; - pushStatus(`WER ${pct}% (dist=${distance}/${ref_tokens}, hyp=${hyp_tokens})`, "info"); - setCalibrationRuns((runs) => [ - { - sentence: reference, - transcript: transcript || controller.state.transcript, - metrics: data.metrics - }, - ...runs - ].slice(0, 50)); - } - success = true; - refreshLexicon(); - } catch (e) { - pushStatus(`Error: ${e.message}`, "err"); - } finally { - setIsLearning(false); - } - return success; - }; - - const handleLearnAndNext = async () => { - const ok = await handleLearn(); - if (ok) { - nextWizard(); - } - }; - - const exportReport = () => { - const payload = { - topic, - runs: calibrationRuns, - generated: sentences - }; - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `calibration-${topic}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - pushStatus("Report exported."); - }; - - const avgWer = () => { - if (!calibrationRuns.length) return null; - const sum = calibrationRuns.reduce((acc, r) => acc + (r.metrics?.wer || 0), 0); - return (sum / calibrationRuns.length) * 100; - }; - - const generateSentences = async () => { - setIsGenerating(true); - pushStatus("Generating sentences..."); - try { - const resp = await fetch( - `/lab/calibrate/generate?topic=${encodeURIComponent(topic)}` - ); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) { - throw new Error(data.error || resp.statusText); - } - if (data.sentences) { - setSentences(data.sentences); - pushStatus("Ready to read."); - } else { - pushStatus("Failed to generate."); - } - } catch (e) { - pushStatus(`Error: ${e.message}`); - } finally { - setIsGenerating(false); - } - }; - - const copyToRef = (text) => { - setReference(text); - // Also clear transcript to prepare for recording - if (isRecording) { - controller.stopStreaming(); - setIsRecording(false); - } - setTranscript(""); - pushStatus(`Reference set. Click 🎙️ Record and read it aloud.`); - }; - - const runWizard = async () => { - setIsGenerating(true); - try { - const resp = await fetch( - `/lab/calibrate/wizard?topic=${encodeURIComponent(topic)}` - ); - const data = await resp.json(); - if (!resp.ok) throw new Error(data.error || resp.statusText); - setSentences(data.sentences || []); - setWizardIndex(0); - pushStatus("Wizard ready. Read each sentence and learn after recording."); - if (data.sentences?.length) { - copyToRef(data.sentences[0]); - } - } catch (e) { - pushStatus(`Wizard error: ${e.message}`, "err"); - } finally { - setIsGenerating(false); - } - }; - - const nextWizard = () => { - if (!sentences.length) return; - const next = Math.min(wizardIndex + 1, sentences.length - 1); - setWizardIndex(next); - copyToRef(sentences[next]); - setTranscript(""); - pushStatus(`Sentence ${next + 1}/${sentences.length}`); - }; - - const clearLexicon = async () => { - try { - const resp = await fetch( - `/lab/lexicon/clear?topic=${encodeURIComponent(topic)}`, - { method: "POST" } - ); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data.error || resp.statusText); - setLexiconPreview([]); - setLexiconCount(0); - pushStatus("Lexicon cleared."); - } catch (e) { - pushStatus(`Clear failed: ${e.message}`, "err"); - } - }; - - const exportLexicon = async () => { - try { - const resp = await fetch( - `/lab/lexicon/export?topic=${encodeURIComponent(topic)}` - ); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data.error || resp.statusText); - setLexiconPreview(data.entries || []); - setLexiconCount(data.count || 0); - pushStatus(`Exported ${data.count || 0} entries for ${data.topic}`); - } catch (e) { - pushStatus(`Export failed: ${e.message}`, "err"); - } - }; - - const reloadLexicon = async () => { - try { - const resp = await fetch("/lab/lexicon/refresh", { method: "POST" }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(data.error || resp.statusText); - const m = data.metrics || {}; - const last = m.last_reload_ts - ? new Date(m.last_reload_ts * 1000).toLocaleTimeString() - : "n/a"; - pushStatus( - `Reloaded lexicon (${data.count} entries, rules=${m.rules ?? "?"}, reloads=${m.reloads ?? "?"}, last=${last}).` - ); - refreshLexicon(); - } catch (e) { - pushStatus(`Reload failed: ${e.message}`, "err"); - } - }; - - // Recording toggle - uses controller's WebSocket streaming - const toggleRecording = () => { - if (isRecording) { - controller.stopStreaming(); - setIsRecording(false); - pushStatus("Recording stopped. Pulling transcript..."); - // Wait for final transcript - setTimeout(() => { - const t = controller.state.transcript; - if (t) { - setTranscript(t); - pushStatus("Transcript ready. Review and click Learn."); - } - }, 800); - } else { - setTranscript(""); // Clear old transcript - controller.startStreaming(); - setIsRecording(true); - pushStatus("Recording... Read the reference text aloud."); - } - }; - - return html` -
-

🎓 The Teacher (Active Learning)

- -
- - setTopic(e.target.value)} - placeholder="e.g. liturgia, rust, cooking" - /> - - - - - - -
- - ${sentences.length > 0 && html` -
-

Calibration Sentences (${sentences.length})

-
- Step ${wizardIndex + 1} / ${sentences.length} - -
-
    - ${sentences.map(s => html` -
  • - - ${s} -
  • - `)} -
-
- `} - - ${calibrationRuns.length > 0 && html` -
-
- Calibration metrics - -
-
- Avg WER: ${avgWer()?.toFixed(1) ?? "n/a"}% (runs: ${calibrationRuns.length}) -
-
- `} - -
- - ${isRecording && html`● REC`} -
- -
-
- -