git:20261029.978ce8a to git:20261030.a8295bf

3 added, 2 removed. Audit A to A.

# OpenClicky - Agent Instructions
<!-- This is the single source of truth for all AI coding agents. CLAUDE.md is a symlink to this file. -->
<!-- AGENTS.md spec: https://github.com/agentsmd/agents.md — supported by Claude Code, Cursor, Copilot, Gemini CLI, and others. -->
## Overview
macOS dictation app and voice companion. **Dictation first** (the Kivi port, `OpenClicky/Dictation/`):
hold the dictation key (fn) anywhere, the take is heard by the chosen engine (offline Apple,
Sarvam, OpenClicky backend, AssemblyAI), formatted in the front app's style with the dictionary and
spoken shortcuts, pasted and verified through Accessibility, and written to a local SQLite history;
the orb at the bottom of the screen shows every step; the main window (record, history, dictionary,
shortcuts, styles, settings) opens from the menu bar. Design: `../../docs/superpowers/specs/2026-10-06-dictation-kivi-port-design.md`;
the product it follows: `../../docs/research/2026-10-06-kivi-reverse-engineering.md`.
The companion is unchanged underneath: a menu bar companion app. Lives entirely in the macOS status bar (no dock icon, no main window). Clicking the menu bar icon opens a custom floating panel with companion voice controls. Uses push-to-talk (ctrl+option) to capture voice input, transcribes it through the OpenClicky backend, and either sends the transcript + a screenshot to Claude (teacher lane) or hands it to a Codex agent thread via the `openclicky` CLI (agent mode). Claude responds with text (streamed via SSE) and voice (ElevenLabs TTS). A blue cursor overlay can fly to and point at UI elements Claude references on any connected monitor.
Own keys are the primary route: with `openaiApiKey` / `anthropicApiKey` in `~/.openclicky/shell.json` the app calls OpenAI and Anthropic directly and needs no account. The OpenClicky backend (`../../backend`, which holds OpenClicky's own provider keys — nothing sensitive ships in the app) is only for a capability the person has no key for, on their account token (also in shell.json). `ProviderRouter` makes that decision for every call.
## Architecture
- **App Type**: Menu bar-only (`LSUIElement=true`), no dock icon or main window
- **Framework**: SwiftUI (macOS native) with AppKit bridging for menu bar panel and cursor overlay
- **Pattern**: MVVM with `@StateObject` / `@Published` state management
- **AI Chat**: Claude (Sonnet 4.6 default, Opus 4.6 optional) via the OpenClicky backend with SSE streaming
- **Speech-to-Text**: AssemblyAI real-time streaming (`u3-rt-pro` model) via websocket, with OpenAI and Apple Speech as fallbacks
- **Text-to-Speech**: ElevenLabs (`eleven_flash_v2_5` model) via the OpenClicky backend
- **Screen Capture**: ScreenCaptureKit (macOS 14.2+), multi-monitor support
- **Voice Input**: Push-to-talk via `AVAudioEngine` + pluggable transcription-provider layer. System-wide keyboard shortcut via listen-only CGEvent tap.
- **Element Pointing**: Claude embeds `[POINT:x,y:label:screenN]` tags in responses. The overlay parses these, maps coordinates to the correct monitor, and animates the blue cursor along a bezier arc to the target.
- **Concurrency**: `@MainActor` isolation, async/await throughout
- **Analytics**: PostHog via `ClickyAnalytics.swift`
### Own keys first, the OpenClicky backend as the fallback
Every model call asks `ProviderRouter` (`ProviderRoute.swift`) where to go: the person's own key straight to its vendor (the request shaped exactly as the backend's own-key path sends it upstream, `DirectProviderRequests`), else the OpenClicky backend on the account's bearer token (`OpenClickyConfiguration.authorize`, which never sends own keys), else nothing, with a sentence naming the key that would do. Own keys never go to the backend. Each route change is logged (`route: …` in app.log, no key material). Unit-tested in `ProviderRouteTests`.
| Capability | Own key → vendor | Fallback on an account |
|-------|---------|---------|
| Claude: ask lane, screen grounding (`ClaudeAPI`) | Anthropic Messages (`anthropicApiKey`) | `POST /chat` |
| Spoken answers (`AnswerVoicePlayer`, `SpeechVoices.swift`) | The voice picked in Settings → Voice, or Automatic's order: OpenAI, ElevenLabs, Google Cloud, Amazon Polly, Sarvam, Gemini keys; Kokoro and this Mac are local | `POST /tts` (ElevenLabs) |
| Upload transcription (`OpenAIAudioTranscriptionProvider`) | OpenAI `/audio/transcriptions`, multipart | `POST /agent/transcribe` |
| Realtime voice (`RealtimeVoiceClient`) | OpenAI `/realtime/client_secrets` | `POST /agent/realtime/session` |
| Dictation clean-up (`TakePolishers.swift`, `AppleModelTakePolisher`) | Apple's on-device model first where the Mac has it (`AppleModelTakePolisher`: Foundation Models, macOS 26 + Apple Intelligence, weak-linked so older macOS still launches; the take goes in `<dictation>` tags, because asked plainly it answered dictated questions; `TakeFormatter.reusesTheSpokenWords` rejects a rewrite that drops the spoken words, e.g. a joke for "tell me a joke"; about 0.6–1.2 s per take); then a Sarvam key; then OpenAI chat; Claude Haiku with only an Anthropic key; otherwise Groq, Gemini, Mistral, OpenRouter or xAI through the ask lane's provider (quick models for OpenRouter and Mistral; the plans' CLIs are too slow for the 3 s limit). Debug: `OPENCLICKY_SMOKE_POLISH=1` with `--openclicky-smoke-transcribe` forces the clean-up on | `POST /v1/polish` (`AccountTakePolisher`) |
| Simple requests in the ask lane (`LocalAsk/`, the local ask rung) | Before either route: Apple's on-device model (macOS 26 + Apple Intelligence, English) gets the transcript inside the pre-send pause. `LocalAskGate` (keywords: a question or how-to never pays for the model) → `AppleLocalAskModel.classify` (guided generation into `LocalAskIntent`, the request in `<request>` tags) → `LocalAskIntentMapper` (`open_app` with the name resolved by `InstalledAppResolver`, `open_url`, `create_folder`, `reveal_in_finder`, `set_volume` with spoken numbers read by `SpokenNumbers`, `media_control`; a pronoun, an unknown app, a missing number → cloud) → `MacAction.parse`; a rewrite of a readable `WritingTarget` (≤ 2,500 chars) is a second call checked by `LocalRewriteCheck` and pasted through `WriteInPlace.replace`. Budget 2.0 s for the classification (0.9 s warmed, up to 1.7 s cold on an M2 Pro; the session is warmed at the ⌃⌥ press and when the composer opens), 2.5 s more for a rewrite; a timeout, an error or `needsCloud` means the ordinary path with no added wait when the pause is on. The island says "speaking · this Mac"; `local rung:` lines in app.log, `scripts/measure-actions.sh local:…`. Settings → Clicky → "Simple requests stay on this Mac" (`answerSimpleRequestsOnThisMac`, on). Not the Realtime lane. Design: `docs/superpowers/specs/2026-10-27-on-device-ask-rung-design.md`; research: `docs/research/reports/OS native on device AI assistants.md`. Debug: `--openclicky-local-ask "<request>"`, `--openclicky-local-rewrite "<instruction>" "<text>"`, `--openclicky-ask "<question>"` (the whole lane, exits when the turn ends), `--openclicky-paste-probe "<text>"` (⌘A + the inserter into the front app's box, with the landing). Live-checked 2026-10-28 through `--openclicky-ask` on a scratch account profile: open_app, set_volume and media_control on this Mac in about 1.5 s cold, a how-to and "open it" to the cloud path with the pause; a rewrite produced 230→157 chars in 2.6 s and called `WriteInPlace.replace`, but a Debug build launched from a terminal on this Mac cannot post keystrokes at all (the probe confirms it, trusted or not), so the paste itself is to be confirmed with the installed build. | the same, before the account's route |
| AssemblyAI dictation | — (minted on OpenClicky's key) | `POST /transcribe-token`, account only |
| `openclicky` CLI | Agent mode: `do --gate-only` (lane) and `run --events` (Codex thread) via `OpenClickyAgentClient.swift`; the CLI routes the same way (`agent/src/providerRoute.ts`) | |
An OpenAI key alone does not cover Claude: with no Anthropic key and no account the ask lane says "the claude lanes need your anthropic key too" (the backend's `byok_missing_anthropic_key`). `isConfigured` means "can make model calls" (an own key or an account); `hasAccountToken` means signed in and gates `/billing/me`, the account rows and sign-out.
### Key Architecture Decisions
**Menu bar**: the `NSStatusItem` opens a plain `NSMenu` (either click). The old custom panel is gone; first run happens on the island (permission cards, then `FirstRunTour`).
**Cursor Overlay**: A full-screen transparent `NSPanel` hosts the blue cursor companion. It's non-activating, joins all Spaces, and never steals focus. The cursor position, response text, waveform, and pointing animations all render in this overlay via SwiftUI through `NSHostingView`.
**Global Push-To-Talk Shortcut**: Background push-to-talk uses a listen-only `CGEvent` tap instead of an AppKit global monitor so modifier-based shortcuts like `ctrl + option` are detected more reliably while the app is running in the background. The same tap feeds `CompanionShortcutRecognizer`: the dictation key (fn by default, `DictationHotkey`, identified by its key code on the flagsChanged event) held or tapped → `DictationTakeController`; ⌃ joining the held key → Hey Clicky edit mode; two quick taps or esc → cancel; ⌃ ×2 opens the notch text composer; fn tapped twice with ⌃ held toggles hands-free (Realtime always-on; ⌃ must go down first, and the Globe key event (key code 179) macOS sends after each fn release is ignored).
**Screenshot pixel size for pointing**: `CompanionScreenCapture` records the captured image's real pixel size, not the size requested from ScreenCaptureKit; a capture that comes back at another size would otherwise put every pointed-at element off by that ratio.
**Shared URLSession for AssemblyAI**: A single long-lived `URLSession` is shared across all AssemblyAI streaming sessions (owned by the provider, not the session). Creating and invalidating a URLSession per session corrupts the OS connection pool and causes "Socket is not connected" errors after a few rapid reconnections.
**Transient Cursor Mode**: When "Show OpenClicky" is off, pressing the hotkey fades in the cursor overlay for the duration of the interaction (recording → response → TTS → optional pointing), then fades it out automatically after 1 second of inactivity.
**Realtime connection lifecycle**: `RealtimeVoiceClient` counts as connected only once OpenAI's first event arrives on the socket (a refused handshake is an error with its status, not a silent success). Every press reuses the socket only if it is under 50 minutes old (OpenAI caps sessions at 60) and was heard from in the last minute or answers a ping; otherwise it reconnects. The keep-warm loop reconnects in the background only in always-on mode or within 15 minutes of the last turn, because every session is minted by the backend and billed. A socket lost under a turn calls `onConnectionLost`, which returns `voiceState` to idle (a stuck `.processing` also blocked dictation).
**Act, then look** (2026-10-28): the fast-lane actions run on `RealtimeVoiceClient.actionChain`, one after another, off the receive loop (a Mail script, Spotify starting, a page settling must never hold up audio, barge-in or `response.done`). An outcome with `thenLooksAtScreen` (`open_url`, `web_search`, Gmail's inbox or compose, Mail unavailable) waits for the screen to settle — `settleDelay`: another app coming to the front is waited for up to 1.5 s (a browser already in front never changes), then a second for the page to paint, four seconds in all at most, polled every 250 ms with `frontAppProvider` — then `attachScreenContext()` (a fresh screenshot into the conversation, `lastScreenCapture` replaced, OCR restarted), then the `function_call_output` (`toolOutput`) and, only if `turnGeneration` is still the one the call was made in, `requestContinuation`. The generation is bumped by a cancel, a barge-in (`speech_started`) and a new turn's response: the common timing is the model's response finishing while the action still waits, and Esc then must not be followed by Clicky reading the inbox it just cancelled (`RealtimeSessionHandshakeOrderTests`: Esc after `response.done`, speech after `response.done`, a cancel while the response is active). The settle numbers are the spec's defaults; the Gmail readability spike (`docs/superpowers/plans/2026-10-27-do-things-for-me.md`, Task 9 Step 5) has not run yet. Esc while the lane is busy (`RealtimeVoiceClient.isBusy`: a reply being made or played, or an action still working) is the `.realtime` target of `AskLaneEscape.target`: `stopSpeaking()` bumps the generation, so the action's output goes out but nothing is said — before this, esc in hands-free only knew the classic ask lane and Clicky read the Gmail inbox the person had just cancelled (2026-10-28). The ask lane and the plans get the same tools without the look; they speak `spokenSentence`.
**Ending hands-free by voice**: in always-on mode only, the session also offers `stop_listening` (`RealtimeVoiceClient.stopListeningToolDefinition`). The model calls it on a clear sign-off ("that's all", "stop listening", "ok bye"), never on thanks alone or a request to stop something else; its output asks for a short goodbye, and once that response is done and has played (`endHandsFreeOnceTheGoodbyeHasPlayed`, at most 8 s) `onStopListeningRequested` turns hands-free off through the shortcut's own path (`announceHandsFreeChange`; logged `hands-free off (by voice)`). Which phrases count is the model's judgement, so it is checked live, not by unit tests.
## Key Files
Dictation (`OpenClicky/Dictation/`):
| File | Purpose |
|------|---------|
| `DictationTakeController.swift` | One take at a time as a state machine (idle → starting → listening → finishing): press → mic → engine → `TakeFormatter` → paste (`FrontAppTextInserter` + `PasteLanding.verify`, refused when the app changed, another element of it has focus, or secure input is on; a take that starts outside any text box (Accessibility reads a focused element that is not editable) has the pointer say so beside the mouse (`onNoTextBoxAtPress` → `showQuietNotice`, waking a resting pointer) and goes to a text box clicked into before release, password fields still refused; nothing editable after the paste says "couldn't see it land — copied", an editable field that cannot be read back counts as landed but keeps the words on the pasteboard instead of restoring the clipboard) → `TakeStore`. Every continuation carries the take id, so a cancelled take never pastes into the next. Hold vs tap vs double-tap vs esc, Hey Clicky edits (`HeyClickyEditor`; a control-held press waits out the tap window so the hands-free gesture never opens one), live typing for takes no model will polish (see `LiveTyping.swift`; once a phrase was typed the take finishes by typing the rest, a take that typed nothing live still pastes), inactivity timeout, the orb's phases, earcons, retry from retained audio. `DictationEngineResolver` builds the provider for the chosen engine, says why one is unavailable, and decides whether a model may polish (never for the offline engine unless "also polish offline takes"). Unit-tested through the seams in `DictationCapturing.swift`. |
| `DictationCapturing.swift` | The seams the controller is tested through: `DictationCapturing` (the microphone-and-engine side; `BuddyDictationManager` conforms) and `DictationHost` (focused field, front app, secure input, the paste, live typing's keystrokes / pasteboard copy / clock, the polisher). |
| `LiveTyping.swift` | Live typing: a dictation into a real text field (AXTextField/TextArea/ComboBox/SearchField — never AXWebArea, where letters are page shortcuts; never a terminal or OpenClicky's own window, `LiveTypingExclusions`) that no model will polish (`TakeFormatter.modelPassWillRun`) is typed while it is spoken, only while the take listens. `StableWordCommitter` commits a word once it is clear of the partial's last 2 words and unchanged for 0.6 s, never retracts, commits nothing while the partial no longer lines up, holds back a multi-word dictionary alias split across phrases and a take that could still be a whole-take spoken shortcut (those always paste); `LivePhraseFormatter` gives each phrase fillers/dictionary/sentence case and its spacing, and refuses control characters and line/paragraph separators (a typed new line sends or runs a command; joiners, soft hyphens and emoji sequences type); `TranscriptAlignment` lines the final transcript up against the typed words (case/punctuation-insensitive; prefix, then the last 3/2 typed words within 3 words of where they should end, the last 1 within 1 word; otherwise everything after the common start goes to the pasteboard — never retype, delete or skip); `LiveTypingModifierGuard` defers a phrase while a modifier other than the dictation key's own is held. Before each phrase the focused element is re-read system-wide and must be the press's element (`CFEqual` + pid), still a text field, with secure input off; a stop, a failure or a control character leaves the rest on the pasteboard. ⌃ joining after words were typed does not become an edit: typing stops there, what was heard before ⌃ and not typed goes to the pasteboard, the spoken instruction is dropped. Cancel keeps typed words. Unit-tested, controller included. |
| `DictationSettings.swift` | Every dictation preference in UserDefaults (`dictation.*`): engine, language, script, key, the orb's look/theme/size/behaviour, sounds, haptics, appearance, timeout, incognito, clipboard history. "Clean up my words with ai" (`polishWithModel`) is on from a fresh install since 2026-10-16 (it was off so words typed live); a take whose model fails keeps the local clean-up, marked degraded. `DictationEngineChoice` carries each engine's rules and words in one place (`isOnDevice`, titles, tags, details, badges, sentences): a new engine is a new case plus its provider, not a hunt through the UI. |
| `DictationHotkey.swift` | The dictation key (fn, right ⌥, right ⌘, right ⌃) and how a flagsChanged event is read as its down/up. |
| `SarvamSpeechClient.swift`, `SarvamTranscriptionProviders.swift` | Sarvam: the realtime socket (`/speech-to-text-realtime/ws`, `saaras:v4` with the dictionary as `keyterms`, then `saaras:v3-realtime` with a `prompt` if v4 refuses the session; manual endpointing, raw PCM, partial words while the key is held), the chunked socket (`/speech-to-text/ws`, base64 WAV chunks, `flush`), REST transcription (multipart WAV, `saaras:v4`), chat completions (`sarvam-105b`) for polish and edits. One session class serves both sockets; a session falls back realtime → chunked → upload. Request builders and decoders are pure and tested; both sockets verified live 2026-10-06. Bulbul (`/text-to-speech`, `bulbul:v3`, base64 WAV; v2 is retired) speaks answers on a Sarvam key, verified live 2026-10-12. |
| `TakeFormatter.swift`, `SpokenNumbers.swift`, `TakePolishers.swift`, `AppleModelTakePolisher.swift` | Raw transcript → pasted text: shortcuts, dictionary, fillers, spoken English numbers as digits (`SpokenNumbers`: ten and up or counted in hundreds; one to nine stay words; runs that can't be one number stay as said), sentence case locally; one model rewrite when configured (Sarvam key → `SarvamTakePolisher`, account → `BackendTakePolisher`), guarded by a timeout and a same-take check. |
| `DictationSpace.swift` | Styles (five seeded, app assignments), dictionary terms, spoken shortcuts; JSON under `~/.openclicky/dictation`, watched. |
| `TakeStore.swift` | SQLite history (`~/Library/Application Support/OpenClicky/dictation.sqlite`): takes, revisions, search, stats. |
| `FocusedFieldReader.swift` | The focused element through Accessibility: editable? (`isEditable(role:…)`: text roles always; AXGroup, AXStaticText, AXUnknown and AXWebArea only with a settable value or an editable ancestor, since a Chrome page with nothing clicked reports the page as focused and a take pasted into it and lost the words, 2026-10-23; `FocusedFieldEditabilityTests`), secure?, value, selection, nearby captions; `PasteLanding.verify` reads the field back after a paste. `FocusedTextTarget` (system-wide focused element, its pid, role, secure) is what live typing keeps at press and compares before every phrase. |
| `OrbPanel.swift` | The orb: `OrbModel` (phase, live transcript, box, level), the non-activating panel sized to its content, dragging, hide-when-idle, the SwiftUI pill and transcript box. |
| `UI/` | `AppIconView` (app icons and names, `AppIconCache`), `DictationWindow` (OpenClicky's one window: a native `NavigationSplitView` sidebar with no heading, in this order: General, Dictation, Clicky, Voice (the pages whose `showsBeforeYourPages` is true), "Your words" (`WordsPageView`: how to dictate and the engine, try it here, this week, every take with search, ask, filters, details; record and history merged), "How it writes" (`WritingPageView`: dictionary, snippets, styles with app assignments and whether clean-up is on), The Orb, Shortcuts (the `keyboard` case), Keys & Plans, Account, Privacy & Security, About — all drawn as native grouped forms, laid out by what people come looking for (docs/superpowers/specs/2026-10-12-settings-revamp.md): the dictation key, language, script and clean-up are on Dictation; the voice and Clicky's language on Voice; own keys and the plans on Keys & Plans; updates on General. General → Region (`AppRegion`, `DictationSettings.region`: India or the rest of the world, detected from the Mac until picked) decides whether Sarvam, the Indian languages and the script row are offered; an option already in use always stays. A sidebar search over every setting, plus search-only rows (`searchOnlyGroup`) that open Your words and How it writes; result pages naming the query come first. Incognito + account at the foot. Every entry point goes through `DictationWindowModel.open(section:settingsPage:)` (a settings page wins, a leftover search is cleared); `CompanionManager.showSettings(_:)` opens a page; ⌘, opens General), the settings pages (`SettingsPages` holds the page enum, the grouped `SettingsFormView` and the native tokens `SettingsLook`; `SettingsCatalog` holds every setting as a `SettingsItem`, so a page and a search result draw the same row; one `Settings*Page.swift` per page), `AccountSheet`. |
| `UI/SettingsTour.swift` | The one-minute Settings tour: the pointer flies to key rows in the real window (rows report their frames with `.settingsTourTarget(_:)` into `SettingsTourTargets`; `SettingsFormView` scrolls a requested row into view), "clicks" each page in the sidebar (a ripple, `pointerClickToken`), and recorded narration (`RecordedVoice`, made by `scripts/record-voice-lines.py`, OpenAI marin) explains each step. `pointerStaysOnTarget` keeps the pointer on a row until the next step. Plays once the first time Settings opens at General after the welcome tour; General › Startup › Settings tour replays it; esc, closing the window or a new question stops it. Unit-tested (`SettingsTourTests`). |
| `DiskImageSelfInstaller.swift`, `AppUpdater.swift`, `FnKeyGuard.swift`, `SMAppServiceBridge.swift` | Installation: move-to-Applications from the dmg, Sparkle, the fn key's system action, the login item. Sparkle must stay at 2.9.3 or later: the bundle id ends in ".app", so before 2.9.3 Sparkle's cache folder `~/Library/Caches/org.openclicky.app` looked like an app bundle and macOS 26 App Management refused the update ("OpenClicky was prevented from modifying apps"). Copies with Sparkle 2.9.0 (up to 0.8.5) still hit it once; switching OpenClicky on in Privacy & Security › App Management, or installing from the dmg, gets them past it. |
| `ClipboardHistoryMonitor.swift`, `MicrophoneDevices.swift`, `DictationEarcons.swift`, `DictationSmokeRun.swift`, `OpenClickyMark.swift` | Opt-in clipboard capture, input device list/selection, synthesised tones + haptics, `--openclicky-smoke-transcribe`, the mark (ring with a square top-right corner + pointer) drawn by the menu bar and window; `scripts/make-app-icon.py` draws the app icon from the same proportions. The orb keeps its own `OrbMarkShape`. |
Companion (`OpenClicky/`):
| File | Lines | Purpose |
|------|-------|---------|
| `OpenClickyApp.swift` | ~89 | Menu bar app entry point. Uses `@NSApplicationDelegateAdaptor` with `CompanionAppDelegate` which creates `MenuBarPanelManager` and starts `CompanionManager`. No main window — the app lives entirely in the status bar. |
| `CompanionManager.swift` | ~2500 | Central state machine. Owns dictation, shortcut monitoring, screen capture, the answer client, the voice player, and overlay management. One `interruptCurrentAnswer(includingAgentWork:)` silences every voice (the island's stop keeps agent work going); agent replies are spoken in pieces through `speakWholeReply` like answers; system-voice notices end in idle when they finish. Tracks voice state (idle/listening/processing/responding), conversation history, model selection, and cursor visibility. Coordinates the full push-to-talk → screenshot → Claude → TTS → pointing pipeline. |
| `MenuBarPanelManager.swift` | ~170 | The NSStatusItem and its plain menu (status, agent mode, open the window or the island, settings, replay the welcome tour, quit). No custom panel. |
| `FirstRunTour.swift` | ~420 | The first-run tour, told from the island once the permission cards are done: the pointer flies out of the notch and says hello, then one card per step (animated keycaps) for dictate, ask, hand over a task; a step advances only on proof (a take with words, a spoken answer, an agent task or quick action), says what is missing (fn freed, a key or account) and offers a skip. Step kept in UserDefaults across the Screen Recording relaunch; earlier users are marked done. |
| `OverlayWindow.swift` | ~1000 | Full-screen transparent overlay (one per screen) hosting the blue cursor, the typewriter caption (`cursorCaptionText`: onboarding prompt, (i) explanation, hands-free feedback; wraps at 300 pt, flips left near the edge), waveform, and spinner. Handles cursor animation, element pointing with bezier arcs, multi-monitor coordinate mapping, and the cross-display flight legs (`performFlightLeg`). |
| `CompanionScreenCaptureUtility.swift` | ~132 | Multi-monitor screenshot capture using ScreenCaptureKit. Returns labeled image data for each connected display. |
| `BuddyDictationManager.swift` | ~615 | Push-to-talk voice pipeline. Handles microphone capture via `AVAudioEngine`, provider-aware permission checks, keyboard dictation sessions, transcript finalization, contextual keyterms, and live audio levels. Every session callback carries the session's identity, so a late partial, final or error from one take never reaches the next; a start whose audio engine fails tears the session down. |
| `BuddyTranscriptionProvider.swift` | ~100 | Protocol surface and provider factory for voice transcription backends. Resolves provider based on `VoiceTranscriptionProvider` in Info.plist — AssemblyAI, OpenAI, or Apple Speech. |
| `AssemblyAIStreamingTranscriptionProvider.swift` | ~529 | Streaming transcription provider. Fetches temp tokens from the OpenClicky backend, opens an AssemblyAI v3 websocket, streams PCM16 audio, tracks turn-based transcripts, and delivers finalized text on key-up. Shares a single URLSession across all sessions. |
| `OpenAIAudioTranscriptionProvider.swift` | ~317 | Upload-based transcription provider. Buffers push-to-talk audio locally, uploads as WAV on release, returns finalized transcript. |
| `AppleSpeechTranscriptionProvider.swift` | ~320 | Local transcription provider, on-device only. macOS 26+: `SpeechAnalyzer` + `DictationTranscriber` (`progressiveLongDictation`, model downloaded through `AssetInventory` on first use); older macOS or an unsupported language: `SFSpeechRecognizer`, which is years behind (it typed "12245" for a spoken sentence in Saathi). `appendAudioBuffer` runs on the render thread, so the analyzer session keeps its state behind a lock. Listens in `ReplyLanguage`. SFSpeechRecognizer closes a stretch at each pause (the result carries `speechRecognitionMetadata`) and starts the next from nothing, so closed stretches are kept and joined (`SpokenStretches`); without it a three-sentence take pasted only the last sentence on macOS before 26. Debug: `OPENCLICKY_FORCE_SFSPEECH=1` uses SFSpeechRecognizer on macOS 26 too (with `--openclicky-smoke-transcribe <wav> offline`). |
| `ReplyLanguage.swift` | ~85 | The one language OpenClicky speaks and transcribes: `language` in shell.json (Settings → Voice → Language), English when unset. Its rule ends both lanes' prompts (`CompanionManager.withReplyLanguage`) and pins Realtime and upload transcription. Never "follow the user": that freedom made Saathi answer English in Italian, Japanese and Portuguese. Unit-tested. |
| `ChromiumAccessibility.swift` | ~115 | Chrome, Edge, Brave, Arc and every Electron app keep their web accessibility tree off until an assistive client announces itself, and until then report **no focused element at all** (not a non-editable one): no text box for a take, no email for "rephrase this". The announcement is the application attribute `AXEnhancedUserInterface` (plus `AXManualAccessibility` for Electron); Chromium answers the write with an error and builds the tree about two seconds later anyway (measured 2026-10-16 against a fresh Chrome profile; queries and tree walks never wake it). Written on every Chromium app as it comes to the front (`startWakingFrontApps`, from `CompanionManager.start`), at the ask lane's press and the dictation press (through `DictationHost.wakeFrontAppAccessibility`). Only Chromium-based apps get it (a `… Helper (Renderer).app` in Contents/Frameworks), because on a native AppKit app the attribute is a real switch. On a Mac where VoiceOver or another assistive tool already set it the bug never shows. Logs `accessibility: asked <app> …` once per pid. Unit-tested. |
| `SelectedTextReader.swift` | ~65 | The front app's highlighted text (Accessibility, the front app asked first — the system-wide focused-element query fails for Terminal), added to each turn's screen context in both lanes as what "this" means. Capped at 600 characters; nothing from password fields. Unit-tested. |
| `BuddyAudioConversionSupport.swift` | ~108 | Audio conversion helpers. Converts live mic buffers to PCM16 mono audio and builds WAV payloads for upload-based providers. |
| `GlobalPushToTalkShortcutMonitor.swift` | ~150 | System-wide shortcut monitor. Owns the listen-only `CGEvent` tap, feeds it to `CompanionShortcutRecognizer`, publishes talk press/release plus the other shortcut events. |
| `CompanionShortcutRecognizer.swift` | ~270 | Pure state machine for the shortcuts: Dictate (the dictation key held or tapped; ⌃ joining = Hey Clicky; two taps = cancel; esc), Talk (hold ⌃⌥), Text (tap ⌃ twice → notch composer), Hands-free (hold ⌃, tap the key twice → always-on toggle; the Globe key event, key code 179, that follows each fn release with ⌃ held is not a key press). Tap window 350 ms, double-tap window 450 ms; a key press while the modifier is down (⌃C) is never a tap. A right-hand dictation key's own flag is taken out of the companion shortcuts (the per-key device bits, which `GlobalPushToTalkShortcutMonitor` passes through, tell it from its left twin), so right ⌃ held is dictation and right ⌥ + ⌃ is an edit, not talk; talk never starts while the key is held; a key typed during the hold is reported on release (`keyWasPressedDuringHold`) and the controller throws the take away. Every key × every gesture is unit-tested. |
| `FrontAppTextInserter.swift` | ~150 | Types dictated text into the app in front: pasteboard + posted ⌘V (needs Accessibility), pasteboard restored 0.6 s later; without Accessibility the text stays on the pasteboard. Live typing instead posts keystrokes carrying the text (`CGEventKeyboardSetUnicodeString`, ≤ 20 UTF-16 units per event) from a private event source at the session tap, on the unassigned virtual key 0x42 with flags cleared, so the still-held dictation key never merges in; stamped with `liveTypingEventMarker` (event field and source user data) so `GlobalPushToTalkShortcutMonitor` ignores them. |
| `CursorFlightPlanner.swift` | ~100 | Splits a buddy flight whose destination is on another display (fly back to a mouse that moved screens) into a leg to this display's nearest edge and a leg across the destination display. Unit-tested against this Mac's layout. |
| `ScreenTextLocator.swift` | ~190 | `point_at` snapping. The Realtime model's pixel guesses are 30–100 px off, so the screenshot it saw is OCR'd (Vision, accurate, no language correction, upscaled 2× because 11 px captions come back as "Now"/"HNew" otherwise) and the guess snaps to the element's visible `text` nearest to it: whole hint → distinctive words (never "button"/"menu"…), exact matches only, within 30 % of the width. Near misses ("reditt" for a Reddit tab) are left to the Claude fallback, and so are ambiguous ones: when a second candidate sits within 10 % of the width of the winner ("Edit" on every row of a list), the model's own 30–100 px error is what separates them, so nothing is resolved and Claude picks with the user's request in hand. Unit-tested, including a rendered-caption OCR test. |
| `BrowserTabLocator.swift` | ~190 | `point_at` for browser tabs ("where is the Reddit tab"): a 16 px favicon and a truncated title are unreadable to every screenshot model (all went to a menu bar item spelled "reditt"). When the request says "tab", every running Chrome/Brave/Edge/Safari with a window on screen is tried (active one first; the browser need not be in front — a terminal over the lower half of Chrome still shows the tab strip — but a browser behind the front app is matched on the user's words alone, `matchTexts`, never on the model's own reading of its strip: "minimize this tab" with Terminal in front went to a Chrome tab behind it, 2026-10-27): AppleScript lists its front window's tabs (title + URL; first use prompts to allow controlling the browser), request words are matched against the URL host (×3) and title (×1), the tab's AXTabButton frame (Accessibility, strip order, counts must agree) is mapped into the capture, and CGWindowList z-order rules out a tab that is off the captured display or under another app's window. Runs before OCR/Claude in the pointing chain. Unit-tested. |
| `WindowControlLocator.swift` | ~130 | `point_at` for a window's close / minimize / zoom buttons: 12 px discs with no caption, so OCR has nothing and Claude guessed the menu bar for the Terminal's yellow button (2026-10-27). `requestedControl` (pure, unit-tested) reads the model's label ("minimize button"), else the user's words naming the button or the window ("the yellow button", "minimize this window"; never a tab), and the front app's focused window gives the button's frame through `kAXMinimizeButtonAttribute` and friends. Runs first in the pointing chain. |
| `AccessibleElementLocator.swift` | ~215 | `point_at` for a caption that appears more than once ("Edit" on every row). OCR only sees pixels, so identical captions are separated by distance alone and the model's 30-100 px error decides; Accessibility knows each control's role and the title of the row it sits in. The front app's focused window is walked (600 nodes, depth 12, 0.25 s messaging timeout), every captioned control is mapped into the capture, and candidates are ranked by request words matching their container titles (x3) plus an actionable-role bonus, distance only breaking ties. Two equal candidates closer than the ambiguity margin stay unresolved and go to Claude. Runs between OCR and Claude in the pointing chain. Pure ranking unit-tested; the tree walk is not. |
| `AppLog.swift` | ~50 | `~/Library/Logs/OpenClicky/app.log` (truncated past 2 MB at launch): every Realtime log line (`screen attached`, `point_at … snapped/located by Claude`) and hands-free toggles, since Finder-launched stdout goes nowhere. Read it when the buddy pointed at the wrong thing. |
| `ScreenElementGrounder.swift` | ~60 | `point_at` fallback when there is no caption to snap to (icons, symbols, OCR misses): Claude Haiku 4.5 on the own Anthropic key or the backend `/chat` proxy, or the picked answer source's model on an OpenAI-compatible or Gemini key (plans are skipped; `point_at: no grounding` / `grounding failed` in app.log), locates the element on the same screenshot (label + text + the user's last utterance) and answers `[POINT:x,y]`; measured ≤ 8 px off, 2–4 s. `RealtimeVoiceClient` resolves `point_at` calls on a serial chain (`pointingChain`) off the receive loop so audio keeps flowing and steps fly in order. Unit-tested (parsing, prompt). |
| `ClaudeAPI.swift` | ~450 | The answer client, streaming (SSE) and non-streaming; Anthropic directly on an own key, the backend's `/chat` on an account (through `BackendSignIn`, so a refused token is renewed and retried), or an `AnswerProvider`. A refusal throws a typed `BackendHTTPError(service:status:body:)`, which the sentence helpers read (no scraping of error text). TLS warmup optimization, image MIME detection, conversation history support. |
| `AnswerVoicePlayer.swift` | ~160 | Speech client: `fetchAudio(for:)` asks `SpeechVoiceChoice.current()` who speaks; the free voice goes to the backend's `/tts` (ElevenLabs) on the account token, every other voice through `CloudVoiceSpeaker`. `playToCompletion(_:)` plays it (`AVAudioPlayer`); both are the steps `SpeechQueue` uses. `makeAnswerSession()` resolves the voice once per answer and wraps the free voice in `AnswerVoiceSession` (see `AccountVoiceGuard.swift`). A piece whose fallback also fails goes to the system voice, slowed by `MacVoice.speakingRate`. Logs `voice: <choice>` when the voice changes and `voice: account first sound in N s` per answer. |
| `AccountVoiceGuard.swift` | ~200 | A slow or failing free voice never makes anyone wait. `AnswerVoiceSession` (one per answer) races each /tts piece against `AccountVoiceDeadline` (2.5 s for the first piece, 6 s for later ones, which are fetched while earlier ones play); a late or failed piece and the rest of that answer go to `SpeechVoiceChoice.fastestLocalVoice` (Kokoro if downloaded, else this Mac), pieces already in flight follow, and a cancel (esc, a new question) is never counted as trouble. `AccountVoiceRest`: two troubled answers in a row rest the free voice for 10 minutes, and `resolve` passes it by in Automatic only (a voice picked by hand falls back one answer at a time). Logged `voice: account piece N not back / failed` and `voice: account resting`. Unit-tested (`AccountVoiceGuardTests`). |
| `SpeechVoices.swift` | ~330 | Who speaks answers. `SpeechVoiceChoice` (shell.json `speechVoice`: automatic, account, openai, elevenlabs, google, gemini, polly, sarvam, kokoro, mac): what the picker offers (voices with a key or account, Kokoro, this Mac), `resolve` (Automatic's order; a picked voice without its key, or Kokoro not yet downloaded, falls back to this Mac). `CloudVoiceRequests` builds each vendor's request (ElevenLabs on your key with the premade voice Sarah, Google Cloud TTS with a Cloud key — AI Studio keys are refused, Gemini `gemini-3.8-flash-tts` on the Gemini key, Amazon Polly neural voices signed with `AWSSignatureV4`, checked against AWS's get-vanilla vector). `CloudVoiceSpeaker.audio` speaks for the answers and the keys' Test buttons. Live-checked 2026-10-12: OpenAI, ElevenLabs, Gemini, Sarvam, Kokoro, Mac; Google Cloud and Polly not (no keys). |
| `Sherpa/SherpaHelperProcess.swift`, `Sherpa/SherpaLibrary.swift`, `Sherpa/ModelDownload.swift`, `Sherpa/sherpa-onnx-c-api.h` | ~330 | What Kokoro and Parakeet share: the helper process (the app's own binary with a flag, branched off in `OpenClickyMain`; one JSON line each way; `SherpaHelperClient` starts it on the first request and `unload` ends it, which is the only way sherpa-onnx gives a model's memory back), the dlopen of the embedded library, and the checked model download (pinned SHA-256, tar into Application Support, `ModelInstallState`, the free-space and metered-network conditions of an automatic download). |
| `Parakeet/ParakeetDictation.swift`, `Parakeet/ParakeetEngine.swift`, `Parakeet/ParakeetTranscriptionProvider.swift` | ~420 | The offline dictation engine for Macs before macOS 26 (`DictationEngineChoice.parakeet`): NVIDIA Parakeet TDT 0.6B v3 int8 (465 MB, 25 European languages, its own punctuation; no Indian languages, which stay with Apple or Sarvam). On every Mac (macOS 26 included since 2026-10-16: a tester on 26 only found it in Settings) it downloads on its own once the tour is done (`startAutomaticModelDownloads`, Kokoro's rules, `openclicky.parakeet.declined`) and replaces the `offline` engine; Settings → Dictation shows its download row. The helper starts when a take starts (model loads in ~0.9 s on an M2 Pro while the person speaks), the take's audio is read again about once a second for live words (for the first 30 s of a take), and the final words come ~0.3–0.5 s after release; peak ~1.2 GB in the helper, which ends after 180 s idle or on memory pressure. Debug: `--openclicky-smoke-transcribe <wav> parakeet` downloads the model first. Unit-tested (`ParakeetDictationTests`). |
| `Kokoro/KokoroVoice.swift`, `Kokoro/KokoroEngine.swift`, `Kokoro/KokoroHelper.swift` | ~300 | Kokoro v1.0 (open, multilingual) on this Mac through sherpa-onnx 1.13.8's C API. The engine (libsherpa-onnx-c-api + libonnxruntime, universal) ships in Contents/Frameworks: the "Embed Kokoro engine" build phase (`scripts/embed-kokoro-engine.sh`) fetches it once into git-ignored ThirdParty/, checks a pinned SHA-256, copies and signs it with the app's identity, and release.sh re-signs it with Developer ID — so it loads with library validation on (no `disable-library-validation`). It adds 11.4 MB to the LZMA dmg and 18.9 MB to the Sparkle zip; the user's rule is to ship it only while it adds under 20 MB. Only the model (350 MB, ~400 MB unpacked) downloads, checked against its SHA-256: when picked, and on its own once the welcome tour is done (`startAutomaticSetup`, at launch + 8 s and at the tour's end; not on an expensive/constrained network, not with under 2 GB free, never again after Remove or Cancel — `openclicky.kokoro.declined`). Since 2026-10-29 it no longer downloads on its own (only when picked), and Automatic puts the free ElevenLabs voice before it; it is the first stand-in when the free voice is late or fails. Kokoro runs in a helper process (the app's own binary with `--openclicky-kokoro-helper`, branched off in `OpenClickyMain` before any UI; one JSON line each way over stdin/stdout; it exits when stdin closes). Freeing the model in-process returned almost nothing (sherpa-onnx keeps ~300 MB allocated); ending the helper returns all ~600 MB. The helper starts and warms when a question starts (`prepareForAnswer`, from `voiceState` going to listening/processing, not in hands-free) and ends after 180 s without an answer or on memory pressure. Debug: `OPENCLICKY_KOKORO_IDLE_SECONDS` shortens the idle time. The model stays loaded (0.8 s load; a sentence in ~0.3x its length on an M2 Pro, ~0.55x under Rosetta). The header must match the embedded version. Debug: `--openclicky-voice-smoke <choice>`. |
| `AskPointerContext.swift` | ~110 | "What is this?" with the mouse on something. `PointerInCapture.locate` puts the mouse in the screenshot it is on (pixels from the top-left, the [POINT:] space) and its `promptLine` goes into the question; `PointerCloseUp` cuts a 480 px square around it and sends it as one more image (labelled as not for pointing); `RepeatedQuestion.isRepeat` (edit distance ≤ 25 % of the words, within 180 s of the last question) adds a line saying the previous answer probably missed what they meant. Before this the ask lane never sent the pointer, so the model described the focused window instead of the thing pointed at (tester, 2026-10-15). Unit-tested. |
| `GuidedSteps.swift` | ~220 | Step by step guidance in the ask lane: a how-to reply is one step ending in `[NEXT]` (`GuidedStepMarker`, never spoken: `SpokenChunker` strips it); `GuidedStepWatcher` (a global monitor, only while a step waits) asks for the next step on a fresh screenshot after a click (left or right; 1.5 s settle), Return or Tab, waiting 4 s of quiet while typing after a click. Each "what's next?" repeats the original question (history keeps 10 exchanges). Ends on esc, a new question, the stop button, 120 s idle or 15 steps. While a guide is under way (`guideStepUnderWay`) the island is only a "stop guide" strip (`stopGuide()`, hover never opens the panel) and a waiting step shows "esc to stop" beside the pointer. The click that finishes a step is checked against the pointer's last spot for it (`GuidedStepClickCheck`, AppKit screen points): more than 120 away is off target, which shows "that wasn't the spot i pointed at, let me look" and asks the model to check and point back (a hint, never a block; Return or Tab count as unknown). Ask lane only, not Realtime or agent mode. Unit-tested (`GuidedStepsTests`). |
| `AskLaneControl.swift` | ~90 | The person stays in charge of the ask lane. `AskLaneEscape`: esc goes to a dictation take in progress, else stops the ask lane (`CompanionManager.stopAskLane`: a press listening or transcribing, a question in flight (`isAskInFlight`), an answer being said, or a guide), shows "stopped"; the island's strip stops a question in flight too, so a guide's first step can be stopped. `AskSendPause`: 1.2 s "sending your screen · esc to cancel" before the screenshot (Settings → Privacy "Pause before sending my screen", on by default; never for a guide's own next-step request; classic lane only). `UnsureMarker`: an `[UNSURE]` reply never points (a tag with it is ignored), ends a guide, island reads "not sure · tell me what you see". Unit-tested (`AskLaneControlTests`). |
| `WriteInPlace.swift` | ~300 | "Can you rephrase this?" written in place. When a question is asked, `WritingTarget.captureFromFrontApp` reads (Accessibility) the highlighted text, or else the whole focused text box (Gmail's body in Safari/Chrome, TextEdit), never a password field or OpenClicky's own window, at most 6,000 characters; it waits out the composer handing focus back and asks a browser again (its accessibility tree is built on the first request; a Chromium browser is woken through `ChromiumAccessibility` and given up to 2.5 s). The exact words go into the question and the ask lane offers the `write_in_place` tool (any lane setup, plans included). The tool's text replaces the target straight away: only if the same box still has the same text (else nothing is pasted and it says so), ⌘A for a whole box, paste, `PasteLanding.verify`, the original left on the clipboard, the spacing at the ends kept; "press command z if you want yours back". Logs `write in place:` with sizes and roles, never the words. Live-tested 2026-10-12 in TextEdit and a Gmail-like box in Safari; a tester's real Gmail in Chrome on a fresh Mac got no target at all until the Chromium wake was added (2026-10-16), and a second Mac hit it on 2026-10-18 (the wake was unreleased). Since then a highlight Accessibility cannot read is read by copying it: `SelectionCopier` posts ⌘C, reads the pasteboard and puts it back (nothing copied, or a Finder file copy, is no target; only where Accessibility saw no focused element or an empty editable box, never a readable non-editable one), the target carries `source: .clipboard`, is checked again by copying before the paste, and counts as probably replaced (`copied highlight` in the log). With no target at all the prompt carries `noTargetPromptInstruction`, so the model asks for a highlight instead of reading a rewrite out. Since 2026-10-23 the Realtime lane has the tool too (`WriteInPlace.realtimeToolDefinition`, the target captured with each turn's screen through `writingTargetProvider` and quoted by `RealtimeVoiceClient.screenCaption`): with an own OpenAI key ⌃⌥ goes to Realtime, which had only talked, so "rephrase this" in a Gmail draft was read out. Asking how to rephrase is answered in words. Apple Mail's message body is an editable AXWebArea that reports no text, so with nothing highlighted there was nothing to write over (worked in Gmail, whose box Chrome reads out; 2026-10-24): such a box becomes a `.screenOnly` target, the model reads it from the screenshot, and only when it calls `write_in_place` is the cursor checked to still be in that box, ⌘A selects it, ⌘C keeps the original for ⌘V, and the new text is pasted (never selected at capture: every question captures, and a selected email would be wiped by the next keystroke). Unit-tested (`WriteInPlaceTests`, `RealtimeWriteInPlaceTests`, `UnreadableBoxWriteInPlaceTests`); live-checked on a TextEdit document and a Mail compose window. |
| `SystemPromptGuard.swift` | ~60 | Keeps the pointer off macOS's permission and password prompts (UserNotificationCenter, SecurityAgent, coreautha, CoreServicesUIAgent, universalAccessAuthWarn; found through CGWindowList, 40 pt margin): asked how to save a file with the Documents prompt up, the model pointed at Allow every time. `pointAt` drops such a point (`point: not shown` in app.log). Unit-tested. |
| `SpokenChunker.swift` | ~120 | Cuts the ask lane's streamed answer into speakable pieces: the first sentence at once, later ones grouped to ≥120 characters; holds back and records the `[POINT:…]` tag (the ask prompt puts it first; a trailing one is found at the end). Unit-tested. |
| `SpeechQueue.swift` | ~170 | `SpeechQueue` fetches each piece's audio as it is enqueued (at most two requests at once) and plays them in text order, reporting the first audio and, on failure, everything left unsaid (the caller falls back to the system voice). `StreamingAnswerSpeaker` joins it to the chunker and moves the pointer when the voice starts; agent replies use `receiveWholeReply`. Unit-tested with injected fetch/play. |
| `DesignSystem.swift` | ~125 | The few shared tokens left: `DS.Colors` (accent, warning, the overlay cursor), the island's `DS.HUD`, `pointerCursor()` and `Color(hex:)`. Settings pages use `SettingsLook` instead. |
| `ClickyAnalytics.swift` | ~121 | PostHog analytics integration for usage tracking. |
| `WindowPositionManager.swift` | ~275 | Window placement logic, Screen Recording permission flow, accessibility permission helpers, and `relaunchApp()` (`open -n`, then terminate) for the permission that only takes effect on a fresh launch. |
| `AppBundleConfiguration.swift` | ~28 | Runtime configuration reader for keys stored in the app bundle Info.plist. |
| `OpenClickyConfiguration.swift` | ~190 | Reads `~/.openclicky/shell.json` + env overrides; bearer auth (`authorize`: the account token only, never own keys, no header without one); `isConfigured` (an own key or an account) vs `hasAccountToken` (signed in); CLI environment. Own keys: `openaiApiKey` / `anthropicApiKey` are read by `ProviderRouter` for direct vendor calls and reach the CLI as `OPENCLICKY_OPENAI_KEY` / `OPENCLICKY_ANTHROPIC_KEY`, never `OPENAI_API_KEY`. The `~/.openclicky` folder is watched (`startWatchingSettingsFile`): an edit made while the app runs is reloaded, and a changed token, backend or key reconnects Realtime. |
| `BillingStatus.swift` | ~230 | `GET /billing/me` (`BillingStatusModel`) and the sentences that describe it: `allowanceStanding` / `allowanceSentence` (this month, today, the shared budget and a paused account each say their own thing), the UTC reset day, the "details" line. Also the island's account rows in Settings → more (sign in; or the allowance and sign out; or "your own (not metered)"). Creating an account happens in the window's `AccountSheet`. No Stripe UI. Every answer feeds `AccountProfileStore` (in `AccountCapabilities.swift`): its persisted `plan` ("grant" / "byok" / "unmetered") is what makes a login a free account; until the backend says "grant" nothing is taken away. |
| `OpenClickyAuthSession.swift` | ~140 | Sign-in and session refresh: `GET /auth/config` on the backend gives the Supabase URL + publishable key, the password grant returns access + refresh tokens, both stored in shell.json (`token`, `refreshToken`, `tokenExpiresAt`, `accountEmail`) and refreshed 10 min before expiry on a 5-min timer (started from `NotchHUDManager.show`). A sign-in that dies (refused refresh, or a 401 nothing can renew) signs out with `sessionExpiredMessage` kept on the account form, a log line, `signInExpiredNotification` and a quiet caption. A guest can confirm the email with the 6-digit code (`/auth/confirm`). |
| `BackendSignIn.swift` | ~90 | Every backend caller's answer to a 401: refresh once, send again with the new token, a second 401 signs out and throws `BackendSignInError.expired`. A 401 relayed from the person's own provider key (error object in the body), or one for a request sent with no token (own keys, no account), is passed through, never a sign-out. Unit-tested with the session side injected. |
| `ProviderRoute.swift` | ~290 | Own keys first (issue #4): `ProviderRouter.route(for:credentials:)` per capability (own key → vendor, else the account's backend route, else a sentence naming the missing key), `current(for:)` logs each route change; `DirectProviderRequests` builds the vendor requests the backend's own-key path would send (Anthropic Messages, OpenAI speech / multipart transcription / Realtime client secret / chat, the skill-drafting prompt, Haiku polish). Unit-tested (`ProviderRouteTests`). |
| `AnswerProvider.swift` | ~150 | `AnswerSource` (Anthropic, OpenAI, OpenRouter, Gemini, Groq, Mistral, xAI, Claude Code plan, Codex plan, the free account), default models, the provider-neutral `AnswerRequest` / `AnswerResult` and the `AnswerProvider` protocol. Questions on a non-Anthropic source go `ClaudeAPI.analyzeImageStreaming` → `ProviderRouter` (`.answerProvider`) → `AnswerProviderFactory`. Order and picker: `ProviderCredentials.answerSourcesInOrder`, `answerSource` in shell.json, "Answers come from" in Settings → Clicky. |
| `AnswerProviderFactory.swift` | ~50 | Builds the provider for a source from the credentials and `answerModels` overrides. |
| `AnswerSourceCheck.swift` | ~80 | The Test buttons in Settings → Keys & Plans: one red-square question to a single source ("✓ Works. It answered in 3.8 s."); Anthropic is checked by listing models. Debug: `--openclicky-check-source <AnswerSource raw value>`. |
| `MacVoice.swift` | ~120 | This Mac's own voice for answers (`speechVoice: "mac"` in shell.json, Settings → Voice): AVSpeechSynthesizer renders each piece to audio so it shares SpeechQueue, the player and the stop button with the cloud voice; best installed voice for the reply language unless `macVoiceIdentifier` is set; Premium/Enhanced voices come from Read & Speak. Debug: `--openclicky-mac-voice-smoke`. |
| `OpenAIChatAnswerProvider.swift` | — | OpenAI-compatible chat completions with SSE for OpenAI, OpenRouter, Groq, Mistral and xAI; images and tools. Tested in `AnswerProviderHTTPTests`. |
| `GeminiAnswerProvider.swift` | — | Gemini `streamGenerateContent` (x-goog-api-key, schema keywords stripped, thinkingLevel low on gemini-3). |
| `PlanCLIs.swift` | — | Finds the `claude` and `codex` CLIs (real home via getpwuid, so CFFIXED_USER_HOME demo profiles still see them; the newest Claude Code by `--version`) and their signed-in status. |
| `ClaudeCodeAnswerProvider.swift` / `CodexAnswerProvider.swift` | — | Answer on the person's Claude or ChatGPT plan through `claude -p` (stream-json, no tools, no session) and `codex exec` (ephemeral, one shot). Plans answer, point and run the quick actions through `PlanToolProtocol`. Tested in `AnswerProviderPlanTests`. Debug smoke: `--openclicky-answer-smoke`. |
| `PlanToolProtocol.swift` | ~300 | Quick actions for the plan CLIs, which run with no tools: the ask lane's tools are listed in the system prompt and asked for as `[ACTION: <name> <json>]` lines after the spoken words; `parse` turns them into `ClaudeToolCall`s (at most two; unknown names and bad JSON dropped; fences, missing colon or bracket, trailing punctuation tolerated), `PlanActionLineFilter` keeps tags out of the streamed text. The ask lane offers the tools on a plan (`CompanionManager.offersQuickActionsToAskLane`). Unit-tested in `PlanToolProtocolTests`. |
| `OpenClickyAgentClient.swift` | ~200 | Runs the `openclicky` CLI and parses `--events` JSON Lines for agent mode. |
| `NotchHUD.swift` | ~1200 | The notch island: black around the camera, melting into glass below when open (`IslandSurface`), top corners flared into the menu bar, bottom radii that scale with the panel, a shadow only when open, pop-up-menu window level. States: collapsed (the notch itself; a thin handle line on displays without a notch — flush with the top edge on a display that shows no menu bar, whose band is 0 pt), **peek** (hovering: the island grows a little and shows the pointer's status and "click to open"; nothing opens on hover — a side drawer still opens on hover), compact (the busy strip, laid out beside the camera: indicator left, words right, black between; the (i) and hands-free captions type out beside the mouse), connect (app card), permission (permission card), composer (⌃ tapped twice: one-line field, ↩ sends / esc closes; the app activates for the keyboard and the previous app gets it back; opened from the agent component it goes to that thread), full (a click: the band with the pointer mark, the component capsule, the voice pill, Settings and quit; under it the component slot — `NotchHUDModel.selection`, fed by `IslandContext` from the take controller, the orb, the agent line and the result card). Motion: width then height on springs (`IslandMotion`), a press scale, fades under Reduce motion. One window per screen. Debug hooks for the UI tour (`debugPeekPrimary`, `debugSetPrimaryContext`). |
| `NotchHUDPanels.swift` | ~800 | The open island: the band beside the camera holds the pointer's status (`NotchPointerStatus`) and the voice pill, Settings (opens the window) and quit; under it the slot shows the active component (`NotchFullPanelView`) and, with more than one available, the navigation capsule. `NotchPanelBody` is the shortcuts component's row — (i) → `explainWhatOpenClickyDoes`, the four shortcuts as a 2 x 2 grid (`NotchShortcutGrid`) or the engine warning; its tiles turn translucent on the glass band. A side drawer stacks the same pieces. Also the peek line and the agent result card (whose `state.isVisible` the island reads). |
| `IslandStyle.swift` | ~90 | The island's look as pure maths: `IslandCorners` (bottom radius and top flare by state), `IslandGlass` (where the black melts into glass for a glass level, `openclicky.island.glassLevel`, default 0 = all black and no material view at all (it was 0.6 until 2026-10-28: solid black reads more easily; the Settings slider was removed the same day as clutter, the glass still works through `defaults write org.openclicky.app openclicky.island.glassLevel 0.6`)), `IslandMotion` (the unfold, peek and press springs; fades under Reduce motion). Unit-tested. |
| `IslandComponents.swift` | ~200 | The slot's contract: `IslandComponent` (id, title, symbol, height, `isPlugin` — a plugin is off until turned on under `openclicky.island.plugins`, availability, view), `IslandContext` (what the moment is: a live or just-landed take and its words, a working agent, a result card; `contextualKey` names the component the moment asks for), `IslandComponentSelector` (override → the moment → the user's default → the first available, in the user's order), `IslandComponentSettings` (default and order in UserDefaults), and the three built-ins: shortcuts, transcript (live and 8 s after landing), agent. Unit-tested. |
| `Plugins/IslandPlugins.swift` | ~25 | The plugin registry: optional island extras built for the Mac, each in its own folder under `Plugins/` (System, Shelf, CodingAgents, Media, Teacher), off until switched on in Settings → Plugins (`Dictation/UI/SettingsPluginsPage.swift`: one row with a switch per plugin, then the island's default component and capsule order). Nothing outside `Plugins/` depends on a plugin's files beyond this registry, except the voice lanes calling the Teacher's facade `TeacherPlugin`, and the Windows and Linux clients (their own trees) never see them. |
| `Plugins/System/SystemStats.swift` | ~110 | The System component's figures: `SystemStatsSnapshot` (CPU fraction, memory and disk bytes, an optional battery), `SystemStatsFormatter` ("23%", "7 / 16 GB", "210 GB free", "86% ⚡"), `SystemStatsTone` (amber above 85 %, a battery at or under 20 %), and `SystemStatsStore`, one per app, sampling every 2 s only while a tile row watches. Unit-tested with a fake sampler. |
| `Plugins/System/SystemStatsSampler.swift` | ~110 | The live sampler: CPU tick deltas from `host_processor_info` (the first sample is 0), memory pages from `host_statistics64`, the boot volume's capacity, the internal battery from IOKit power sources. |
| `Plugins/System/SystemIslandView.swift` | ~100 | The System plugin (id `system`, 84 pt): a 28 pt ring per figure with the percentage inside, the name and the long figure ("7 / 16 GB", "210 GB free") in white under it; three rings on a Mac without a battery. |
| `Plugins/Shelf/ShelfStore.swift` | ~200 | The shelf's items (`ShelfItem`: a file by path, a note, a link, an image saved as PNG; a file's Finder icon rendered once at drop time), `ShelfStore` (one per app, newest first, at most 40, `index.json` under Application Support/OpenClicky/Shelf; nothing stat-ed at launch), `ShelfDropReader` (the drag pasteboard: files, else a web link, else an image, else text) and how an item writes itself back to a pasteboard. Unit-tested. |
| `Plugins/Shelf/IslandDrop.swift` | ~60 | `IslandDropDelegate`: the whole island takes drops only with the shelf on; a drag over it unfolds it to the shelf, a drop adds the items and holds the island open 1.5 s. |
| `Plugins/Shelf/ShelfIslandView.swift` | ~250 | The Shelf plugin (id `shelf`, 84 pt): a horizontal row of 76 × 70 tiles, a dashed "drop to keep" tile while a drag hovers, hover shows a cross that removes. Each tile is an AppKit drag source (`ShelfTileDragSourceView`): 4 pt of movement starts a session with the item, a landed drop removes the tile, a refused one keeps it; a plain click copies the item ("copied" for 1.2 s). |
| `Plugins/CodingAgents/AgentSessions.swift` | ~330 | The Coding agents component's eyes: `AgentSessionParser` reads the tail of a Claude Code (`~/.claude/projects/*/*.jsonl`, `$CLAUDE_CONFIG_DIR`) or Codex (`~/.codex/sessions/**/rollout-*.jsonl`) log into an `AgentActivity` (editing Foo.swift, running: a command, thinking, writing, waiting, idle once the answer finished), `AgentSession` (live = written within 120 s and not idle), and `AgentSessionWatcher`, one per app, polling every 3 s while a row is on screen, 20 s while the component is merely on, never otherwise; OpenClicky's own scratch Codex runs are skipped. Unit-tested with fixture logs. |
| `Plugins/CodingAgents/ClaudeUsage.swift` | ~220 | Claude's plan limits: `ClaudeUsage` (5 h and 7 d windows from the usage endpoint), `KeychainClaudeCredentialReader` (the `Claude Code-credentials` item's `claudeAiOauth.accessToken`), `ClaudeUsagePolicy` (fetch every 5 min while on screen, the Keychain at most every 30 min, "resets 2 h 10 m"), and `ClaudeUsageStore` with its states (sign in needed, Keychain refused and remembered, figures possibly stale). Unit-tested with a fake reader and transport. |
| `Plugins/CodingAgents/AgentsIslandView.swift` | ~190 | The Coding agents plugin (id `agents`, 76 pt; never the moment's component): up to three session rows with a running bar while live, "+n more", an empty state; Claude's two usage rings on the right (amber above 80 %, red at 100 %) or the sign-in / Keychain line. The peek line names the newest live session while the component is on. |
| `Plugins/Media/MediaSnapshot.swift` | ~90 | The Now playing plugin's track: `MediaSnapshot` (player, playing/paused, title, artist, position at the last poll and `position(at:)` running on while playing, duration, artwork key or URL) parsed from the player script's one-line reply (fields joined by ASCII 30), and `MediaFormatter.clock`. Unit-tested. |
| `Plugins/Media/MediaStore.swift` | ~150 | One per app: asks the player every 2 s while a row is on screen, 5 s while the plugin is merely on, never while off; artwork fetched once per track; play/pause, next, previous, seek forwarded and followed by a fresh read; a generation counter drops stale reads. Unit-tested with a fake source. |
| `Plugins/Media/AppleScriptMediaSource.swift` | ~140 | Music and Spotify through their scripting interfaces, only while running (never launched): one script per player returns the track as one line, commands are one-liners, Music artwork comes as raw data, Spotify's from its URL; a failure is logged once per player, a refusal (-1743) is shown in the empty state; every script runs under a 3 s timeout, one at a time; a command never runs for a player that just quit (a `tell` would relaunch it). Apple Events from the hardened runtime need `com.apple.security.automation.apple-events` in the entitlements (added here; `BrowserTabLocator` needed it too) and `NSAppleEventsUsageDescription` in Info.plist. |
| `Plugins/Media/MediaIslandView.swift` | ~160 | The Now playing plugin (id `media`, 84 pt; the moment's component while a track plays, after the agent): 56 pt artwork, title and artist, a bar that runs once a second (a click seeks), previous / play-pause / next; "nothing playing" otherwise. The peek line shows "♪ title · artist" while playing, after a live coding session. |
| `Plugins/Teacher/TeacherBoard.swift` | ~330 | The Teacher plugin (id `teacher`, 64 pt): "teach me how Pythagoras works" draws the lesson on the screen while Clicky explains it. `TeacherPlugin` is the one facade the rest of the app calls: `isEnabled`, the prompt line (`pluginPromptLines` in `CompanionManager` adds it to both lanes), the two tools for Realtime (`RealtimeVoiceClient.pluginToolsProvider` / `onPluginToolCall`) and Anthropic shape (the ask lane, and plans through `[ACTION:]`), `perform` and `clearBoard`. `draw_on_screen` takes a sketch in the drawing language; `show_picture` searches in the background (the Realtime receive loop must never wait on the network). `TeacherBoard` owns a click-through `.statusBar` panel under the buddy's overlay plus a small ✕ panel; cleared by esc (even with nothing else to stop), ✕, the island's "clear" or the next question. As each label is written the buddy flies beside it (`onLabelWritten` → `CompanionManager.pointAt(screenLocation:displayFrame:label:)`; at most six, 1.2 s apart). Debug: `--openclicky-teacher-smoke [sketch.txt \| picture:<words>]` draws without a voice turn; `--openclicky-teacher-ask "<question>"` asks the routed answer source with the ask lane's prompt and tools (no screenshot sent) and draws the reply; `OPENCLICKY_SMOKE_TEACHER=1` with `--openclicky-smoke-talk-file` offers the tools on Realtime. On Realtime, `draw_on_screen` takes `what_to_draw` (words) instead of a sketch and `TeacherSketchWriter` writes it; the ask lane's model writes the sketch itself. Verified 2026-10-23: Claude Code plan route (a valid 15-stroke Pythagoras, 17 s to first text, the CLI's start-up) and Realtime on an own key (refraction drawn correctly 4.7 s after the question: 1.7 s to the tool call, 2.9 s to write). `--openclicky-teacher-render sketch.txt out.png [ink\|chalk\|paper]` draws a sketch into a PNG, for comparing models without screenshots. |
| `Plugins/Teacher/TeacherSketch.swift` | ~330 | The drawing language: one command per line or `;` (`line`, `arrow`, `curve`, `shape`, `circle`, `arc`, `box`, `right_angle`, `angle`, `dot`, `text x y "words"`, `pause`; options: a colour role, `dashed`, `fill`, `thick`/`big`, `thin`/`small`), chosen over nested JSON because every lane can carry a string. The parser (aliases, curly quotes, off-board points clamped, 80 strokes max, skipped lines reported), `TeacherSketchTimeline` (lines at a hand's pace, words ~0.07 s a letter) and `TeacherSketchLayout`, which fits the board around what was actually drawn (models use part of their board and write past its edge). Unit-tested. |
| `Plugins/Teacher/TeacherQuestion.swift` | ~50 | Whether a question asks to be taught ("teach me", "explain how", "how does X work", "X vs Y", "compared to"), never one about the screen ("how does this button work") or a how-to ("how do I export"). The Realtime model sometimes describes the drawing in words instead of calling the tool (a typed "how does a rotary engine work compared to a normal engine", 2026-10-23), so `TeacherPlugin.startLesson` starts the lesson itself the moment the question is known (Realtime's user transcript, a typed turn, the ask lane) when an own OpenAI key makes the writer fast; the writer answers drawing commands or `PICTURE: <search>`, and a later tool call for the same question is told the drawing is on its way (`TeacherBoard.questionHasLesson`, reset by every clear). Unit-tested. |
| `Plugins/Teacher/TeacherListening.swift` | ~110 | Whether they can hear: a typed question, muted output or volume under 40 % (CoreAudio, the default output's main volume and mute) means answers are written on screen too (a student in a loud room at 31 % typed "solve this equation" and got only a voice, 2026-10-23). `TeacherPlugin.writeAnswerIfTheyCannotHear` shows a reply that brought no lesson as a `TeacherNote` (wrapped, left-aligned handwriting, 14 lines max); "solve this" questions are lessons whose writer gets the screenshot and writes the worked solution line by line (3.7 s for a two-equation system). `CompanionManager.lastQuestionWasTyped` is set by the composer and cleared by both press handlers. Unit-tested. |
| `Plugins/Teacher/TeacherSketchWriter.swift` | ~200 | Writes the sketch for the Realtime lane, whose voice model draws wrong geometry (refraction with the ray down the normal), and the lessons the app starts itself. `route(for:)`, best first: an own OpenAI key → gpt-5.5 with `reasoning_effort: none` (refraction right in ~3 s; gpt-4.1 piled labels up, gpt-5.4-mini barely bent the ray); an Anthropic key → claude-sonnet-5-5 with `thinking: between_tools` (3-4 s, the clearest drawings; it rejects `disabled` and, thinking by default, spent all 1,024 tokens before a stroke; Haiku 4.5 overlapped labels); a free account → the backend's /chat (the server maps no-thinking per model, `noThinkingFor` in backend/src/anthropicGrant.ts); another API key → its AnswerProvider. A plan alone never starts a lesson early (`canStartLessonsEarly`). Measured 2026-10-23/24; `--openclicky-teacher-lesson "<question>" out.png [screen.jpg]` writes and renders one lesson on this Mac's route (`CFFIXED_USER_HOME` with another shell.json tries another route). Runs in the background; the board's request token drops a drawing that arrives after a clear or a newer lesson. Reply parsing unit-tested. |
| `Plugins/Teacher/TeacherBoardPlacement.swift` | ~230 | Where the board lands: the screenshot drawn small in greys, 16 pt cells, a cell empty when its contrast is under 6 %; the largest empty rectangle (histogram method, top 64 pt skipped, ties to the roomier one) that holds a board of the drawing's shape at 340–620 pt wide. A light area gets ink on the page, a dark one chalk, a mid-grey one or no room at all a paper sheet on the half away from the mouse. Unit-tested on synthetic grids. |
| `Plugins/Teacher/TeacherBoardView.swift` | ~280 | Drawing it: a `Canvas` in a `TimelineView` that is paused once the last stroke is down (an idle board costs nothing); trimmed paths with a seeded wobble, Catmull-Rom curves, arrowheads, right-angle squares, angle arcs, handwriting in Noteworthy written a letter at a time from the left and kept inside the board; palettes for ink and chalk; the paper sheet; the picture card with its credit. |
| `Plugins/Teacher/TeacherPictureFinder.swift` | ~120 | `show_picture`: Wikimedia Commons file search (`filetype:bitmap\|drawing`, still images only, 900 px thumbnails; an SVG's thumbnail is a PNG), then the lead image of a Wikipedia search; a User-Agent naming OpenClicky as Wikimedia asks. Only the search words leave the Mac. Reply parsing unit-tested. |
| `Plugins/Teacher/TeacherIslandView.swift` | ~70 | The Teacher's island row: what is on the board and "clear", or what to ask. |
| `IslandComponentViews.swift` | ~250 | How the island is drawn: `IslandGlassBackground` (Liquid Glass on macOS 26, a frosted material before, kept active in the non-key panel), `IslandSurface` (glass under gradient-masked black, cut to the outline, shadow when open), the transcript and agent component views, and `IslandNavigationCapsule`. |
| `PermissionPrompt.swift` | ~400 | HeyClicky's permission cards on the island, one permission at a time in order (Accessibility → Microphone → Screen Recording → Screen Content): headline, one line of copy, one blue button, and under them the four permissions by name with a tick each as they land (`checklist`, replacing nameless dashes). On a fresh Mac the first card is the `overview` stage ("i need four permissions to work. here they are."), so the prompts that follow are expected; its button still grants accessibility, so it costs no tap (a tester was taken aback by the four prompts, 2026-10-15). The button takes the macOS route for that step (`WindowPositionManager`'s system-prompt-then-System-Settings rule; Screen Content is granted by actually capturing, so `CompanionManager` injects that one) and the card moves to its `waiting` stage — which for Accessibility opens the drag helper, and for Screen Recording offers Quit & Reopen. Fed by the same 1.5 s permission poll as the flags, so cards advance on their own; ✕ hides them until relaunch, the gear opens the menu bar panel. The waiting accessibility card also offers "Already switched on? Reset it and try again": `tccutil reset Accessibility <bundle id>` plus a relaunch, the only way out of a TCC row whose recorded signature no longer matches the running build (an update signed differently, or a dev build sharing the bundle id) — the list shows the app switched on while macOS keeps refusing it. Unit-tested. |
| `AccessibilityDragPanel.swift` | ~189 | The "I'm OpenClicky — drag me into the list above" panel: a floating pop-up-menu-level `NSPanel` whose icon row is a `public.file-url` drag source for the app bundle, for when macOS never adds OpenClicky to the Accessibility list by itself. Follows the System Settings window (found through `CGWindowListCopyWindowInfo`, which gives bounds without Screen Recording permission) on a 1 s timer, and — unlike HeyClicky's — stays up through the drag, closing only when `AXIsProcessTrusted()` flips. The Settings window is the largest one System Settings owns; while a smaller window from any other process sits in front of it and inside it ("Quit & Reopen", an account password sheet) the panel steps out of sight (`mainSettingsWindow(inFrontToBackOrder:)`, unit-tested). |
| `AppConnectPrompt.swift` | ~360 | "Connect \<app\> to OpenClicky" card: polls the frontmost app/site every 1.5 s off the main thread, opens once per app skill that has an `integration` (Composio toolkit), remembers Yes/No in UserDefaults (`appSkillConnectDecisions`); Yes runs `openclicky integrations status/login` (Codex MCP OAuth into Composio Connect, browser page) then hands the agent a Composio connection task (or shows a "set COMPOSIO_MCP_URL" notice), declined skills are left out of the talk prompts. Example chips come from the skill's "Pointing hints"/"Common tasks". |
| `LocalAsk/LocalAskGate.swift` | ~75 | The local ask rung's keyword gate: English, ≤ 24 words, not a question or how-to opener ("how do i", "what", "teach me"…), and a command word (open, launch, volume, mute, pause, next, folder…) or a rewrite word (rephrase, shorter, fix, translate…). `tryModel(mentionsRewriting:)` or `skip(reason:)`. Generous on purpose: it keeps the model off questions, it does not classify. Unit-tested. |
| `LocalAsk/LocalAskIntent.swift` | ~150 | `LocalAskIntent` (the model's reading: kind, name, location, volumeLevel, playback, rewriteInstruction) and `LocalAskIntentMapper.map`, where the safety sits: a pronoun or an unresolvable app → cloud; a bare website word that names an installed app is the app ("open safari" came back as a website); folders need an item name; the volume needs 0…100, read from "thirty"/"mute"/"full" when the model left it in words; play → `play` and pause → `pause` (never `playpause`: the key goes to whatever macOS last played, a paused YouTube tab included, 2026-10-29); playback naming an app, song or list, or longer than six words, → cloud; every quick action is run through `MacAction.parse` before it counts. Unit-tested. |
| `LocalAsk/InstalledAppResolver.swift` | ~135 | The spoken name resolved against the apps installed here (/Applications, /System/Applications and its Utilities, the macOS 26 cryptex that holds Safari, ~/Applications; cached once): aliases ("email" → Mail, "browser" → the default browser), exact, the spoken words as the start or initials of the name ("final cut", "vs code"), one distinctive word of four letters or more ("chrome", "word"; never "pro"). Pure matching unit-tested. |
| `LocalAsk/LocalRewriteCheck.swift` | ~55 | Whether a local rewrite may be pasted: not empty, not an answer ("sure", "here is", "i can help"…), not identical, between a third and three times the original (a short original may grow to 200 chars). `isCorrectionInstruction` tells "fix the grammar" from "warmer", so unchanged text means "already reads well" for the first and the cloud for the second. Unit-tested. |
| `LocalAsk/LocalAskModel.swift`, `LocalAsk/AppleLocalAskModel.swift` | ~45, ~160 | The `LocalAskModel` protocol (classify, rewrite, prewarm; a fake drives the rung's tests) and Apple's model behind it: `@Generable` guided generation (constrained decoding, the model cannot answer outside the enum), the request in `<request>` tags and the text in `<text>` tags with the line that it is never addressed to the model, temperature 0 for classification; `AppleLocalAskSessionCache` holds the one session warmed at the press for the next classify (0.9 s instead of 1.0–1.7 s). `LocalAskModelAvailability` reuses the polisher's availability and reasons. |
| `LocalAsk/LocalAskRung.swift` | ~140 | The rung itself: `gate`, then `decide(transcript:writingTarget:budgetSeconds:)`, the classification raced against the budget (2.0 s), a rewrite raced against 2.5 s more; `LocalAskDecision` is `quickAction`, `rewrite`, `textAlreadyReadsWell` or `cloud(reason:)` (timed out, error, cancelled, model declined, no app match, no readable text, text too long, rewrite: <check>). Logs one `local rung:` line per ask. Unit-tested with a fake model, timeouts and cancellation included. |
- | `MacActions.swift` | ~830 | The fast lane (and the tool schemas offered for it, `MacAction.realtimeToolDefinitions` / `anthropicToolDefinitions`): the local actions OpenClicky performs itself instead of routing them to Codex — `open_app`, `open_url`, `create_folder`, `reveal_in_finder`, `set_volume`, `media_control` (with `pause`/`play` that go to the player with the track — `playerForControl`: Music playing while Spotify sits idle gets Music — so "stop that song" never starts playback the way the play/pause key would; with no player running, pause does nothing and says so, which also means a browser video is no longer paused by voice), and since 2026-10-28 the "do things for me" set (`docs/superpowers/specs/2026-10-27-do-things-for-me-design.md`): `web_search` (a site's search page), `minimize_all` / `restore_windows`, `check_email` / `draft_email` (Mail or Gmail, never sent), `play_music` (Spotify), and since 2026-10-29 `scroll`, `open_folder`, `find_files`, `find_on_page` (see `ScreenNavigation.swift`). Typed arguments only: `location` and `site` are closed enums, names are rejected if they could escape, queries are capped at 200 characters with no control characters, a recipient is at most 80 characters with no angle brackets, a body is cut at 2,000 — a mishearing can pick the wrong song or recipient, never produce a command or send anything. Each outcome has three texts: `spokenSentence` (the person; the ask lane says it verbatim), `toolOutput` (the Realtime model: a list to read, a screenshot to look at), `logDescription` (app.log; never a body or recipient), plus `thenLooksAtScreen` (see act-then-look under Realtime). `MacActionRunner.Extras` holds every system call the newer actions need, each defaulted to "nothing happened" so a test passes only the ones it drives; `live` fills them all. Targets about two seconds against twelve through the agent lane; `scripts/measure-actions.sh` is how that gets checked. Anything outside the set still goes to `send_to_agent`. Unit-tested (`MacActionsTests.swift`: four suites). |
- | `ScreenNavigation.swift` | ~270 | Moving around by voice (2026-10-29: "scroll down" in Chrome was answered with trackpad instructions, "click the first link" with a point). Fast actions `scroll` (the front app's focused window via AX; the pointer is warped to its middle only when it is outside it, since scroll events go to the window under the pointer; pixel wheel events, a page is 80 % of the window, `end` is ten 5,000 px steps; negative is down, checked live in Chrome), `open_folder` (a whole location in Finder, or a folder in it: "open downloads" had gone to `reveal_in_finder` as Downloads/Downloads), `find_files` (`mdfind -onlyin ~` on `kMDItemFSName` with every spoken word, quotes and wildcards dropped; Library, hidden, app-bundle and node_modules paths skipped; exact, prefix, then word-start names first; the best is selected in Finder; names never logged) and `find_on_page` (⌘F, then the words are typed only once focus moved into a new text field, through `FrontAppTextInserter.typeAsKeystrokes`; an app without a find bar gets nothing typed). And the Realtime `click` tool (`RealtimeVoiceClient.clickToolName`, point_at's arguments): the target goes through the same locator chain as `point_at` (`locateElement`), the buddy flies there, and a real left click is posted, then act-then-look. It refuses, pointing instead: a control whose words commit something (`clickIsRisky`: send, buy, pay, order, delete, submit, confirm, sign out…, whole words), a target nothing located (the raw guess), a spot on OpenClicky's own window, or a turn that moved on (esc) while Claude was still locating. Realtime only; the ask lane has scroll/open_folder/find_* through `MacAction`. Live-checked 2026-10-29 on a test page in Chrome from a probe built from this file (scroll down/top, find 1/1, click followed the link); not yet by voice in the app. Pure parts unit-tested (`ScreenNavigationTests`). |
+ | `MacActions.swift` | ~830 | The fast lane (and the tool schemas offered for it, `MacAction.realtimeToolDefinitions` / `anthropicToolDefinitions`): the local actions OpenClicky performs itself instead of routing them to Codex — `open_app`, `open_url`, `create_folder`, `reveal_in_finder`, `set_volume`, `media_control` (with `pause`/`play` that go to the player with the track — `playerForControl`: Music playing while Spotify sits idle gets Music — so "stop that song" never starts playback the way the play/pause key would; with no player running, pause does nothing and says so, which also means a browser video is no longer paused by voice), and since 2026-10-28 the "do things for me" set (`docs/superpowers/specs/2026-10-27-do-things-for-me-design.md`): `web_search` (a site's search page), `minimize_all` / `restore_windows`, `check_email` / `draft_email` (Mail or Gmail, never sent), `play_music` (Spotify), and since 2026-10-29 `scroll`, `open_folder`, `find_files`, `find_on_page` (see `ScreenNavigation.swift`), `switch_tab` (see `BrowserControl.swift`). Typed arguments only: `location` and `site` are closed enums, names are rejected if they could escape, queries are capped at 200 characters with no control characters, a recipient is at most 80 characters with no angle brackets, a body is cut at 2,000 — a mishearing can pick the wrong song or recipient, never produce a command or send anything. Each outcome has three texts: `spokenSentence` (the person; the ask lane says it verbatim), `toolOutput` (the Realtime model: a list to read, a screenshot to look at), `logDescription` (app.log; never a body or recipient), plus `thenLooksAtScreen` (see act-then-look under Realtime). `MacActionRunner.Extras` holds every system call the newer actions need, each defaulted to "nothing happened" so a test passes only the ones it drives; `live` fills them all. Targets about two seconds against twelve through the agent lane; `scripts/measure-actions.sh` is how that gets checked. Anything outside the set still goes to `send_to_agent`. Unit-tested (`MacActionsTests.swift`: four suites). |
+ | `ScreenNavigation.swift` | ~270 | Moving around by voice (2026-10-29: "scroll down" in Chrome was answered with trackpad instructions, "click the first link" with a point). Fast actions `scroll` (the front app's focused window via AX; the pointer is warped to its middle only when it is outside it, since scroll events go to the window under the pointer; pixel wheel events, a page is 80 % of the window, `end` is ten 5,000 px steps; negative is down, checked live in Chrome), `open_folder` (a whole location in Finder, or a folder in it: "open downloads" had gone to `reveal_in_finder` as Downloads/Downloads), `find_files` (`mdfind -onlyin ~` on `kMDItemFSName` with every spoken word, quotes and wildcards dropped; Library, hidden, app-bundle and node_modules paths skipped; exact, prefix, then word-start names first; the best is selected in Finder; names never logged) and `find_on_page` (⌘F, then the words are typed only once focus moved into a new text field, through `FrontAppTextInserter.typeAsKeystrokes`; an app without a find bar gets nothing typed). And the Realtime `click` tool (`RealtimeVoiceClient.clickToolName`, point_at's arguments): the target goes through the same locator chain as `point_at` (`locateElement`), the buddy flies there, and a real left click is posted (a web link in a browser is followed in its own tab instead, see `BrowserControl.swift`), then act-then-look. It refuses, pointing instead: a control whose words commit something (`clickIsRisky`: send, buy, pay, order, delete, submit, confirm, sign out…, whole words), a target nothing located (the raw guess), a spot on OpenClicky's own window, or a turn that moved on (esc) while Claude was still locating. Realtime only; the ask lane has scroll/open_folder/find_* through `MacAction`. Live-checked 2026-10-29 on a test page in Chrome from a probe built from this file (scroll down/top, find 1/1, click followed the link); not yet by voice in the app. Pure parts unit-tested (`ScreenNavigationTests`). |
+ | `BrowserControl.swift` | ~170 | Tabs by voice (2026-10-29: a voice click on a search result opened it in a background tab, and "show me that tab" was answered with how to click the strip). A `click` on a web link in the browser in front (Chrome, Brave, Edge, Safari) reads the link's AXURL under the point (`webLink`, the AXLink among the first six ancestors, http(s) only) and loads it in the selected tab by AppleScript, so a `target=_blank` link stays in its tab; the click tool's `new_tab` (only for "in a new / another tab") opens it in a new tab that becomes the selected one (a non-link gets ⌘-click). `switch_tab` (a `MacAction`): `which` named / next / previous / newest / first, `name` matched as the words run together inside the site name ("cars and bids" → carsandbids.com) or else `BrowserTabLocator.bestMatch`; the browser in front, or the one on screen, which is brought forward; act-then-look. Live-checked 2026-10-29 with a probe: a `_blank` link followed in its tab, new tab selected, first / by name / previous switched. Pure parts unit-tested (`BrowserControlTests`). |
| `AppleScriptQueue.swift` | ~15 | The app's one serial queue for every `NSAppleScript` (the Media plugin's polling, Mail, Spotify): NSAppleScript is not documented as safe across concurrent instances, and three subsystems with a queue each would run scripts at the same moment. |
| `WebSearchURL.swift` | ~40 | `web_search`: `WebSearchSite` (web = Google, youtube, wikipedia, maps, amazon) and the search page for a query, percent-encoded by hand so `+` and `&` travel (every search page reads a bare `+` as a space). Unit-tested. |
| `OnScreenWindows.swift`, `WindowMinimizer.swift` | ~40, ~210 | `OnScreenWindows.current()`: every visible window on the current Space (CGWindowList, owner pid, bounds, layer), shared by the tab locator and `minimize_all`. `WindowMinimizer.windowsToMinimize` (pure, unit-tested): layer 0, regular apps only, never OpenClicky's own, at least 50 pt a side; `minimize` sets `kAXMinimizedAttribute` on the AX windows whose frames match and returns those AX elements; a restore un-minimizes exactly those elements (restoring by frame also brought back a window the person had minimized themselves at the same size and place: 8 minimized, 9 back). `MinimizedWindowsMemory` keeps what each "minimize everything" minimized, newest set first, so "bring them back" restores those and re-activates the app that was in front — never every window in the Dock. A named app (`processIDs(ofAppsNamed:)`, pure: exact name or bundle id, else whole words, so "Chrome" is Google Chrome) narrows both: `minimize_all` `keep` leaves it up, `restore_windows` `app` brings back only its windows (every minimized one of it, ours or not), drops them from the remembered sets and activates it; an app that is not running is `windowAppNotRunning`, whose tool output points the model at `open_app`. Before the `app` argument, "show just the terminal" restored all nine windows and the model said the Terminal was restored (2026-10-28). Windows on other Spaces are not on screen and are never touched. Mechanism checked 2026-10-28 against TextEdit. |
| `EmailActions.swift` | ~140 | `check_email` / `draft_email`. `EmailApp.choose`: the app the person named, else `emailApp` in shell.json (Settings → Voice → Doing things), else Apple Mail when `~/Library/Mail/V*/MailData/Accounts.plist` exists and Gmail otherwise (every Mac has Mail as its mailto handler, set up or not, so the handler says nothing; decided with Prasanth 2026-10-27). `MailScripts` (pure, unit-tested): the unread-inbox script (sender + subject of the newest 10, fields by ASCII 30, records by ASCII 29, `with timeout of 5 seconds`, bodies never fetched) and the draft script (`make new outgoing message … visible:true`, an address as `{address:}`, a name as `{name:}` so Mail offers its own contact matches — Contacts is never read); `GmailURLs`: the inbox and the compose URL (`view=cm&fs=1&to=&su=&body=`, encoded by hand: `+` → `%2B`, newline → `%0A`). The unread script fetches the senders and subjects as two lists (three Apple events in all, each under the cap — the timeout is per event, so a loop was never capped); the draft script is capped at 5 s and answers "no-recipient" (`.recipientNotFilled`, the open draft kept) when Mail refuses the recipient; a sender is spoken by name, never by address (`spokenSender`); a script's error message is never logged (it can quote the body). `MailAppleScriptRunner` maps -1743 to `.notAllowed` (the Automation sentence names System Settings › Privacy & Security › Automation) and -1712 to `.timedOut`; either activates Mail and the model reads the screen instead. Nothing sends, and there is no send tool. Live checks on the user's checklist (an Automation prompt must not appear unattended). |
| `MusicLookup.swift` | ~200 | `play_music`. `MusicLookupRoute.route`: own `spotifyCredentials` ("clientId:clientSecret", Settings → Keys & Plans) straight to Spotify (client-credentials token cached until a minute before expiry, `market` from the Mac's locale), else the backend's public `GET /music/search?q=` (words only, catalogue out; `backend/src/music.ts`, 30 a minute per IP, 1 h query cache, 503 without `SPOTIFY_CLIENT_ID`/`SECRET` in the env), logged `route: music lookup → spotify|backend` on change from `MacActionRunner.live`. `MusicTrackPicker.pick`: the result sharing the most words (3+ letters) with what was said, else the first — the confirmation names the pick ("Playing Blinding Lights by The Weeknd.") so a wrong one is heard. Only a `spotify:track:` URI is ever played (the backend's answer is not trusted); Spotify not installed is said as such. The token cache is per client id, so new credentials are really tested. `SpotifyPlayer`: launch and wait up to 6 s for `player state`, `play track "<uri>"`, and `spotify:search:<query>` when nothing answered ("I couldn't look that up, so I opened Spotify's search for …"). Unit-tested; not yet checked live (no Spotify app registered as of 2026-10-28). |
## Build & Run
```bash
# Open in Xcode
open OpenClicky.xcodeproj
# Select the OpenClicky scheme, set signing team, Cmd+R to build and run
# Known non-blocking warnings: Swift 6 concurrency warnings,
# deprecated onChange warning in OverlayWindow.swift.
scripts/release.sh --no-notarize # fast local install over /Applications, Developer ID signing kept
scripts/release.sh # the same, notarized (needed only for other Macs)
```
Release builds are universal (arm64 + x86_64) so Intel Macs run them; `release.sh` fails if the binary lacks either slice.
`scripts/measure-actions.sh` reports p50/p95 per verb from `~/Library/Logs/OpenClicky/app.log`
(`mac action:` and `agent task finished in` lines). The fast lane's acceptance number is p95 under
2 s for `open_app` and `create_folder`. `scripts/measure-actions.sh local:open_app` (or any
`local:<verb>`, `local:rewrite`, `local:cloud`) reads the local ask rung's `local rung:` lines the
same way; its number to beat is the same 2 s, measured 1.5 s cold on an M2 Pro (2026-10-28).
**Debug builds carry their own bundle id** (`org.openclicky.app.debug`, shown as "OpenClicky Debug"),
so they get their own TCC rows and can run beside the installed app without either invalidating the
other's Accessibility / Screen Recording grants. That separation is what makes `xcodebuild` safe here:
a Debug build no longer costs the installed app its permissions, and `clean-stray-builds.sh` leaves
Debug bundles alone for the same reason. Two rules still hold: **one bundle id must only ever be
signed by one identity** (mixing Apple Development and Developer ID under the same id is what makes
System Settings show the app switched on while macOS keeps refusing it — the accessibility card's
"Already switched on? Reset it and try again" is the way out), and a Debug build gets its own
UserDefaults domain, so its onboarding state and toggles are separate from the installed app's.
## OpenClicky backend
Run it from the repository root: `npm run dev -w backend` (see the root README for keys and the auth flow).
## Code Style & Conventions
### Variable and Method Naming
IMPORTANT: Follow these naming rules strictly. Clarity is the top priority.
- Be as clear and specific with variable and method names as possible
- **Optimize for clarity over concision.** A developer with zero context on the codebase should immediately understand what a variable or method does just from reading its name
- Use longer names when it improves clarity. Do NOT use single-character variable names
- Example: use `originalQuestionLastAnsweredDate` instead of `originalAnswered`
- When passing props or arguments to functions, keep the same names as the original variable. Do not shorten or abbreviate parameter names. If you have `currentCardData`, pass it as `currentCardData`, not `card` or `cardData`
### Code Clarity
- **Clear is better than clever.** Do not write functionality in fewer lines if it makes the code harder to understand
- Write more lines of code if additional lines improve readability and comprehension
- Make things so clear that someone with zero context would completely understand the variable names, method names, what things do, and why they exist
- When a variable or method name alone cannot fully explain something, add a comment explaining what is happening and why
### Swift/SwiftUI Conventions
- Use SwiftUI for all UI unless a feature is only supported in AppKit (e.g., `NSPanel` for floating windows)
- All UI state updates must be on `@MainActor`
- Use async/await for all asynchronous operations
- Comments should explain "why" not just "what", especially for non-obvious AppKit bridging
- AppKit `NSPanel`/`NSWindow` bridged into SwiftUI via `NSHostingView`
- All buttons must show a pointer cursor on hover (`.pointerCursor()`; `SettingsSwitch` carries its own, settings-page buttons and pickers add it themselves)
- For any interactive element, explicitly think through its hover behavior (cursor, visual feedback, and whether hover should communicate clickability)
### Do NOT
- Do not add features, refactor code, or make "improvements" beyond what was asked
- Do not add docstrings, comments, or type annotations to code you did not change
- Do not try to fix the deprecated-onChange warning
- Treat the Swift 6 concurrency warnings as a backlog, not as noise. They are suppressed only in the
sense that the project builds in Swift 5 mode; 139 of them remain and each one is a claim the
compiler cannot check. Two of them were real: the audio taps in `RealtimeVoiceClient` and
`BuddyDictationManager` read main-actor / queue-owned state from the CoreAudio render thread, which
the Thread Sanitizer reports and aborts on (`RealtimeAudioEngineConcurrencyTests` pins it down).
Do not fix them in bulk — a file at a time, with a test, is what caught the real ones.
- **Audio taps are the dangerous case and the compiler is silent about them.** `AVAudioNodeTapBlock`
is not `@Sendable`, so a tap closure inside a `@MainActor` class is treated as main-actor isolated
while in fact running on the render thread: zero warnings, real race. A tap block must touch
nothing mutable on `self` — capture what it needs by value when the tap is installed.
- The project, targets, and scheme are named OpenClicky (renamed from upstream's "leanring-buddy")
- Running `xcodebuild` from the terminal is fine now that Debug builds carry their own bundle id (see Build & Run); before that separation it cost the installed app its TCC permissions
## Git Workflow
- Branch naming: `feature/description` or `fix/description`
- Commit messages: imperative mood, concise, explain the "why" not the "what"
- Do not force-push to main
## Self-Update Instructions
<!-- AI agents: follow these instructions to keep this file accurate. -->
When you make changes to this project that affect the information in this file, update this file to reflect those changes. Specifically:
1. **New files**: Add new source files to the "Key Files" table with their purpose and approximate line count
2. **Deleted files**: Remove entries for files that no longer exist
3. **Architecture changes**: Update the architecture section if you introduce new patterns, frameworks, or significant structural changes
4. **Build changes**: Update build commands if the build process changes
5. **New conventions**: If the user establishes a new coding convention during a session, add it to the appropriate conventions section
6. **Line count drift**: If a file's line count changes significantly (>50 lines), update the approximate count in the Key Files table
Do NOT update this file for minor edits, bug fixes, or changes that don't affect the documented architecture or conventions.