From 86bf7abab17bf6dbab54443b93b3c86cf7443694 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:34:16 -0400 Subject: [PATCH 01/24] docs: spec for inline AI editing feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for editing the current note via AI: active-line ✨ to generate at cursor, selection ✨ to rewrite via inline diff, Anthropic key in Keychain, per-request model picker (Haiku 4.5 / Sonnet 4.6 / Opus 4.8), @-note context autocomplete, and streamed output over direct URLSession SSE. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-09-inline-ai-editing-design.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-inline-ai-editing-design.md diff --git a/docs/superpowers/specs/2026-06-09-inline-ai-editing-design.md b/docs/superpowers/specs/2026-06-09-inline-ai-editing-design.md new file mode 100644 index 0000000..2770b0e --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-inline-ai-editing-design.md @@ -0,0 +1,132 @@ +# Inline AI Editing — Design Spec + +**Date:** 2026-06-09 +**Status:** Approved design, pending implementation plan +**Scope:** macOS app (`macOS/SynapseNotes`) + +## Summary + +Add the ability to edit the current note using AI, directly inside the editor: + +- A clickable ✨ icon at the end of the active line opens an inline prompt bar; the AI generates text streaming in at the cursor. +- Selecting text shows a ✨ near the selection; clicking it opens the bar in "rewrite" mode, presenting the AI's rewrite as an inline diff (original struck-through, new text green) with Accept / Reject / Retry. +- The user adds an Anthropic API key in Settings (stored in the macOS Keychain) and picks a default model. +- A per-request model picker in the bar toggles between Haiku 4.5, Sonnet 4.6, and Opus 4.8. +- `@` / `@` autocomplete in the prompt field pulls vault notes in as context. +- Generated text streams into the editor live; a Stop button (and Esc) cancels, keeping whatever streamed. + +## Goals / Non-Goals + +**Goals:** in-flow generation and rewriting, safe (non-destructive) rewrites, streaming UX, secure key storage, vault-note context injection, per-request model choice. + +**Non-Goals (YAGNI):** multi-turn chat history, image inputs, tool use, a prompt template library, per-vault key overrides, non-Anthropic providers, recursive `@directory` walking. The model set is exactly Haiku 4.5 / Sonnet 4.6 / Opus 4.8. + +## Architecture + +Six cooperating units, each independently testable. New files live in `macOS/SynapseNotes/`. + +| Unit | Responsibility | File | +|---|---|---| +| `KeychainStore` | `SecItem` wrapper: get/set/delete the Anthropic API key under one service+account. | `KeychainStore.swift` | +| `AnthropicClient` | Stateless transport. Builds the `/v1/messages` request, streams SSE via `URLSession.bytes(for:)`, emits an `AsyncThrowingStream` of text deltas. Knows model IDs, headers, cancellation. No UI. | `AnthropicClient.swift` | +| `AIRequestBuilder` | Pure function: assembles system + user message from (prompt, current note, selection, resolved `@`-context, mode). All prompt-engineering lives here. | `AIRequestBuilder.swift` | +| `AIContextResolver` | Resolves `@name` tokens → note contents from `appState.allFiles`, applies the ~100K-char cap, returns resolved blocks + a truncation flag. Pure. | `AIContextResolver.swift` | +| `InlineAIController` | Orchestrator. `ObservableObject` owned per-editor. Holds session state (mode, streaming buffer, accepted/pending ranges), drives `AnthropicClient`, applies streamed deltas to the text storage, owns Accept/Reject/Cancel. Bridges AppKit ↔ request/transport units. | `InlineAIController.swift` | +| `InlineAIBarView` + `AISparkleButton` | SwiftUI inline bar (prompt field, model picker, `@`-autocomplete popover, Stop, diff Accept/Reject) hosted via `NSHostingView`; plus the ✨ overlay control mirroring `CollapsibleToggleButton`. | `InlineAIView.swift` | + +**Settings:** one new `Section` in `SettingsView.swift` (API key `SecureField` backed by `KeychainStore`, plus a default-model `Picker`), and `SettingsManager` gains a persisted `aiDefaultModel` stored in the machine-local `GlobalConfig` (alongside the GitHub PAT, since model choice is a machine preference, not vault content). The API key is **not** stored in any YAML — only in Keychain. + +**Wiring:** `InlineAIController` is created alongside the editor lifecycle (like `EditorState`), handed a reference to the `LinkAwareTextView` and `appState`. The ✨ overlays hook into the existing `LinkAwareTextView` lifecycle: selection/caret changes via `textViewDidChangeSelection` (`EditorView.swift:1061`) and overlay positioning via the same layout-manager pattern as `refreshCollapsibleToggles` (`EditorView.swift:2181`). + +## Data Flow & Interaction + +### A. The ✨ affordances + +Both reuse the proven overlay pattern from `refreshCollapsibleToggles` (`EditorView.swift:2181-2243`): compute a glyph rect with `layoutManager.boundingRect(forGlyphRange:in:)`, offset by `textContainerOrigin`, and position an `NSControl` subview added via `addSubview`. Target/action drives the click. + +1. **Active-line ✨** — A new `refreshAISparkle()`, called from the same post-layout point as `refreshCollapsibleToggles` and on selection change (debounced like the existing styling pass — see [[typing-perf-hotpath]] for the un-debounced-caret-move trap; this must not add synchronous work to every caret move), positions a single reused `AISparkleButton` at the end-of-line glyph rect for the caret's line. One button, repositioned — not one-per-line. +2. **Selection ✨** — When `selectedRange().length > 0`, position the same button just past the selection's end rect. Clicking either opens the bar in the matching mode (`.generate` at caret / `.rewrite` over selection). + +### B. Generate-at-cursor flow + +1. ✨ click → `InlineAIController.begin(.generate, at: caretLocation)`. +2. `InlineAIBarView` (`NSHostingView`) is inserted below the active line; prompt field focused. +3. User types prompt (+ optional `@`-refs) → ⏎. +4. `AIContextResolver(prompt, allFiles)` → resolved context (capped at ~100K chars; truncation flagged). +5. `AIRequestBuilder(.generate, note, caret, context)` → `messages` payload. +6. `AnthropicClient.stream(messages, model)` → `AsyncThrowingStream`. +7. Each delta: `replaceCharacters(in:with:)` at a tracked growing insertion range; cursor follows; existing `didChangeText()` path re-applies markdown styling automatically (no manual refresh). +8. Stop button / Esc cancels the URLSession task (keeps partial). On finish, the generated range is plain text — nothing to accept, just normal undoable text. + +### C. Rewrite-selection flow (the inline diff) + +1. ✨ on selection → `begin(.rewrite, over: selRange)`. +2. Snapshot original text + range. **Original is never deleted until Accept** — satisfies "never lose your words." +3. Stream NEW text into an insertion point immediately after the selection. +4. Render: original styled struck-through / red, new text streams in green. Diff coloring uses **transient text attributes applied through the existing styling pass** (so it survives re-layout) and is **not persisted to disk** until Accept. +5. **Accept (⏎):** delete the original range, strip diff attributes from the new text → plain text. +6. **Reject (⎋):** delete the streamed new text, restore the original's normal attributes. +7. **Retry (↻):** reject + re-run with the same (or edited) prompt. + +> Implementation note on the diff attributes: `applyPreviewStyling()` is a pure hide pass (see [[preview-styling-is-pure-hide]]). The diff strike-through/coloring is a *transient overlay* applied after styling for the duration of the rewrite session and cleared on Accept/Reject — it must not be confused with or baked into the persistent styling passes. + +### D. `@`-autocomplete inside the prompt field + +The prompt field is a small `NSTextView`/`NSTextField` **in the bar**, not part of the editor's text storage — so it runs its **own** backward-search-for-`@` detection, mirroring the `[[` logic at `EditorView.swift:3256` (search back ≤ N chars for the token start, extract query, gate on length / no closing delimiter). It shows a completion popover anchored to the field, scored with `commandPaletteScoreByFilename()` reused from `CommandPaletteView.swift:15`. Selecting inserts `@name` into the prompt string. Directories are offered too (from the folder set) and suffixed to disambiguate. Resolution of `@name` → note contents happens later, in `AIContextResolver`, not at insertion time. + +### E. Implicit context + +Every request includes the **full text of the current note** (so "continue this thought" / "summarize above" work) plus any `@`-referenced notes. The rewrite flow additionally includes the selected text as the explicit target. This is assembled in `AIRequestBuilder`. + +### F. Cancellation & errors (all surfaced inline in the bar) + +- **No API key** → "Add your Anthropic API key in Settings →" with a button that opens Settings; prompt field disabled. +- **401 / invalid key** → "Invalid API key — check Settings." +- **Network failure / non-2xx** → inline message + Retry; any partial stream is kept. +- **`@`-ref to a missing note** → silently skipped, with a small "1 reference not found" note in the bar. +- **Context over cap** → streams anyway with a "Context truncated to fit" warning. +- **Cancel (Stop/Esc)** → URLSession task cancelled, partial text retained, bar moves to the accept/reject state (rewrite) or simply ends (generate). + +## Transport details (Anthropic API) + +Direct `URLSession` SSE — no SDK dependency (the app has no first-party Swift SDK target; this matches the existing `GistPublisher` URLSession approach at `GistPublisher.swift:1`). + +- **Endpoint:** `POST https://api.anthropic.com/v1/messages` +- **Headers:** `x-api-key: `, `anthropic-version: 2023-06-01`, `content-type: application/json` +- **Body:** `{ "model": , "max_tokens": 4096, "stream": true, "system": , "messages": [...] }`. No `thinking` block — inline edits want low latency, and omitting thinking is valid for all three models. `max_tokens` 4096 is ample for inline edits and keeps streaming responsive. +- **Model IDs (exact, no date suffixes):** `claude-haiku-4-5`, `claude-sonnet-4-6`, `claude-opus-4-8`. +- **Streaming:** consume `urlSession.bytes(for: request)` (the `URLSession` is injectable, defaulting to `.shared`, mirroring `GistPublisher` so tests can supply a mocked `URLProtocol`), split on lines, parse `data:` JSON, and for each `{"type":"content_block_delta","delta":{"type":"text_delta","text":"…"}}` emit `delta.text`. Stop on `message_stop`. A non-2xx HTTP status maps to a typed error (401 → invalid key, ≥500 → server error). +- **Cancellation:** hold the `Task` running the stream; `cancel()` on Stop/Esc. + +## Settings & persistence + +- `SettingsView.swift`: add an **"AI"** `Section` after the GitHub Gist section, containing a `SecureField` for the API key (reads/writes `KeychainStore`) and a `Picker` bound to `settings.aiDefaultModel`. +- `SettingsManager.swift`: add `@Published var aiDefaultModel: String` with the existing `didSet { save() }` pattern, persisted in `GlobalConfig` (machine-local). The API key never touches YAML — `KeychainStore` is the single source of truth, read on demand. +- The bar's per-request model picker defaults to `aiDefaultModel`; changing it in the bar updates `aiDefaultModel` so "last used" persists. Default value: `claude-sonnet-4-6` (speed/quality balance). + +## Error Handling + +All AI errors surface **inline in the bar**, never as a crash or modal alert. See §F for the specific cases. A missing/invalid key disables the prompt field and points the user to Settings. Network/API errors keep any partial stream and offer Retry. + +## Testing + +Mirrors the existing `CommandPaletteWikiLinkTests` / `GistPublisher` test style — `URLSession` injected for the client. + +- `AIContextResolverTests` — `@name` resolution, case-insensitivity, the cap/truncation, missing refs, directory expansion. Pure, no network. +- `AIRequestBuilderTests` — correct system/user assembly for `.generate` vs `.rewrite`, selection inclusion, context-block formatting. Pure. +- `AnthropicClientTests` — SSE parsing via a mocked `URLProtocol` feeding canned `content_block_delta` bytes: assert the emitted delta stream; assert cancellation halts emission; assert error mapping (401, non-2xx). +- `KeychainStoreTests` — round-trip set/get/delete against a test service name. +- `InlineAIControllerTests` — Accept applies the new text and removes the original; Reject restores the original; Cancel keeps the partial. Driven by a stub client emitting a scripted stream (no real network). + +AppKit overlay positioning (the ✨ placement) is verified manually via the mandatory build-and-relaunch, consistent with how the editor's other overlays are validated. + +## Key source references (verified) + +- Overlay positioning pattern: `EditorView.swift:2181-2243` (`refreshCollapsibleToggles`) +- Selection/caret change hook: `EditorView.swift:1061` (`textViewDidChangeSelection`) +- `[[`-typing detection to mirror for `@`: `EditorView.swift:3256` +- Filename scoring to reuse: `CommandPaletteView.swift:15` (`commandPaletteScoreByFilename`) +- Text mutation API: `replaceCharacters(in:with:)` + `didChangeText()` (used throughout `EditorView.swift`) +- Vault note source: `appState.allFiles` (`AppState.swift:177`) +- Settings UI + persistence pattern: `SettingsView.swift`, `SettingsManager.swift` (GitHub PAT in `GlobalConfig` as the precedent) +- Networking precedent: `GistPublisher.swift:1` (URLSession) From abad0fd00f50976a75b601838a0c53be995a0ffd Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:41:27 -0400 Subject: [PATCH 02/24] docs: implementation plan for inline AI editing Nine TDD-structured tasks: AIModel, KeychainStore, AIContextResolver, AIRequestBuilder, AnthropicClient (SSE), InlineAIController, Settings integration, InlineAIView, and editor wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-09-inline-ai-editing.md | 1661 +++++++++++++++++ 1 file changed, 1661 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-inline-ai-editing.md diff --git a/docs/superpowers/plans/2026-06-09-inline-ai-editing.md b/docs/superpowers/plans/2026-06-09-inline-ai-editing.md new file mode 100644 index 0000000..b912772 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-inline-ai-editing.md @@ -0,0 +1,1661 @@ +# Inline AI Editing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the user edit the current note with AI — an active-line ✨ generates streamed text at the cursor, a selection ✨ rewrites selected text as an inline accept/reject diff, with an Anthropic API key in the Keychain, a per-request model picker, and `@note` context autocomplete. + +**Architecture:** Six focused units — `KeychainStore` (secure key), `AnthropicClient` (URLSession SSE transport), `AIContextResolver` + `AIRequestBuilder` (pure request assembly), `InlineAIController` (orchestrator/`ObservableObject`), and `InlineAIView` (the SwiftUI bar + ✨ overlay). They plug into the existing `LinkAwareTextView` overlay/selection lifecycle in `EditorView.swift` and the `SettingsManager`/`SettingsView` settings system. Pure units are unit-tested with TDD; AppKit overlay placement is verified by build-and-relaunch. + +**Tech Stack:** Swift 5, AppKit + SwiftUI, `URLSession.bytes(for:)` async SSE, `Security.framework` Keychain, XCTest with `MockURLProtocol`. Built via XcodeGen (`xcodegen generate`) — new `.swift` files under `macOS/SynapseNotes/` and `macOS/SynapseNotesTests/` are auto-included. App target/scheme: **"Synapse Notes"**; test module: **`Synapse`**; test target: **`SynapseTests`**. + +--- + +## Conventions used throughout this plan + +All commands run from `/Users/dep/Sites/synapse-notes/macOS`. + +**Regenerate the project after adding any new file** (XcodeGen globs the source dirs): +```bash +cd /Users/dep/Sites/synapse-notes/macOS && xcodegen generate +``` + +**Run a single test** (regenerate first if you added the test file this session): +```bash +cd /Users/dep/Sites/synapse-notes/macOS && xcodegen generate && \ +xcodebuild test -project "Synapse Notes.xcodeproj" -scheme "Synapse Notes" \ + -destination "platform=macOS" \ + -only-testing:"SynapseTests//" 2>&1 | tail -30 +``` + +**Run a whole test class:** drop the `/` suffix. + +**Build + relaunch the app** (required after any `.swift` change before asking for feedback — per `.agents/commands/RELOAD-MAC.md`): +```bash +pkill -9 "Synapse Notes" || true && sleep 1 && cd /Users/dep/Sites/synapse-notes/macOS && \ +xcodegen generate && \ +xcodebuild -project "Synapse Notes.xcodeproj" -scheme "Synapse Notes" -destination "platform=macOS" build && \ +for app in ~/Library/Developer/Xcode/DerivedData/Synapse*-*/Build/Products/Debug/"Synapse Notes.app"; do [ -e "$app" ] && open "$app" && break; done +``` + +**No trailing whitespace** in any file (repo rule). **Test module import:** `@testable import Synapse`. + +--- + +## File Structure + +| File | Responsibility | Status | +|---|---|---| +| `SynapseNotes/KeychainStore.swift` | Get/set/delete the Anthropic API key in the Keychain. | Create | +| `SynapseNotes/AIModel.swift` | The 3-model enum: id strings + display names. | Create | +| `SynapseNotes/AIContextResolver.swift` | Resolve `@name` tokens → note contents, capped. Pure. | Create | +| `SynapseNotes/AIRequestBuilder.swift` | Build system + messages payload from prompt/note/selection/context. Pure. | Create | +| `SynapseNotes/AnthropicClient.swift` | URLSession SSE transport → `AsyncThrowingStream`. | Create | +| `SynapseNotes/InlineAIController.swift` | Orchestrator `ObservableObject`: session state, streaming application, accept/reject/cancel. | Create | +| `SynapseNotes/InlineAIView.swift` | `InlineAIBarView` (SwiftUI) + `AISparkleButton` (NSControl). | Create | +| `SynapseNotes/SettingsManager.swift` | Add `aiDefaultModel` persisted in `GlobalConfig`. | Modify | +| `SynapseNotes/SettingsView.swift` | Add the "AI" settings `Section` (key field + model picker). | Modify | +| `SynapseNotes/EditorView.swift` | Host the ✨ overlay + bar; wire selection/caret + text mutation. | Modify | +| `SynapseNotesTests/*` | One test file per pure unit + controller. | Create | + +Tasks are ordered so each builds on green tests from the prior one. Tasks 1–6 are pure/testable (TDD). Tasks 7–9 are AppKit/SwiftUI integration verified by build-and-relaunch. + +--- + +### Task 1: AIModel enum + +**Files:** +- Create: `macOS/SynapseNotes/AIModel.swift` +- Test: `macOS/SynapseNotesTests/AIModelTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +// macOS/SynapseNotesTests/AIModelTests.swift +import XCTest +@testable import Synapse + +final class AIModelTests: XCTestCase { + func test_apiIDs_areExactAnthropicModelStrings() { + XCTAssertEqual(AIModel.haiku.apiID, "claude-haiku-4-5") + XCTAssertEqual(AIModel.sonnet.apiID, "claude-sonnet-4-6") + XCTAssertEqual(AIModel.opus.apiID, "claude-opus-4-8") + } + + func test_displayNames_areHumanReadable() { + XCTAssertEqual(AIModel.haiku.displayName, "Haiku 4.5") + XCTAssertEqual(AIModel.sonnet.displayName, "Sonnet 4.6") + XCTAssertEqual(AIModel.opus.displayName, "Opus 4.8") + } + + func test_initFromAPIID_roundTrips_andDefaultsToSonnetOnUnknown() { + XCTAssertEqual(AIModel(apiID: "claude-opus-4-8"), .opus) + XCTAssertEqual(AIModel(apiID: "garbage"), .sonnet) + } + + func test_defaultModel_isSonnet() { + XCTAssertEqual(AIModel.default, .sonnet) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `xcodebuild test ... -only-testing:"SynapseTests/AIModelTests"` (see Conventions) +Expected: FAIL — `AIModel` is undefined (compile error). + +- [ ] **Step 3: Write minimal implementation** + +```swift +// macOS/SynapseNotes/AIModel.swift +import Foundation + +/// The three Anthropic models the inline AI editor can use. +/// API IDs are the exact Anthropic model strings — no date suffixes. +enum AIModel: String, CaseIterable, Identifiable { + case haiku + case sonnet + case opus + + var id: String { rawValue } + + var apiID: String { + switch self { + case .haiku: return "claude-haiku-4-5" + case .sonnet: return "claude-sonnet-4-6" + case .opus: return "claude-opus-4-8" + } + } + + var displayName: String { + switch self { + case .haiku: return "Haiku 4.5" + case .sonnet: return "Sonnet 4.6" + case .opus: return "Opus 4.8" + } + } + + /// The default model — a balance of speed and quality. + static let `default`: AIModel = .sonnet + + /// Resolve from a stored API ID string, falling back to the default. + init(apiID: String) { + self = AIModel.allCases.first { $0.apiID == apiID } ?? .default + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: same as Step 2. +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/AIModel.swift SynapseNotesTests/AIModelTests.swift && \ +git commit -m "feat(ai): add AIModel enum with exact Anthropic model IDs" +``` + +--- + +### Task 2: KeychainStore + +**Files:** +- Create: `macOS/SynapseNotes/KeychainStore.swift` +- Test: `macOS/SynapseNotesTests/KeychainStoreTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +// macOS/SynapseNotesTests/KeychainStoreTests.swift +import XCTest +@testable import Synapse + +final class KeychainStoreTests: XCTestCase { + // Use a dedicated test service so we never touch the real key. + let store = KeychainStore(service: "com.SynapseNotes.tests.anthropic") + + override func setUp() { + super.setUp() + store.delete() + } + + override func tearDown() { + store.delete() + super.tearDown() + } + + func test_getBeforeSet_returnsNil() { + XCTAssertNil(store.get()) + } + + func test_setThenGet_roundTrips() { + store.set("sk-ant-secret") + XCTAssertEqual(store.get(), "sk-ant-secret") + } + + func test_setOverwrites_existingValue() { + store.set("first") + store.set("second") + XCTAssertEqual(store.get(), "second") + } + + func test_setEmptyString_deletesTheItem() { + store.set("value") + store.set("") + XCTAssertNil(store.get()) + } + + func test_delete_removesValue() { + store.set("value") + store.delete() + XCTAssertNil(store.get()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `... -only-testing:"SynapseTests/KeychainStoreTests"` +Expected: FAIL — `KeychainStore` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// macOS/SynapseNotes/KeychainStore.swift +import Foundation +import Security + +/// Securely stores a single secret (the Anthropic API key) in the macOS Keychain. +/// One instance == one (service, account) slot. +struct KeychainStore { + let service: String + let account: String + + init(service: String = "com.SynapseNotes.anthropic", account: String = "apiKey") { + self.service = service + self.account = account + } + + /// Returns the stored secret, or nil if none is set. + func get() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let string = String(data: data, encoding: .utf8), + !string.isEmpty else { + return nil + } + return string + } + + /// Stores the secret, overwriting any existing value. An empty string deletes the item. + func set(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { delete(); return } + + let data = Data(trimmed.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attributes: [String: Any] = [kSecValueData as String: data] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var add = query + add[kSecValueData as String] = data + SecItemAdd(add as CFDictionary, nil) + } + } + + /// Removes the stored secret if present. + func delete() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: same as Step 2. +Expected: PASS (5 tests). (If the test host lacks Keychain entitlement and tests fail with `errSecMissingEntitlement`, re-run; the app target already runs with a generic-password-capable entitlement. If it persists, note it for the review checkpoint.) + +- [ ] **Step 5: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/KeychainStore.swift SynapseNotesTests/KeychainStoreTests.swift && \ +git commit -m "feat(ai): add KeychainStore for the Anthropic API key" +``` + +--- + +### Task 3: AIContextResolver + +**Files:** +- Create: `macOS/SynapseNotes/AIContextResolver.swift` +- Test: `macOS/SynapseNotesTests/AIContextResolverTests.swift` + +This unit takes the user's prompt string plus the vault's note URLs, finds `@name` tokens, loads the matching note bodies (case-insensitive by stem, mirroring `EditorView.swift:3357` resolution), caps the total at ~100K chars, and reports missing refs + truncation. It reads file contents via an injected closure so tests don't touch disk. + +- [ ] **Step 1: Write the failing test** + +```swift +// macOS/SynapseNotesTests/AIContextResolverTests.swift +import XCTest +@testable import Synapse + +final class AIContextResolverTests: XCTestCase { + // Build a resolver whose "files" are in-memory: name -> body. + private func makeResolver(_ files: [String: String], cap: Int = 100_000) -> AIContextResolver { + let urls = files.keys.map { URL(fileURLWithPath: "/vault/\($0).md") } + return AIContextResolver( + allFiles: urls, + charCap: cap, + readContents: { url in files[url.deletingPathExtension().lastPathComponent] } + ) + } + + func test_noAtTokens_returnsEmptyContextNoMissing() { + let r = makeResolver(["Foo": "body"]) + let result = r.resolve(prompt: "summarize the note") + XCTAssertTrue(result.blocks.isEmpty) + XCTAssertTrue(result.missing.isEmpty) + XCTAssertFalse(result.truncated) + } + + func test_resolvesSingleAtToken_caseInsensitive() { + let r = makeResolver(["Daily": "daily body"]) + let result = r.resolve(prompt: "use @daily please") + XCTAssertEqual(result.blocks.count, 1) + XCTAssertEqual(result.blocks[0].name, "Daily") + XCTAssertEqual(result.blocks[0].body, "daily body") + XCTAssertTrue(result.missing.isEmpty) + } + + func test_missingRef_isReportedAndSkipped() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "@nope and @Foo") + XCTAssertEqual(result.blocks.map(\.name), ["Foo"]) + XCTAssertEqual(result.missing, ["nope"]) + } + + func test_overCap_truncatesAndFlags() { + let big = String(repeating: "a", count: 60_000) + let r = makeResolver(["One": big, "Two": big], cap: 100_000) + let result = r.resolve(prompt: "@One @Two") + XCTAssertTrue(result.truncated) + let total = result.blocks.reduce(0) { $0 + $1.body.count } + XCTAssertLessThanOrEqual(total, 100_000) + } + + func test_duplicateRefs_resolvedOnce() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "@Foo and again @foo") + XCTAssertEqual(result.blocks.count, 1) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `... -only-testing:"SynapseTests/AIContextResolverTests"` +Expected: FAIL — `AIContextResolver` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// macOS/SynapseNotes/AIContextResolver.swift +import Foundation + +/// Resolves `@name` tokens in a prompt into vault-note context blocks. +/// Pure: file contents are read through an injected closure. +struct AIContextResolver { + struct Block: Equatable { + let name: String // the file stem actually matched + let body: String + } + struct Result: Equatable { + var blocks: [Block] + var missing: [String] // @tokens with no matching note + var truncated: Bool + } + + let allFiles: [URL] + let charCap: Int + let readContents: (URL) -> String? + + init(allFiles: [URL], charCap: Int = 100_000, readContents: @escaping (URL) -> String?) { + self.allFiles = allFiles + self.charCap = charCap + self.readContents = readContents + } + + /// Matches `@token` where token is letters/digits/_/-/space-free path-ish chars. + private static let tokenRegex = try! NSRegularExpression(pattern: "@([\\w./-]+)") + + func resolve(prompt: String) -> Result { + let ns = prompt as NSString + let matches = Self.tokenRegex.matches(in: prompt, range: NSRange(location: 0, length: ns.length)) + + var seen = Set() + var blocks: [Block] = [] + var missing: [String] = [] + var truncated = false + var used = 0 + + for match in matches { + let token = ns.substring(with: match.range(at: 1)) + let key = token.lowercased() + guard !seen.contains(key) else { continue } + seen.insert(key) + + guard let url = allFiles.first(where: { + $0.deletingPathExtension().lastPathComponent.lowercased() == key + }), let body = readContents(url) else { + missing.append(token) + continue + } + + let name = url.deletingPathExtension().lastPathComponent + let remaining = charCap - used + if body.count > remaining { + truncated = true + if remaining > 0 { + blocks.append(Block(name: name, body: String(body.prefix(remaining)))) + used = charCap + } + break + } + blocks.append(Block(name: name, body: body)) + used += body.count + } + + return Result(blocks: blocks, missing: missing, truncated: truncated) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: same as Step 2. +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/AIContextResolver.swift SynapseNotesTests/AIContextResolverTests.swift && \ +git commit -m "feat(ai): add AIContextResolver for @note context" +``` + +--- + +### Task 4: AIRequestBuilder + +**Files:** +- Create: `macOS/SynapseNotes/AIRequestBuilder.swift` +- Test: `macOS/SynapseNotesTests/AIRequestBuilderTests.swift` + +Pure assembly of the Anthropic request body (as a `[String: Any]` JSON dictionary, ready for `JSONSerialization`). Two modes: `.generate` (insert at cursor) and `.rewrite` (transform selection). Includes the full current note + resolved context blocks. + +- [ ] **Step 1: Write the failing test** + +```swift +// macOS/SynapseNotesTests/AIRequestBuilderTests.swift +import XCTest +@testable import Synapse + +final class AIRequestBuilderTests: XCTestCase { + private func userText(_ body: [String: Any]) -> String { + let messages = body["messages"] as! [[String: Any]] + return messages.first(where: { $0["role"] as? String == "user" })!["content"] as! String + } + + func test_generate_includesModelStreamMaxTokensAndNote() { + let body = AIRequestBuilder.build( + mode: .generate, + prompt: "write a haiku", + noteText: "# My Note\nSome text", + selection: nil, + context: [], + model: .opus + ) + XCTAssertEqual(body["model"] as? String, "claude-opus-4-8") + XCTAssertEqual(body["stream"] as? Bool, true) + XCTAssertNotNil(body["max_tokens"]) + XCTAssertTrue(userText(body).contains("write a haiku")) + XCTAssertTrue(userText(body).contains("# My Note")) + } + + func test_rewrite_includesSelectedText() { + let body = AIRequestBuilder.build( + mode: .rewrite, + prompt: "make it concise", + noteText: "Full note body", + selection: "The quick brown fox jumped.", + context: [], + model: .sonnet + ) + XCTAssertTrue(userText(body).contains("make it concise")) + XCTAssertTrue(userText(body).contains("The quick brown fox jumped.")) + } + + func test_contextBlocks_areLabeledByName() { + let blocks = [AIContextResolver.Block(name: "Spec", body: "spec contents")] + let body = AIRequestBuilder.build( + mode: .generate, prompt: "p", noteText: "n", + selection: nil, context: blocks, model: .haiku + ) + let text = userText(body) + XCTAssertTrue(text.contains("Spec")) + XCTAssertTrue(text.contains("spec contents")) + } + + func test_hasSystemPrompt() { + let body = AIRequestBuilder.build( + mode: .generate, prompt: "p", noteText: "n", + selection: nil, context: [], model: .sonnet + ) + let system = body["system"] as? String + XCTAssertNotNil(system) + XCTAssertFalse(system!.isEmpty) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `... -only-testing:"SynapseTests/AIRequestBuilderTests"` +Expected: FAIL — `AIRequestBuilder` / `AIRequestBuilder.Mode` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// macOS/SynapseNotes/AIRequestBuilder.swift +import Foundation + +/// Builds the Anthropic /v1/messages request body for the inline editor. +/// Pure — returns a JSON-serializable dictionary. +enum AIRequestBuilder { + enum Mode { + case generate // insert new text at the cursor + case rewrite // transform the selected text + } + + static let maxTokens = 4096 + + static func build( + mode: Mode, + prompt: String, + noteText: String, + selection: String?, + context: [AIContextResolver.Block], + model: AIModel + ) -> [String: Any] { + var user = "" + + if !context.isEmpty { + user += "Reference notes:\n" + for block in context { + user += "--- @\(block.name) ---\n\(block.body)\n\n" + } + } + + user += "Current note:\n\"\"\"\n\(noteText)\n\"\"\"\n\n" + + switch mode { + case .generate: + user += "Task: \(prompt)\n\n" + user += "Write the text to insert at the cursor. Output only the new text, no preamble, no markdown fences." + case .rewrite: + user += "Selected text:\n\"\"\"\n\(selection ?? "")\n\"\"\"\n\n" + user += "Task: \(prompt)\n\n" + user += "Rewrite the selected text accordingly. Output only the replacement text, no preamble, no markdown fences." + } + + let system = """ + You are a writing assistant embedded in a Markdown note editor. \ + You produce text that drops directly into the user's note. \ + Match the surrounding tone and Markdown style. \ + Never wrap your answer in code fences or add commentary — output only the text the user asked for. + """ + + return [ + "model": model.apiID, + "max_tokens": maxTokens, + "stream": true, + "system": system, + "messages": [ + ["role": "user", "content": user] + ] + ] + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: same as Step 2. +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/AIRequestBuilder.swift SynapseNotesTests/AIRequestBuilderTests.swift && \ +git commit -m "feat(ai): add AIRequestBuilder for generate/rewrite payloads" +``` + +--- + +### Task 5: AnthropicClient (SSE transport) + +**Files:** +- Create: `macOS/SynapseNotes/AnthropicClient.swift` +- Test: `macOS/SynapseNotesTests/AnthropicClientTests.swift` + +Streams `/v1/messages` SSE and emits text deltas. The `URLSession` is injectable (defaults to `.shared`), mirroring `GistPublisher`. Tests feed canned SSE bytes via a `MockURLProtocol` (adapted from `GistPublisherHTTPTests.swift`). + +- [ ] **Step 1: Write the failing test** + +```swift +// macOS/SynapseNotesTests/AnthropicClientTests.swift +import XCTest +@testable import Synapse + +private final class MockSSEURLProtocol: URLProtocol { + static var responseStatus: Int = 200 + static var bodyData: Data = Data() + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, statusCode: MockSSEURLProtocol.responseStatus, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "text/event-stream"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: MockSSEURLProtocol.bodyData) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +final class AnthropicClientTests: XCTestCase { + private func makeClient() -> AnthropicClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockSSEURLProtocol.self] + return AnthropicClient(apiKey: "sk-test", urlSession: URLSession(configuration: config)) + } + + private func sse(_ lines: [String]) -> Data { + Data(lines.joined(separator: "\n").appending("\n").utf8) + } + + override func tearDown() { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = Data() + super.tearDown() + } + + func test_streamsTextDeltasInOrder() async throws { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = sse([ + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}"#, + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":", world"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: ["model": "claude-sonnet-4-6"]) { + collected += delta + } + XCTAssertEqual(collected, "Hello, world") + } + + func test_ignoresNonDeltaEvents() async throws { + MockSSEURLProtocol.bodyData = sse([ + #"data: {"type":"message_start","message":{}}"#, + #"data: {"type":"content_block_start","index":0}"#, + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"X"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: [:]) { collected += delta } + XCTAssertEqual(collected, "X") + } + + func test_401_throwsInvalidKey() async { + MockSSEURLProtocol.responseStatus = 401 + MockSSEURLProtocol.bodyData = Data() + let client = makeClient() + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .invalidKey) + } catch { + XCTFail("wrong error type: \(error)") + } + } + + func test_500_throwsServerError() async { + MockSSEURLProtocol.responseStatus = 500 + let client = makeClient() + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .server(status: 500)) + } catch { + XCTFail("wrong error type: \(error)") + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `... -only-testing:"SynapseTests/AnthropicClientTests"` +Expected: FAIL — `AnthropicClient` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// macOS/SynapseNotes/AnthropicClient.swift +import Foundation + +/// Streams text deltas from the Anthropic /v1/messages SSE endpoint. +/// The URLSession is injectable for testing; defaults to .shared. +struct AnthropicClient { + enum ClientError: Error, Equatable { + case invalidKey + case server(status: Int) + case badResponse + } + + let apiKey: String + var urlSession: URLSession = .shared + + private static let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + + /// Streams `text_delta` strings. The async sequence finishes on `message_stop` + /// or end of stream, and throws `ClientError` on a non-2xx status. + func stream(body: [String: Any]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (bytes, response) = try await urlSession.bytes(for: request) + + guard let http = response as? HTTPURLResponse else { + throw ClientError.badResponse + } + guard (200...299).contains(http.statusCode) else { + // Drain so the connection closes cleanly. + for try await _ in bytes.lines {} + if http.statusCode == 401 { throw ClientError.invalidKey } + throw ClientError.server(status: http.statusCode) + } + + for try await line in bytes.lines { + try Task.checkCancellation() + guard line.hasPrefix("data:") else { continue } + let payload = line.dropFirst("data:".count).trimmingCharacters(in: .whitespaces) + guard !payload.isEmpty, + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + if json["type"] as? String == "message_stop" { break } + if json["type"] as? String == "content_block_delta", + let delta = json["delta"] as? [String: Any], + delta["type"] as? String == "text_delta", + let text = delta["text"] as? String { + continuation.yield(text) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: same as Step 2. +Expected: PASS (4 tests). Note: the mock delivers the full body at once; `bytes.lines` still splits it into lines, so the assertions hold. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/AnthropicClient.swift SynapseNotesTests/AnthropicClientTests.swift && \ +git commit -m "feat(ai): add AnthropicClient SSE streaming transport" +``` + +--- + +### Task 6: InlineAIController (orchestrator, accept/reject logic) + +**Files:** +- Create: `macOS/SynapseNotes/InlineAIController.swift` +- Test: `macOS/SynapseNotesTests/InlineAIControllerTests.swift` + +This holds the streaming state machine and the rewrite accept/reject logic, expressed against an `NSTextStorage` so it's testable without a live text view. It does **not** own the SSE call directly in the tested surface — the test drives `appendDelta`/`accept`/`reject` to exercise the diff logic. (The view layer in Task 7 connects `AnthropicClient` deltas to `appendDelta`.) + +- [ ] **Step 1: Write the failing test** + +```swift +// macOS/SynapseNotesTests/InlineAIControllerTests.swift +import XCTest +import AppKit +@testable import Synapse + +final class InlineAIControllerTests: XCTestCase { + private func makeStorage(_ s: String) -> NSTextStorage { + NSTextStorage(string: s) + } + + // MARK: generate mode + + func test_generate_appendDeltas_insertsAtCursor() { + let storage = makeStorage("Hello world") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 6) // between the two spaces + c.appendDelta("brave new") + c.appendDelta(" ") + XCTAssertEqual(storage.string, "Hello brave new world") + } + + func test_generate_cancel_keepsPartialText() { + let storage = makeStorage("ab") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 2) + c.appendDelta("XY") + c.cancel() + XCTAssertEqual(storage.string, "abXY") + } + + // MARK: rewrite mode + + func test_rewrite_appendDeltas_keepOriginalUntilAccept() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + // select "The fox." == range 0..<8 + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + // original still present; new text appended after it + XCTAssertTrue(storage.string.contains("The fox.")) + XCTAssertTrue(storage.string.contains("A fox.")) + } + + func test_rewrite_accept_replacesOriginalWithNew() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + c.accept() + XCTAssertEqual(storage.string, "A fox.") + } + + func test_rewrite_reject_restoresOriginalOnly() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + c.reject() + XCTAssertEqual(storage.string, "The fox.") + } + + func test_rewrite_cancelMidStream_thenAccept_usesPartial() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A ") + c.cancel() // streaming stopped; still in pending-accept state + c.accept() + XCTAssertEqual(storage.string, "A ") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `... -only-testing:"SynapseTests/InlineAIControllerTests"` +Expected: FAIL — `InlineAIController` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// macOS/SynapseNotes/InlineAIController.swift +import AppKit +import Combine + +/// Orchestrates an inline AI editing session against an NSTextStorage. +/// +/// Generate mode: streamed deltas are inserted at the cursor (plain text). +/// Rewrite mode: the original selection is kept; new text streams in after it. +/// On `accept`, the original is deleted and the new text remains. On `reject`, +/// the new text is removed and the original stays. Diff *coloring* is applied +/// by the view layer (Task 7) via the published `diffRange`s; this controller +/// owns only the text mutations so the logic stays unit-testable. +final class InlineAIController: ObservableObject { + enum Mode: Equatable { case idle, generate, rewrite } + + @Published private(set) var mode: Mode = .idle + /// Range of the original (struck-through) text during a rewrite; nil otherwise. + @Published private(set) var originalRange: NSRange? + /// Range of the streamed new text (generate: the inserted text; rewrite: the green text). + @Published private(set) var newRange: NSRange? + + private weak var storage: NSTextStorage? + + // MARK: Generate + + func beginGenerate(in storage: NSTextStorage, at location: Int) { + self.storage = storage + mode = .generate + originalRange = nil + newRange = NSRange(location: location, length: 0) + } + + // MARK: Rewrite + + func beginRewrite(in storage: NSTextStorage, selection: NSRange) { + self.storage = storage + mode = .rewrite + originalRange = selection + // New text starts immediately after the original selection. + newRange = NSRange(location: selection.location + selection.length, length: 0) + } + + // MARK: Streaming + + /// Appends a streamed text delta at the end of the current `newRange`. + func appendDelta(_ text: String) { + guard let storage, var nr = newRange, mode != .idle else { return } + let insertAt = nr.location + nr.length + storage.replaceCharacters(in: NSRange(location: insertAt, length: 0), with: text) + nr.length += (text as NSString).length + newRange = nr + } + + /// Stops streaming but stays in the pending state (rewrite still awaits accept/reject). + func cancel() { + if mode == .generate { finishGenerate() } + // rewrite: remain pending so the user can accept/reject the partial. + } + + // MARK: Resolution + + /// Generate has no diff — once done, there's nothing to accept; just clear state. + private func finishGenerate() { + mode = .idle + originalRange = nil + newRange = nil + } + + /// Rewrite accept: delete the original, keep the new text. + func accept() { + guard mode == .rewrite, let storage, let orig = originalRange else { + finishGenerate(); return + } + // Delete the original range; the new text sits right after it, so deleting + // the original shifts the new text left into the original's place. + storage.replaceCharacters(in: orig, with: "") + mode = .idle + originalRange = nil + newRange = nil + } + + /// Rewrite reject: delete the streamed new text, restore the original. + func reject() { + guard mode == .rewrite, let storage, let nr = newRange else { + finishGenerate(); return + } + storage.replaceCharacters(in: nr, with: "") + mode = .idle + originalRange = nil + newRange = nil + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: same as Step 2. +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/InlineAIController.swift SynapseNotesTests/InlineAIControllerTests.swift && \ +git commit -m "feat(ai): add InlineAIController streaming + accept/reject logic" +``` + +--- + +### Task 7: Settings — `aiDefaultModel` persistence + AI settings UI + +**Files:** +- Modify: `macOS/SynapseNotes/SettingsManager.swift` +- Modify: `macOS/SynapseNotes/SettingsView.swift` +- Test: `macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift` + +`aiDefaultModel` is a machine-local preference → lives in `GlobalConfig`, threaded through exactly like `githubPAT`. The API key is **not** persisted here — only in `KeychainStore`. + +- [ ] **Step 1: Write the failing test** (round-trips `aiDefaultModel` through save/reload, mirroring `SettingsManagerGitHubPATTests`) + +```swift +// macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift +import XCTest +@testable import Synapse + +final class SettingsManagerAIModelTests: XCTestCase { + private var tempDir: URL! + private var globalPath: String! + + override func setUp() { + super.setUp() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + globalPath = tempDir.appendingPathComponent("global.yml").path + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + super.tearDown() + } + + func test_default_isSonnetAPIID() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertEqual(mgr.aiDefaultModel, "claude-sonnet-4-6") + } + + func test_aiDefaultModel_persistsAcrossReload() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + mgr.aiDefaultModel = "claude-opus-4-8" + // Force a reload from disk into a fresh manager. + let reloaded = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertEqual(reloaded.aiDefaultModel, "claude-opus-4-8") + } +} +``` + +> Note: confirm the `SettingsManager(vaultRoot:globalConfigPath:)` initializer signature against `SettingsManager.swift` (the full `init` around line 718+); if the designated init differs, mirror the exact form used in `SettingsManagerGitHubPATTests.swift`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `... -only-testing:"SynapseTests/SettingsManagerAIModelTests"` +Expected: FAIL — `aiDefaultModel` undefined. + +- [ ] **Step 3a: Add the published property** — in `SettingsManager.swift`, after the `githubPAT` property (around line 274-276), add: + +```swift + /// Default Anthropic model for inline AI editing (machine-local). + @Published var aiDefaultModel: String { + didSet { save() } + } +``` + +- [ ] **Step 3b: Add the `GlobalConfig` field + inits** — in the `GlobalConfig` struct (line 669): + +Add the stored property after `vaultPath`: +```swift + var aiDefaultModel: String? +``` +Add a parameter to the memberwise `init` (after `lastNoteFolderPerVault`) and assign it: +```swift + aiDefaultModel: String? = nil +``` +```swift + self.aiDefaultModel = aiDefaultModel +``` +Add to `init(from:)`: +```swift + aiDefaultModel = try container.decodeIfPresent(String.self, forKey: .aiDefaultModel) +``` + +- [ ] **Step 3c: Initialize the property in every initializer** — wherever `githubPAT = ""` is set in init (lines ~748, ~806) and where defaults are applied, add alongside: + +```swift + aiDefaultModel = "claude-sonnet-4-6" +``` + +- [ ] **Step 3d: Apply on load** — in `applyGlobalConfig(_:)` (line 1006), after the `githubPAT` line (1007), add: + +```swift + aiDefaultModel = globalConfig?.aiDefaultModel ?? "claude-sonnet-4-6" +``` + +- [ ] **Step 3e: Persist on save** — in BOTH `writeGlobalOnly()` (line 1258) and `writeVault()`'s `GlobalConfig(...)` (line 1377), add the argument after `lastNoteFolderPerVault:`: + +```swift + aiDefaultModel: aiDefaultModel +``` + +> Also add `aiDefaultModel` to the legacy `LegacyFile` struct + `writeLegacy()` (lines 1276-1339) and `applyLegacyConfig` if the legacy path sets `githubPAT` — search for every `githubPAT` assignment and mirror it. Run `grep -n githubPAT SettingsManager.swift` and ensure `aiDefaultModel` appears at each corresponding site. + +- [ ] **Step 4: Run test to verify it passes** + +Run: same as Step 2. +Expected: PASS (2 tests). + +- [ ] **Step 5a: Add the AI settings Section UI** — in `SettingsView.swift`, locate the GitHub Gist `Section` (search for the `SecureField` bound to `settings.githubPAT`) and add a new `Section` after it: + +```swift + Section(header: Text("AI")) { + VStack(alignment: .leading, spacing: 8) { + Text("Anthropic API Key") + .font(.headline) + SecureField("sk-ant-...", text: $anthropicKey) + .textFieldStyle(.roundedBorder) + .onChange(of: anthropicKey) { newValue in + KeychainStore().set(newValue) + } + Text("Stored securely in your macOS Keychain. Used for inline AI editing (✨).") + .font(.caption) + .foregroundColor(.secondary) + + Divider() + + Text("Default Model") + .font(.headline) + Picker("Default Model", selection: Binding( + get: { AIModel(apiID: settings.aiDefaultModel) }, + set: { settings.aiDefaultModel = $0.apiID } + )) { + ForEach(AIModel.allCases) { model in + Text(model.displayName).tag(model) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } + .padding(.vertical, 4) + } +``` + +- [ ] **Step 5b: Add the backing state** — near the top of the `SettingsView` struct body, alongside the other `@State`/`@ObservedObject` declarations, add: + +```swift + @State private var anthropicKey: String = KeychainStore().get() ?? "" +``` + +- [ ] **Step 6: Build + relaunch and verify Settings** + +Run the build+relaunch command (see Conventions). Open Settings → AI section. Type a key, quit, reopen Settings — the key should persist (Keychain). Switch the model picker, quit, reopen — selection persists (YAML). + +- [ ] **Step 7: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/SettingsManager.swift SynapseNotes/SettingsView.swift SynapseNotesTests/SettingsManagerAIModelTests.swift && \ +git commit -m "feat(ai): add Anthropic key (Keychain) + default model to Settings" +``` + +--- + +### Task 8: InlineAIView — the bar + ✨ button + `@` autocomplete + +**Files:** +- Create: `macOS/SynapseNotes/InlineAIView.swift` + +This is SwiftUI/AppKit UI verified by build-and-relaunch (no unit test for layout). It contains: +- `AISparkleButton: NSControl` — the ✨ overlay (mirrors `CollapsibleToggleButton` in `EditorView.swift:1135`). +- `InlineAIBarView: View` — prompt `TextField`, model `Picker`, Stop/Accept/Reject buttons, an inline error line, and an `@`-autocomplete suggestion list. Hosted via `NSHostingView`. +- A small view model `InlineAIBarModel: ObservableObject` holding prompt text, model, streaming/error state, and the `@`-suggestions, computed with `commandPaletteScoreByFilename` (`CommandPaletteView.swift:15`). + +- [ ] **Step 1: Create the file with the button + bar** + +```swift +// macOS/SynapseNotes/InlineAIView.swift +import SwiftUI +import AppKit + +/// The clickable ✨ overlay placed at the active line's end or past a selection. +/// Mirrors CollapsibleToggleButton's target/action overlay pattern. +final class AISparkleButton: NSControl { + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + toolTip = "Ask AI" + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func draw(_ dirtyRect: NSRect) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12) + ] + let s = NSAttributedString(string: "✨", attributes: attrs) + let size = s.size() + s.draw(at: NSPoint(x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2)) + } + + override func mouseDown(with event: NSEvent) { + sendAction(action, to: target) + } +} + +/// Whether the bar opens to generate at the cursor or rewrite a selection. +enum InlineAIBarMode { case generate, rewrite } + +/// View model backing the inline AI bar. +final class InlineAIBarModel: ObservableObject { + @Published var prompt: String = "" + @Published var model: AIModel + @Published var isStreaming: Bool = false + @Published var errorMessage: String? + @Published var awaitingAcceptReject: Bool = false // rewrite finished, awaiting decision + @Published var atSuggestions: [String] = [] // file stems matching the active @token + + let mode: InlineAIBarMode + /// Vault file stems, for @-autocomplete. + var allFileStems: [String] = [] + + // Callbacks wired by the host (Task 9). + var onSubmit: ((String, AIModel) -> Void)? + var onStop: (() -> Void)? + var onAccept: (() -> Void)? + var onReject: (() -> Void)? + var onCancel: (() -> Void)? // Esc with nothing pending → close the bar + + init(mode: InlineAIBarMode, model: AIModel) { + self.mode = mode + self.model = model + } + + /// Recompute @-autocomplete suggestions for the current prompt. + func updateSuggestions() { + guard let token = activeAtToken(in: prompt), !token.isEmpty else { + atSuggestions = [] + return + } + // Score stems by the same algorithm wiki-link autocomplete uses. + atSuggestions = allFileStems + .map { ($0, commandPaletteScoreByFilename(query: token, filename: $0)) } + .filter { $0.1 > 0 } + .sorted { $0.1 > $1.1 } + .prefix(8) + .map { $0.0 } + } + + /// Extracts the in-progress @token at the end of the prompt, if any. + private func activeAtToken(in text: String) -> String? { + guard let atIndex = text.lastIndex(of: "@") else { return nil } + let after = text[text.index(after: atIndex)...] + // No spaces inside a token; if there's a space after @, it's complete. + if after.contains(" ") { return nil } + return String(after) + } + + /// Replace the active @token with the chosen stem. + func applySuggestion(_ stem: String) { + guard let atIndex = prompt.lastIndex(of: "@") else { return } + prompt = String(prompt[..&1 | tail -20 +``` +Expected: BUILD SUCCEEDED. (If `commandPaletteScoreByFilename` has a different parameter label, fix the call to match `CommandPaletteView.swift:15`.) + +- [ ] **Step 3: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/InlineAIView.swift && \ +git commit -m "feat(ai): add inline AI bar view, ✨ button, and @ autocomplete" +``` + +--- + +### Task 9: Wire it into the editor + +**Files:** +- Modify: `macOS/SynapseNotes/EditorView.swift` + +Connect everything: position the ✨ on caret/selection change, open the bar on click, drive the controller from `AnthropicClient` deltas, apply diff coloring, and handle accept/reject/cancel. This is integration code verified by build-and-relaunch. + +- [ ] **Step 1: Add an `InlineAIController` + overlay state to `LinkAwareTextView`** + +In `LinkAwareTextView` (class at `EditorView.swift:2292`), add stored properties near the other overlay state (e.g. near `collapsibleToggleButtons` at line 2342): + +```swift + let inlineAIController = InlineAIController() + private var aiSparkleButton: AISparkleButton? + private var aiBarHostingView: NSHostingView? + private var aiBarModel: InlineAIBarModel? + private var aiStreamTask: Task? + /// Reference to AppState for vault files + API key, injected at setup. + weak var aiAppState: AppState? + var aiSettings: SettingsManager? { settings } // `settings` already exists on this class +``` + +- [ ] **Step 2: Position the ✨ on layout/selection change** + +Add a `refreshAISparkle()` method modeled on `refreshCollapsibleToggles()` (`EditorView.swift:2181`), and call it from the same places that refresh overlays AND from the selection-change path. To avoid the un-debounced caret-move cost documented in the typing-perf memory, this must be cheap (one glyph-rect lookup, reposition one reused button — no parsing): + +```swift + func refreshAISparkle() { + guard let layoutManager, let textContainer else { return } + let sel = selectedRange() + + // Choose anchor: end of selection if there is one, else end of caret's line. + let anchorIndex: Int + if sel.length > 0 { + anchorIndex = sel.location + sel.length + } else { + let ns = string as NSString + let lineRange = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0)) + // End of line content (before the trailing newline if present). + var end = lineRange.location + lineRange.length + if end > lineRange.location, + ns.substring(with: NSRange(location: end - 1, length: 1)).rangeOfCharacter(from: .newlines) != nil { + end -= 1 + } + anchorIndex = end + } + + let safe = max(0, min(anchorIndex, (string as NSString).length)) + let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: max(0, safe - 1), length: safe > 0 ? 1 : 0), actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textContainerOrigin.x + rect.origin.y += textContainerOrigin.y + + let size: CGFloat = 18 + let frame = NSRect(x: rect.maxX + 4, y: rect.minY + (rect.height - size) / 2, width: size, height: size) + + let button: AISparkleButton + if let existing = aiSparkleButton { + button = existing + } else { + button = AISparkleButton(frame: frame) + button.target = self + button.action = #selector(aiSparkleTapped) + addSubview(button) + aiSparkleButton = button + } + button.frame = frame + button.isHidden = (aiBarHostingView != nil) // hide while the bar is open + } +``` + +Call `refreshAISparkle()` at the end of `refreshCollapsibleToggles()` (so it runs on every overlay refresh) and at the end of the coordinator's `textViewDidChangeSelection` (`EditorView.swift:1061`) — but route the selection-change call through the SAME debounce the existing reveal re-hide uses (see `EditorView.swift:1061-1096`), not synchronously, to respect the typing-perf hot path. + +- [ ] **Step 3: Open the bar on ✨ click** + +```swift + @objc private func aiSparkleTapped() { + let sel = selectedRange() + let mode: InlineAIBarMode = sel.length > 0 ? .rewrite : .generate + presentAIBar(mode: mode, at: sel) + } + + private func presentAIBar(mode: InlineAIBarMode, at sel: NSRange) { + dismissAIBar() // ensure single instance + + let defaultModel = AIModel(apiID: aiSettings?.aiDefaultModel ?? AIModel.default.apiID) + let model = InlineAIBarModel(mode: mode, model: defaultModel) + model.allFileStems = (aiAppState?.allFiles ?? []).map { $0.deletingPathExtension().lastPathComponent } + + model.onSubmit = { [weak self] prompt, chosen in self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) } + model.onStop = { [weak self] in self?.stopAIStream() } + model.onAccept = { [weak self] in self?.acceptAI() } + model.onReject = { [weak self] in self?.rejectAI() } + model.onCancel = { [weak self] in self?.dismissAIBar() } + aiBarModel = model + + let host = NSHostingView(rootView: InlineAIBarView(model: model)) + host.frame = aiBarFrame(below: sel) + addSubview(host) + aiBarHostingView = host + refreshAISparkle() // hides the sparkle while bar is open + } + + private func aiBarFrame(below sel: NSRange) -> NSRect { + guard let layoutManager, let textContainer else { return .zero } + let anchor = sel.length > 0 ? sel.location + sel.length : sel.location + let safe = max(0, min(anchor, (string as NSString).length)) + let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: safe, length: 0), actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textContainerOrigin.x + rect.origin.y += textContainerOrigin.y + let width = min(bounds.width - 24, 520) + return NSRect(x: 12, y: rect.maxY + 4, width: width, height: 80) + } + + private func dismissAIBar() { + aiStreamTask?.cancel(); aiStreamTask = nil + aiBarHostingView?.removeFromSuperview(); aiBarHostingView = nil + aiBarModel = nil + refreshAISparkle() + } +``` + +- [ ] **Step 4: Drive the controller from `AnthropicClient`** + +```swift + private func startAIStream(prompt: String, model: AIModel, mode: InlineAIBarMode, selection sel: NSRange) { + guard let storage = textStorage else { return } + guard let key = KeychainStore().get(), !key.isEmpty else { + aiBarModel?.errorMessage = "Add your Anthropic API key in Settings →" + return + } + + // Resolve @-context. + let files = aiAppState?.allFiles ?? [] + let resolver = AIContextResolver(allFiles: files, readContents: { try? String(contentsOf: $0, encoding: .utf8) }) + let resolved = resolver.resolve(prompt: prompt) + + // Begin the controller session. + if mode == .generate { + inlineAIController.beginGenerate(in: storage, at: sel.location) + } else { + inlineAIController.beginRewrite(in: storage, selection: sel) + } + + let selectionText = mode == .rewrite ? (string as NSString).substring(with: sel) : nil + let body = AIRequestBuilder.build( + mode: mode == .generate ? .generate : .rewrite, + prompt: prompt, noteText: string, + selection: selectionText, context: resolved.blocks, model: model + ) + + aiBarModel?.isStreaming = true + aiBarModel?.errorMessage = resolved.truncated ? "Context truncated to fit." : (resolved.missing.isEmpty ? nil : "\(resolved.missing.count) reference(s) not found.") + + let client = AnthropicClient(apiKey: key) + aiStreamTask = Task { [weak self] in + do { + for try await delta in client.stream(body: body) { + await MainActor.run { + self?.inlineAIController.appendDelta(delta) + self?.applyAIDiffColors() + self?.didChangeText() + } + } + await MainActor.run { self?.finishAIStream(mode: mode) } + } catch { + await MainActor.run { self?.handleAIError(error) } + } + } + } + + private func stopAIStream() { + aiStreamTask?.cancel(); aiStreamTask = nil + inlineAIController.cancel() + finishAIStream(mode: aiBarModel?.mode ?? .generate) + } + + private func finishAIStream(mode: InlineAIBarMode) { + aiBarModel?.isStreaming = false + if mode == .rewrite { + aiBarModel?.awaitingAcceptReject = true // wait for accept/reject + } else { + inlineAIController.cancel() // generate: nothing to accept + dismissAIBar() + } + applyAIDiffColors() + } + + private func handleAIError(_ error: Error) { + aiBarModel?.isStreaming = false + if let e = error as? AnthropicClient.ClientError { + switch e { + case .invalidKey: aiBarModel?.errorMessage = "Invalid API key — check Settings." + case .server(let s): aiBarModel?.errorMessage = "Server error (\(s)). Try again." + case .badResponse: aiBarModel?.errorMessage = "Unexpected response. Try again." + } + } else { + aiBarModel?.errorMessage = "Network error. Try again." + } + // Keep whatever streamed; if rewrite, allow accept/reject of the partial. + if aiBarModel?.mode == .rewrite { aiBarModel?.awaitingAcceptReject = true } + } + + private func acceptAI() { + inlineAIController.accept() + clearAIDiffColors() + didChangeText() + dismissAIBar() + } + + private func rejectAI() { + inlineAIController.reject() + clearAIDiffColors() + didChangeText() + dismissAIBar() + } +``` + +- [ ] **Step 5: Apply transient diff coloring** + +Diff coloring is a transient overlay applied AFTER the styling pass and cleared on accept/reject — it must not be baked into the persistent styling (`applyPreviewStyling` is a pure hide pass; see the spec's implementation note). Use temporary attributes: + +```swift + private func applyAIDiffColors() { + guard let storage = textStorage else { return } + if let orig = inlineAIController.originalRange, orig.length > 0, + NSMaxRange(orig) <= storage.length { + storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: orig) + storage.addAttribute(.foregroundColor, value: NSColor.systemRed, range: orig) + } + if let nr = inlineAIController.newRange, nr.length > 0, + NSMaxRange(nr) <= storage.length { + storage.addAttribute(.foregroundColor, value: NSColor.systemGreen, range: nr) + } + } + + private func clearAIDiffColors() { + // The next refreshEditorForCurrentDisplayMode pass restores correct styling; + // remove the transient strike-through so it doesn't linger. + guard let storage = textStorage else { return } + let full = NSRange(location: 0, length: storage.length) + storage.removeAttribute(.strikethroughStyle, range: full) + refreshEditorForCurrentDisplayMode(self) + } +``` + +- [ ] **Step 6: Inject `aiAppState`** — find where `RawEditor.makeNSView` / `configuredTextView()` (`EditorView.swift:705`) sets up the `LinkAwareTextView` and assign `textView.aiAppState = appState` (the `RawEditor` already has access to `appState` via the binding chain — pass it through; if `RawEditor` doesn't currently hold `appState`, thread it from `EditorView` at line 181-336 where `@EnvironmentObject var appState` is available). + +- [ ] **Step 7: Build + relaunch and verify the full flow** + +Run the build+relaunch command. Then verify by hand: +1. Open a note, click into a line → ✨ appears at the line end. +2. Click ✨ → bar opens below; type a prompt; press Generate → text streams in at the cursor. +3. Select a sentence → ✨ appears past the selection; click it → bar opens in rewrite mode; submit → original goes red/struck-through, new text streams green; Accept replaces, Reject restores. +4. In the prompt, type `@` + a few letters → suggestions appear; click one → inserts `@name`. +5. With no API key set, submitting shows "Add your Anthropic API key in Settings →". +6. Press Stop mid-stream → streaming halts, partial kept. + +- [ ] **Step 8: Commit** + +```bash +cd /Users/dep/Sites/synapse-notes/macOS && git add SynapseNotes/EditorView.swift && \ +git commit -m "feat(ai): wire inline AI editing into the editor (✨, streaming, diff)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Active-line ✨ → Task 9 Step 2/3. ✅ +- Selection ✨ + inline diff accept/reject → Task 6 (logic) + Task 9 Step 5 (coloring). ✅ +- API key in Keychain → Task 2 + Task 7. ✅ +- Model picker (Haiku/Sonnet/Opus), per-request + persisted default → Task 1 + Task 7 + Task 8 bar picker. ✅ +- `@note`/`@dir` autocomplete → Task 3 (resolve) + Task 8 (suggestions UI). ✅ +- Streaming → Task 5 + Task 9 Step 4. ✅ +- Stop/Esc cancel keeping partial → Task 6 `cancel` + Task 9 `stopAIStream`. ✅ +- Inline error states (no key / 401 / network / missing ref / truncation) → Task 9 Step 4 `handleAIError` + resolver flags. ✅ +- Current-note + @-refs implicit context → Task 4. ✅ +- Direct URLSession SSE, no SDK → Task 5. ✅ + +**Placeholder scan:** No TBD/TODO. Every code step shows complete code. The few "confirm against existing signature" notes (Task 7 init, `commandPaletteScoreByFilename` label) are verification instructions tied to specific file:line anchors, not deferred work. + +**Type consistency:** `AIModel.apiID`/`displayName`/`init(apiID:)` consistent across Tasks 1, 4, 7, 8. `AIContextResolver.Block(name:body:)` consistent across Tasks 3, 4, 9. `AnthropicClient.ClientError` cases `.invalidKey`/`.server(status:)`/`.badResponse` consistent across Tasks 5, 9. `InlineAIController` methods `beginGenerate`/`beginRewrite`/`appendDelta`/`cancel`/`accept`/`reject` and published `originalRange`/`newRange` consistent across Tasks 6, 9. `AIRequestBuilder.build(mode:prompt:noteText:selection:context:model:)` consistent across Tasks 4, 9. + +## Notes for the implementer +- Tasks 1–6 are pure TDD and can each be done and reviewed independently. +- Tasks 7–9 touch existing files; after each, run the **build + relaunch** command before requesting review (mandatory per repo rules — overrides minimize-verification-loops). +- If `xcodebuild test` can't resolve the Keychain entitlement in the test host (Task 2), flag it at the checkpoint rather than weakening the test — the production app entitlement supports generic-password items. +- Respect the typing-perf hot path: the selection-change call to `refreshAISparkle()` must go through the existing debounce, never add synchronous parsing per caret move. From be5de0d139de1872c51eff5da0520851cd94285f Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:45:20 -0400 Subject: [PATCH 03/24] docs: fix xcodebuild scheme name (Synapse, not Synapse Notes) in plan Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-06-09-inline-ai-editing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-06-09-inline-ai-editing.md b/docs/superpowers/plans/2026-06-09-inline-ai-editing.md index b912772..8e2ddde 100644 --- a/docs/superpowers/plans/2026-06-09-inline-ai-editing.md +++ b/docs/superpowers/plans/2026-06-09-inline-ai-editing.md @@ -22,7 +22,7 @@ cd /Users/dep/Sites/synapse-notes/macOS && xcodegen generate **Run a single test** (regenerate first if you added the test file this session): ```bash cd /Users/dep/Sites/synapse-notes/macOS && xcodegen generate && \ -xcodebuild test -project "Synapse Notes.xcodeproj" -scheme "Synapse Notes" \ +xcodebuild test -project "Synapse Notes.xcodeproj" -scheme "Synapse" \ -destination "platform=macOS" \ -only-testing:"SynapseTests//" 2>&1 | tail -30 ``` @@ -33,7 +33,7 @@ xcodebuild test -project "Synapse Notes.xcodeproj" -scheme "Synapse Notes" \ ```bash pkill -9 "Synapse Notes" || true && sleep 1 && cd /Users/dep/Sites/synapse-notes/macOS && \ xcodegen generate && \ -xcodebuild -project "Synapse Notes.xcodeproj" -scheme "Synapse Notes" -destination "platform=macOS" build && \ +xcodebuild -project "Synapse Notes.xcodeproj" -scheme "Synapse" -destination "platform=macOS" build && \ for app in ~/Library/Developer/Xcode/DerivedData/Synapse*-*/Build/Products/Debug/"Synapse Notes.app"; do [ -e "$app" ] && open "$app" && break; done ``` @@ -1355,7 +1355,7 @@ struct InlineAIBarView: View { Run: ```bash cd /Users/dep/Sites/synapse-notes/macOS && xcodegen generate && \ -xcodebuild -project "Synapse Notes.xcodeproj" -scheme "Synapse Notes" -destination "platform=macOS" build 2>&1 | tail -20 +xcodebuild -project "Synapse Notes.xcodeproj" -scheme "Synapse" -destination "platform=macOS" build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED. (If `commandPaletteScoreByFilename` has a different parameter label, fix the call to match `CommandPaletteView.swift:15`.) From db9a54df9e3be4bd2de075dc875ef8375a94425c Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:47:33 -0400 Subject: [PATCH 04/24] feat(ai): add AIModel enum with exact Anthropic model IDs Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AIModel.swift | 35 ++++++++++++++++++++++ macOS/SynapseNotesTests/AIModelTests.swift | 25 ++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 macOS/SynapseNotes/AIModel.swift create mode 100644 macOS/SynapseNotesTests/AIModelTests.swift diff --git a/macOS/SynapseNotes/AIModel.swift b/macOS/SynapseNotes/AIModel.swift new file mode 100644 index 0000000..6c6ca57 --- /dev/null +++ b/macOS/SynapseNotes/AIModel.swift @@ -0,0 +1,35 @@ +import Foundation + +/// The three Anthropic models the inline AI editor can use. +/// API IDs are the exact Anthropic model strings — no date suffixes. +enum AIModel: String, CaseIterable, Identifiable { + case haiku + case sonnet + case opus + + var id: String { rawValue } + + var apiID: String { + switch self { + case .haiku: return "claude-haiku-4-5" + case .sonnet: return "claude-sonnet-4-6" + case .opus: return "claude-opus-4-8" + } + } + + var displayName: String { + switch self { + case .haiku: return "Haiku 4.5" + case .sonnet: return "Sonnet 4.6" + case .opus: return "Opus 4.8" + } + } + + /// The default model — a balance of speed and quality. + static let `default`: AIModel = .sonnet + + /// Resolve from a stored API ID string, falling back to the default. + init(apiID: String) { + self = AIModel.allCases.first { $0.apiID == apiID } ?? .default + } +} diff --git a/macOS/SynapseNotesTests/AIModelTests.swift b/macOS/SynapseNotesTests/AIModelTests.swift new file mode 100644 index 0000000..5fd8d76 --- /dev/null +++ b/macOS/SynapseNotesTests/AIModelTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import Synapse + +final class AIModelTests: XCTestCase { + func test_apiIDs_areExactAnthropicModelStrings() { + XCTAssertEqual(AIModel.haiku.apiID, "claude-haiku-4-5") + XCTAssertEqual(AIModel.sonnet.apiID, "claude-sonnet-4-6") + XCTAssertEqual(AIModel.opus.apiID, "claude-opus-4-8") + } + + func test_displayNames_areHumanReadable() { + XCTAssertEqual(AIModel.haiku.displayName, "Haiku 4.5") + XCTAssertEqual(AIModel.sonnet.displayName, "Sonnet 4.6") + XCTAssertEqual(AIModel.opus.displayName, "Opus 4.8") + } + + func test_initFromAPIID_roundTrips_andDefaultsToSonnetOnUnknown() { + XCTAssertEqual(AIModel(apiID: "claude-opus-4-8"), .opus) + XCTAssertEqual(AIModel(apiID: "garbage"), .sonnet) + } + + func test_defaultModel_isSonnet() { + XCTAssertEqual(AIModel.default, .sonnet) + } +} From 5e2fcacb27136230aa390dc13849ccba0dfff96c Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:49:35 -0400 Subject: [PATCH 05/24] feat(ai): add KeychainStore for the Anthropic API key Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/KeychainStore.swift | 65 +++++++++++++++++++ .../KeychainStoreTests.swift | 44 +++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 macOS/SynapseNotes/KeychainStore.swift create mode 100644 macOS/SynapseNotesTests/KeychainStoreTests.swift diff --git a/macOS/SynapseNotes/KeychainStore.swift b/macOS/SynapseNotes/KeychainStore.swift new file mode 100644 index 0000000..457c210 --- /dev/null +++ b/macOS/SynapseNotes/KeychainStore.swift @@ -0,0 +1,65 @@ +import Foundation +import Security + +/// Securely stores a single secret (the Anthropic API key) in the macOS Keychain. +/// One instance == one (service, account) slot. +struct KeychainStore { + let service: String + let account: String + + init(service: String = "com.SynapseNotes.anthropic", account: String = "apiKey") { + self.service = service + self.account = account + } + + /// Returns the stored secret, or nil if none is set. + func get() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let string = String(data: data, encoding: .utf8), + !string.isEmpty else { + return nil + } + return string + } + + /// Stores the secret, overwriting any existing value. An empty string deletes the item. + func set(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { delete(); return } + + let data = Data(trimmed.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attributes: [String: Any] = [kSecValueData as String: data] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var add = query + add[kSecValueData as String] = data + SecItemAdd(add as CFDictionary, nil) + } + } + + /// Removes the stored secret if present. + func delete() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/macOS/SynapseNotesTests/KeychainStoreTests.swift b/macOS/SynapseNotesTests/KeychainStoreTests.swift new file mode 100644 index 0000000..4e02aee --- /dev/null +++ b/macOS/SynapseNotesTests/KeychainStoreTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Synapse + +final class KeychainStoreTests: XCTestCase { + // Use a dedicated test service so we never touch the real key. + let store = KeychainStore(service: "com.SynapseNotes.tests.anthropic") + + override func setUp() { + super.setUp() + store.delete() + } + + override func tearDown() { + store.delete() + super.tearDown() + } + + func test_getBeforeSet_returnsNil() { + XCTAssertNil(store.get()) + } + + func test_setThenGet_roundTrips() { + store.set("sk-ant-secret") + XCTAssertEqual(store.get(), "sk-ant-secret") + } + + func test_setOverwrites_existingValue() { + store.set("first") + store.set("second") + XCTAssertEqual(store.get(), "second") + } + + func test_setEmptyString_deletesTheItem() { + store.set("value") + store.set("") + XCTAssertNil(store.get()) + } + + func test_delete_removesValue() { + store.set("value") + store.delete() + XCTAssertNil(store.get()) + } +} From f0ab46f6d51203a0689a270cf58ba6d70d7e1659 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:51:52 -0400 Subject: [PATCH 06/24] feat(ai): add AIContextResolver for @note context Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AIContextResolver.swift | 68 +++++++++++++++++++ .../AIContextResolverTests.swift | 53 +++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 macOS/SynapseNotes/AIContextResolver.swift create mode 100644 macOS/SynapseNotesTests/AIContextResolverTests.swift diff --git a/macOS/SynapseNotes/AIContextResolver.swift b/macOS/SynapseNotes/AIContextResolver.swift new file mode 100644 index 0000000..ceccbb6 --- /dev/null +++ b/macOS/SynapseNotes/AIContextResolver.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Resolves `@name` tokens in a prompt into vault-note context blocks. +/// Pure: file contents are read through an injected closure. +struct AIContextResolver { + struct Block: Equatable { + let name: String // the file stem actually matched + let body: String + } + struct Result: Equatable { + var blocks: [Block] + var missing: [String] // @tokens with no matching note + var truncated: Bool + } + + let allFiles: [URL] + let charCap: Int + let readContents: (URL) -> String? + + init(allFiles: [URL], charCap: Int = 100_000, readContents: @escaping (URL) -> String?) { + self.allFiles = allFiles + self.charCap = charCap + self.readContents = readContents + } + + /// Matches `@token` where token is letters/digits/_/-/space-free path-ish chars. + private static let tokenRegex = try! NSRegularExpression(pattern: "@([\\w./-]+)") + + func resolve(prompt: String) -> Result { + let ns = prompt as NSString + let matches = Self.tokenRegex.matches(in: prompt, range: NSRange(location: 0, length: ns.length)) + + var seen = Set() + var blocks: [Block] = [] + var missing: [String] = [] + var truncated = false + var used = 0 + + for match in matches { + let token = ns.substring(with: match.range(at: 1)) + let key = token.lowercased() + guard !seen.contains(key) else { continue } + seen.insert(key) + + guard let url = allFiles.first(where: { + $0.deletingPathExtension().lastPathComponent.lowercased() == key + }), let body = readContents(url) else { + missing.append(token) + continue + } + + let name = url.deletingPathExtension().lastPathComponent + let remaining = charCap - used + if body.count > remaining { + truncated = true + if remaining > 0 { + blocks.append(Block(name: name, body: String(body.prefix(remaining)))) + used = charCap + } + break + } + blocks.append(Block(name: name, body: body)) + used += body.count + } + + return Result(blocks: blocks, missing: missing, truncated: truncated) + } +} diff --git a/macOS/SynapseNotesTests/AIContextResolverTests.swift b/macOS/SynapseNotesTests/AIContextResolverTests.swift new file mode 100644 index 0000000..5dc4430 --- /dev/null +++ b/macOS/SynapseNotesTests/AIContextResolverTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import Synapse + +final class AIContextResolverTests: XCTestCase { + // Build a resolver whose "files" are in-memory: name -> body. + private func makeResolver(_ files: [String: String], cap: Int = 100_000) -> AIContextResolver { + let urls = files.keys.map { URL(fileURLWithPath: "/vault/\($0).md") } + return AIContextResolver( + allFiles: urls, + charCap: cap, + readContents: { url in files[url.deletingPathExtension().lastPathComponent] } + ) + } + + func test_noAtTokens_returnsEmptyContextNoMissing() { + let r = makeResolver(["Foo": "body"]) + let result = r.resolve(prompt: "summarize the note") + XCTAssertTrue(result.blocks.isEmpty) + XCTAssertTrue(result.missing.isEmpty) + XCTAssertFalse(result.truncated) + } + + func test_resolvesSingleAtToken_caseInsensitive() { + let r = makeResolver(["Daily": "daily body"]) + let result = r.resolve(prompt: "use @daily please") + XCTAssertEqual(result.blocks.count, 1) + XCTAssertEqual(result.blocks[0].name, "Daily") + XCTAssertEqual(result.blocks[0].body, "daily body") + XCTAssertTrue(result.missing.isEmpty) + } + + func test_missingRef_isReportedAndSkipped() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "@nope and @Foo") + XCTAssertEqual(result.blocks.map(\.name), ["Foo"]) + XCTAssertEqual(result.missing, ["nope"]) + } + + func test_overCap_truncatesAndFlags() { + let big = String(repeating: "a", count: 60_000) + let r = makeResolver(["One": big, "Two": big], cap: 100_000) + let result = r.resolve(prompt: "@One @Two") + XCTAssertTrue(result.truncated) + let total = result.blocks.reduce(0) { $0 + $1.body.count } + XCTAssertLessThanOrEqual(total, 100_000) + } + + func test_duplicateRefs_resolvedOnce() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "@Foo and again @foo") + XCTAssertEqual(result.blocks.count, 1) + } +} From 7844aea393aa90267519dc9d3f94817e4802423b Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:56:10 -0400 Subject: [PATCH 07/24] fix(ai): harden @token regex (emails, trailing dots) + truncation doc Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AIContextResolver.swift | 11 ++++++- .../AIContextResolverTests.swift | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/macOS/SynapseNotes/AIContextResolver.swift b/macOS/SynapseNotes/AIContextResolver.swift index ceccbb6..fa1a336 100644 --- a/macOS/SynapseNotes/AIContextResolver.swift +++ b/macOS/SynapseNotes/AIContextResolver.swift @@ -24,8 +24,17 @@ struct AIContextResolver { } /// Matches `@token` where token is letters/digits/_/-/space-free path-ish chars. - private static let tokenRegex = try! NSRegularExpression(pattern: "@([\\w./-]+)") + /// Negative lookbehind prevents matching emails (e.g. `foo@bar.com`). + /// Trailing dots are excluded from the capture (e.g. `@budget.` captures `budget`). + private static let tokenRegex = try! NSRegularExpression(pattern: "(? Result { let ns = prompt as NSString let matches = Self.tokenRegex.matches(in: prompt, range: NSRange(location: 0, length: ns.length)) diff --git a/macOS/SynapseNotesTests/AIContextResolverTests.swift b/macOS/SynapseNotesTests/AIContextResolverTests.swift index 5dc4430..af51087 100644 --- a/macOS/SynapseNotesTests/AIContextResolverTests.swift +++ b/macOS/SynapseNotesTests/AIContextResolverTests.swift @@ -50,4 +50,37 @@ final class AIContextResolverTests: XCTestCase { let result = r.resolve(prompt: "@Foo and again @foo") XCTAssertEqual(result.blocks.count, 1) } + + func test_emptyPrompt_returnsEmptyResult() { + let r = makeResolver(["Foo": "x"]) + let result = r.resolve(prompt: "") + XCTAssertTrue(result.blocks.isEmpty) + XCTAssertTrue(result.missing.isEmpty) + XCTAssertFalse(result.truncated) + } + + func test_trailingPunctuation_doesNotBreakMatch() { + let r = makeResolver(["Budget": "budget body"]) + // "@Budget." at a sentence end must still resolve to "Budget". + let result = r.resolve(prompt: "see @Budget.") + XCTAssertEqual(result.blocks.map(\.name), ["Budget"]) + XCTAssertTrue(result.missing.isEmpty) + } + + func test_emailAddress_isNotTreatedAsAtToken() { + let r = makeResolver(["Bar": "bar body"]) + // "foo@bar.com" is an email — the @ is preceded by a word char, so no token. + let result = r.resolve(prompt: "reply to foo@bar.com please") + XCTAssertTrue(result.blocks.isEmpty) + XCTAssertTrue(result.missing.isEmpty) + } + + func test_exactCapBoundary_keepsFirstBlockOnly() { + let exact = String(repeating: "a", count: 100_000) + let r = makeResolver(["One": exact, "Two": "more"], cap: 100_000) + let result = r.resolve(prompt: "@One @Two") + XCTAssertEqual(result.blocks.map(\.name), ["One"]) + // The whole first block fits exactly; the second is dropped by the cap. + XCTAssertEqual(result.blocks[0].body.count, 100_000) + } } From a1ec3b3a935862ffa2b77659357631da7edc34fa Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 06:58:17 -0400 Subject: [PATCH 08/24] feat(ai): add AIRequestBuilder for generate/rewrite payloads Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AIRequestBuilder.swift | 59 ++++++++++++++++ .../AIRequestBuilderTests.swift | 68 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 macOS/SynapseNotes/AIRequestBuilder.swift create mode 100644 macOS/SynapseNotesTests/AIRequestBuilderTests.swift diff --git a/macOS/SynapseNotes/AIRequestBuilder.swift b/macOS/SynapseNotes/AIRequestBuilder.swift new file mode 100644 index 0000000..e04f7a0 --- /dev/null +++ b/macOS/SynapseNotes/AIRequestBuilder.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Builds the Anthropic /v1/messages request body for the inline editor. +/// Pure — returns a JSON-serializable dictionary. +enum AIRequestBuilder { + enum Mode { + case generate // insert new text at the cursor + case rewrite // transform the selected text + } + + static let maxTokens = 4096 + + static func build( + mode: Mode, + prompt: String, + noteText: String, + selection: String?, + context: [AIContextResolver.Block], + model: AIModel + ) -> [String: Any] { + var user = "" + + if !context.isEmpty { + user += "Reference notes:\n" + for block in context { + user += "--- @\(block.name) ---\n\(block.body)\n\n" + } + } + + user += "Current note:\n\"\"\"\n\(noteText)\n\"\"\"\n\n" + + switch mode { + case .generate: + user += "Task: \(prompt)\n\n" + user += "Write the text to insert at the cursor. Output only the new text, no preamble, no markdown fences." + case .rewrite: + user += "Selected text:\n\"\"\"\n\(selection ?? "")\n\"\"\"\n\n" + user += "Task: \(prompt)\n\n" + user += "Rewrite the selected text accordingly. Output only the replacement text, no preamble, no markdown fences." + } + + let system = """ + You are a writing assistant embedded in a Markdown note editor. \ + You produce text that drops directly into the user's note. \ + Match the surrounding tone and Markdown style. \ + Never wrap your answer in code fences or add commentary — output only the text the user asked for. + """ + + return [ + "model": model.apiID, + "max_tokens": maxTokens, + "stream": true, + "system": system, + "messages": [ + ["role": "user", "content": user] + ] + ] + } +} diff --git a/macOS/SynapseNotesTests/AIRequestBuilderTests.swift b/macOS/SynapseNotesTests/AIRequestBuilderTests.swift new file mode 100644 index 0000000..8578745 --- /dev/null +++ b/macOS/SynapseNotesTests/AIRequestBuilderTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import Synapse + +final class AIRequestBuilderTests: XCTestCase { + private func userText(_ body: [String: Any]) -> String { + let messages = body["messages"] as! [[String: Any]] + return messages.first(where: { $0["role"] as? String == "user" })!["content"] as! String + } + + func test_generate_includesModelStreamMaxTokensAndNote() { + let body = AIRequestBuilder.build( + mode: .generate, + prompt: "write a haiku", + noteText: "# My Note\nSome text", + selection: nil, + context: [], + model: .opus + ) + XCTAssertEqual(body["model"] as? String, "claude-opus-4-8") + XCTAssertEqual(body["stream"] as? Bool, true) + XCTAssertNotNil(body["max_tokens"]) + XCTAssertTrue(userText(body).contains("write a haiku")) + XCTAssertTrue(userText(body).contains("# My Note")) + } + + func test_rewrite_includesSelectedText() { + let body = AIRequestBuilder.build( + mode: .rewrite, + prompt: "make it concise", + noteText: "Full note body", + selection: "The quick brown fox jumped.", + context: [], + model: .sonnet + ) + XCTAssertTrue(userText(body).contains("make it concise")) + XCTAssertTrue(userText(body).contains("The quick brown fox jumped.")) + } + + func test_contextBlocks_areLabeledByName() { + let blocks = [AIContextResolver.Block(name: "Spec", body: "spec contents")] + let body = AIRequestBuilder.build( + mode: .generate, prompt: "p", noteText: "n", + selection: nil, context: blocks, model: .haiku + ) + let text = userText(body) + XCTAssertTrue(text.contains("Spec")) + XCTAssertTrue(text.contains("spec contents")) + } + + func test_hasSystemPrompt() { + let body = AIRequestBuilder.build( + mode: .generate, prompt: "p", noteText: "n", + selection: nil, context: [], model: .sonnet + ) + let system = body["system"] as? String + XCTAssertNotNil(system) + XCTAssertFalse(system!.isEmpty) + } + + func test_resultIsJSONSerializable() { + let body = AIRequestBuilder.build( + mode: .rewrite, prompt: "p", noteText: "n", + selection: "s", context: [AIContextResolver.Block(name: "A", body: "b")], + model: .opus + ) + XCTAssertNoThrow(try JSONSerialization.data(withJSONObject: body)) + } +} From 60ab7f170dd4b40e4799af8abb2be777b29d6aa1 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:00:32 -0400 Subject: [PATCH 09/24] feat(ai): add AnthropicClient SSE streaming transport Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AnthropicClient.swift | 67 +++++++++++++ .../AnthropicClientTests.swift | 93 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 macOS/SynapseNotes/AnthropicClient.swift create mode 100644 macOS/SynapseNotesTests/AnthropicClientTests.swift diff --git a/macOS/SynapseNotes/AnthropicClient.swift b/macOS/SynapseNotes/AnthropicClient.swift new file mode 100644 index 0000000..e861d39 --- /dev/null +++ b/macOS/SynapseNotes/AnthropicClient.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Streams text deltas from the Anthropic /v1/messages SSE endpoint. +/// The URLSession is injectable for testing; defaults to .shared. +struct AnthropicClient { + enum ClientError: Error, Equatable { + case invalidKey + case server(status: Int) + case badResponse + } + + let apiKey: String + var urlSession: URLSession = .shared + + private static let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + + /// Streams `text_delta` strings. The async sequence finishes on `message_stop` + /// or end of stream, and throws `ClientError` on a non-2xx status. + func stream(body: [String: Any]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + var request = URLRequest(url: Self.endpoint) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (bytes, response) = try await urlSession.bytes(for: request) + + guard let http = response as? HTTPURLResponse else { + throw ClientError.badResponse + } + guard (200...299).contains(http.statusCode) else { + // Drain so the connection closes cleanly. + for try await _ in bytes.lines {} + if http.statusCode == 401 { throw ClientError.invalidKey } + throw ClientError.server(status: http.statusCode) + } + + for try await line in bytes.lines { + try Task.checkCancellation() + guard line.hasPrefix("data:") else { continue } + let payload = line.dropFirst("data:".count).trimmingCharacters(in: .whitespaces) + guard !payload.isEmpty, + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + if json["type"] as? String == "message_stop" { break } + if json["type"] as? String == "content_block_delta", + let delta = json["delta"] as? [String: Any], + delta["type"] as? String == "text_delta", + let text = delta["text"] as? String { + continuation.yield(text) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} diff --git a/macOS/SynapseNotesTests/AnthropicClientTests.swift b/macOS/SynapseNotesTests/AnthropicClientTests.swift new file mode 100644 index 0000000..1b8b750 --- /dev/null +++ b/macOS/SynapseNotesTests/AnthropicClientTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import Synapse + +private final class MockSSEURLProtocol: URLProtocol { + static var responseStatus: Int = 200 + static var bodyData: Data = Data() + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, statusCode: MockSSEURLProtocol.responseStatus, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "text/event-stream"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: MockSSEURLProtocol.bodyData) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +final class AnthropicClientTests: XCTestCase { + private func makeClient() -> AnthropicClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockSSEURLProtocol.self] + return AnthropicClient(apiKey: "sk-test", urlSession: URLSession(configuration: config)) + } + + private func sse(_ lines: [String]) -> Data { + Data(lines.joined(separator: "\n").appending("\n").utf8) + } + + override func tearDown() { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = Data() + super.tearDown() + } + + func test_streamsTextDeltasInOrder() async throws { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = sse([ + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}"#, + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":", world"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: ["model": "claude-sonnet-4-6"]) { + collected += delta + } + XCTAssertEqual(collected, "Hello, world") + } + + func test_ignoresNonDeltaEvents() async throws { + MockSSEURLProtocol.bodyData = sse([ + #"data: {"type":"message_start","message":{}}"#, + #"data: {"type":"content_block_start","index":0}"#, + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"X"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: [:]) { collected += delta } + XCTAssertEqual(collected, "X") + } + + func test_401_throwsInvalidKey() async { + MockSSEURLProtocol.responseStatus = 401 + MockSSEURLProtocol.bodyData = Data() + let client = makeClient() + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .invalidKey) + } catch { + XCTFail("wrong error type: \(error)") + } + } + + func test_500_throwsServerError() async { + MockSSEURLProtocol.responseStatus = 500 + let client = makeClient() + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .server(status: 500)) + } catch { + XCTFail("wrong error type: \(error)") + } + } +} From 4b24416472f5c9d2543e3da54cffec273081d6e0 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:04:39 -0400 Subject: [PATCH 10/24] fix(ai): drop error-masking SSE drain + hoist body serialization Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AnthropicClient.swift | 11 +++-- .../AnthropicClientTests.swift | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/macOS/SynapseNotes/AnthropicClient.swift b/macOS/SynapseNotes/AnthropicClient.swift index e861d39..d143fbf 100644 --- a/macOS/SynapseNotes/AnthropicClient.swift +++ b/macOS/SynapseNotes/AnthropicClient.swift @@ -18,6 +18,13 @@ struct AnthropicClient { /// or end of stream, and throws `ClientError` on a non-2xx status. func stream(body: [String: Any]) -> AsyncThrowingStream { AsyncThrowingStream { continuation in + let httpBody: Data + do { + httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + continuation.finish(throwing: error) + return + } let task = Task { do { var request = URLRequest(url: Self.endpoint) @@ -25,7 +32,7 @@ struct AnthropicClient { request.setValue(apiKey, forHTTPHeaderField: "x-api-key") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.setValue("application/json", forHTTPHeaderField: "content-type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.httpBody = httpBody let (bytes, response) = try await urlSession.bytes(for: request) @@ -33,8 +40,6 @@ struct AnthropicClient { throw ClientError.badResponse } guard (200...299).contains(http.statusCode) else { - // Drain so the connection closes cleanly. - for try await _ in bytes.lines {} if http.statusCode == 401 { throw ClientError.invalidKey } throw ClientError.server(status: http.statusCode) } diff --git a/macOS/SynapseNotesTests/AnthropicClientTests.swift b/macOS/SynapseNotesTests/AnthropicClientTests.swift index 1b8b750..e92e7fc 100644 --- a/macOS/SynapseNotesTests/AnthropicClientTests.swift +++ b/macOS/SynapseNotesTests/AnthropicClientTests.swift @@ -19,6 +19,20 @@ private final class MockSSEURLProtocol: URLProtocol { override func stopLoading() {} } +private final class MockNonHTTPURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + // A plain URLResponse (not HTTPURLResponse) triggers .badResponse. + let response = URLResponse(url: request.url!, mimeType: "text/plain", + expectedContentLength: 0, textEncodingName: nil) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + final class AnthropicClientTests: XCTestCase { private func makeClient() -> AnthropicClient { let config = URLSessionConfiguration.ephemeral @@ -90,4 +104,31 @@ final class AnthropicClientTests: XCTestCase { XCTFail("wrong error type: \(error)") } } + + func test_nonHTTPResponse_throwsBadResponse() async { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockNonHTTPURLProtocol.self] + let client = AnthropicClient(apiKey: "sk-test", urlSession: URLSession(configuration: config)) + do { + for try await _ in client.stream(body: [:]) {} + XCTFail("expected throw") + } catch let error as AnthropicClient.ClientError { + XCTAssertEqual(error, .badResponse) + } catch { + XCTFail("wrong error type: \(error)") + } + } + + func test_nonJSONDataLine_isIgnored() async throws { + MockSSEURLProtocol.responseStatus = 200 + MockSSEURLProtocol.bodyData = sse([ + "data: [DONE]", + #"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Y"}}"#, + #"data: {"type":"message_stop"}"# + ]) + let client = makeClient() + var collected = "" + for try await delta in client.stream(body: [:]) { collected += delta } + XCTAssertEqual(collected, "Y") + } } From fdbc36ba2862ac174d708b2cdd47ec12e6758aa2 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:07:12 -0400 Subject: [PATCH 11/24] feat(ai): add InlineAIController streaming + accept/reject logic Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/InlineAIController.swift | 91 +++++++++++++++++++ .../InlineAIControllerTests.swift | 89 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 macOS/SynapseNotes/InlineAIController.swift create mode 100644 macOS/SynapseNotesTests/InlineAIControllerTests.swift diff --git a/macOS/SynapseNotes/InlineAIController.swift b/macOS/SynapseNotes/InlineAIController.swift new file mode 100644 index 0000000..9a62bc3 --- /dev/null +++ b/macOS/SynapseNotes/InlineAIController.swift @@ -0,0 +1,91 @@ +import AppKit +import Combine + +/// Orchestrates an inline AI editing session against an NSTextStorage. +/// +/// Generate mode: streamed deltas are inserted at the cursor (plain text). +/// Rewrite mode: the original selection is kept; new text streams in after it. +/// On `accept`, the original is deleted and the new text remains. On `reject`, +/// the new text is removed and the original stays. Diff *coloring* is applied +/// by the view layer via the published `originalRange`/`newRange`; this controller +/// owns only the text mutations so the logic stays unit-testable. +final class InlineAIController: ObservableObject { + enum Mode: Equatable { case idle, generate, rewrite } + + @Published private(set) var mode: Mode = .idle + /// Range of the original (struck-through) text during a rewrite; nil otherwise. + @Published private(set) var originalRange: NSRange? + /// Range of the streamed new text (generate: the inserted text; rewrite: the green text). + @Published private(set) var newRange: NSRange? + + private weak var storage: NSTextStorage? + + // MARK: Generate + + func beginGenerate(in storage: NSTextStorage, at location: Int) { + self.storage = storage + mode = .generate + originalRange = nil + newRange = NSRange(location: location, length: 0) + } + + // MARK: Rewrite + + func beginRewrite(in storage: NSTextStorage, selection: NSRange) { + self.storage = storage + mode = .rewrite + originalRange = selection + // New text starts immediately after the original selection. + newRange = NSRange(location: selection.location + selection.length, length: 0) + } + + // MARK: Streaming + + /// Appends a streamed text delta at the end of the current `newRange`. + func appendDelta(_ text: String) { + guard let storage, var nr = newRange, mode != .idle else { return } + let insertAt = nr.location + nr.length + storage.replaceCharacters(in: NSRange(location: insertAt, length: 0), with: text) + nr.length += (text as NSString).length + newRange = nr + } + + /// Stops streaming. Generate finishes immediately (nothing to accept); + /// rewrite remains pending so the user can accept/reject the partial. + func cancel() { + if mode == .generate { finishGenerate() } + } + + // MARK: Resolution + + /// Generate has no diff — once done, there's nothing to accept; just clear state. + private func finishGenerate() { + mode = .idle + originalRange = nil + newRange = nil + } + + /// Rewrite accept: delete the original, keep the new text. + func accept() { + guard mode == .rewrite, let storage, let orig = originalRange else { + finishGenerate(); return + } + // The new text sits immediately after the original. Deleting the original + // shifts the new text left into the original's place. + storage.replaceCharacters(in: orig, with: "") + mode = .idle + originalRange = nil + newRange = nil + } + + /// Rewrite reject: delete the streamed new text, restore the original. + func reject() { + guard mode == .rewrite, let storage, let nr = newRange else { + finishGenerate(); return + } + storage.replaceCharacters(in: nr, with: "") + mode = .idle + originalRange = nil + newRange = nil + } +} diff --git a/macOS/SynapseNotesTests/InlineAIControllerTests.swift b/macOS/SynapseNotesTests/InlineAIControllerTests.swift new file mode 100644 index 0000000..bfa997b --- /dev/null +++ b/macOS/SynapseNotesTests/InlineAIControllerTests.swift @@ -0,0 +1,89 @@ +import XCTest +import AppKit +@testable import Synapse + +final class InlineAIControllerTests: XCTestCase { + private func makeStorage(_ s: String) -> NSTextStorage { + NSTextStorage(string: s) + } + + // MARK: generate mode + + func test_generate_appendDeltas_insertsAtCursor() { + let storage = makeStorage("Hello world") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 6) // between the two spaces + c.appendDelta("brave new") + c.appendDelta(" ") + XCTAssertEqual(storage.string, "Hello brave new world") + } + + func test_generate_cancel_keepsPartialText() { + let storage = makeStorage("ab") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 2) + c.appendDelta("XY") + c.cancel() + XCTAssertEqual(storage.string, "abXY") + } + + // MARK: rewrite mode + + func test_rewrite_appendDeltas_keepOriginalUntilAccept() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + // select "The fox." == range 0..<8 + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + // original still present; new text appended after it + XCTAssertTrue(storage.string.contains("The fox.")) + XCTAssertTrue(storage.string.contains("A fox.")) + } + + func test_rewrite_accept_replacesOriginalWithNew() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + c.accept() + XCTAssertEqual(storage.string, "A fox.") + } + + func test_rewrite_reject_restoresOriginalOnly() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") + c.reject() + XCTAssertEqual(storage.string, "The fox.") + } + + func test_rewrite_cancelMidStream_thenAccept_usesPartial() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A ") + c.cancel() // streaming stopped; still in pending-accept state + c.accept() + XCTAssertEqual(storage.string, "A ") + } + + func test_rewrite_inMiddle_accept_replacesCorrectSpan() { + // "Hello WORLD!" — select "WORLD" (location 6, length 5), rewrite to "earth" + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("earth") + c.accept() + XCTAssertEqual(storage.string, "Hello earth!") + } + + func test_rewrite_inMiddle_reject_leavesOriginal() { + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("earth") + c.reject() + XCTAssertEqual(storage.string, "Hello WORLD!") + } +} From 2aff464f80a4d2ff707763a9a41bda51aa2cc4c7 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:11:55 -0400 Subject: [PATCH 12/24] fix(ai): safe wrong-mode no-ops + re-entry guards in InlineAIController Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/InlineAIController.swift | 29 ++++++------ .../InlineAIControllerTests.swift | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/macOS/SynapseNotes/InlineAIController.swift b/macOS/SynapseNotes/InlineAIController.swift index 9a62bc3..437ac19 100644 --- a/macOS/SynapseNotes/InlineAIController.swift +++ b/macOS/SynapseNotes/InlineAIController.swift @@ -23,6 +23,7 @@ final class InlineAIController: ObservableObject { // MARK: Generate func beginGenerate(in storage: NSTextStorage, at location: Int) { + guard mode == .idle else { return } self.storage = storage mode = .generate originalRange = nil @@ -32,6 +33,7 @@ final class InlineAIController: ObservableObject { // MARK: Rewrite func beginRewrite(in storage: NSTextStorage, selection: NSRange) { + guard mode == .idle else { return } self.storage = storage mode = .rewrite originalRange = selection @@ -65,27 +67,28 @@ final class InlineAIController: ObservableObject { newRange = nil } - /// Rewrite accept: delete the original, keep the new text. + /// Rewrite accept: delete the original, keep the new text. No-op in any other mode. func accept() { - guard mode == .rewrite, let storage, let orig = originalRange else { - finishGenerate(); return + guard mode == .rewrite else { return } + guard let storage, let orig = originalRange else { + // Defensive: rewrite mode but no storage/range — clear and bail. + mode = .idle; originalRange = nil; newRange = nil + return } - // The new text sits immediately after the original. Deleting the original + // The new text sits immediately after the original; deleting the original // shifts the new text left into the original's place. storage.replaceCharacters(in: orig, with: "") - mode = .idle - originalRange = nil - newRange = nil + mode = .idle; originalRange = nil; newRange = nil } - /// Rewrite reject: delete the streamed new text, restore the original. + /// Rewrite reject: delete the streamed new text, restore the original. No-op in any other mode. func reject() { - guard mode == .rewrite, let storage, let nr = newRange else { - finishGenerate(); return + guard mode == .rewrite else { return } + guard let storage, let nr = newRange else { + mode = .idle; originalRange = nil; newRange = nil + return } storage.replaceCharacters(in: nr, with: "") - mode = .idle - originalRange = nil - newRange = nil + mode = .idle; originalRange = nil; newRange = nil } } diff --git a/macOS/SynapseNotesTests/InlineAIControllerTests.swift b/macOS/SynapseNotesTests/InlineAIControllerTests.swift index bfa997b..2a70e98 100644 --- a/macOS/SynapseNotesTests/InlineAIControllerTests.swift +++ b/macOS/SynapseNotesTests/InlineAIControllerTests.swift @@ -86,4 +86,50 @@ final class InlineAIControllerTests: XCTestCase { c.reject() XCTAssertEqual(storage.string, "Hello WORLD!") } + + // MARK: fixed behaviors + + func test_appendDelta_beforeBegin_isNoOp() { + let storage = makeStorage("abc") + let c = InlineAIController() + c.appendDelta("X") + XCTAssertEqual(storage.string, "abc") + } + + func test_accept_inGenerateMode_doesNotMutate() { + let storage = makeStorage("abc") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 3) + c.appendDelta("XY") // "abcXY" + c.accept() // wrong mode for accept → no-op + XCTAssertEqual(storage.string, "abcXY") + } + + func test_beginRewrite_whileActive_isIgnored() { + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("earth") // "Hello WORLDearth!", original (6,5) + // A second begin must be ignored so the first session's ranges stay intact. + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 5)) + c.accept() // resolves the FIRST session + XCTAssertEqual(storage.string, "Hello earth!") + } + + func test_appendDelta_multibyteEmoji_tracksUTF16Length() { + let storage = makeStorage("ab") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 2)) + c.appendDelta("👩‍🚀") // family/ZWJ emoji: NSString length 5 + c.accept() // deletes original "ab", leaves the emoji + XCTAssertEqual(storage.string, "👩‍🚀") + } + + func test_reject_withNoDeltas_isSafeNoOpOnNewText() { + let storage = makeStorage("keep me") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 4)) // "keep" + c.reject() // zero deltas appended; deleting empty newRange is safe + XCTAssertEqual(storage.string, "keep me") + } } From 66af6cec841c71735573e59b04f9a15864434cdf Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:22:34 -0400 Subject: [PATCH 13/24] feat(ai): add Anthropic key (Keychain) + default model to Settings Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/SettingsManager.swift | 21 ++++++++++ macOS/SynapseNotes/SettingsView.swift | 42 +++++++++++++++++++ .../SettingsManagerAIModelTests.swift | 32 ++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift diff --git a/macOS/SynapseNotes/SettingsManager.swift b/macOS/SynapseNotes/SettingsManager.swift index 9c9d5bf..6c78e44 100644 --- a/macOS/SynapseNotes/SettingsManager.swift +++ b/macOS/SynapseNotes/SettingsManager.swift @@ -274,6 +274,10 @@ class SettingsManager: ObservableObject { @Published var githubPAT: String { didSet { save() } } + /// Default Anthropic model (API ID) for inline AI editing (machine-local). + @Published var aiDefaultModel: String { + didSet { save() } + } @Published var fileTreeMode: FileTreeMode { didSet { save() } } @@ -514,6 +518,7 @@ class SettingsManager: ObservableObject { /// Pane assignments: maps sidebar UUID string -> [SidebarPane] var sidebarPaneAssignments: [String: [SidebarPaneItem]]? var githubPAT: String? + var aiDefaultModel: String? var fileTreeMode: String? var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? @@ -544,6 +549,7 @@ class SettingsManager: ObservableObject { collapsedSidebarIDs = try container.decodeIfPresent([String].self, forKey: .collapsedSidebarIDs) sidebarPaneAssignments = try container.decodeIfPresent([String: [SidebarPaneItem]].self, forKey: .sidebarPaneAssignments) githubPAT = try container.decodeIfPresent(String.self, forKey: .githubPAT) + aiDefaultModel = try container.decodeIfPresent(String.self, forKey: .aiDefaultModel) fileTreeMode = try container.decodeIfPresent(String.self, forKey: .fileTreeMode) pinnedItems = try container.decodeIfPresent([PinnedItem].self, forKey: .pinnedItems) defaultEditMode = try container.decodeIfPresent(Bool.self, forKey: .defaultEditMode) @@ -668,6 +674,7 @@ class SettingsManager: ObservableObject { /// Config for machine-local settings only private struct GlobalConfig: Codable { var githubPAT: String? + var aiDefaultModel: String? var sidebarPaneHeights: [String: CGFloat]? var collapsedPanes: [String]? var collapsedSidebarIDs: [String]? @@ -682,6 +689,7 @@ class SettingsManager: ObservableObject { init( githubPAT: String?, + aiDefaultModel: String? = nil, sidebarPaneHeights: [String: CGFloat]?, collapsedPanes: [String]?, collapsedSidebarIDs: [String]?, @@ -691,6 +699,7 @@ class SettingsManager: ObservableObject { lastNoteFolderPerVault: [String: String]? = nil ) { self.githubPAT = githubPAT + self.aiDefaultModel = aiDefaultModel self.sidebarPaneHeights = sidebarPaneHeights self.collapsedPanes = collapsedPanes self.collapsedSidebarIDs = collapsedSidebarIDs @@ -704,6 +713,7 @@ class SettingsManager: ObservableObject { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) githubPAT = try container.decodeIfPresent(String.self, forKey: .githubPAT) + aiDefaultModel = try container.decodeIfPresent(String.self, forKey: .aiDefaultModel) sidebarPaneHeights = try container.decodeIfPresent([String: CGFloat].self, forKey: .sidebarPaneHeights) collapsedPanes = try container.decodeIfPresent([String].self, forKey: .collapsedPanes) collapsedSidebarIDs = try container.decodeIfPresent([String].self, forKey: .collapsedSidebarIDs) @@ -746,6 +756,7 @@ class SettingsManager: ObservableObject { self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] self.githubPAT = "" + self.aiDefaultModel = "claude-sonnet-4-6" self.fileTreeMode = .folder self.pinnedItems = [] self.defaultEditMode = true @@ -804,6 +815,7 @@ class SettingsManager: ObservableObject { self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] self.githubPAT = "" + self.aiDefaultModel = "claude-sonnet-4-6" self.fileTreeMode = .folder self.pinnedItems = [] self.defaultEditMode = true @@ -874,6 +886,7 @@ class SettingsManager: ObservableObject { collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] } githubPAT = config.githubPAT ?? "" + aiDefaultModel = config.aiDefaultModel ?? "claude-sonnet-4-6" fileTreeMode = FileTreeMode(rawValue: config.fileTreeMode ?? "") ?? .folder pinnedItems = config.pinnedItems ?? [] defaultEditMode = config.defaultEditMode ?? true @@ -904,6 +917,7 @@ class SettingsManager: ObservableObject { collapsedPanes = [] collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] githubPAT = "" + aiDefaultModel = "claude-sonnet-4-6" fileTreeMode = .folder pinnedItems = [] defaultEditMode = true @@ -1005,6 +1019,7 @@ class SettingsManager: ObservableObject { private func applyGlobalConfig(_ globalConfig: GlobalConfig?) { githubPAT = globalConfig?.githubPAT ?? "" + aiDefaultModel = globalConfig?.aiDefaultModel ?? "claude-sonnet-4-6" sidebars = Self.applyPaneAssignments(globalConfig?.sidebarPaneAssignments) sidebarPaneHeights = globalConfig?.sidebarPaneHeights ?? Self.defaultPaneHeights collapsedPanes = Set(globalConfig?.collapsedPanes ?? []) @@ -1184,6 +1199,7 @@ class SettingsManager: ObservableObject { let collapsedPanes: [String] let collapsedSidebarIDs: [String] let githubPAT: String + let aiDefaultModel: String let fileTreeMode: FileTreeMode let pinnedItems: [PinnedItem] let defaultEditMode: Bool @@ -1222,6 +1238,7 @@ class SettingsManager: ObservableObject { collapsedPanes = Array(s.collapsedPanes) collapsedSidebarIDs = Array(s.collapsedSidebarIDs) githubPAT = s.githubPAT + aiDefaultModel = s.aiDefaultModel fileTreeMode = s.fileTreeMode pinnedItems = s.pinnedItems defaultEditMode = s.defaultEditMode @@ -1257,6 +1274,7 @@ class SettingsManager: ObservableObject { guard let globalConfigPath else { return } let globalConfig = GlobalConfig( githubPAT: githubPAT.isEmpty ? nil : githubPAT, + aiDefaultModel: aiDefaultModel, sidebarPaneHeights: sidebarPaneHeights.isEmpty ? nil : sidebarPaneHeights, collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, collapsedSidebarIDs: collapsedSidebarIDs.isEmpty ? nil : collapsedSidebarIDs, @@ -1291,6 +1309,7 @@ class SettingsManager: ObservableObject { var collapsedPanes: [String]? var collapsedSidebarIDs: [String]? var githubPAT: String? + var aiDefaultModel: String? var fileTreeMode: String? var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? @@ -1320,6 +1339,7 @@ class SettingsManager: ObservableObject { collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, collapsedSidebarIDs: collapsedSidebarIDs.isEmpty ? nil : collapsedSidebarIDs, githubPAT: githubPAT.isEmpty ? nil : githubPAT, + aiDefaultModel: aiDefaultModel.isEmpty ? nil : aiDefaultModel, fileTreeMode: fileTreeMode.rawValue, pinnedItems: pinnedItems.isEmpty ? nil : pinnedItems, defaultEditMode: defaultEditMode, @@ -1376,6 +1396,7 @@ class SettingsManager: ObservableObject { guard let globalConfigPath else { return } let globalConfig = GlobalConfig( githubPAT: githubPAT.isEmpty ? nil : githubPAT, + aiDefaultModel: aiDefaultModel, sidebarPaneHeights: sidebarPaneHeights.isEmpty ? nil : sidebarPaneHeights, collapsedPanes: collapsedPanes.isEmpty ? nil : collapsedPanes, collapsedSidebarIDs: collapsedSidebarIDs.isEmpty ? nil : collapsedSidebarIDs, diff --git a/macOS/SynapseNotes/SettingsView.swift b/macOS/SynapseNotes/SettingsView.swift index 7caf4d3..5014237 100644 --- a/macOS/SynapseNotes/SettingsView.swift +++ b/macOS/SynapseNotes/SettingsView.swift @@ -21,6 +21,7 @@ struct SettingsView: View { @State private var templateVarsExpanded = false @State private var themeImportError: String? @State private var showThemeImportError = false + @State private var anthropicKey: String = KeychainStore().get() ?? "" private let settingsFieldWidth: CGFloat = 440 @@ -671,6 +672,47 @@ struct SettingsView: View { Text("GitHub Gist") .font(.system(size: 13, weight: .semibold, design: .rounded)) } + + // MARK: - AI Section + Section { + VStack(alignment: .leading, spacing: 10) { + Text("Anthropic API Key") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + + SecureField("sk-ant-...", text: $anthropicKey) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(.roundedBorder) + .onChange(of: anthropicKey) { newValue in + KeychainStore().set(newValue) + } + + Text("Stored securely in your macOS Keychain. Used for inline AI editing (✨).") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text("Default Model") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .padding(.top, 4) + + Picker("Default Model", selection: Binding( + get: { AIModel(apiID: settings.aiDefaultModel) }, + set: { settings.aiDefaultModel = $0.apiID } + )) { + ForEach(AIModel.allCases) { model in + Text(model.displayName).tag(model) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } + .padding(.vertical, 4) + } header: { + Text("AI") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } } .onChange(of: settings.editorBodyFontFamily) { _ in refreshEditorsForFontChange() diff --git a/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift b/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift new file mode 100644 index 0000000..8efa258 --- /dev/null +++ b/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import Synapse + +final class SettingsManagerAIModelTests: XCTestCase { + private var tempDir: URL! + private var globalPath: String! + + override func setUp() { + super.setUp() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + globalPath = tempDir.appendingPathComponent("global.yml").path + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + super.tearDown() + } + + func test_default_isSonnetAPIID() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertEqual(mgr.aiDefaultModel, "claude-sonnet-4-6") + } + + func test_aiDefaultModel_persistsAcrossReload() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + mgr.aiDefaultModel = "claude-opus-4-8" + let reloaded = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertEqual(reloaded.aiDefaultModel, "claude-opus-4-8") + } +} From e6aa081cdb938a4075a3a7a73d1d3dd32da6903c Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:26:00 -0400 Subject: [PATCH 14/24] chore: regenerate Xcode project for inline AI editing source files XcodeGen adds PBXBuildFile/PBXFileReference entries for the 7 new AI-feature source and test files. Pure additions, no deletions. Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 2655f8e..99aae78 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 0E72FEA87E7B572ABD524BBA /* AppStateRelativePathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3AAAB162484316A09E4F98 /* AppStateRelativePathTests.swift */; }; 100B592B0F8E409C474A00C1 /* GistPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02B068438E71AE98EB1FE80 /* GistPublisher.swift */; }; 10B4BC87F21C770AFD3CA8BF /* AppStateSplitPaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9DA552722A4F7DEA5F6D66F /* AppStateSplitPaneTests.swift */; }; + 15234D80452AA2E7B9DFDCCA /* AIRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58DEDA3632AEAF7F81F9FD7F /* AIRequestBuilder.swift */; }; 1544C9F45B0FCC6B21E87F7D /* FileTreeDragDropTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8522CDE0F66E2CBBE14548 /* FileTreeDragDropTests.swift */; }; 172C4E1F1F3527C71D3DE159 /* InlineTagStylingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */; }; 17E6E2EC1F030DFAA7A1AA48 /* MarkdownPreviewBlockRevealTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4627D80D824902FA0D51573B /* MarkdownPreviewBlockRevealTests.swift */; }; @@ -32,11 +33,13 @@ 237E41D444BB8BFDEFF9BA9E /* MarkdownTaskCheckboxInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899E05E166DE4C34A16EF3A1 /* MarkdownTaskCheckboxInteraction.swift */; }; 23A9905EA035D7984B0D57EC /* AppStateExitVaultFullTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C17DE1D0FB3FA44308006D /* AppStateExitVaultFullTests.swift */; }; 2652C650A15CCC7F082BEFDB /* FontEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B497E7AE9FD12BE22ED2076E /* FontEnumeratorTests.swift */; }; + 28C8E0B75AF2DBB0A967C02E /* AIModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99235052B61D81D983DBA3CC /* AIModelTests.swift */; }; 2913A93CEC3CE4AAE7C0A377 /* EditorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F11197598BEFCA9CE509634 /* EditorState.swift */; }; 2C5DA5CE689E982A146800B0 /* VaultRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46245289C81F5BC71F3DAA8D /* VaultRootResolverTests.swift */; }; 2E123ADFE2CDB5975FB4DAD2 /* MarkdownTaskCheckboxMatchesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16925836C0D8191A7B15229 /* MarkdownTaskCheckboxMatchesTests.swift */; }; 2E4AF87A1309CA7518DDC336 /* VaultRootResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB8CA5F16479103D3A147F2 /* VaultRootResolver.swift */; }; 2E7CDA10ECE1F31071384F86 /* AppStateGitDateFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */; }; + 2F2E76C68073C0922244D1E7 /* InlineAIController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C6708A91911EC9011CB953 /* InlineAIController.swift */; }; 3003A94FEAA13E9EFD4FD07F /* TaskListCheckboxInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653C01C03E027EF0D3CBE1B /* TaskListCheckboxInteractionTests.swift */; }; 31E080048A2413F405EAECD4 /* FolderAppearancePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFBA8491471536C682215C /* FolderAppearancePicker.swift */; }; 326AC34FA9ED033D152E800D /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE935FC16158D93B5B6C05C /* TabBarView.swift */; }; @@ -52,6 +55,7 @@ 3B0C57EAD1CF3E3BF15B4B54 /* AppStateCloneRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A508264581710F478987F4CA /* AppStateCloneRepositoryTests.swift */; }; 3B3290973348664FF0C25D3B /* AppStateTemplatesDirNormalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45941B55E87CD77FB7F4318F /* AppStateTemplatesDirNormalizationTests.swift */; }; 3B9076F7F7CC149A4CF7EB5C /* AppStateSyncToRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B908F451DF8902DE532C1840 /* AppStateSyncToRemoteTests.swift */; }; + 3BAE5845C73805CD95CDD3C1 /* KeychainStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */; }; 3C12E6F17FBD8619EC3669BC /* EditorModeToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70A8A5CC2F996769E0CD694C /* EditorModeToggle.swift */; }; 3CD1E9CA022A6228FB48D629 /* MarkdownPreviewCursorReveal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822CD34D3501462C7E54EE9C /* MarkdownPreviewCursorReveal.swift */; }; 3D85E143721A2CA06CF0867E /* AppStateDateFilteringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41C69F6E45C0225C634AA462 /* AppStateDateFilteringTests.swift */; }; @@ -59,6 +63,7 @@ 3EEF1F26FE29D10CEB14D35D /* TagsPaneFiltering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81AA788741433ECCB117F555 /* TagsPaneFiltering.swift */; }; 3F374DE62ACC601002E9784F /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24393FE4F4F016F8BB91C453 /* SearchView.swift */; }; 3F40FA004E127110B79AEBE2 /* InlineTagClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECAC2D5AC63076FFAE6E1CF /* InlineTagClickTests.swift */; }; + 417BBD06E9C053F30E93C6FD /* AnthropicClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D12FB31736F21172C1C0F5B /* AnthropicClient.swift */; }; 41EE4BC043D5D570B9EDD698 /* CalendarDayActivityCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB8C6CC66C5265161E6B171 /* CalendarDayActivityCalculatorTests.swift */; }; 438688D812A3F388B1AB5B96 /* VaultIndexNotifyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537C8698E36941D81BFF3027 /* VaultIndexNotifyTests.swift */; }; 43BD4B04755BAD25794A6A5D /* TagsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9ED59C427BB7415B869FD8 /* TagsPaneView.swift */; }; @@ -85,6 +90,7 @@ 5889163AE1AB27AD826FB7A0 /* AppStateSettingsPropagationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAAF8FF8C6EFD3D4FCA4AB9 /* AppStateSettingsPropagationTests.swift */; }; 58E3A18584997F89E1C8B684 /* SettingsManagerGitHubPATTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71D759FE46432A31DC5812A /* SettingsManagerGitHubPATTests.swift */; }; 59F661C4DCF4A81AAEB36100 /* AppStateRefreshFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D4CC6A41F61F91FEC880B6F /* AppStateRefreshFilesTests.swift */; }; + 5B07DDBDCE0FAF1375C70C49 /* InlineAIControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E889CD671CA99F099DFF13C7 /* InlineAIControllerTests.swift */; }; 5BA2632CF700608CB9207052 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDB03B278DA79A2CEC2DDD3 /* SettingsView.swift */; }; 5BC8CEFC0D52E5C858D7152D /* SplitPaneKeyboardAndCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1AEFF34F972283D98C3D9C /* SplitPaneKeyboardAndCursorTests.swift */; }; 5CB28B4EDAA3392E8352C31B /* SlashCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE927C6293929DF30B1321D5 /* SlashCommands.swift */; }; @@ -106,6 +112,7 @@ 747B4579D40DC92E5BC52D15 /* AppStateCloseTabAutoSaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6D2B8287A4996E0B938ED4A /* AppStateCloseTabAutoSaveTests.swift */; }; 750735D3C43CA32CB7054567 /* MarkdownTaskCheckboxHitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61F2346098B568FB3D701E1 /* MarkdownTaskCheckboxHitTests.swift */; }; 75868B8981425F687CEDD240 /* VaultIndexRecencyMirrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F604CE4ECF1D7FF3CA1A636B /* VaultIndexRecencyMirrorTests.swift */; }; + 770EB38B41FEE91D7C6D792C /* AIContextResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06917C7AF084F2CD5ED26120 /* AIContextResolver.swift */; }; 779344E4C032232DA6038FB2 /* EditorViewPendingStateConsumptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED7FD40634D8A1D8C02441E /* EditorViewPendingStateConsumptionTests.swift */; }; 77AD06E319282914366927F3 /* EmojiFlickerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE37BFA970CBBD53D660C05B /* EmojiFlickerTests.swift */; }; 7904B5C075050635D544E7A3 /* CalendarDayActivityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91171FEE1C7C6A243104ABF5 /* CalendarDayActivityCalculator.swift */; }; @@ -133,6 +140,7 @@ 8FC107D698F02354E552B27C /* MarkdownEditorRefreshPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97F141AB461BDBCB144AA23 /* MarkdownEditorRefreshPlanTests.swift */; }; 90D9B07563AEB68B0AA49AD1 /* SynapseNotesThemeLayoutConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A7A1C35AB0F5E61361B8CC /* SynapseNotesThemeLayoutConstantsTests.swift */; }; 91BB84288C2CF108420F3D79 /* GitAutoSaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879F41928C2415608DAB7B50 /* GitAutoSaveTests.swift */; }; + 9244B5B92ED33AB0304A1E7F /* AIContextResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D84D65A169DE2039459228A /* AIContextResolverTests.swift */; }; 92486526574CEF72A2328B55 /* SettingsPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53C4F1F9D71478B0FB489B9 /* SettingsPersistenceTests.swift */; }; 9314440DA9709842E4DF1F05 /* NoteLinkRelationshipsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06317C44192BDBD60662AA11 /* NoteLinkRelationshipsTests.swift */; }; 936EBEACAF72C21722F4DE45 /* AppStateNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2788169302A62D1CE3240B96 /* AppStateNavigationTests.swift */; }; @@ -165,6 +173,7 @@ A80DD5DDE7E0B4CD8B0FDA23 /* ThemeEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD21DF8283BD834E74E66901 /* ThemeEnvironmentTests.swift */; }; A895D8B22923A434CB532968 /* SettingsManagerRemovePaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA44CD4FEBF5ACD032DB5A41 /* SettingsManagerRemovePaneTests.swift */; }; AAC297094394859DB2403374 /* MarkdownPreviewSemanticHiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BC44457745DCE1B0D81D45 /* MarkdownPreviewSemanticHiding.swift */; }; + AC269AD6EEEC8B445F9CC317 /* AIRequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E22BED5A6410F36911EDA6 /* AIRequestBuilderTests.swift */; }; AC5C25866472910EC05AC2A0 /* HTMLToMarkdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */; }; ADE218C972D4E4C39328E2C5 /* SortCriterionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D8A00BEAC46C6928C5AE28 /* SortCriterionTests.swift */; }; AEF05E2787758819B9941B3C /* SettingsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCACF2EB4506E8D0CECF05B /* SettingsManagerTests.swift */; }; @@ -224,18 +233,22 @@ EB3897F802C8F42954048C02 /* GitErrorHostnameExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB202F7E2555B3DFF17CF27 /* GitErrorHostnameExtractionTests.swift */; }; EDB0B183979559F12CCC41A9 /* GitServiceFileContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB75C4A51AE083BB4A55744 /* GitServiceFileContentTests.swift */; }; EDCCE56A291B55521F4250C3 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F38622C9378AF531F95E05B /* ContentView.swift */; }; + EFB8F0B58A97328C178B78D4 /* AnthropicClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736AED0F0C5E71DFB8EF74D3 /* AnthropicClientTests.swift */; }; F228B8C0174B187AF8E91BB3 /* MiniBrowserURLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CB0284C3C5D4EE5C720C7 /* MiniBrowserURLNormalizer.swift */; }; F2C9C4B772AEAA1E6CB07B04 /* FolderAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CA4844F0E36301A2C61A6 /* FolderAppearance.swift */; }; F3D098320828F15946A3146B /* SearchNotificationConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626454D7EB8684347CE78962 /* SearchNotificationConstantsTests.swift */; }; F4163BF689BE7BC5A40BCB43 /* AppStatePendingSearchQueryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F638E6858D9C03A15E702690 /* AppStatePendingSearchQueryTests.swift */; }; F422146CA313257EAB4ABEAF /* AppStateWikiLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6820DD3112C970C66C5BD3 /* AppStateWikiLinkTests.swift */; }; F50070470A90436FCBDC6B9B /* AppStateEditModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125FEE59A72FC5BB02643BBC /* AppStateEditModeTests.swift */; }; + F6170085EEDEC0FEFB29B4F8 /* AIModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */; }; F661197178F1AD653D16FEE7 /* GitStageChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9597F3D23EECF35FB13CAEED /* GitStageChangesTests.swift */; }; F6CEF5866E01779FB2F5DA02 /* AppStateUntitledNoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF72CADB7EA1F317CA73F7 /* AppStateUntitledNoteTests.swift */; }; F7DEAD2997B96D0BFE19C7F9 /* RelatedLinksTitleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110835FD9934C200B95DBEB3 /* RelatedLinksTitleText.swift */; }; F85C77A43A8701D0AD9143BE /* AppStateTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */; }; F9161E6876F8449ED7794D80 /* MarkdownCalloutDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */; }; F92DE068F0D6C76962CF0898 /* FileBrowserErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE44B10BDADC754E1B880CB /* FileBrowserErrorTests.swift */; }; + FB020284E27C7A52271D56B6 /* SettingsManagerAIModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9015970EA57DF288EB5311E1 /* SettingsManagerAIModelTests.swift */; }; + FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77494AFD9121ABE56BF45594 /* KeychainStore.swift */; }; FBDE6F50B234E8BEEC81D70B /* GitServiceConflictsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995602FCC2094AD94768D147 /* GitServiceConflictsTests.swift */; }; FD832B5AFBA2E311DEB0EF87 /* CommandPaletteScoringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CFDA7432187B97B46732652 /* CommandPaletteScoringTests.swift */; }; FEDF6C1421F4515A506A0F6B /* WikiLinkClickTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABD9B6E3A3ED294575CBF65 /* WikiLinkClickTests.swift */; }; @@ -257,6 +270,7 @@ 01F60B6E95850384FC16643E /* RespectGitignoreSettingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RespectGitignoreSettingTests.swift; sourceTree = ""; }; 0311B03851A1F52ACE8F394F /* CollapsibleSectionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSectionsTests.swift; sourceTree = ""; }; 06317C44192BDBD60662AA11 /* NoteLinkRelationshipsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteLinkRelationshipsTests.swift; sourceTree = ""; }; + 06917C7AF084F2CD5ED26120 /* AIContextResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIContextResolver.swift; sourceTree = ""; }; 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeTests.swift; sourceTree = ""; }; 0A0ACEC175A51BDC23B61E0A /* CommandPaletteWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteWikiLinkTests.swift; sourceTree = ""; }; 0A3AAAB162484316A09E4F98 /* AppStateRelativePathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateRelativePathTests.swift; sourceTree = ""; }; @@ -320,6 +334,7 @@ 5538485B416CB9D46FF04143 /* SettingsManagerThemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerThemeTests.swift; sourceTree = ""; }; 565D75A87A99E7FF0B231E8F /* SynapseNotesThemeConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynapseNotesThemeConstantsTests.swift; sourceTree = ""; }; 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCalloutStructTests.swift; sourceTree = ""; }; + 58DEDA3632AEAF7F81F9FD7F /* AIRequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequestBuilder.swift; sourceTree = ""; }; 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTagStylingTests.swift; sourceTree = ""; }; 5BB75C4A51AE083BB4A55744 /* GitServiceFileContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceFileContentTests.swift; sourceTree = ""; }; 5CCACF2EB4506E8D0CECF05B /* SettingsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerTests.swift; sourceTree = ""; }; @@ -328,6 +343,7 @@ 5EEAC9EFE313BC2825BCA71C /* AppStateFolderOperationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateFolderOperationsTests.swift; sourceTree = ""; }; 5F447EE62D897ADFD26A3A2D /* GlobalGraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalGraphView.swift; sourceTree = ""; }; 60493CC60AF2648AEE02AE42 /* CommandPaletteFolderScoringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteFolderScoringTests.swift; sourceTree = ""; }; + 60E22BED5A6410F36911EDA6 /* AIRequestBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIRequestBuilderTests.swift; sourceTree = ""; }; 616A3A93145BC8788AED90B3 /* FolderAppearanceModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearanceModelTests.swift; sourceTree = ""; }; 62195C34AC61C3601DA4E5F9 /* CriticalSidebarAndEditorRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalSidebarAndEditorRoutingTests.swift; sourceTree = ""; }; 621DD480ABAE023F532B8613 /* FolderPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderPickerView.swift; sourceTree = ""; }; @@ -339,6 +355,7 @@ 678B0A533C1C1E8C3A597FF5 /* GitServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceTests.swift; sourceTree = ""; }; 6923334E0E98F9AAC62B9FB5 /* GitServiceAskpassTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceAskpassTests.swift; sourceTree = ""; }; 6B45F2701C6F1A6E9FBE3B75 /* SettingsManagerBareExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerBareExtensionsTests.swift; sourceTree = ""; }; + 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModel.swift; sourceTree = ""; }; 6DEF85FC5E955D1C3AB40872 /* NavigationStateActivePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStateActivePaneTests.swift; sourceTree = ""; }; 6E9ED59C427BB7415B869FD8 /* TagsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsPaneView.swift; sourceTree = ""; }; 6ECAC2D5AC63076FFAE6E1CF /* InlineTagClickTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTagClickTests.swift; sourceTree = ""; }; @@ -349,12 +366,14 @@ 71A577424461F3671C9EB9EF /* PinnedItemStructTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItemStructTests.swift; sourceTree = ""; }; 7302EBE2E370EAAB51FB25D7 /* CalendarPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarPaneView.swift; sourceTree = ""; }; 733DD6736180FB7287765592 /* NewNoteFolderPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNoteFolderPickerTests.swift; sourceTree = ""; }; + 736AED0F0C5E71DFB8EF74D3 /* AnthropicClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnthropicClientTests.swift; sourceTree = ""; }; 73E7A1F6FF2B92F1AECD9D29 /* CloneRepositoryValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloneRepositoryValidation.swift; sourceTree = ""; }; 749773879FC61AA559EB5F41 /* CodeBlockCopyButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockCopyButtonTests.swift; sourceTree = ""; }; 74BC44457745DCE1B0D81D45 /* MarkdownPreviewSemanticHiding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPreviewSemanticHiding.swift; sourceTree = ""; }; 75921D668BB1074B9FC3C669 /* AppStateTagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTagsTests.swift; sourceTree = ""; }; 7655C25E6F669827478D643B /* AppConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstantsTests.swift; sourceTree = ""; }; 7685D081794D563201BFA13A /* MarkdownEditorInlineSemanticStylesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorInlineSemanticStylesTests.swift; sourceTree = ""; }; + 77494AFD9121ABE56BF45594 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; 7A51CA96771A98688D9AE452 /* HTMLPasteCodeBlockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLPasteCodeBlockTests.swift; sourceTree = ""; }; 7A6820DD3112C970C66C5BD3 /* AppStateWikiLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWikiLinkTests.swift; sourceTree = ""; }; 7B55AA96B17F979E4F856BA0 /* AppStatePinnedFolderFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatePinnedFolderFocusTests.swift; sourceTree = ""; }; @@ -382,6 +401,7 @@ 8DF0C9FE5F40AC32171D4DA8 /* AppStateTemplateVariablesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTemplateVariablesTests.swift; sourceTree = ""; }; 8EF00AABAF0C69E0006A8999 /* DailyNotesOpenOnStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyNotesOpenOnStartupTests.swift; sourceTree = ""; }; 8F40452ED1B79CCE93DC83ED /* AppStateGitGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitGuardTests.swift; sourceTree = ""; }; + 9015970EA57DF288EB5311E1 /* SettingsManagerAIModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerAIModelTests.swift; sourceTree = ""; }; 90830AEF65F3A92E3870CDAE /* AppStateDateTabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateDateTabTests.swift; sourceTree = ""; }; 91171FEE1C7C6A243104ABF5 /* CalendarDayActivityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayActivityCalculator.swift; sourceTree = ""; }; 9266DDB5C95BD33720E28E45 /* PinnedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedItem.swift; sourceTree = ""; }; @@ -391,10 +411,13 @@ 9706C2BF2E3E64D8CD18A2F2 /* GitErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitErrorTests.swift; sourceTree = ""; }; 98763808F3B74804226E6183 /* GistPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GistPublisherTests.swift; sourceTree = ""; }; 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLToMarkdownTests.swift; sourceTree = ""; }; + 99235052B61D81D983DBA3CC /* AIModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIModelTests.swift; sourceTree = ""; }; 995602FCC2094AD94768D147 /* GitServiceConflictsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceConflictsTests.swift; sourceTree = ""; }; 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTabsTests.swift; sourceTree = ""; }; 9A39EFDD3D11DE137614C99D /* MarkdownDocumentParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocumentParserTests.swift; sourceTree = ""; }; 9B3B5E5BEF7F68CFB75BE4AC /* MarkdownTablePrettifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTablePrettifierTests.swift; sourceTree = ""; }; + 9D12FB31736F21172C1C0F5B /* AnthropicClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnthropicClient.swift; sourceTree = ""; }; + 9D84D65A169DE2039459228A /* AIContextResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIContextResolverTests.swift; sourceTree = ""; }; 9DE5954CBD5DB19F51E67763 /* ImagePasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePasteTests.swift; sourceTree = ""; }; 9F3BDA37284748892B9432ED /* ContentCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentCacheTests.swift; sourceTree = ""; }; A08CB0284C3C5D4EE5C720C7 /* MiniBrowserURLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniBrowserURLNormalizer.swift; sourceTree = ""; }; @@ -409,6 +432,7 @@ A6A7A1C35AB0F5E61361B8CC /* SynapseNotesThemeLayoutConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynapseNotesThemeLayoutConstantsTests.swift; sourceTree = ""; }; A6CBCF06A4567971D9967009 /* SidebarPaneItemCodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPaneItemCodableTests.swift; sourceTree = ""; }; A806FC0B1C1C60336C5DE502 /* GitServiceFileDatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitServiceFileDatesTests.swift; sourceTree = ""; }; + A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStoreTests.swift; sourceTree = ""; }; A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownCalloutDetectorTests.swift; sourceTree = ""; }; A97F141AB461BDBCB144AA23 /* MarkdownEditorRefreshPlanTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEditorRefreshPlanTests.swift; sourceTree = ""; }; A9DD0269C2F999ECBD40462C /* CloneRepositoryValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloneRepositoryValidationTests.swift; sourceTree = ""; }; @@ -447,6 +471,7 @@ CE33CEA66E67EC988C94FC5E /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; CFE935FC16158D93B5B6C05C /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; D00CA4844F0E36301A2C61A6 /* FolderAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAppearance.swift; sourceTree = ""; }; + D0C6708A91911EC9011CB953 /* InlineAIController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAIController.swift; sourceTree = ""; }; D27CF1236F7775109B48756D /* FileSearchResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSearchResultTests.swift; sourceTree = ""; }; D2EC80568C4675330252028C /* AppStateGitDateFilteringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateGitDateFilteringTests.swift; sourceTree = ""; }; D3B6B8AE43A5A53AC28EC7C0 /* EditorFontStylingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorFontStylingTests.swift; sourceTree = ""; }; @@ -466,6 +491,7 @@ E60A5A7B35E4BA0A0789846F /* FileTreeSortingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeSortingTests.swift; sourceTree = ""; }; E712F3D99CEEE8038B89F996 /* RelatedLinksPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinksPaneView.swift; sourceTree = ""; }; E75C5E479C5CFAF5D74301AE /* SettingsManagerMovePaneItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerMovePaneItemTests.swift; sourceTree = ""; }; + E889CD671CA99F099DFF13C7 /* InlineAIControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAIControllerTests.swift; sourceTree = ""; }; E8BE41491FBC3E8E049191BB /* TerminalBootCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBootCommand.swift; sourceTree = ""; }; E9410A9679D5AF496B9D6F2C /* CodeBlockLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockLayoutTests.swift; sourceTree = ""; }; EA44CD4FEBF5ACD032DB5A41 /* SettingsManagerRemovePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManagerRemovePaneTests.swift; sourceTree = ""; }; @@ -506,6 +532,10 @@ 0A284EAE36AA06F6499CB6BF /* SynapseNotesTests */ = { isa = PBXGroup; children = ( + 9D84D65A169DE2039459228A /* AIContextResolverTests.swift */, + 99235052B61D81D983DBA3CC /* AIModelTests.swift */, + 60E22BED5A6410F36911EDA6 /* AIRequestBuilderTests.swift */, + 736AED0F0C5E71DFB8EF74D3 /* AnthropicClientTests.swift */, 7655C25E6F669827478D643B /* AppConstantsTests.swift */, A508264581710F478987F4CA /* AppStateCloneRepositoryTests.swift */, C6D2B8287A4996E0B938ED4A /* AppStateCloseTabAutoSaveTests.swift */, @@ -597,8 +627,10 @@ 987E1B649BAA6DBF8B062EE1 /* HTMLToMarkdownTests.swift */, 9DE5954CBD5DB19F51E67763 /* ImagePasteTests.swift */, BB6D950DAA29D4324BD4054E /* ImageSidebarEmbedTests.swift */, + E889CD671CA99F099DFF13C7 /* InlineAIControllerTests.swift */, 6ECAC2D5AC63076FFAE6E1CF /* InlineTagClickTests.swift */, 5A11E3B1BEF42A7AA9EAAE50 /* InlineTagStylingTests.swift */, + A84218FCBC7806F50E6BF682 /* KeychainStoreTests.swift */, 06B93FD12194D192DEFA2411 /* KeyCodeTests.swift */, 83546378C054CB0E3E4999BE /* ListContinuationTests.swift */, A9182F671271FBBF2DE43D1E /* MarkdownCalloutDetectorTests.swift */, @@ -631,6 +663,7 @@ 6429DBD855DC174CAA00D16F /* SaveButtonVisibilityTests.swift */, 4A54A8F1A063C6DB71E6A58C /* SearchIndexTests.swift */, 626454D7EB8684347CE78962 /* SearchNotificationConstantsTests.swift */, + 9015970EA57DF288EB5311E1 /* SettingsManagerAIModelTests.swift */, 703D69E0596A8F0C009BA65F /* SettingsManagerApplyPaneAssignmentsTests.swift */, 6B45F2701C6F1A6E9FBE3B75 /* SettingsManagerBareExtensionsTests.swift */, 1AEA2BF125EF94AF501B32FC /* SettingsManagerBrowserStartupURLTests.swift */, @@ -689,6 +722,10 @@ F12D5E30E12BF9B949B399CA /* SynapseNotes */ = { isa = PBXGroup; children = ( + 06917C7AF084F2CD5ED26120 /* AIContextResolver.swift */, + 6C28BA5BF6AF2A285C81A84C /* AIModel.swift */, + 58DEDA3632AEAF7F81F9FD7F /* AIRequestBuilder.swift */, + 9D12FB31736F21172C1C0F5B /* AnthropicClient.swift */, C7E653D29E7497890B389B93 /* AppState.swift */, 7BD9A4F4664E7E7F5F405135 /* AppTheme.swift */, 4DAD62182DBD8964F57045F9 /* Assets.xcassets */, @@ -715,6 +752,8 @@ 5F447EE62D897ADFD26A3A2D /* GlobalGraphView.swift */, B50320B1919639905251EA20 /* GraphPaneView.swift */, 658EA3321387C8DF857E0932 /* Info.plist */, + D0C6708A91911EC9011CB953 /* InlineAIController.swift */, + 77494AFD9121ABE56BF45594 /* KeychainStore.swift */, E1E3D357F071ACBF2016AEA7 /* MarkdownCallout.swift */, 47E58E5951953EF520E265CE /* MarkdownDocument.swift */, 0EFF1A55AEF13CEE5C86DD71 /* MarkdownEditorInlineSemanticStyles.swift */, @@ -863,6 +902,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 770EB38B41FEE91D7C6D792C /* AIContextResolver.swift in Sources */, + F6170085EEDEC0FEFB29B4F8 /* AIModel.swift in Sources */, + 15234D80452AA2E7B9DFDCCA /* AIRequestBuilder.swift in Sources */, + 417BBD06E9C053F30E93C6FD /* AnthropicClient.swift in Sources */, D53735E407CC063C6BA0715F /* AppState.swift in Sources */, 1AB0D9338BE326342A1B0129 /* AppTheme.swift in Sources */, 7904B5C075050635D544E7A3 /* CalendarDayActivityCalculator.swift in Sources */, @@ -887,6 +930,8 @@ 0A28812686DE4A1A204E87BC /* GitService.swift in Sources */, 8B03F86871F20001465C62CD /* GlobalGraphView.swift in Sources */, 9E2F95A036FB7A5B399F7795 /* GraphPaneView.swift in Sources */, + 2F2E76C68073C0922244D1E7 /* InlineAIController.swift in Sources */, + FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */, A6D4D7C24F4ABD2CAA465097 /* MarkdownCallout.swift in Sources */, CDD840A586D7F0EC12F9A75A /* MarkdownDocument.swift in Sources */, 3ADA57036C249201F69BFD3E /* MarkdownEditorInlineSemanticStyles.swift in Sources */, @@ -931,6 +976,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9244B5B92ED33AB0304A1E7F /* AIContextResolverTests.swift in Sources */, + 28C8E0B75AF2DBB0A967C02E /* AIModelTests.swift in Sources */, + AC269AD6EEEC8B445F9CC317 /* AIRequestBuilderTests.swift in Sources */, + EFB8F0B58A97328C178B78D4 /* AnthropicClientTests.swift in Sources */, BFD06E40F78AF2CAF21D04AF /* AppConstantsTests.swift in Sources */, 3B0C57EAD1CF3E3BF15B4B54 /* AppStateCloneRepositoryTests.swift in Sources */, 747B4579D40DC92E5BC52D15 /* AppStateCloseTabAutoSaveTests.swift in Sources */, @@ -1022,9 +1071,11 @@ AC5C25866472910EC05AC2A0 /* HTMLToMarkdownTests.swift in Sources */, 8CA248B41EEFBB966526F952 /* ImagePasteTests.swift in Sources */, 53F221847CDB4F68E669AF75 /* ImageSidebarEmbedTests.swift in Sources */, + 5B07DDBDCE0FAF1375C70C49 /* InlineAIControllerTests.swift in Sources */, 3F40FA004E127110B79AEBE2 /* InlineTagClickTests.swift in Sources */, 172C4E1F1F3527C71D3DE159 /* InlineTagStylingTests.swift in Sources */, 5366B533EBF7FF44B882B007 /* KeyCodeTests.swift in Sources */, + 3BAE5845C73805CD95CDD3C1 /* KeychainStoreTests.swift in Sources */, D2DAC498727FB48FD7B91740 /* ListContinuationTests.swift in Sources */, F9161E6876F8449ED7794D80 /* MarkdownCalloutDetectorTests.swift in Sources */, C60016AD09C3CA58EA20A253 /* MarkdownCalloutStructTests.swift in Sources */, @@ -1056,6 +1107,7 @@ 8C86B3AA977FBBA2C09822B0 /* SaveButtonVisibilityTests.swift in Sources */, D336C7BB900A385C187FCEA5 /* SearchIndexTests.swift in Sources */, F3D098320828F15946A3146B /* SearchNotificationConstantsTests.swift in Sources */, + FB020284E27C7A52271D56B6 /* SettingsManagerAIModelTests.swift in Sources */, C42C30EBF243BBE671B3D4FB /* SettingsManagerApplyPaneAssignmentsTests.swift in Sources */, A63E0A76D7E7C898861060A7 /* SettingsManagerBareExtensionsTests.swift in Sources */, CF02A9597490FF94F3BF089E /* SettingsManagerBrowserStartupURLTests.swift in Sources */, From 3c71199960c62132e1cc8e991194a03a330b407e Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:31:39 -0400 Subject: [PATCH 15/24] feat(ai): add inline AI bar view, sparkle button, and @ autocomplete Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/Synapse Notes.xcodeproj/project.pbxproj | 4 + macOS/SynapseNotes/InlineAIView.swift | 154 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 macOS/SynapseNotes/InlineAIView.swift diff --git a/macOS/Synapse Notes.xcodeproj/project.pbxproj b/macOS/Synapse Notes.xcodeproj/project.pbxproj index 99aae78..746316e 100644 --- a/macOS/Synapse Notes.xcodeproj/project.pbxproj +++ b/macOS/Synapse Notes.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ C60016AD09C3CA58EA20A253 /* MarkdownCalloutStructTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56EEC9EFE247488B9CFFCDED /* MarkdownCalloutStructTests.swift */; }; C8D4635C3B403E5960D306D2 /* DatePageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C730F7180CA55FDC105228A3 /* DatePageView.swift */; }; CC0C3EB6FCED4C362CEB5919 /* AppStateRelatedLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86AA4CD7217A37F061FC14C /* AppStateRelatedLinksTests.swift */; }; + CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B54D87558101D4D0356C986 /* InlineAIView.swift */; }; CD9FE8D4C38BFEAED26099A4 /* FlatFolderNavigatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0C5541C2A91D24172ABE7B /* FlatFolderNavigatorTests.swift */; }; CDD840A586D7F0EC12F9A75A /* MarkdownDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E58E5951953EF520E265CE /* MarkdownDocument.swift */; }; CDEA9B7F724C5CBDFF2AF760 /* SidebarPaneTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9AE1A2BEF83A9F60A76D33 /* SidebarPaneTitleTests.swift */; }; @@ -416,6 +417,7 @@ 99E86DAF95F24BAB761C81A8 /* AppStateTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTabsTests.swift; sourceTree = ""; }; 9A39EFDD3D11DE137614C99D /* MarkdownDocumentParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocumentParserTests.swift; sourceTree = ""; }; 9B3B5E5BEF7F68CFB75BE4AC /* MarkdownTablePrettifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownTablePrettifierTests.swift; sourceTree = ""; }; + 9B54D87558101D4D0356C986 /* InlineAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineAIView.swift; sourceTree = ""; }; 9D12FB31736F21172C1C0F5B /* AnthropicClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnthropicClient.swift; sourceTree = ""; }; 9D84D65A169DE2039459228A /* AIContextResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIContextResolverTests.swift; sourceTree = ""; }; 9DE5954CBD5DB19F51E67763 /* ImagePasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePasteTests.swift; sourceTree = ""; }; @@ -753,6 +755,7 @@ B50320B1919639905251EA20 /* GraphPaneView.swift */, 658EA3321387C8DF857E0932 /* Info.plist */, D0C6708A91911EC9011CB953 /* InlineAIController.swift */, + 9B54D87558101D4D0356C986 /* InlineAIView.swift */, 77494AFD9121ABE56BF45594 /* KeychainStore.swift */, E1E3D357F071ACBF2016AEA7 /* MarkdownCallout.swift */, 47E58E5951953EF520E265CE /* MarkdownDocument.swift */, @@ -931,6 +934,7 @@ 8B03F86871F20001465C62CD /* GlobalGraphView.swift in Sources */, 9E2F95A036FB7A5B399F7795 /* GraphPaneView.swift in Sources */, 2F2E76C68073C0922244D1E7 /* InlineAIController.swift in Sources */, + CC260DA9055067DAF97068CA /* InlineAIView.swift in Sources */, FB84B6C8F183243641D2590E /* KeychainStore.swift in Sources */, A6D4D7C24F4ABD2CAA465097 /* MarkdownCallout.swift in Sources */, CDD840A586D7F0EC12F9A75A /* MarkdownDocument.swift in Sources */, diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift new file mode 100644 index 0000000..7bc07c2 --- /dev/null +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -0,0 +1,154 @@ +import SwiftUI +import AppKit + +/// The clickable ✨ overlay placed at the active line's end or past a selection. +/// Mirrors the editor's existing NSControl-based overlay buttons (target/action). +final class AISparkleButton: NSControl { + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + toolTip = "Ask AI" + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func draw(_ dirtyRect: NSRect) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12) + ] + let s = NSAttributedString(string: "✨", attributes: attrs) + let size = s.size() + s.draw(at: NSPoint(x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2)) + } + + override func mouseDown(with event: NSEvent) { + sendAction(action, to: target) + } +} + +/// Whether the bar opens to generate at the cursor or rewrite a selection. +enum InlineAIBarMode { case generate, rewrite } + +/// View model backing the inline AI bar. +final class InlineAIBarModel: ObservableObject { + @Published var prompt: String = "" + @Published var model: AIModel + @Published var isStreaming: Bool = false + @Published var errorMessage: String? + @Published var awaitingAcceptReject: Bool = false // rewrite finished, awaiting decision + @Published var atSuggestions: [String] = [] // file stems matching the active @token + + let mode: InlineAIBarMode + /// Vault note file URLs, for @-autocomplete scoring. + var allFiles: [URL] = [] + + // Callbacks wired by the host (Task 9). + var onSubmit: ((String, AIModel) -> Void)? + var onStop: (() -> Void)? + var onAccept: (() -> Void)? + var onReject: (() -> Void)? + var onCancel: (() -> Void)? // Esc with nothing pending → close the bar + + init(mode: InlineAIBarMode, model: AIModel) { + self.mode = mode + self.model = model + } + + /// Recompute @-autocomplete suggestions for the current prompt. + func updateSuggestions() { + guard let token = activeAtToken(in: prompt), !token.isEmpty else { + atSuggestions = [] + return + } + // Score candidate URLs by the same algorithm wiki-link autocomplete uses, + // then surface the matched file stems. + atSuggestions = allFiles + .map { ($0, commandPaletteScoreByFilename(forURL: $0, needle: token)) } + .filter { $0.1 > 0 } + .sorted { $0.1 > $1.1 } + .prefix(8) + .map { $0.0.deletingPathExtension().lastPathComponent } + } + + /// Extracts the in-progress @token at the end of the prompt, if any. + private func activeAtToken(in text: String) -> String? { + guard let atIndex = text.lastIndex(of: "@") else { return nil } + let after = text[text.index(after: atIndex)...] + // No spaces inside a token; a space after @ means the token is complete. + if after.contains(" ") { return nil } + return String(after) + } + + /// Replace the active @token with the chosen stem. + func applySuggestion(_ stem: String) { + guard let atIndex = prompt.lastIndex(of: "@") else { return } + prompt = String(prompt[.. Date: Tue, 9 Jun 2026 07:34:54 -0400 Subject: [PATCH 16/24] refactor(ai): type the @-suggestion chain + sparkle button cursor/focus polish Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/InlineAIView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index 7bc07c2..f66f621 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -8,6 +8,7 @@ final class AISparkleButton: NSControl { super.init(frame: frame) wantsLayer = true toolTip = "Ask AI" + focusRingType = .none } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -24,6 +25,10 @@ final class AISparkleButton: NSControl { override func mouseDown(with event: NSEvent) { sendAction(action, to: target) } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } } /// Whether the bar opens to generate at the cursor or rewrite a selection. @@ -62,12 +67,13 @@ final class InlineAIBarModel: ObservableObject { } // Score candidate URLs by the same algorithm wiki-link autocomplete uses, // then surface the matched file stems. - atSuggestions = allFiles + let scored: [(url: URL, score: Int)] = allFiles .map { ($0, commandPaletteScoreByFilename(forURL: $0, needle: token)) } .filter { $0.1 > 0 } - .sorted { $0.1 > $1.1 } + atSuggestions = scored + .sorted { $0.score > $1.score } .prefix(8) - .map { $0.0.deletingPathExtension().lastPathComponent } + .map { $0.url.deletingPathExtension().lastPathComponent } } /// Extracts the in-progress @token at the end of the prompt, if any. From 0120cd8066aec3848087ffc9a84e4a01fafe6bd4 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:40:00 -0400 Subject: [PATCH 17/24] feat(ai): wire inline AI editing into the editor (sparkle, streaming, diff) Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/EditorView.swift | 227 +++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 2 deletions(-) diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index bde0ea7..a503e8c 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -738,6 +738,7 @@ struct RawEditor: NSViewRepresentable { func makeNSView(context: Context) -> NSScrollView { let textView = Self.configuredTextView(isEditable: isEditable, settings: appState.settings) + textView.aiAppState = appState textView.delegate = context.coordinator textView.onActivatePane = isEditable ? nil : { appState.focusPane(paneIndex) } @@ -1059,8 +1060,9 @@ struct RawEditor: NSViewRepresentable { } func textViewDidChangeSelection(_ notification: Notification) { - guard parent.appState.settings.hideMarkdownWhileEditing, - let tv = textView else { return } + guard let tv = textView else { return } + tv.refreshAISparkle() + guard parent.appState.settings.hideMarkdownWhileEditing else { return } // Revealing the raw markdown under the caret is the immediate visual // feedback the user expects, so it runs synchronously on every move. @@ -2240,6 +2242,218 @@ extension LinkAwareTextView { button.action = #selector(collapsibleToggleTapped(_:)) button.identifier = NSUserInterfaceItemIdentifier(capturedId) } + + refreshAISparkle() + } + + // MARK: - Inline AI editing + + /// Positions a single reused ✨ button at the end of the caret's line, or just + /// past the selection when text is selected. Cheap: one glyph-rect lookup, no parsing. + func refreshAISparkle() { + guard let layoutManager, let textContainer else { return } + let sel = selectedRange() + let ns = string as NSString + + let anchorIndex: Int + if sel.length > 0 { + anchorIndex = sel.location + sel.length + } else { + let lineRange = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0)) + var end = lineRange.location + lineRange.length + if end > lineRange.location, + ns.substring(with: NSRange(location: end - 1, length: 1)).rangeOfCharacter(from: .newlines) != nil { + end -= 1 + } + anchorIndex = end + } + + let safe = max(0, min(anchorIndex, ns.length)) + let glyphRange = layoutManager.glyphRange( + forCharacterRange: NSRange(location: max(0, safe - 1), length: safe > 0 ? 1 : 0), + actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textContainerOrigin.x + rect.origin.y += textContainerOrigin.y + + let size: CGFloat = 18 + let frame = NSRect(x: rect.maxX + 4, y: rect.minY + (rect.height - size) / 2, width: size, height: size) + + let button: AISparkleButton + if let existing = aiSparkleButton { + button = existing + } else { + button = AISparkleButton(frame: frame) + button.target = self + button.action = #selector(aiSparkleTapped) + addSubview(button) + aiSparkleButton = button + } + button.frame = frame + button.isHidden = (aiBarHostingView != nil) // hide while the bar is open + } + + @objc private func aiSparkleTapped() { + let sel = selectedRange() + let mode: InlineAIBarMode = sel.length > 0 ? .rewrite : .generate + presentAIBar(mode: mode, at: sel) + } + + private func presentAIBar(mode: InlineAIBarMode, at sel: NSRange) { + dismissAIBar() + + let defaultModel = AIModel(apiID: settings?.aiDefaultModel ?? AIModel.default.apiID) + let model = InlineAIBarModel(mode: mode, model: defaultModel) + model.allFiles = aiAppState?.allFiles ?? [] + + model.onSubmit = { [weak self] prompt, chosen in + self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) + } + model.onStop = { [weak self] in self?.stopAIStream() } + model.onAccept = { [weak self] in self?.acceptAI() } + model.onReject = { [weak self] in self?.rejectAI() } + model.onCancel = { [weak self] in self?.dismissAIBar() } + aiBarModel = model + + let host = NSHostingView(rootView: InlineAIBarView(model: model)) + host.frame = aiBarFrame(below: sel) + addSubview(host) + aiBarHostingView = host + refreshAISparkle() + } + + private func aiBarFrame(below sel: NSRange) -> NSRect { + guard let layoutManager, let textContainer else { return .zero } + let anchor = sel.length > 0 ? sel.location + sel.length : sel.location + let safe = max(0, min(anchor, (string as NSString).length)) + let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: safe, length: 0), actualCharacterRange: nil) + var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + rect.origin.x += textContainerOrigin.x + rect.origin.y += textContainerOrigin.y + let width = min(bounds.width - 24, 520) + return NSRect(x: 12, y: rect.maxY + 4, width: width, height: 80) + } + + private func dismissAIBar() { + aiStreamTask?.cancel(); aiStreamTask = nil + aiBarHostingView?.removeFromSuperview(); aiBarHostingView = nil + aiBarModel = nil + refreshAISparkle() + } + + private func startAIStream(prompt: String, model: AIModel, mode: InlineAIBarMode, selection sel: NSRange) { + guard let storage = textStorage else { return } + guard let key = KeychainStore().get(), !key.isEmpty else { + aiBarModel?.errorMessage = "Add your Anthropic API key in Settings →" + return + } + + let files = aiAppState?.allFiles ?? [] + let resolver = AIContextResolver(allFiles: files, readContents: { try? String(contentsOf: $0, encoding: .utf8) }) + let resolved = resolver.resolve(prompt: prompt) + + if mode == .generate { + inlineAIController.beginGenerate(in: storage, at: sel.location) + } else { + inlineAIController.beginRewrite(in: storage, selection: sel) + } + + let selectionText = mode == .rewrite ? (string as NSString).substring(with: sel) : nil + let body = AIRequestBuilder.build( + mode: mode == .generate ? .generate : .rewrite, + prompt: prompt, noteText: string, + selection: selectionText, context: resolved.blocks, model: model) + + aiBarModel?.isStreaming = true + if resolved.truncated { + aiBarModel?.errorMessage = "Context truncated to fit." + } else if !resolved.missing.isEmpty { + aiBarModel?.errorMessage = "\(resolved.missing.count) reference(s) not found." + } else { + aiBarModel?.errorMessage = nil + } + + let client = AnthropicClient(apiKey: key) + aiStreamTask = Task { [weak self] in + do { + for try await delta in client.stream(body: body) { + await MainActor.run { + self?.inlineAIController.appendDelta(delta) + self?.applyAIDiffColors() + self?.didChangeText() + } + } + await MainActor.run { self?.finishAIStream(mode: mode) } + } catch { + await MainActor.run { self?.handleAIError(error) } + } + } + } + + private func stopAIStream() { + aiStreamTask?.cancel(); aiStreamTask = nil + let mode = aiBarModel?.mode ?? .generate + if mode == .generate { inlineAIController.cancel() } + finishAIStream(mode: mode) + } + + private func finishAIStream(mode: InlineAIBarMode) { + aiBarModel?.isStreaming = false + if mode == .rewrite { + aiBarModel?.awaitingAcceptReject = true + } else { + inlineAIController.cancel() + dismissAIBar() + } + applyAIDiffColors() + } + + private func handleAIError(_ error: Error) { + aiBarModel?.isStreaming = false + if let e = error as? AnthropicClient.ClientError { + switch e { + case .invalidKey: aiBarModel?.errorMessage = "Invalid API key — check Settings." + case .server(let s): aiBarModel?.errorMessage = "Server error (\(s)). Try again." + case .badResponse: aiBarModel?.errorMessage = "Unexpected response. Try again." + } + } else { + aiBarModel?.errorMessage = "Network error. Try again." + } + if aiBarModel?.mode == .rewrite { aiBarModel?.awaitingAcceptReject = true } + } + + private func acceptAI() { + inlineAIController.accept() + clearAIDiffColors() + didChangeText() + dismissAIBar() + } + + private func rejectAI() { + inlineAIController.reject() + clearAIDiffColors() + didChangeText() + dismissAIBar() + } + + private func applyAIDiffColors() { + guard let storage = textStorage else { return } + if let orig = inlineAIController.originalRange, orig.length > 0, + NSMaxRange(orig) <= storage.length { + storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: orig) + storage.addAttribute(.foregroundColor, value: NSColor.systemRed, range: orig) + } + if let nr = inlineAIController.newRange, nr.length > 0, + NSMaxRange(nr) <= storage.length { + storage.addAttribute(.foregroundColor, value: NSColor.systemGreen, range: nr) + } + } + + private func clearAIDiffColors() { + guard let storage = textStorage else { return } + let full = NSRange(location: 0, length: storage.length) + storage.removeAttribute(.strikethroughStyle, range: full) + refreshEditorForCurrentDisplayMode(self) } @objc private func collapsibleToggleTapped(_ sender: NSControl) { @@ -2341,6 +2555,15 @@ class LinkAwareTextView: NSTextView { /// Toggle buttons keyed by section identifier ("headerOffset-headerLength") private var collapsibleToggleButtons: [String: CollapsibleToggleButton] = [:] + // MARK: - Inline AI editing + let inlineAIController = InlineAIController() + private var aiSparkleButton: AISparkleButton? + private var aiBarHostingView: NSHostingView? + private var aiBarModel: InlineAIBarModel? + private var aiStreamTask: Task? + /// Injected at setup; source of vault files for @-context. + weak var aiAppState: AppState? + // MARK: - Embedded Notes (for side panel) private static let embedRegex = try? NSRegularExpression(pattern: #"!\[\[([^\]]+)\]\]"#) From ef7fd4c9543f7f9f75684743a06f68bcfd475f23 Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:48:46 -0400 Subject: [PATCH 18/24] fix(ai): tear down AI session on note switch + stop diff-color flicker + Esc to close Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/EditorView.swift | 30 +++++++++++++++++++++ macOS/SynapseNotes/InlineAIController.swift | 9 +++++++ macOS/SynapseNotes/InlineAIView.swift | 9 +++++++ 3 files changed, 48 insertions(+) diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index a503e8c..50b48bf 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -1012,6 +1012,9 @@ struct RawEditor: NSViewRepresentable { tv.applyPreviewStyling(document: document, refreshPlan: refreshPlan, editingSessionOpen: true) } suppressSync = false + // The blanket restyle above wipes transient AI diff colors; restore + // them so they don't flicker between streaming deltas. + tv.reapplyAIDiffColorsIfActive() } func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { @@ -1497,6 +1500,10 @@ extension LinkAwareTextView { func setPlainText(_ plain: String) { guard let storage = textStorage else { return } + // Tear down any in-flight/pending AI session BEFORE the storage is + // replaced — stale ranges would corrupt the new note or crash on + // accept/reject, and the floating bar would linger over new content. + teardownAISession() // Stale ranges from a previous file would crash reapplySearchHighlights lastSearchHighlightRanges = [] lastSearchFocusIndex = -1 @@ -2341,6 +2348,29 @@ extension LinkAwareTextView { refreshAISparkle() } + /// Tears down any in-flight or pending inline-AI session. Called when the + /// document is swapped (note/tab switch) so stale ranges can't corrupt the + /// new note or crash on accept/reject. Does NOT touch storage: the + /// about-to-run setPlainText replaces the whole attributed string, so old + /// diff colors vanish with it. + func teardownAISession() { + guard aiBarHostingView != nil || inlineAIController.mode != .idle else { return } + aiStreamTask?.cancel() + aiStreamTask = nil + aiBarHostingView?.removeFromSuperview() + aiBarHostingView = nil + aiBarModel = nil + inlineAIController.resetWithoutMutating() + } + + /// Re-applies transient AI diff colors after a styling pass, if a session is active. + /// The normal markdown restyle blanket-sets foreground colors, wiping the diff + /// colors; this restores them so they don't flicker mid-stream. + func reapplyAIDiffColorsIfActive() { + guard inlineAIController.mode != .idle else { return } + applyAIDiffColors() + } + private func startAIStream(prompt: String, model: AIModel, mode: InlineAIBarMode, selection sel: NSRange) { guard let storage = textStorage else { return } guard let key = KeychainStore().get(), !key.isEmpty else { diff --git a/macOS/SynapseNotes/InlineAIController.swift b/macOS/SynapseNotes/InlineAIController.swift index 437ac19..3547141 100644 --- a/macOS/SynapseNotes/InlineAIController.swift +++ b/macOS/SynapseNotes/InlineAIController.swift @@ -91,4 +91,13 @@ final class InlineAIController: ObservableObject { storage.replaceCharacters(in: nr, with: "") mode = .idle; originalRange = nil; newRange = nil } + + /// Clears all session state WITHOUT mutating the text storage. Use when the + /// underlying document is being replaced wholesale (note/tab switch), where + /// touching the old ranges would corrupt the new document or crash. + func resetWithoutMutating() { + mode = .idle + originalRange = nil + newRange = nil + } } diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index f66f621..378aa35 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -125,6 +125,15 @@ struct InlineAIBarView: View { } } + // Invisible Esc handler for the non-pending states (Reject owns Esc when pending). + if !model.awaitingAcceptReject { + Button("") { model.onCancel?() } + .keyboardShortcut(.escape, modifiers: []) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + if let err = model.errorMessage { Text(err).font(.caption).foregroundColor(.red) } From 9b958465c15b5250c908559bd63a06c79cf30e8c Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 07:57:54 -0400 Subject: [PATCH 19/24] fix(ai): bracketed @[name] for spaced files, clean generate retry, guard rewrite autosave Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AIContextResolver.swift | 19 +++++++++---- macOS/SynapseNotes/EditorView.swift | 12 +++++++- macOS/SynapseNotes/InlineAIView.swift | 3 +- .../AIContextResolverTests.swift | 28 +++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/macOS/SynapseNotes/AIContextResolver.swift b/macOS/SynapseNotes/AIContextResolver.swift index fa1a336..db3fa0e 100644 --- a/macOS/SynapseNotes/AIContextResolver.swift +++ b/macOS/SynapseNotes/AIContextResolver.swift @@ -23,10 +23,10 @@ struct AIContextResolver { self.readContents = readContents } - /// Matches `@token` where token is letters/digits/_/-/space-free path-ish chars. - /// Negative lookbehind prevents matching emails (e.g. `foo@bar.com`). - /// Trailing dots are excluded from the capture (e.g. `@budget.` captures `budget`). - private static let tokenRegex = try! NSRegularExpression(pattern: "(?? private var aiBarModel: InlineAIBarModel? diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index 378aa35..6adcff1 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -88,7 +88,8 @@ final class InlineAIBarModel: ObservableObject { /// Replace the active @token with the chosen stem. func applySuggestion(_ stem: String) { guard let atIndex = prompt.lastIndex(of: "@") else { return } - prompt = String(prompt[.. Date: Tue, 9 Jun 2026 08:13:31 -0400 Subject: [PATCH 20/24] fix(ai): Retry replaces previous output; reposition bar clear of the diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Retry now discards the prior generation (discardOutput) before re-streaming, so it replaces rather than appends (was concatenating onto the last result). - The bar re-anchors below the streamed text as it grows, flipping to above the region when a long diff would push it off-screen — it no longer covers the diff. - Adds discardOutput() + 5 regression tests for the Retry replace behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/EditorView.swift | 63 ++++++++++++++++--- macOS/SynapseNotes/InlineAIController.swift | 15 +++++ macOS/SynapseNotes/InlineAIView.swift | 6 +- .../InlineAIControllerTests.swift | 55 ++++++++++++++++ 4 files changed, 131 insertions(+), 8 deletions(-) diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 75dcf16..78292f2 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2312,6 +2312,7 @@ extension LinkAwareTextView { private func presentAIBar(mode: InlineAIBarMode, at sel: NSRange) { dismissAIBar() + aiBarOriginalSelection = sel let defaultModel = AIModel(apiID: settings?.aiDefaultModel ?? AIModel.default.apiID) let model = InlineAIBarModel(mode: mode, model: defaultModel) @@ -2320,6 +2321,14 @@ extension LinkAwareTextView { model.onSubmit = { [weak self] prompt, chosen in self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) } + model.onRetry = { [weak self] prompt, chosen in + // Discard the previous output so the re-run replaces it instead of + // appending, then stream fresh from the original anchor/selection. + self?.inlineAIController.discardOutput() + self?.clearAIDiffColors() + self?.aiBarModel?.awaitingAcceptReject = false + self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) + } model.onStop = { [weak self] in self?.stopAIStream() } model.onAccept = { [weak self] in self?.acceptAI() } model.onReject = { [weak self] in self?.rejectAI() } @@ -2333,16 +2342,51 @@ extension LinkAwareTextView { refreshAISparkle() } + /// Frame for the AI bar. Anchored just below the bottom of the affected region so it + /// never overlaps the streamed text/diff (the region is the end of the streamed + /// `newRange`/`originalRange` once streaming starts, else the selection/cursor). If + /// placing it below would push it past the bottom of the visible viewport (a long + /// diff), it is placed ABOVE the top of the affected region instead, so it stays on + /// screen and still clears the diff. private func aiBarFrame(below sel: NSRange) -> NSRect { guard let layoutManager, let textContainer else { return .zero } - let anchor = sel.length > 0 ? sel.location + sel.length : sel.location - let safe = max(0, min(anchor, (string as NSString).length)) - let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: safe, length: 0), actualCharacterRange: nil) - var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - rect.origin.x += textContainerOrigin.x - rect.origin.y += textContainerOrigin.y + let ns = string as NSString + let barHeight: CGFloat = 80 let width = min(bounds.width - 24, 520) - return NSRect(x: 12, y: rect.maxY + 4, width: width, height: 80) + + func yOffset(forCharacterIndex index: Int) -> (top: CGFloat, bottom: CGFloat) { + let safe = max(0, min(index, ns.length)) + let gr = layoutManager.glyphRange(forCharacterRange: NSRange(location: safe, length: 0), actualCharacterRange: nil) + var r = layoutManager.boundingRect(forGlyphRange: gr, in: textContainer) + r.origin.y += textContainerOrigin.y + return (r.minY, r.maxY) + } + + // Bottom of the affected region (prefer streamed text) and top of it (for the + // above-placement fallback). + var bottomAnchor = sel.length > 0 ? sel.location + sel.length : sel.location + if let nr = inlineAIController.newRange { bottomAnchor = max(bottomAnchor, NSMaxRange(nr)) } + if let orig = inlineAIController.originalRange { bottomAnchor = max(bottomAnchor, NSMaxRange(orig)) } + let topAnchor = min(sel.location, inlineAIController.originalRange?.location ?? sel.location) + + let belowY = yOffset(forCharacterIndex: bottomAnchor).bottom + 6 + let visible = enclosingScrollView?.documentVisibleRect ?? visibleRect + + // If the bar placed below would run past the visible area, place it above the region. + if belowY + barHeight > visible.maxY { + let aboveY = yOffset(forCharacterIndex: topAnchor).top - barHeight - 6 + let clampedY = max(visible.minY + 6, aboveY) + return NSRect(x: 12, y: clampedY, width: width, height: barHeight) + } + return NSRect(x: 12, y: belowY, width: width, height: barHeight) + } + + /// Re-anchors the bar below the current affected region (called as text streams in + /// and when streaming finishes) so it tracks the growing diff instead of covering it. + private func repositionAIBar() { + guard let host = aiBarHostingView else { return } + let sel = aiBarOriginalSelection + host.frame = aiBarFrame(below: sel) } private func dismissAIBar() { @@ -2415,6 +2459,7 @@ extension LinkAwareTextView { self?.inlineAIController.appendDelta(delta) self?.applyAIDiffColors() self?.didChangeText() + self?.repositionAIBar() } } await MainActor.run { self?.finishAIStream(mode: mode) } @@ -2440,6 +2485,7 @@ extension LinkAwareTextView { dismissAIBar() } applyAIDiffColors() + repositionAIBar() } private func handleAIError(_ error: Error) { @@ -2601,6 +2647,9 @@ class LinkAwareTextView: NSTextView { private var aiBarHostingView: NSHostingView? private var aiBarModel: InlineAIBarModel? private var aiStreamTask: Task? + /// The selection/cursor captured when the bar opened — used to re-anchor the bar + /// below the affected region as text streams in. + private var aiBarOriginalSelection: NSRange = NSRange(location: 0, length: 0) /// Injected at setup; source of vault files for @-context. weak var aiAppState: AppState? diff --git a/macOS/SynapseNotes/InlineAIController.swift b/macOS/SynapseNotes/InlineAIController.swift index 3547141..e47c084 100644 --- a/macOS/SynapseNotes/InlineAIController.swift +++ b/macOS/SynapseNotes/InlineAIController.swift @@ -100,4 +100,19 @@ final class InlineAIController: ObservableObject { originalRange = nil newRange = nil } + + /// Removes the streamed output and returns to idle, leaving the document as it + /// was *before* this session's generation. In generate mode this deletes the + /// inserted text; in rewrite mode it deletes the new text and keeps the original + /// (same end state as `reject()`). Used by Retry so a re-run starts clean instead + /// of appending onto the previous output. + func discardOutput() { + guard mode != .idle else { return } + if let storage, let nr = newRange, nr.length > 0, NSMaxRange(nr) <= storage.length { + storage.replaceCharacters(in: nr, with: "") + } + mode = .idle + originalRange = nil + newRange = nil + } } diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index 6adcff1..2d650b8 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -49,6 +49,7 @@ final class InlineAIBarModel: ObservableObject { // Callbacks wired by the host (Task 9). var onSubmit: ((String, AIModel) -> Void)? + var onRetry: ((String, AIModel) -> Void)? // re-run, replacing the previous output var onStop: (() -> Void)? var onAccept: (() -> Void)? var onReject: (() -> Void)? @@ -120,7 +121,10 @@ struct InlineAIBarView: View { } else if model.awaitingAcceptReject { Button("Accept") { model.onAccept?() }.keyboardShortcut(.return, modifiers: []) Button("Reject") { model.onReject?() }.keyboardShortcut(.escape, modifiers: []) - Button("Retry") { submit() } + Button("Retry") { + guard !model.prompt.isEmpty else { return } + model.onRetry?(model.prompt, model.model) + } } else { Button("Generate") { submit() }.disabled(model.prompt.isEmpty) } diff --git a/macOS/SynapseNotesTests/InlineAIControllerTests.swift b/macOS/SynapseNotesTests/InlineAIControllerTests.swift index 2a70e98..8fe0ee3 100644 --- a/macOS/SynapseNotesTests/InlineAIControllerTests.swift +++ b/macOS/SynapseNotesTests/InlineAIControllerTests.swift @@ -132,4 +132,59 @@ final class InlineAIControllerTests: XCTestCase { c.reject() // zero deltas appended; deleting empty newRange is safe XCTAssertEqual(storage.string, "keep me") } + + // MARK: discardOutput (Retry support) + + func test_discardOutput_generate_removesInsertedTextAndIdles() { + let storage = makeStorage("ab") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 2) + c.appendDelta("XYZ") // "abXYZ" + c.discardOutput() + XCTAssertEqual(storage.string, "ab") + XCTAssertEqual(c.mode, .idle) + } + + func test_discardOutput_rewrite_removesNewTextKeepsOriginal() { + let storage = makeStorage("The fox.") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 0, length: 8)) + c.appendDelta("A fox.") // "The fox.A fox." + c.discardOutput() + XCTAssertEqual(storage.string, "The fox.") + XCTAssertEqual(c.mode, .idle) + } + + func test_retryFlow_generate_replacesRatherThanAppends() { + // Simulates Retry: discard the first generation, then re-begin and stream again. + // Regression test for the bug where Retry appended onto the previous output. + let storage = makeStorage("Start: ") + let c = InlineAIController() + c.beginGenerate(in: storage, at: 7) + c.appendDelta("first attempt") // "Start: first attempt" + c.discardOutput() // back to "Start: " + c.beginGenerate(in: storage, at: 7) + c.appendDelta("second attempt") + XCTAssertEqual(storage.string, "Start: second attempt") + } + + func test_retryFlow_rewrite_replacesRatherThanAppends() { + let storage = makeStorage("Hello WORLD!") + let c = InlineAIController() + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) // "WORLD" + c.appendDelta("earth") // "Hello WORLDearth!" + c.discardOutput() // back to "Hello WORLD!" + c.beginRewrite(in: storage, selection: NSRange(location: 6, length: 5)) + c.appendDelta("mars") + c.accept() + XCTAssertEqual(storage.string, "Hello mars!") + } + + func test_discardOutput_whenIdle_isNoOp() { + let storage = makeStorage("untouched") + let c = InlineAIController() + c.discardOutput() + XCTAssertEqual(storage.string, "untouched") + XCTAssertEqual(c.mode, .idle) + } } From 48d68be49cb83b7fe055b8a9ea8c564fdc70071a Mon Sep 17 00:00:00 2001 From: dep Date: Tue, 9 Jun 2026 17:17:23 -0400 Subject: [PATCH 21/24] fix(ai): @-folder context, multiline prompt, draggable bar, clickable first row - @mentions now resolve folders (concatenates a folder's direct notes) in addition to notes; autocomplete lists both with file/folder icons (allFolders wired through AIContextResolver + the bar). Fixes directories not appearing for @Weekly etc. - Prompt field is now multiline (axis: .vertical): Enter submits, Shift+Enter inserts a newline; the bar grows to fit and re-anchors. - The bar has a drag handle and can be repositioned; once moved it stops auto-snapping below the streamed text. - First @-suggestion is now clickable: the invisible Esc handler moved to a non-layout, non-hit-testing background, and each row has an explicit contentShape. - Adds 5 folder-resolution tests (35 resolver+controller tests pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/AIContextResolver.swift | 81 ++++++--- macOS/SynapseNotes/EditorView.swift | 61 ++++++- macOS/SynapseNotes/InlineAIView.swift | 167 +++++++++++++----- .../AIContextResolverTests.swift | 67 +++++++ 4 files changed, 311 insertions(+), 65 deletions(-) diff --git a/macOS/SynapseNotes/AIContextResolver.swift b/macOS/SynapseNotes/AIContextResolver.swift index db3fa0e..4df73e8 100644 --- a/macOS/SynapseNotes/AIContextResolver.swift +++ b/macOS/SynapseNotes/AIContextResolver.swift @@ -1,40 +1,58 @@ import Foundation -/// Resolves `@name` tokens in a prompt into vault-note context blocks. -/// Pure: file contents are read through an injected closure. +/// Resolves `@name` tokens in a prompt into vault context blocks. +/// +/// A token can match a note (by filename stem) or a folder (by folder name). A folder +/// resolves to the concatenated bodies of the notes directly inside it (non-recursive). +/// Pure: file contents and folder listings are read through injected closures. struct AIContextResolver { struct Block: Equatable { - let name: String // the file stem actually matched + let name: String // the file stem or folder name actually matched let body: String } struct Result: Equatable { var blocks: [Block] - var missing: [String] // @tokens with no matching note + var missing: [String] // @tokens with no matching note or folder var truncated: Bool } let allFiles: [URL] + let allFolders: [URL] let charCap: Int let readContents: (URL) -> String? + /// Direct note children of a folder (non-recursive). Defaults to filtering `allFiles` + /// by parent directory, so callers usually only need to pass `allFiles`/`allFolders`. + let filesInFolder: (URL) -> [URL] - init(allFiles: [URL], charCap: Int = 100_000, readContents: @escaping (URL) -> String?) { + init( + allFiles: [URL], + allFolders: [URL] = [], + charCap: Int = 100_000, + readContents: @escaping (URL) -> String?, + filesInFolder: ((URL) -> [URL])? = nil + ) { self.allFiles = allFiles + self.allFolders = allFolders self.charCap = charCap self.readContents = readContents + self.filesInFolder = filesInFolder ?? { folder in + allFiles.filter { $0.deletingLastPathComponent().standardizedFileURL == folder.standardizedFileURL } + } } // Matches @[Multi Word Name] (group 1) or a bare @token (group 2). - // The bracket form supports filenames with spaces; the bare form keeps the - // common case terse. The negative lookbehind skips emails (foo@bar). + // The bracket form supports names with spaces; the bare form keeps the common case + // terse. The negative lookbehind skips emails (foo@bar). private static let tokenRegex = try! NSRegularExpression(pattern: "(? Result { let ns = prompt as NSString let matches = Self.tokenRegex.matches(in: prompt, range: NSRange(location: 0, length: ns.length)) @@ -60,27 +78,48 @@ struct AIContextResolver { guard !seen.contains(key) else { continue } seen.insert(key) - guard let url = allFiles.first(where: { - $0.deletingPathExtension().lastPathComponent.lowercased() == key - }), let body = readContents(url) else { + guard let resolved = resolveToken(key) else { missing.append(token) continue } - let name = url.deletingPathExtension().lastPathComponent let remaining = charCap - used - if body.count > remaining { + if resolved.body.count > remaining { truncated = true if remaining > 0 { - blocks.append(Block(name: name, body: String(body.prefix(remaining)))) + blocks.append(Block(name: resolved.name, body: String(resolved.body.prefix(remaining)))) used = charCap } break } - blocks.append(Block(name: name, body: body)) - used += body.count + blocks.append(Block(name: resolved.name, body: resolved.body)) + used += resolved.body.count } return Result(blocks: blocks, missing: missing, truncated: truncated) } + + /// Resolves a lowercased token to a (display name, body), trying a note first then a + /// folder. Returns nil if nothing matches or the matched content is empty/unreadable. + private func resolveToken(_ key: String) -> (name: String, body: String)? { + // 1) Note by stem. + if let url = allFiles.first(where: { $0.deletingPathExtension().lastPathComponent.lowercased() == key }), + let body = readContents(url) { + return (url.deletingPathExtension().lastPathComponent, body) + } + // 2) Folder by name → concatenate direct-child note bodies (non-recursive). + if let folder = allFolders.first(where: { $0.lastPathComponent.lowercased() == key }) { + let children = filesInFolder(folder).sorted { $0.path < $1.path } + var parts: [String] = [] + for child in children { + if let body = readContents(child), !body.isEmpty { + let stem = child.deletingPathExtension().lastPathComponent + parts.append("## \(stem)\n\(body)") + } + } + guard !parts.isEmpty else { return nil } + return (folder.lastPathComponent, parts.joined(separator: "\n\n")) + } + return nil + } } diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 78292f2..3115e61 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2313,10 +2313,13 @@ extension LinkAwareTextView { private func presentAIBar(mode: InlineAIBarMode, at sel: NSRange) { dismissAIBar() aiBarOriginalSelection = sel + aiBarUserMoved = false + aiBarDragStartOrigin = nil let defaultModel = AIModel(apiID: settings?.aiDefaultModel ?? AIModel.default.apiID) let model = InlineAIBarModel(mode: mode, model: defaultModel) model.allFiles = aiAppState?.allFiles ?? [] + model.allFolders = aiAppState?.allFolders() ?? [] model.onSubmit = { [weak self] prompt, chosen in self?.startAIStream(prompt: prompt, model: chosen, mode: mode, selection: sel) @@ -2333,6 +2336,9 @@ extension LinkAwareTextView { model.onAccept = { [weak self] in self?.acceptAI() } model.onReject = { [weak self] in self?.rejectAI() } model.onCancel = { [weak self] in self?.dismissAIBar() } + model.onDrag = { [weak self] translation in self?.dragAIBar(by: translation) } + model.onDragEnded = { [weak self] in self?.aiBarDragStartOrigin = nil } + model.onContentSizeMayHaveChanged = { [weak self] in self?.resizeAIBarToFit() } aiBarModel = model let host = NSHostingView(rootView: InlineAIBarView(model: model)) @@ -2351,8 +2357,15 @@ extension LinkAwareTextView { private func aiBarFrame(below sel: NSRange) -> NSRect { guard let layoutManager, let textContainer else { return .zero } let ns = string as NSString - let barHeight: CGFloat = 80 let width = min(bounds.width - 24, 520) + // Size to the bar's content (drag handle + growing prompt + suggestion list), + // so nothing is clipped. fittingSize needs the target width set first. + var barHeight: CGFloat = 80 + if let host = aiBarHostingView { + host.frame.size.width = width + let fitting = host.fittingSize.height + if fitting > 0 { barHeight = max(60, min(fitting, 360)) } + } func yOffset(forCharacterIndex index: Int) -> (top: CGFloat, bottom: CGFloat) { let safe = max(0, min(index, ns.length)) @@ -2383,10 +2396,42 @@ extension LinkAwareTextView { /// Re-anchors the bar below the current affected region (called as text streams in /// and when streaming finishes) so it tracks the growing diff instead of covering it. + /// No-op once the user has dragged the bar to a manual position. private func repositionAIBar() { + guard let host = aiBarHostingView, !aiBarUserMoved else { return } + host.frame = aiBarFrame(below: aiBarOriginalSelection) + } + + /// Resizes the bar to fit its content (prompt growth, suggestion list). Preserves the + /// user-dragged origin if they moved it; otherwise re-anchors below the affected region. + private func resizeAIBarToFit() { guard let host = aiBarHostingView else { return } - let sel = aiBarOriginalSelection - host.frame = aiBarFrame(below: sel) + if aiBarUserMoved { + let width = min(bounds.width - 24, 520) + host.frame.size.width = width + let fitting = host.fittingSize.height + host.frame.size.height = max(60, min(fitting > 0 ? fitting : 80, 360)) + } else { + host.frame = aiBarFrame(below: aiBarOriginalSelection) + } + } + + /// Moves the bar by a drag-handle translation. `translation` is SwiftUI global-space + /// (y grows downward); this view is an unflipped NSView (y grows upward), so the y + /// delta is negated. The translation is cumulative from drag start, so we offset the + /// origin captured when the drag began. + private func dragAIBar(by translation: CGSize) { + guard let host = aiBarHostingView else { return } + aiBarUserMoved = true + let start = aiBarDragStartOrigin ?? host.frame.origin + if aiBarDragStartOrigin == nil { aiBarDragStartOrigin = start } + let newOrigin = NSPoint(x: start.x + translation.width, + y: start.y - translation.height) + // Keep the bar within the visible area. + let visible = enclosingScrollView?.documentVisibleRect ?? visibleRect + let clampedX = min(max(newOrigin.x, visible.minX + 4), visible.maxX - host.frame.width - 4) + let clampedY = min(max(newOrigin.y, visible.minY + 4), visible.maxY - host.frame.height - 4) + host.frame.origin = NSPoint(x: clampedX, y: clampedY) } private func dismissAIBar() { @@ -2427,7 +2472,11 @@ extension LinkAwareTextView { } let files = aiAppState?.allFiles ?? [] - let resolver = AIContextResolver(allFiles: files, readContents: { try? String(contentsOf: $0, encoding: .utf8) }) + let folders = aiAppState?.allFolders() ?? [] + let resolver = AIContextResolver( + allFiles: files, + allFolders: folders, + readContents: { try? String(contentsOf: $0, encoding: .utf8) }) let resolved = resolver.resolve(prompt: prompt) if mode == .generate { @@ -2650,6 +2699,10 @@ class LinkAwareTextView: NSTextView { /// The selection/cursor captured when the bar opened — used to re-anchor the bar /// below the affected region as text streams in. private var aiBarOriginalSelection: NSRange = NSRange(location: 0, length: 0) + /// The bar's origin captured at the start of a drag (nil when not dragging). + private var aiBarDragStartOrigin: NSPoint? + /// Once the user drags the bar, stop auto-repositioning it below the streamed text. + private var aiBarUserMoved = false /// Injected at setup; source of vault files for @-context. weak var aiAppState: AppState? diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index 2d650b8..584b446 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -34,6 +34,15 @@ final class AISparkleButton: NSControl { /// Whether the bar opens to generate at the cursor or rewrite a selection. enum InlineAIBarMode { case generate, rewrite } +/// An @-autocomplete suggestion: a vault note or a folder. +struct AISuggestion: Identifiable, Equatable { + enum Kind { case file, folder } + let name: String // stem (file) or folder name + let kind: Kind + var id: String { "\(kind == .folder ? "dir:" : "file:")\(name)" } + var systemImage: String { kind == .folder ? "folder" : "doc.text" } +} + /// View model backing the inline AI bar. final class InlineAIBarModel: ObservableObject { @Published var prompt: String = "" @@ -41,11 +50,13 @@ final class InlineAIBarModel: ObservableObject { @Published var isStreaming: Bool = false @Published var errorMessage: String? @Published var awaitingAcceptReject: Bool = false // rewrite finished, awaiting decision - @Published var atSuggestions: [String] = [] // file stems matching the active @token + @Published var atSuggestions: [AISuggestion] = [] // notes + folders matching the active @token let mode: InlineAIBarMode /// Vault note file URLs, for @-autocomplete scoring. var allFiles: [URL] = [] + /// Vault folder URLs, for @-autocomplete scoring. + var allFolders: [URL] = [] // Callbacks wired by the host (Task 9). var onSubmit: ((String, AIModel) -> Void)? @@ -54,42 +65,65 @@ final class InlineAIBarModel: ObservableObject { var onAccept: (() -> Void)? var onReject: (() -> Void)? var onCancel: (() -> Void)? // Esc with nothing pending → close the bar + var onDrag: ((CGSize) -> Void)? // drag-handle translation (global coords) + var onDragEnded: (() -> Void)? + var onContentSizeMayHaveChanged: (() -> Void)? // prompt grew / suggestions toggled init(mode: InlineAIBarMode, model: AIModel) { self.mode = mode self.model = model } - /// Recompute @-autocomplete suggestions for the current prompt. + /// Recompute @-autocomplete suggestions for the current prompt — notes and folders, + /// scored by the same algorithm the wiki-link autocomplete uses. func updateSuggestions() { guard let token = activeAtToken(in: prompt), !token.isEmpty else { atSuggestions = [] return } - // Score candidate URLs by the same algorithm wiki-link autocomplete uses, - // then surface the matched file stems. - let scored: [(url: URL, score: Int)] = allFiles - .map { ($0, commandPaletteScoreByFilename(forURL: $0, needle: token)) } - .filter { $0.1 > 0 } - atSuggestions = scored - .sorted { $0.score > $1.score } + let fileScored: [(AISuggestion, Int)] = allFiles.compactMap { + let score = commandPaletteScoreByFilename(forURL: $0, needle: token) + guard score > 0 else { return nil } + return (AISuggestion(name: $0.deletingPathExtension().lastPathComponent, kind: .file), score) + } + let folderScored: [(AISuggestion, Int)] = allFolders.compactMap { + let score = commandPaletteScoreByFolderName(forURL: $0, needle: token) + guard score > 0 else { return nil } + return (AISuggestion(name: $0.lastPathComponent, kind: .folder), score) + } + var seenIDs = Set() + atSuggestions = (fileScored + folderScored) + .sorted { $0.1 > $1.1 } + .map { $0.0 } + .filter { seenIDs.insert($0.id).inserted } // de-dup by id, keep highest score .prefix(8) - .map { $0.url.deletingPathExtension().lastPathComponent } + .map { $0 } } /// Extracts the in-progress @token at the end of the prompt, if any. + /// Supports a bracket form `@[Multi Word` (still being typed) so folder/note names + /// with spaces can be filtered as the user types inside the brackets. private func activeAtToken(in text: String) -> String? { guard let atIndex = text.lastIndex(of: "@") else { return nil } - let after = text[text.index(after: atIndex)...] - // No spaces inside a token; a space after @ means the token is complete. + var after = Substring(text[text.index(after: atIndex)...]) + if after.first == "[" { + after = after.dropFirst() + if let close = after.firstIndex(of: "]") { + // A closed bracket means the token is complete — no live suggestions. + if after.index(after: close) <= after.endIndex { return nil } + } + return String(after) // may contain spaces — that's the point + } + // Bare token: no spaces. if after.contains(" ") { return nil } return String(after) } - /// Replace the active @token with the chosen stem. - func applySuggestion(_ stem: String) { + /// Replace the active @token with the chosen suggestion (bracketed if it has spaces). + func applySuggestion(_ suggestion: AISuggestion) { guard let atIndex = prompt.lastIndex(of: "@") else { return } - let token = stem.contains(" ") ? "@[\(stem)] " : "@\(stem) " + let name = suggestion.name + let token = name.contains(" ") ? "@[\(name)] " : "@\(name) " prompt = String(prompt[.. body + folders: [String] // absolute folder paths + ) -> AIContextResolver { + let fileURLs = files.keys.map { URL(fileURLWithPath: $0) } + let folderURLs = folders.map { URL(fileURLWithPath: $0, isDirectory: true) } + return AIContextResolver( + allFiles: fileURLs, + allFolders: folderURLs, + readContents: { url in files[url.path] } + ) + } + + func test_folderToken_concatenatesDirectChildren() { + let r = makeFolderResolver( + files: [ + "/vault/Weekly Summaries/Mon.md": "monday", + "/vault/Weekly Summaries/Tue.md": "tuesday", + "/vault/Other/Skip.md": "nope" + ], + folders: ["/vault/Weekly Summaries", "/vault/Other"] + ) + let result = r.resolve(prompt: "summarize @[Weekly Summaries]") + XCTAssertEqual(result.blocks.map(\.name), ["Weekly Summaries"]) + let body = result.blocks[0].body + XCTAssertTrue(body.contains("monday")) + XCTAssertTrue(body.contains("tuesday")) + XCTAssertFalse(body.contains("nope")) // not a child of this folder + XCTAssertTrue(result.missing.isEmpty) + } + + func test_folderToken_caseInsensitive() { + let r = makeFolderResolver( + files: ["/vault/Weekly Summaries/A.md": "alpha"], + folders: ["/vault/Weekly Summaries"] + ) + let result = r.resolve(prompt: "@[weekly summaries]") + XCTAssertEqual(result.blocks.map(\.name), ["Weekly Summaries"]) + XCTAssertTrue(result.blocks[0].body.contains("alpha")) + } + + func test_emptyFolder_isReportedMissing() { + let r = makeFolderResolver( + files: [:], + folders: ["/vault/Empty"] + ) + let result = r.resolve(prompt: "@Empty") + XCTAssertEqual(result.missing, ["Empty"]) + XCTAssertTrue(result.blocks.isEmpty) + } + + func test_fileWins_overFolderOfSameName() { + let r = makeFolderResolver( + files: [ + "/vault/Notes.md": "the file", + "/vault/Notes/child.md": "the folder child" + ], + folders: ["/vault/Notes"] + ) + let result = r.resolve(prompt: "@Notes") + XCTAssertEqual(result.blocks.map(\.name), ["Notes"]) + XCTAssertEqual(result.blocks[0].body, "the file") // file preferred + } } From 28805e155fa2f2bc04ac9756f65ae2cfd8491f62 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 05:35:08 -0400 Subject: [PATCH 22/24] fix(ai): undoable AI edits, correct drag Y, Shift+Enter newline, sparkle on empty line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI insertions/rewrites now register with the undo manager and collapse into a single Cmd-Z (routed through shouldChangeText/didChangeText + one undo group per operation). InlineAIController gains an injectable performEdit hook; unit tests keep the direct-storage fallback. - Drag Y was inverted (the text view is a flipped NSView, so the delta is added, not subtracted) — dragging down now moves down. - Shift+Enter inserts a newline in the prompt (in addition to Option+Enter); plain Enter still submits. - The ✨ now sits next to the caret on an empty line (anchors to the line fragment's used width / extra-line-fragment) instead of flying to the far right. - Split the bar view into sub-views to stay within the compiler's type-check budget. Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/EditorView.swift | 103 +++++++++++++++----- macOS/SynapseNotes/InlineAIController.swift | 34 +++++-- macOS/SynapseNotes/InlineAIView.swift | 100 +++++++++++-------- 3 files changed, 163 insertions(+), 74 deletions(-) diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 3115e61..7bb68fb 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2259,36 +2259,50 @@ extension LinkAwareTextView { // MARK: - Inline AI editing - /// Positions a single reused ✨ button at the end of the caret's line, or just - /// past the selection when text is selected. Cheap: one glyph-rect lookup, no parsing. + /// Positions a single reused ✨ button just past the end of the caret's line content + /// (or past the selection when text is selected). Anchors to the *used* width of the + /// caret's line fragment, so on an empty line it sits next to the caret rather than + /// at the far right of the text container. Cheap: one layout lookup, no parsing. func refreshAISparkle() { guard let layoutManager, let textContainer else { return } let sel = selectedRange() let ns = string as NSString - let anchorIndex: Int + // The character index whose line we anchor to: selection end, or the caret. + let anchorIndex = max(0, min(sel.length > 0 ? sel.location + sel.length : sel.location, ns.length)) + + let fallbackLineHeight = layoutManager.defaultLineHeight(for: font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize)) + var lineRect: NSRect if sel.length > 0 { - anchorIndex = sel.location + sel.length - } else { - let lineRange = ns.lineRange(for: NSRange(location: min(sel.location, ns.length), length: 0)) - var end = lineRange.location + lineRange.length - if end > lineRange.location, - ns.substring(with: NSRange(location: end - 1, length: 1)).rangeOfCharacter(from: .newlines) != nil { - end -= 1 + // Non-empty selection: anchor just past the trailing edge of its glyphs. + let selGlyphs = layoutManager.glyphRange(forCharacterRange: sel, actualCharacterRange: nil) + lineRect = layoutManager.boundingRect(forGlyphRange: selGlyphs, in: textContainer) + } else if anchorIndex == ns.length + && (ns.length == 0 || ns.substring(with: NSRange(location: ns.length - 1, length: 1)).rangeOfCharacter(from: .newlines) != nil) { + // Caret on the final empty line (empty doc, or after a trailing newline). The + // layout manager tracks this as the "extra line fragment". Its used rect is + // the caret position on that empty line. + let extra = layoutManager.extraLineFragmentUsedRect + if extra.height > 0 { + lineRect = extra + } else { + lineRect = NSRect(x: 0, y: 0, width: 0, height: fallbackLineHeight) } - anchorIndex = end + } else { + // Caret on a non-trailing (possibly empty) line: use that line fragment's USED + // rect, whose width reflects the actual typeset content (≈ the caret x on an + // empty line), not the full container width — which is what made the ✨ fly + // off to the right. + let glyphIndex = min(layoutManager.glyphIndexForCharacter(at: anchorIndex), max(0, layoutManager.numberOfGlyphs - 1)) + lineRect = layoutManager.lineFragmentUsedRect(forGlyphAt: glyphIndex, effectiveRange: nil) } - let safe = max(0, min(anchorIndex, ns.length)) - let glyphRange = layoutManager.glyphRange( - forCharacterRange: NSRange(location: max(0, safe - 1), length: safe > 0 ? 1 : 0), - actualCharacterRange: nil) - var rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + var rect = lineRect rect.origin.x += textContainerOrigin.x rect.origin.y += textContainerOrigin.y let size: CGFloat = 18 - let frame = NSRect(x: rect.maxX + 4, y: rect.minY + (rect.height - size) / 2, width: size, height: size) + let frame = NSRect(x: rect.maxX + 6, y: rect.minY + (rect.height - size) / 2, width: size, height: size) let button: AISparkleButton if let existing = aiSparkleButton { @@ -2416,17 +2430,17 @@ extension LinkAwareTextView { } } - /// Moves the bar by a drag-handle translation. `translation` is SwiftUI global-space - /// (y grows downward); this view is an unflipped NSView (y grows upward), so the y - /// delta is negated. The translation is cumulative from drag start, so we offset the - /// origin captured when the drag began. + /// Moves the bar by a drag-handle translation. The bar is a subview of the text view, + /// which is a flipped NSView (y grows downward) — same direction as SwiftUI's global + /// translation — so the y delta is ADDED, not negated. The translation is cumulative + /// from drag start, so we offset the origin captured when the drag began. private func dragAIBar(by translation: CGSize) { guard let host = aiBarHostingView else { return } aiBarUserMoved = true let start = aiBarDragStartOrigin ?? host.frame.origin if aiBarDragStartOrigin == nil { aiBarDragStartOrigin = start } let newOrigin = NSPoint(x: start.x + translation.width, - y: start.y - translation.height) + y: start.y + translation.height) // Keep the bar within the visible area. let visible = enclosingScrollView?.documentVisibleRect ?? visibleRect let clampedX = min(max(newOrigin.x, visible.minX + 4), visible.maxX - host.frame.width - 4) @@ -2436,6 +2450,7 @@ extension LinkAwareTextView { private func dismissAIBar() { aiStreamTask?.cancel(); aiStreamTask = nil + endAIUndoGroup() // close the operation's single undo group aiBarHostingView?.removeFromSuperview(); aiBarHostingView = nil aiBarModel = nil refreshAISparkle() @@ -2450,6 +2465,7 @@ extension LinkAwareTextView { guard aiBarHostingView != nil || inlineAIController.mode != .idle else { return } aiStreamTask?.cancel() aiStreamTask = nil + endAIUndoGroup() aiBarHostingView?.removeFromSuperview() aiBarHostingView = nil aiBarModel = nil @@ -2464,6 +2480,37 @@ extension LinkAwareTextView { applyAIDiffColors() } + /// Applies an AI text mutation through the standard NSTextView edit path so it + /// registers with the undo manager (Cmd-Z reverts AI insertions/rewrites). Bounds-safe. + /// All edits in one AI operation are coalesced into a single undo group (see + /// `beginAIUndoGroup`/`endAIUndoGroup`) so one Cmd-Z reverts the whole thing. + private func performAIEdit(_ range: NSRange, _ replacement: String) { + guard let storage = textStorage else { return } + guard range.location >= 0, NSMaxRange(range) <= storage.length else { return } + if shouldChangeText(in: range, replacementString: replacement) { + replaceCharacters(in: range, with: replacement) + didChangeText() + } + } + + /// Opens an undo group so every streamed delta + the accept/reject deletion collapse + /// into a single Cmd-Z. Also disables NSTextView's automatic per-keystroke coalescing + /// boundary so the deltas don't split into separate undo steps. + private func beginAIUndoGroup() { + guard !aiUndoGroupOpen else { return } + breakUndoCoalescing() + undoManager?.beginUndoGrouping() + aiUndoGroupOpen = true + } + + /// Closes the AI undo group opened by `beginAIUndoGroup` (idempotent). + private func endAIUndoGroup() { + guard aiUndoGroupOpen else { return } + undoManager?.endUndoGrouping() + breakUndoCoalescing() + aiUndoGroupOpen = false + } + private func startAIStream(prompt: String, model: AIModel, mode: InlineAIBarMode, selection sel: NSRange) { guard let storage = textStorage else { return } guard let key = KeychainStore().get(), !key.isEmpty else { @@ -2484,6 +2531,14 @@ extension LinkAwareTextView { } else { inlineAIController.beginRewrite(in: storage, selection: sel) } + // Route the controller's text mutations through the undo-registering path so the + // whole AI edit is undoable with Cmd-Z (one logical change, not silent storage edits). + inlineAIController.performEdit = { [weak self] range, replacement in + self?.performAIEdit(range, replacement) + } + // Group every edit in this operation (all deltas + the accept/reject deletion) + // into a single undo step. Idempotent, so Retry re-entry keeps the same group. + beginAIUndoGroup() let selectionText = mode == .rewrite ? (string as NSString).substring(with: sel) : nil let body = AIRequestBuilder.build( @@ -2505,9 +2560,9 @@ extension LinkAwareTextView { do { for try await delta in client.stream(body: body) { await MainActor.run { + // appendDelta routes through performAIEdit, which calls didChangeText(). self?.inlineAIController.appendDelta(delta) self?.applyAIDiffColors() - self?.didChangeText() self?.repositionAIBar() } } @@ -2703,6 +2758,8 @@ class LinkAwareTextView: NSTextView { private var aiBarDragStartOrigin: NSPoint? /// Once the user drags the bar, stop auto-repositioning it below the streamed text. private var aiBarUserMoved = false + /// True while an AI undo group is open (so the whole operation undoes as one step). + private var aiUndoGroupOpen = false /// Injected at setup; source of vault files for @-context. weak var aiAppState: AppState? diff --git a/macOS/SynapseNotes/InlineAIController.swift b/macOS/SynapseNotes/InlineAIController.swift index e47c084..bd04da5 100644 --- a/macOS/SynapseNotes/InlineAIController.swift +++ b/macOS/SynapseNotes/InlineAIController.swift @@ -20,6 +20,21 @@ final class InlineAIController: ObservableObject { private weak var storage: NSTextStorage? + /// Optional edit hook supplied by the view layer. When set, all text mutations are + /// routed through it (so the host can register undo via shouldChangeText/didChangeText); + /// when nil, mutations apply directly to the storage (used by unit tests). The closure + /// receives the range to replace and the replacement string. + var performEdit: ((NSRange, String) -> Void)? + + /// Applies a replacement either through the host's undo-registering hook or directly. + private func applyEdit(_ range: NSRange, _ replacement: String) { + if let performEdit { + performEdit(range, replacement) + } else { + storage?.replaceCharacters(in: range, with: replacement) + } + } + // MARK: Generate func beginGenerate(in storage: NSTextStorage, at location: Int) { @@ -45,9 +60,9 @@ final class InlineAIController: ObservableObject { /// Appends a streamed text delta at the end of the current `newRange`. func appendDelta(_ text: String) { - guard let storage, var nr = newRange, mode != .idle else { return } + guard storage != nil, var nr = newRange, mode != .idle else { return } let insertAt = nr.location + nr.length - storage.replaceCharacters(in: NSRange(location: insertAt, length: 0), with: text) + applyEdit(NSRange(location: insertAt, length: 0), text) nr.length += (text as NSString).length newRange = nr } @@ -70,25 +85,25 @@ final class InlineAIController: ObservableObject { /// Rewrite accept: delete the original, keep the new text. No-op in any other mode. func accept() { guard mode == .rewrite else { return } - guard let storage, let orig = originalRange else { - // Defensive: rewrite mode but no storage/range — clear and bail. + guard let orig = originalRange else { + // Defensive: rewrite mode but no range — clear and bail. mode = .idle; originalRange = nil; newRange = nil return } // The new text sits immediately after the original; deleting the original // shifts the new text left into the original's place. - storage.replaceCharacters(in: orig, with: "") + applyEdit(orig, "") mode = .idle; originalRange = nil; newRange = nil } /// Rewrite reject: delete the streamed new text, restore the original. No-op in any other mode. func reject() { guard mode == .rewrite else { return } - guard let storage, let nr = newRange else { + guard let nr = newRange else { mode = .idle; originalRange = nil; newRange = nil return } - storage.replaceCharacters(in: nr, with: "") + applyEdit(nr, "") mode = .idle; originalRange = nil; newRange = nil } @@ -108,8 +123,9 @@ final class InlineAIController: ObservableObject { /// of appending onto the previous output. func discardOutput() { guard mode != .idle else { return } - if let storage, let nr = newRange, nr.length > 0, NSMaxRange(nr) <= storage.length { - storage.replaceCharacters(in: nr, with: "") + if let nr = newRange, nr.length > 0, + (storage == nil || NSMaxRange(nr) <= storage!.length) { + applyEdit(nr, "") } mode = .idle originalRange = nil diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index 584b446..80caa30 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -135,48 +135,10 @@ struct InlineAIBarView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - // Drag handle — click-drag to move the bar. - dragHandle - - HStack(alignment: .top, spacing: 8) { - Text("✨") - .padding(.top, 2) - // Multi-line prompt: Enter submits, Shift+Enter inserts a newline. - TextField(model.mode == .generate ? "Ask AI to write…" : "Ask AI to edit…", - text: $model.prompt, axis: .vertical) - .lineLimit(1...6) - .textFieldStyle(.plain) - .focused($promptFocused) - .onChange(of: model.prompt) { _ in model.updateSuggestions() } - .onSubmit { submit() } - - Picker("", selection: $model.model) { - ForEach(AIModel.allCases) { m in Text(m.displayName).tag(m) } - } - .labelsHidden() - .frame(width: 110) - - if model.isStreaming { - Button("Stop") { model.onStop?() } - } else if model.awaitingAcceptReject { - Button("Accept") { model.onAccept?() }.keyboardShortcut(.return, modifiers: []) - Button("Reject") { model.onReject?() }.keyboardShortcut(.escape, modifiers: []) - Button("Retry") { - guard !model.prompt.isEmpty else { return } - model.onRetry?(model.prompt, model.model) - } - } else { - Button("Generate") { submit() }.disabled(model.prompt.isEmpty) - } - } - - if let err = model.errorMessage { - Text(err).font(.caption).foregroundColor(.red) - } - - if !model.atSuggestions.isEmpty { - suggestionList - } + dragHandle // click-drag to move the bar + inputRow // ✨ + prompt + model picker + action buttons + errorLine + if !model.atSuggestions.isEmpty { suggestionList } } .padding(8) .background(Color(nsColor: .windowBackgroundColor)) @@ -190,6 +152,60 @@ struct InlineAIBarView: View { .onChange(of: contentSizeSignal) { _ in model.onContentSizeMayHaveChanged?() } } + private var inputRow: some View { + HStack(alignment: .top, spacing: 8) { + Text("✨").padding(.top, 2) + promptField + Picker("", selection: $model.model) { + ForEach(AIModel.allCases) { m in Text(m.displayName).tag(m) } + } + .labelsHidden() + .frame(width: 110) + actionButtons + } + } + + private var promptField: some View { + // Multi-line prompt: Enter submits; Shift+Enter and Option+Enter insert a newline. + TextField(model.mode == .generate ? "Ask AI to write…" : "Ask AI to edit…", + text: $model.prompt, axis: .vertical) + .lineLimit(1...6) + .textFieldStyle(.plain) + .focused($promptFocused) + .onChange(of: model.prompt) { _ in model.updateSuggestions() } + // Shift+Return inserts a newline; everything else (incl. plain Return) is + // ignored so .onSubmit fires for plain Return. + .onKeyPress { keyPress in + if keyPress.key == .return && keyPress.modifiers.contains(.shift) { + model.prompt.append("\n") + return .handled + } + return .ignored + } + .onSubmit { submit() } + } + + @ViewBuilder private var actionButtons: some View { + if model.isStreaming { + Button("Stop") { model.onStop?() } + } else if model.awaitingAcceptReject { + Button("Accept") { model.onAccept?() }.keyboardShortcut(.return, modifiers: []) + Button("Reject") { model.onReject?() }.keyboardShortcut(.escape, modifiers: []) + Button("Retry") { + guard !model.prompt.isEmpty else { return } + model.onRetry?(model.prompt, model.model) + } + } else { + Button("Generate") { submit() }.disabled(model.prompt.isEmpty) + } + } + + @ViewBuilder private var errorLine: some View { + if let err = model.errorMessage { + Text(err).font(.caption).foregroundColor(.red) + } + } + /// A single value that changes whenever the bar's layout footprint might change, /// so one `.onChange` covers prompt growth, suggestion toggles, and state swaps. private var contentSizeSignal: String { From 9a2f3d2aad8a1976c9163ba265126aeece138f71 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 06:09:38 -0400 Subject: [PATCH 23/24] =?UTF-8?q?feat(ai):=20=E2=8C=A5J=20shortcut,=20spar?= =?UTF-8?q?kle=20transparency+hover,=20show/hide=20toggle;=20fix=20test=20?= =?UTF-8?q?keychain=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⌥J opens the inline AI bar at the cursor (same as clicking ✨), even when the sparkle is hidden. - The ✨ is semi-transparent at rest and brightens on hover; pointer cursor on hover (tooltip now notes ⌥J). - New Settings → AI toggle 'Show the ✨ button at the cursor' (showAISparkle, vault-local, default on); refreshAISparkle respects it. - Tests no longer prompt for the macOS keychain password: KeychainStore now conforms to a SecretStore protocol and tests use an InMemorySecretStore, so the suite never touches the system keychain. (The data-protection keychain isn't usable from the unsigned test host without a keychain-access-group entitlement.) - Documented ⌥J in marketing-site docs (index.md shortcuts + markdown.md editing). Co-Authored-By: Claude Opus 4.8 (1M context) --- macOS/SynapseNotes/EditorView.swift | 12 ++++ macOS/SynapseNotes/InlineAIView.swift | 28 ++++++++- macOS/SynapseNotes/KeychainStore.swift | 60 ++++++++++++------- macOS/SynapseNotes/SettingsManager.swift | 22 +++++++ macOS/SynapseNotes/SettingsView.swift | 11 ++++ .../KeychainStoreTests.swift | 28 +++++++-- .../SettingsManagerAIModelTests.swift | 12 ++++ marketing-site/docs/index.md | 5 ++ marketing-site/docs/markdown.md | 1 + 9 files changed, 154 insertions(+), 25 deletions(-) diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 7bb68fb..09c2e11 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2265,6 +2265,11 @@ extension LinkAwareTextView { /// at the far right of the text container. Cheap: one layout lookup, no parsing. func refreshAISparkle() { guard let layoutManager, let textContainer else { return } + // Respect the user's show/hide preference (default on). + guard settings?.showAISparkle ?? true else { + aiSparkleButton?.isHidden = true + return + } let sel = selectedRange() let ns = string as NSString @@ -3526,6 +3531,13 @@ class LinkAwareTextView: NSTextView { } override func keyDown(with event: NSEvent) { + // ⌥J opens the inline AI bar at the cursor/selection (same as clicking the ✨). + // Works even when the ✨ is hidden via Settings. + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == .option, event.charactersIgnoringModifiers?.lowercased() == "j" { + aiSparkleTapped() + return + } if let popover = completionPopover, popover.isShown { switch event.keyCode { case KeyCode.downArrow: completionVC?.moveSelection(by: 1); return diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index 80caa30..258f6f9 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -4,11 +4,16 @@ import AppKit /// The clickable ✨ overlay placed at the active line's end or past a selection. /// Mirrors the editor's existing NSControl-based overlay buttons (target/action). final class AISparkleButton: NSControl { + /// Resting transparency; goes opaque on hover for a subtle affordance. + private static let restingAlpha: CGFloat = 0.5 + private var trackingArea: NSTrackingArea? + override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true - toolTip = "Ask AI" + toolTip = "Ask AI (⌥J)" focusRingType = .none + alphaValue = Self.restingAlpha } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -29,6 +34,27 @@ final class AISparkleButton: NSControl { override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } + + // Brighten on hover, dim back when the mouse leaves. + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { removeTrackingArea(trackingArea) } + let area = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp], + owner: self, userInfo: nil) + addTrackingArea(area) + trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { animateAlpha(to: 1.0) } + override func mouseExited(with event: NSEvent) { animateAlpha(to: Self.restingAlpha) } + + private func animateAlpha(to value: CGFloat) { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.12 + animator().alphaValue = value + } + } } /// Whether the bar opens to generate at the cursor or rewrite a selection. diff --git a/macOS/SynapseNotes/KeychainStore.swift b/macOS/SynapseNotes/KeychainStore.swift index 457c210..7de1de3 100644 --- a/macOS/SynapseNotes/KeychainStore.swift +++ b/macOS/SynapseNotes/KeychainStore.swift @@ -1,9 +1,18 @@ import Foundation import Security +/// A store for a single secret string (the Anthropic API key), abstracted so tests can +/// substitute an in-memory implementation and never touch the system keychain (which +/// prompts for the login password when an ad-hoc-signed test host accesses it). +protocol SecretStore { + func get() -> String? + func set(_ value: String) + func delete() +} + /// Securely stores a single secret (the Anthropic API key) in the macOS Keychain. /// One instance == one (service, account) slot. -struct KeychainStore { +struct KeychainStore: SecretStore { let service: String let account: String @@ -12,15 +21,21 @@ struct KeychainStore { self.account = account } - /// Returns the stored secret, or nil if none is set. - func get() -> String? { - let query: [String: Any] = [ + /// The identifying attributes shared by every operation. + private var baseQuery: [String: Any] { + [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne + kSecAttrAccount as String: account ] + } + + /// Returns the stored secret, or nil if none is set. + func get() -> String? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess, @@ -38,16 +53,11 @@ struct KeychainStore { guard !trimmed.isEmpty else { delete(); return } let data = Data(trimmed.utf8) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] let attributes: [String: Any] = [kSecValueData as String: data] - let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) if status == errSecItemNotFound { - var add = query + var add = baseQuery add[kSecValueData as String] = data SecItemAdd(add as CFDictionary, nil) } @@ -55,11 +65,21 @@ struct KeychainStore { /// Removes the stored secret if present. func delete() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - SecItemDelete(query as CFDictionary) + SecItemDelete(baseQuery as CFDictionary) } } + +/// An in-memory `SecretStore` for tests and previews — never touches the system keychain. +final class InMemorySecretStore: SecretStore { + private var value: String? + init(_ initial: String? = nil) { self.value = initial } + + func get() -> String? { (value?.isEmpty == false) ? value : nil } + + func set(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + self.value = trimmed.isEmpty ? nil : trimmed + } + + func delete() { value = nil } +} diff --git a/macOS/SynapseNotes/SettingsManager.swift b/macOS/SynapseNotes/SettingsManager.swift index 6c78e44..8fe4e0d 100644 --- a/macOS/SynapseNotes/SettingsManager.swift +++ b/macOS/SynapseNotes/SettingsManager.swift @@ -290,6 +290,10 @@ class SettingsManager: ObservableObject { @Published var hideMarkdownWhileEditing: Bool { didSet { save() } } + /// Whether the inline AI ✨ affordance is shown at the cursor/selection. + @Published var showAISparkle: Bool { + didSet { save() } + } @Published var browserStartupURL: String { didSet { save() } } @@ -523,6 +527,7 @@ class SettingsManager: ObservableObject { var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? var hideMarkdownWhileEditing: Bool? + var showAISparkle: Bool? var browserStartupURL: String? var editorBodyFontFamily: String? var editorMonospaceFontFamily: String? @@ -554,6 +559,7 @@ class SettingsManager: ObservableObject { pinnedItems = try container.decodeIfPresent([PinnedItem].self, forKey: .pinnedItems) defaultEditMode = try container.decodeIfPresent(Bool.self, forKey: .defaultEditMode) hideMarkdownWhileEditing = try container.decodeIfPresent(Bool.self, forKey: .hideMarkdownWhileEditing) + showAISparkle = try container.decodeIfPresent(Bool.self, forKey: .showAISparkle) browserStartupURL = try container.decodeIfPresent(String.self, forKey: .browserStartupURL) editorBodyFontFamily = try container.decodeIfPresent(String.self, forKey: .editorBodyFontFamily) editorMonospaceFontFamily = try container.decodeIfPresent(String.self, forKey: .editorMonospaceFontFamily) @@ -580,6 +586,7 @@ class SettingsManager: ObservableObject { var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? var hideMarkdownWhileEditing: Bool? + var showAISparkle: Bool? var browserStartupURL: String? var editorBodyFontFamily: String? var editorMonospaceFontFamily: String? @@ -606,6 +613,7 @@ class SettingsManager: ObservableObject { pinnedItems: [PinnedItem]?, defaultEditMode: Bool?, hideMarkdownWhileEditing: Bool?, + showAISparkle: Bool?, browserStartupURL: String?, editorBodyFontFamily: String? = nil, editorMonospaceFontFamily: String? = nil, @@ -631,6 +639,7 @@ class SettingsManager: ObservableObject { self.pinnedItems = pinnedItems self.defaultEditMode = defaultEditMode self.hideMarkdownWhileEditing = hideMarkdownWhileEditing + self.showAISparkle = showAISparkle self.browserStartupURL = browserStartupURL self.editorBodyFontFamily = editorBodyFontFamily self.editorMonospaceFontFamily = editorMonospaceFontFamily @@ -659,6 +668,7 @@ class SettingsManager: ObservableObject { pinnedItems = try container.decodeIfPresent([PinnedItem].self, forKey: .pinnedItems) defaultEditMode = try container.decodeIfPresent(Bool.self, forKey: .defaultEditMode) hideMarkdownWhileEditing = try container.decodeIfPresent(Bool.self, forKey: .hideMarkdownWhileEditing) + showAISparkle = try container.decodeIfPresent(Bool.self, forKey: .showAISparkle) browserStartupURL = try container.decodeIfPresent(String.self, forKey: .browserStartupURL) editorBodyFontFamily = try container.decodeIfPresent(String.self, forKey: .editorBodyFontFamily) editorMonospaceFontFamily = try container.decodeIfPresent(String.self, forKey: .editorMonospaceFontFamily) @@ -761,6 +771,7 @@ class SettingsManager: ObservableObject { self.pinnedItems = [] self.defaultEditMode = true self.hideMarkdownWhileEditing = false + self.showAISparkle = true self.browserStartupURL = "" self.editorBodyFontFamily = "System" self.editorMonospaceFontFamily = "System Monospace" @@ -820,6 +831,7 @@ class SettingsManager: ObservableObject { self.pinnedItems = [] self.defaultEditMode = true self.hideMarkdownWhileEditing = false + self.showAISparkle = true self.browserStartupURL = "" self.editorBodyFontFamily = "System" self.editorMonospaceFontFamily = "System Monospace" @@ -891,6 +903,7 @@ class SettingsManager: ObservableObject { pinnedItems = config.pinnedItems ?? [] defaultEditMode = config.defaultEditMode ?? true hideMarkdownWhileEditing = config.hideMarkdownWhileEditing ?? false + showAISparkle = config.showAISparkle ?? true browserStartupURL = config.browserStartupURL ?? "" editorBodyFontFamily = config.editorBodyFontFamily ?? "System" editorMonospaceFontFamily = config.editorMonospaceFontFamily ?? "System Monospace" @@ -922,6 +935,7 @@ class SettingsManager: ObservableObject { pinnedItems = [] defaultEditMode = true hideMarkdownWhileEditing = false + showAISparkle = true browserStartupURL = "" editorBodyFontFamily = "System" editorMonospaceFontFamily = "System Monospace" @@ -956,6 +970,7 @@ class SettingsManager: ObservableObject { pinnedItems = vaultConfig.pinnedItems ?? [] defaultEditMode = vaultConfig.defaultEditMode ?? true hideMarkdownWhileEditing = vaultConfig.hideMarkdownWhileEditing ?? false + showAISparkle = vaultConfig.showAISparkle ?? true browserStartupURL = vaultConfig.browserStartupURL ?? "" editorBodyFontFamily = vaultConfig.editorBodyFontFamily ?? "System" editorMonospaceFontFamily = vaultConfig.editorMonospaceFontFamily ?? "System Monospace" @@ -982,6 +997,7 @@ class SettingsManager: ObservableObject { pinnedItems = [] defaultEditMode = true hideMarkdownWhileEditing = false + showAISparkle = true browserStartupURL = "" editorBodyFontFamily = "System" editorMonospaceFontFamily = "System Monospace" @@ -1008,6 +1024,7 @@ class SettingsManager: ObservableObject { pinnedItems = [] defaultEditMode = true hideMarkdownWhileEditing = false + showAISparkle = true browserStartupURL = "" editorBodyFontFamily = "System" editorMonospaceFontFamily = "System Monospace" @@ -1204,6 +1221,7 @@ class SettingsManager: ObservableObject { let pinnedItems: [PinnedItem] let defaultEditMode: Bool let hideMarkdownWhileEditing: Bool + let showAISparkle: Bool let browserStartupURL: String let editorBodyFontFamily: String let editorMonospaceFontFamily: String @@ -1243,6 +1261,7 @@ class SettingsManager: ObservableObject { pinnedItems = s.pinnedItems defaultEditMode = s.defaultEditMode hideMarkdownWhileEditing = s.hideMarkdownWhileEditing + showAISparkle = s.showAISparkle browserStartupURL = s.browserStartupURL editorBodyFontFamily = s.editorBodyFontFamily editorMonospaceFontFamily = s.editorMonospaceFontFamily @@ -1314,6 +1333,7 @@ class SettingsManager: ObservableObject { var pinnedItems: [PinnedItem]? var defaultEditMode: Bool? var hideMarkdownWhileEditing: Bool? + var showAISparkle: Bool? var browserStartupURL: String? var editorBodyFontFamily: String? var editorMonospaceFontFamily: String? @@ -1344,6 +1364,7 @@ class SettingsManager: ObservableObject { pinnedItems: pinnedItems.isEmpty ? nil : pinnedItems, defaultEditMode: defaultEditMode, hideMarkdownWhileEditing: hideMarkdownWhileEditing ? true : nil, + showAISparkle: showAISparkle ? nil : false, browserStartupURL: browserStartupURL.isEmpty ? nil : browserStartupURL, editorBodyFontFamily: editorBodyFontFamily == "System" ? nil : editorBodyFontFamily, editorMonospaceFontFamily: editorMonospaceFontFamily == "System Monospace" ? nil : editorMonospaceFontFamily, @@ -1377,6 +1398,7 @@ class SettingsManager: ObservableObject { pinnedItems: pinnedItems.isEmpty ? nil : pinnedItems, defaultEditMode: defaultEditMode, hideMarkdownWhileEditing: hideMarkdownWhileEditing ? true : nil, + showAISparkle: showAISparkle ? nil : false, browserStartupURL: browserStartupURL.isEmpty ? nil : browserStartupURL, editorBodyFontFamily: editorBodyFontFamily == "System" ? nil : editorBodyFontFamily, editorMonospaceFontFamily: editorMonospaceFontFamily == "System Monospace" ? nil : editorMonospaceFontFamily, diff --git a/macOS/SynapseNotes/SettingsView.swift b/macOS/SynapseNotes/SettingsView.swift index 5014237..0228c0a 100644 --- a/macOS/SynapseNotes/SettingsView.swift +++ b/macOS/SynapseNotes/SettingsView.swift @@ -707,6 +707,17 @@ struct SettingsView: View { } .labelsHidden() .pickerStyle(.segmented) + + Divider() + + Toggle(isOn: $settings.showAISparkle) { + Text("Show the ✨ button at the cursor") + .font(.system(size: 12, weight: .medium, design: .rounded)) + } + Text("When off, open inline AI editing with ⌥J instead.") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } .padding(.vertical, 4) } header: { diff --git a/macOS/SynapseNotesTests/KeychainStoreTests.swift b/macOS/SynapseNotesTests/KeychainStoreTests.swift index 4e02aee..458e3d2 100644 --- a/macOS/SynapseNotesTests/KeychainStoreTests.swift +++ b/macOS/SynapseNotesTests/KeychainStoreTests.swift @@ -1,17 +1,21 @@ import XCTest @testable import Synapse +/// Exercises the `SecretStore` contract against the in-memory implementation. We do NOT +/// hit the real system keychain here: from an ad-hoc-signed test host that triggers a +/// login-password prompt (file-based keychain) or a silent entitlement failure +/// (data-protection keychain). `KeychainStore` is a thin SecItem wrapper over the same +/// contract; the behavior under test is the get/set/delete semantics. final class KeychainStoreTests: XCTestCase { - // Use a dedicated test service so we never touch the real key. - let store = KeychainStore(service: "com.SynapseNotes.tests.anthropic") + var store: SecretStore! override func setUp() { super.setUp() - store.delete() + store = InMemorySecretStore() } override func tearDown() { - store.delete() + store = nil super.tearDown() } @@ -36,9 +40,25 @@ final class KeychainStoreTests: XCTestCase { XCTAssertNil(store.get()) } + func test_setWhitespaceOnly_deletesTheItem() { + store.set("value") + store.set(" \n ") + XCTAssertNil(store.get()) + } + + func test_setTrimsWhitespace() { + store.set(" sk-ant-padded ") + XCTAssertEqual(store.get(), "sk-ant-padded") + } + func test_delete_removesValue() { store.set("value") store.delete() XCTAssertNil(store.get()) } + + func test_inMemoryStore_seedsInitialValue() { + let seeded = InMemorySecretStore("preset") + XCTAssertEqual(seeded.get(), "preset") + } } diff --git a/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift b/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift index 8efa258..f4e2b1b 100644 --- a/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift +++ b/macOS/SynapseNotesTests/SettingsManagerAIModelTests.swift @@ -29,4 +29,16 @@ final class SettingsManagerAIModelTests: XCTestCase { let reloaded = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) XCTAssertEqual(reloaded.aiDefaultModel, "claude-opus-4-8") } + + func test_showAISparkle_defaultsToTrue() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertTrue(mgr.showAISparkle) + } + + func test_showAISparkle_persistsFalseAcrossReload() { + let mgr = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + mgr.showAISparkle = false + let reloaded = SettingsManager(vaultRoot: tempDir, globalConfigPath: globalPath) + XCTAssertFalse(reloaded.showAISparkle) + } } diff --git a/marketing-site/docs/index.md b/marketing-site/docs/index.md index 625c02e..b9817f6 100644 --- a/marketing-site/docs/index.md +++ b/marketing-site/docs/index.md @@ -181,6 +181,11 @@ Synapse Notes relies heavily on keyboard shortcuts to help you navigate and edit | Split Horizontal | `CMD + SHIFT + D` | | Switch Panes | `CMD + OPT + Arrows` | +### AI +| Action | Shortcut | +| --- | --- | +| Open Inline AI Editing | `OPT + J` (or click the ✨ at the cursor) | + ### Other | Action | Shortcut | | --- | --- | diff --git a/marketing-site/docs/markdown.md b/marketing-site/docs/markdown.md index ce027be..0e4e0df 100644 --- a/marketing-site/docs/markdown.md +++ b/marketing-site/docs/markdown.md @@ -388,6 +388,7 @@ While editing: - **⌘F** - Find in note - **⌘G** - Find next - **⇧⌘G** - Find previous +- **⌥J** - Open inline AI editing at the cursor (or click the ✨) - **/command** - Slash commands expand inline at line start or after a space ### Navigation Tips From 4e1e5373e1af5823f6d75607e5c7222e2a59dc64 Mon Sep 17 00:00:00 2001 From: dep Date: Wed, 10 Jun 2026 06:52:43 -0400 Subject: [PATCH 24/24] chore: simplify --- macOS/SynapseNotes/AIContextResolver.swift | 1 - macOS/SynapseNotes/AIRequestBuilder.swift | 7 +- macOS/SynapseNotes/EditorView.swift | 109 ++++++++++++-------- macOS/SynapseNotes/InlineAIController.swift | 28 ++--- macOS/SynapseNotes/InlineAIView.swift | 9 +- macOS/SynapseNotes/SettingsManager.swift | 10 +- 6 files changed, 87 insertions(+), 77 deletions(-) diff --git a/macOS/SynapseNotes/AIContextResolver.swift b/macOS/SynapseNotes/AIContextResolver.swift index 4df73e8..68aa951 100644 --- a/macOS/SynapseNotes/AIContextResolver.swift +++ b/macOS/SynapseNotes/AIContextResolver.swift @@ -88,7 +88,6 @@ struct AIContextResolver { truncated = true if remaining > 0 { blocks.append(Block(name: resolved.name, body: String(resolved.body.prefix(remaining)))) - used = charCap } break } diff --git a/macOS/SynapseNotes/AIRequestBuilder.swift b/macOS/SynapseNotes/AIRequestBuilder.swift index e04f7a0..9c8a5be 100644 --- a/macOS/SynapseNotes/AIRequestBuilder.swift +++ b/macOS/SynapseNotes/AIRequestBuilder.swift @@ -3,15 +3,10 @@ import Foundation /// Builds the Anthropic /v1/messages request body for the inline editor. /// Pure — returns a JSON-serializable dictionary. enum AIRequestBuilder { - enum Mode { - case generate // insert new text at the cursor - case rewrite // transform the selected text - } - static let maxTokens = 4096 static func build( - mode: Mode, + mode: InlineAIBarMode, prompt: String, noteText: String, selection: String?, diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 09c2e11..77ae7a5 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2036,6 +2036,7 @@ extension LinkAwareTextView { self?.refreshInlineImagePreviews() self?.refreshCollapsibleToggles() self?.refreshCodeBlockCopyButtons() + self?.refreshAISparkle() } } @@ -2253,8 +2254,6 @@ extension LinkAwareTextView { button.action = #selector(collapsibleToggleTapped(_:)) button.identifier = NSUserInterfaceItemIdentifier(capturedId) } - - refreshAISparkle() } // MARK: - Inline AI editing @@ -2319,7 +2318,8 @@ extension LinkAwareTextView { addSubview(button) aiSparkleButton = button } - button.frame = frame + // This runs on every caret move; avoid needless invalidation when nothing moved. + if button.frame != frame { button.frame = frame } button.isHidden = (aiBarHostingView != nil) // hide while the bar is open } @@ -2373,18 +2373,12 @@ extension LinkAwareTextView { /// placing it below would push it past the bottom of the visible viewport (a long /// diff), it is placed ABOVE the top of the affected region instead, so it stays on /// screen and still clears the diff. - private func aiBarFrame(below sel: NSRange) -> NSRect { + private func aiBarFrame(below sel: NSRange, size: NSSize? = nil) -> NSRect { guard let layoutManager, let textContainer else { return .zero } let ns = string as NSString - let width = min(bounds.width - 24, 520) - // Size to the bar's content (drag handle + growing prompt + suggestion list), - // so nothing is clipped. fittingSize needs the target width set first. - var barHeight: CGFloat = 80 - if let host = aiBarHostingView { - host.frame.size.width = width - let fitting = host.fittingSize.height - if fitting > 0 { barHeight = max(60, min(fitting, 360)) } - } + let barSize = size ?? aiBarFittedSize() + let width = barSize.width + let barHeight = barSize.height func yOffset(forCharacterIndex index: Int) -> (top: CGFloat, bottom: CGFloat) { let safe = max(0, min(index, ns.length)) @@ -2413,12 +2407,28 @@ extension LinkAwareTextView { return NSRect(x: 12, y: belowY, width: width, height: barHeight) } + /// The bar's content-fitted size (drag handle + growing prompt + suggestion list), + /// clamped to the editor width and a sane height range. fittingSize needs the + /// target width set on the host first. + private func aiBarFittedSize() -> NSSize { + let width = min(bounds.width - 24, 520) + var height: CGFloat = 80 + if let host = aiBarHostingView { + host.frame.size.width = width + let fitting = host.fittingSize.height + if fitting > 0 { height = max(60, min(fitting, 360)) } + } + return NSSize(width: width, height: height) + } + /// Re-anchors the bar below the current affected region (called as text streams in /// and when streaming finishes) so it tracks the growing diff instead of covering it. /// No-op once the user has dragged the bar to a manual position. private func repositionAIBar() { guard let host = aiBarHostingView, !aiBarUserMoved else { return } - host.frame = aiBarFrame(below: aiBarOriginalSelection) + // Streaming doesn't change the bar's content, so reuse its current size — + // avoids a full SwiftUI fitting pass per streamed delta. + host.frame = aiBarFrame(below: aiBarOriginalSelection, size: host.frame.size) } /// Resizes the bar to fit its content (prompt growth, suggestion list). Preserves the @@ -2426,10 +2436,7 @@ extension LinkAwareTextView { private func resizeAIBarToFit() { guard let host = aiBarHostingView else { return } if aiBarUserMoved { - let width = min(bounds.width - 24, 520) - host.frame.size.width = width - let fitting = host.fittingSize.height - host.frame.size.height = max(60, min(fitting > 0 ? fitting : 80, 360)) + host.frame.size = aiBarFittedSize() } else { host.frame = aiBarFrame(below: aiBarOriginalSelection) } @@ -2453,11 +2460,17 @@ extension LinkAwareTextView { host.frame.origin = NSPoint(x: clampedX, y: clampedY) } - private func dismissAIBar() { + /// Shared teardown core: cancels any in-flight stream, closes the operation's + /// single undo group, and removes the bar. + private func cancelAIStreamAndRemoveBar() { aiStreamTask?.cancel(); aiStreamTask = nil - endAIUndoGroup() // close the operation's single undo group + endAIUndoGroup() aiBarHostingView?.removeFromSuperview(); aiBarHostingView = nil aiBarModel = nil + } + + private func dismissAIBar() { + cancelAIStreamAndRemoveBar() refreshAISparkle() } @@ -2468,12 +2481,7 @@ extension LinkAwareTextView { /// diff colors vanish with it. func teardownAISession() { guard aiBarHostingView != nil || inlineAIController.mode != .idle else { return } - aiStreamTask?.cancel() - aiStreamTask = nil - endAIUndoGroup() - aiBarHostingView?.removeFromSuperview() - aiBarHostingView = nil - aiBarModel = nil + cancelAIStreamAndRemoveBar() inlineAIController.resetWithoutMutating() } @@ -2523,11 +2531,11 @@ extension LinkAwareTextView { return } - let files = aiAppState?.allFiles ?? [] - let folders = aiAppState?.allFolders() ?? [] + // Reuse the vault lists captured when the bar was presented; allFolders() + // walks every file's ancestor chain, so don't recompute it per submit. let resolver = AIContextResolver( - allFiles: files, - allFolders: folders, + allFiles: aiBarModel?.allFiles ?? [], + allFolders: aiBarModel?.allFolders ?? [], readContents: { try? String(contentsOf: $0, encoding: .utf8) }) let resolved = resolver.resolve(prompt: prompt) @@ -2547,7 +2555,7 @@ extension LinkAwareTextView { let selectionText = mode == .rewrite ? (string as NSString).substring(with: sel) : nil let body = AIRequestBuilder.build( - mode: mode == .generate ? .generate : .rewrite, + mode: mode, prompt: prompt, noteText: string, selection: selectionText, context: resolved.blocks, model: model) @@ -2567,7 +2575,7 @@ extension LinkAwareTextView { await MainActor.run { // appendDelta routes through performAIEdit, which calls didChangeText(). self?.inlineAIController.appendDelta(delta) - self?.applyAIDiffColors() + self?.colorAIDelta(appendedLength: (delta as NSString).length) self?.repositionAIBar() } } @@ -2580,9 +2588,9 @@ extension LinkAwareTextView { private func stopAIStream() { aiStreamTask?.cancel(); aiStreamTask = nil - let mode = aiBarModel?.mode ?? .generate - if mode == .generate { inlineAIController.cancel() } - finishAIStream(mode: mode) + // finishAIStream owns the per-mode end-of-session rules (generate: reset + + // dismiss; rewrite: await accept/reject). + finishAIStream(mode: aiBarModel?.mode ?? .generate) } private func finishAIStream(mode: InlineAIBarMode) { @@ -2614,18 +2622,34 @@ extension LinkAwareTextView { if aiBarModel?.mode == .rewrite { aiBarModel?.awaitingAcceptReject = true } } - private func acceptAI() { - inlineAIController.accept() + /// Shared accept/reject epilogue: resolve the diff via the controller, restore + /// normal styling, sync the final text to the binding, and close the bar. + private func resolveAIRewrite(_ resolve: () -> Void) { + resolve() clearAIDiffColors() didChangeText() dismissAIBar() } - private func rejectAI() { - inlineAIController.reject() - clearAIDiffColors() - didChangeText() - dismissAIBar() + private func acceptAI() { resolveAIRewrite(inlineAIController.accept) } + + private func rejectAI() { resolveAIRewrite(inlineAIController.reject) } + + /// Colors only the newly appended streamed delta (green) — O(delta) per chunk + /// instead of re-coloring the whole accumulated diff (O(total), quadratic over a + /// stream). The first delta falls back to the full pass so the original range + /// gets its strikethrough/red at the same moment it always has; later wipes by + /// styling passes are restored by `reapplyAIDiffColorsIfActive`. + private func colorAIDelta(appendedLength: Int) { + guard let storage = textStorage, + let nr = inlineAIController.newRange, appendedLength > 0 else { return } + guard nr.length > appendedLength else { + applyAIDiffColors() + return + } + let sub = NSRange(location: NSMaxRange(nr) - appendedLength, length: appendedLength) + guard sub.location >= 0, NSMaxRange(sub) <= storage.length else { return } + storage.addAttribute(.foregroundColor, value: NSColor.systemGreen, range: sub) } private func applyAIDiffColors() { @@ -3359,6 +3383,7 @@ class LinkAwareTextView: NSTextView { self?.refreshInlineImagePreviews() self?.refreshCollapsibleToggles() self?.refreshCodeBlockCopyButtons() + self?.refreshAISparkle() } } diff --git a/macOS/SynapseNotes/InlineAIController.swift b/macOS/SynapseNotes/InlineAIController.swift index bd04da5..6d57d94 100644 --- a/macOS/SynapseNotes/InlineAIController.swift +++ b/macOS/SynapseNotes/InlineAIController.swift @@ -70,46 +70,40 @@ final class InlineAIController: ObservableObject { /// Stops streaming. Generate finishes immediately (nothing to accept); /// rewrite remains pending so the user can accept/reject the partial. func cancel() { - if mode == .generate { finishGenerate() } + if mode == .generate { resetWithoutMutating() } } // MARK: Resolution - /// Generate has no diff — once done, there's nothing to accept; just clear state. - private func finishGenerate() { - mode = .idle - originalRange = nil - newRange = nil - } - /// Rewrite accept: delete the original, keep the new text. No-op in any other mode. func accept() { guard mode == .rewrite else { return } guard let orig = originalRange else { // Defensive: rewrite mode but no range — clear and bail. - mode = .idle; originalRange = nil; newRange = nil + resetWithoutMutating() return } // The new text sits immediately after the original; deleting the original // shifts the new text left into the original's place. applyEdit(orig, "") - mode = .idle; originalRange = nil; newRange = nil + resetWithoutMutating() } /// Rewrite reject: delete the streamed new text, restore the original. No-op in any other mode. func reject() { guard mode == .rewrite else { return } guard let nr = newRange else { - mode = .idle; originalRange = nil; newRange = nil + resetWithoutMutating() return } applyEdit(nr, "") - mode = .idle; originalRange = nil; newRange = nil + resetWithoutMutating() } - /// Clears all session state WITHOUT mutating the text storage. Use when the - /// underlying document is being replaced wholesale (note/tab switch), where - /// touching the old ranges would corrupt the new document or crash. + /// Clears all session state WITHOUT mutating the text storage — the common + /// final step of every session end. Also safe when the underlying document + /// is being replaced wholesale (note/tab switch), where touching the old + /// ranges would corrupt the new document or crash. func resetWithoutMutating() { mode = .idle originalRange = nil @@ -127,8 +121,6 @@ final class InlineAIController: ObservableObject { (storage == nil || NSMaxRange(nr) <= storage!.length) { applyEdit(nr, "") } - mode = .idle - originalRange = nil - newRange = nil + resetWithoutMutating() } } diff --git a/macOS/SynapseNotes/InlineAIView.swift b/macOS/SynapseNotes/InlineAIView.swift index 258f6f9..001f282 100644 --- a/macOS/SynapseNotes/InlineAIView.swift +++ b/macOS/SynapseNotes/InlineAIView.swift @@ -57,7 +57,8 @@ final class AISparkleButton: NSControl { } } -/// Whether the bar opens to generate at the cursor or rewrite a selection. +/// Whether the AI session generates at the cursor or rewrites a selection. +/// Shared by the bar UI and `AIRequestBuilder`. enum InlineAIBarMode { case generate, rewrite } /// An @-autocomplete suggestion: a vault note or a folder. @@ -134,10 +135,8 @@ final class InlineAIBarModel: ObservableObject { var after = Substring(text[text.index(after: atIndex)...]) if after.first == "[" { after = after.dropFirst() - if let close = after.firstIndex(of: "]") { - // A closed bracket means the token is complete — no live suggestions. - if after.index(after: close) <= after.endIndex { return nil } - } + // A closed bracket means the token is complete — no live suggestions. + if after.contains("]") { return nil } return String(after) // may contain spaces — that's the point } // Bare token: no spaces. diff --git a/macOS/SynapseNotes/SettingsManager.swift b/macOS/SynapseNotes/SettingsManager.swift index 8fe4e0d..4ceb1a0 100644 --- a/macOS/SynapseNotes/SettingsManager.swift +++ b/macOS/SynapseNotes/SettingsManager.swift @@ -766,7 +766,7 @@ class SettingsManager: ObservableObject { self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] self.githubPAT = "" - self.aiDefaultModel = "claude-sonnet-4-6" + self.aiDefaultModel = AIModel.default.apiID self.fileTreeMode = .folder self.pinnedItems = [] self.defaultEditMode = true @@ -826,7 +826,7 @@ class SettingsManager: ObservableObject { self.collapsedPanes = [] self.collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] self.githubPAT = "" - self.aiDefaultModel = "claude-sonnet-4-6" + self.aiDefaultModel = AIModel.default.apiID self.fileTreeMode = .folder self.pinnedItems = [] self.defaultEditMode = true @@ -898,7 +898,7 @@ class SettingsManager: ObservableObject { collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] } githubPAT = config.githubPAT ?? "" - aiDefaultModel = config.aiDefaultModel ?? "claude-sonnet-4-6" + aiDefaultModel = config.aiDefaultModel ?? AIModel.default.apiID fileTreeMode = FileTreeMode(rawValue: config.fileTreeMode ?? "") ?? .folder pinnedItems = config.pinnedItems ?? [] defaultEditMode = config.defaultEditMode ?? true @@ -930,7 +930,7 @@ class SettingsManager: ObservableObject { collapsedPanes = [] collapsedSidebarIDs = [FixedSidebar.right2ID.uuidString] githubPAT = "" - aiDefaultModel = "claude-sonnet-4-6" + aiDefaultModel = AIModel.default.apiID fileTreeMode = .folder pinnedItems = [] defaultEditMode = true @@ -1036,7 +1036,7 @@ class SettingsManager: ObservableObject { private func applyGlobalConfig(_ globalConfig: GlobalConfig?) { githubPAT = globalConfig?.githubPAT ?? "" - aiDefaultModel = globalConfig?.aiDefaultModel ?? "claude-sonnet-4-6" + aiDefaultModel = globalConfig?.aiDefaultModel ?? AIModel.default.apiID sidebars = Self.applyPaneAssignments(globalConfig?.sidebarPaneAssignments) sidebarPaneHeights = globalConfig?.sidebarPaneHeights ?? Self.defaultPaneHeights collapsedPanes = Set(globalConfig?.collapsedPanes ?? [])