Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

8 changes: 0 additions & 8 deletions Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
private let runtimeModel: RuntimeBootstrapModel
private let modelDownloadManager: ModelDownloadManager
private let huggingFaceSearchService: HuggingFaceSearchService
private let suggestionEngine: any SuggestionGenerating
private let configuration: SuggestionConfiguration
private let performanceMetricsStore: PerformanceMetricsStore
private let systemMetricsStore: SystemMetricsStore
private let onShowWelcome: () -> Void
Expand All @@ -37,8 +35,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
runtimeModel: RuntimeBootstrapModel,
modelDownloadManager: ModelDownloadManager,
huggingFaceSearchService: HuggingFaceSearchService,
suggestionEngine: any SuggestionGenerating,
configuration: SuggestionConfiguration,
performanceMetricsStore: PerformanceMetricsStore,
systemMetricsStore: SystemMetricsStore,
onShowWelcome: @escaping () -> Void,
Expand All @@ -52,8 +48,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
self.runtimeModel = runtimeModel
self.modelDownloadManager = modelDownloadManager
self.huggingFaceSearchService = huggingFaceSearchService
self.suggestionEngine = suggestionEngine
self.configuration = configuration
self.performanceMetricsStore = performanceMetricsStore
self.systemMetricsStore = systemMetricsStore
self.onShowWelcome = onShowWelcome
Expand Down Expand Up @@ -83,8 +77,6 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
huggingFaceSearchService: huggingFaceSearchService,
performanceMetricsStore: performanceMetricsStore,
systemMetricsStore: systemMetricsStore,
suggestionEngine: suggestionEngine,
configuration: configuration,
onShowWelcome: onShowWelcome,
clearEmojiHistory: clearEmojiHistory
)
Expand Down
14 changes: 3 additions & 11 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@ final class CotabbyAppEnvironment {
let powerSourceMonitor: PowerSourceMonitor
let clipboardContextProvider: ClipboardContextProvider
let suggestionCoordinator: SuggestionCoordinator
/// Shared with the Advanced settings pane so the user can fire an ad-hoc generation against
/// the currently-selected engine and verify that Extended Context (and other prompt inputs)
/// are actually shaping the output. Reusing the live router means the playground produces the
/// same answer the autocomplete pipeline would, not a stand-in.
let suggestionEngine: any SuggestionGenerating
let emojiPickerController: EmojiPickerController
let macroController: MacroController
let inlineCommandCoordinator: InlineCommandCoordinator
Expand Down Expand Up @@ -76,6 +71,9 @@ final class CotabbyAppEnvironment {
let focusModel = FocusTrackingModel(
permissionProvider: { permissionManager.accessibilityGranted },
ignoredBundleIdentifier: Bundle.main.bundleIdentifier,
// The Context pane's live-preview field is the single sanctioned spot where Cotabby may
// complete inside its own UI; the focus tracker recognises it by this AX identifier.
selfCaptureAllowedElementIdentifier: ContextLivePreview.accessibilityIdentifier,
isCaptureSuppressedForBundle: { bundleIdentifier in
guard suggestionSettings.isGloballyEnabled else { return true }
if let bundleIdentifier,
Expand Down Expand Up @@ -113,9 +111,6 @@ final class CotabbyAppEnvironment {
// Live CPU/RAM graph backing for the Performance pane. Holds no state until the pane asks it
// to start sampling, so constructing it eagerly here costs nothing.
let systemMetricsStore = SystemMetricsStore()
// Settings coordinator construction is deferred below until after `suggestionEngine` is
// built — the Advanced pane's "try it" playground needs the engine so it can fire ad-hoc
// generations using the same router the autocomplete pipeline does.
let suggestionInserter = SuggestionInserter(suppressionController: suppressionController)
let overlayController = OverlayController(suggestionSettings: suggestionSettings)
let activationIndicatorController = ActivationIndicatorController()
Expand Down Expand Up @@ -169,8 +164,6 @@ final class CotabbyAppEnvironment {
runtimeModel: runtimeModel,
modelDownloadManager: modelDownloadManager,
huggingFaceSearchService: huggingFaceSearchService,
suggestionEngine: suggestionEngine,
configuration: configuration,
performanceMetricsStore: performanceMetricsStore,
systemMetricsStore: systemMetricsStore,
onShowWelcome: { [weak welcomeCoordinator] in
Expand Down Expand Up @@ -254,7 +247,6 @@ final class CotabbyAppEnvironment {
self.powerSourceMonitor = powerSourceMonitor
self.clipboardContextProvider = clipboardContextProvider
self.suggestionCoordinator = suggestionCoordinator
self.suggestionEngine = suggestionEngine
self.emojiPickerController = emojiPickerController
self.macroController = macroController
self.inlineCommandCoordinator = inlineCommandCoordinator
Expand Down
2 changes: 2 additions & 0 deletions Cotabby/Models/FocusTrackingModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ final class FocusTrackingModel: ObservableObject {
init(
permissionProvider: @escaping @MainActor () -> Bool,
ignoredBundleIdentifier: String?,
selfCaptureAllowedElementIdentifier: String? = nil,
isCaptureSuppressedForBundle: @escaping @MainActor (String?) -> Bool = { _ in false },
publishesPollingEvents: Bool = false
) {
self.ignoredBundleIdentifier = ignoredBundleIdentifier
tracker = FocusTracker(
permissionProvider: permissionProvider,
ignoredBundleIdentifier: ignoredBundleIdentifier,
selfCaptureAllowedElementIdentifier: selfCaptureAllowedElementIdentifier,
isCaptureSuppressedForBundle: isCaptureSuppressedForBundle
)
snapshot = tracker.snapshot
Expand Down
19 changes: 18 additions & 1 deletion Cotabby/Services/Focus/FocusTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ final class FocusTracker {
private var pollInterval: TimeInterval
private let permissionProvider: @MainActor () -> Bool
private let ignoredBundleIdentifier: String?
/// AX identifier of the one element inside Cotabby's own UI that is allowed to be captured: the
/// Context pane's live-preview field. `nil` (the default) keeps the strict "never complete in our
/// own process" rule with no exception. Keyed on AX identity rather than bundle so every other
/// element in Cotabby's windows stays blocked.
private let selfCaptureAllowedElementIdentifier: String?
/// Returns true when the focused app's bundle should NOT have its AX tree deep-walked. The
/// gate runs after the cheap system-wide focused-element query but before the expensive
/// candidate-elements walk in `FocusSnapshotResolver`. macOS popovers (Calendar's event-detail
Expand Down Expand Up @@ -65,12 +70,14 @@ final class FocusTracker {
pollInterval: TimeInterval = 0.08,
permissionProvider: @escaping @MainActor () -> Bool,
ignoredBundleIdentifier: String?,
selfCaptureAllowedElementIdentifier: String? = nil,
isCaptureSuppressedForBundle: @escaping @MainActor (String?) -> Bool = { _ in false },
snapshotResolver: FocusSnapshotResolver? = nil
) {
self.pollInterval = pollInterval
self.permissionProvider = permissionProvider
self.ignoredBundleIdentifier = ignoredBundleIdentifier
self.selfCaptureAllowedElementIdentifier = selfCaptureAllowedElementIdentifier
self.isCaptureSuppressedForBundle = isCaptureSuppressedForBundle
// Default resolver construction must happen inside the actor-isolated initializer body.
// Swift evaluates default parameter expressions before entering the `@MainActor` context.
Expand Down Expand Up @@ -222,7 +229,17 @@ final class FocusTracker {
)
}

if application.bundleIdentifier == ignoredBundleIdentifier {
// Cotabby never completes inside its own UI, with one sanctioned exception: the Context pane's
// live-preview field, tagged with a known AX identifier so the user can exercise the real
// pipeline against their settings. Every other element in Cotabby's own windows (search field,
// Extended Context editor, menus) stays blocked, so this cannot leak completions into Settings.
// The identifier AX read is an autoclosure, so it runs only when Cotabby itself is focused.
if !SelfCaptureGate.allowsCapture(
focusedBundleIdentifier: application.bundleIdentifier,
ignoredBundleIdentifier: ignoredBundleIdentifier,
focusedElementIdentifier: AXHelper.accessibilityIdentifier(of: focusedElement),
sanctionedElementIdentifier: selfCaptureAllowedElementIdentifier
) {
return inactiveCapture(
applicationName: application.localizedName ?? "Cotabby",
bundleIdentifier: application.bundleIdentifier,
Expand Down
7 changes: 7 additions & 0 deletions Cotabby/Support/AXHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ enum AXHelper {
return nil
}

/// Reads an element's AX identifier (what `NSView.setAccessibilityIdentifier` surfaces). There is
/// no public `kAX...` constant for it; the raw attribute name is "AXIdentifier". Used to recognise
/// Cotabby's own sanctioned live-preview field so the focus pipeline can complete in it.
static func accessibilityIdentifier(of element: AXUIElement) -> String? {
stringValue(for: "AXIdentifier" as CFString, on: element)
}

static func boolValue(for attribute: CFString, on element: AXUIElement) -> Bool? {
guard let number = copyAttributeValue(attribute, on: element) as? NSNumber else {
return nil
Expand Down
34 changes: 34 additions & 0 deletions Cotabby/Support/SelfCaptureGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

/// File overview:
/// The pure decision behind Cotabby's "never complete inside our own UI" rule and its single
/// sanctioned exception. It is kept free of Accessibility objects and timer state so the invariant
/// that matters most here can be unit-tested directly: completions must never leak into Cotabby's own
/// settings surfaces (search field, Extended Context editor, menus) except the one live-preview box.
///
/// `FocusTracker` owns the AX reads; it hands the focused element's bundle and identifier to this type
/// and lets it answer yes/no.
enum SelfCaptureGate {
/// Whether the focus pipeline may capture the currently focused element.
///
/// Apps other than Cotabby are always allowed: this rule only constrains capturing Cotabby's own
/// UI. When Cotabby itself is focused, capture is allowed only for the sanctioned element (the
/// Context pane's live-preview field, matched by AX identifier) and blocked for everything else.
///
/// Fails closed: with no sanctioned identifier configured, or an element whose identifier cannot
/// be read, self-capture stays blocked. `focusedElementIdentifier` is an `@autoclosure` so the AX
/// read it usually wraps is skipped entirely for every other app (the common path, run on every
/// poll tick).
static func allowsCapture(
focusedBundleIdentifier: String?,
ignoredBundleIdentifier: String?,
focusedElementIdentifier: @autoclosure () -> String?,
sanctionedElementIdentifier: String?
) -> Bool {
// Not our own process: untouched by this rule.
guard focusedBundleIdentifier == ignoredBundleIdentifier else { return true }
Comment on lines +28 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 nil == nil silently classifies bundle-less apps as Cotabby

Swift's Optional<String> equality makes nil == nil return true, so if ignoredBundleIdentifier is ever nil (e.g., during unit tests that omit it, or if Bundle.main.bundleIdentifier is somehow absent), any focused process that also has no bundle ID passes the guard and falls into the "our own process" branch. There it needs the AX identifier to match sanctionedElementIdentifier — which it won't — so completions are silently blocked in those apps. A fast-fail guard on both being non-nil keeps the intent clear and makes the behavior explicit.

Suggested change
// Not our own process: untouched by this rule.
guard focusedBundleIdentifier == ignoredBundleIdentifier else { return true }
// Not our own process: untouched by this rule.
// Require both identifiers to be non-nil; a nil ignoredBundleIdentifier means no self-bundle
// is configured, so every app (including those with nil bundle IDs) should be allowed through.
guard let focused = focusedBundleIdentifier,
let ignored = ignoredBundleIdentifier,
focused == ignored else { return true }

Fix in Codex Fix in Claude Code

// Our own process: allow only the one sanctioned element.
guard let sanctioned = sanctionedElementIdentifier else { return false }
return focusedElementIdentifier() == sanctioned
}
}
78 changes: 78 additions & 0 deletions Cotabby/UI/Settings/Components/ContextLivePreviewField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import AppKit
import SwiftUI

/// File overview:
/// The Context pane's live-preview field, and the shared identity that lets the real pipeline drive it.
///
/// Cotabby never completes inside its own UI (`FocusTracker` blocks capture whenever Cotabby is the
/// focused app, so completions can never appear in the settings search field, the Extended Context
/// editor, menus, and so on). The live preview is the single sanctioned exception: it is a real,
/// native editable text view, tagged with `ContextLivePreview.accessibilityIdentifier`, and
/// `FocusTracker` lifts its self-capture rule for that one element only. Typing in it is therefore a
/// genuine end-to-end exercise of the production focus -> suggestion -> overlay -> insertion path, the
/// same one every other app gets.
///
/// Because the real overlay renders the gray suggestion at the caret and the real inserter commits it
/// on the accept key, this view renders nothing itself. That is the whole point of the redesign: the
/// previous in-app editor mirrored a SwiftUI binding into an `NSTextView` and reconciled a ghost run
/// inside the editable storage, and that reconciliation raced with live keystrokes and corrupted typed
/// text. Here the `NSTextView` owns its string outright and nothing reaches into it, so typing is fully
/// native.
enum ContextLivePreview {
/// AX identifier on the preview field. `FocusTracker` keys on this exact value to allow
/// self-capture for this element and nothing else in Cotabby's own windows. Wired into the focus
/// pipeline in `CotabbyAppEnvironment`.
static let accessibilityIdentifier = "com.cotabby.settings.context.live-preview"
}

/// A plain multi-line `NSTextView` whose only special behavior is carrying the sanctioned AX
/// identifier. No ghost logic, no binding round-trip: the running app completes in it like any field.
struct ContextLivePreviewField: NSViewRepresentable {
private static let fontSize: CGFloat = 13
private static let textInset = NSSize(width: 8, height: 8)

func makeNSView(context: Context) -> NSScrollView {
let textView = NSTextView()
textView.font = .monospacedSystemFont(ofSize: Self.fontSize, weight: .regular)
textView.textColor = .labelColor
textView.isRichText = false
// Keep typing literal so the preview shows exactly what the model receives: no curly quotes,
// dash collapsing, autocorrect, or text replacement rewriting the user's characters.
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isContinuousSpellCheckingEnabled = false
textView.allowsUndo = true
textView.textContainerInset = Self.textInset
textView.drawsBackground = false

// The identifier is the entire contract with `FocusTracker`: it is how the focus poller tells
// this sanctioned field apart from every other element in Cotabby's own windows.
textView.setAccessibilityIdentifier(ContextLivePreview.accessibilityIdentifier)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No placeholder text leaves the field visually empty on first open

The previous InlineCompletionEditor was driven by LivePreviewModel which always populated the user's typed text, so the pane always appeared active. The new field is completely blank by default; without a placeholder, a first-time user sees an unlabeled empty box with no affordance hinting that they should type here. Adding a placeholder string gives the discoverable cue the surrounding label already implies.

Suggested change
textView.setAccessibilityIdentifier(ContextLivePreview.accessibilityIdentifier)
textView.setAccessibilityIdentifier(ContextLivePreview.accessibilityIdentifier)
textView.placeholderAttributedString = NSAttributedString(
string: "Start typing — the running model will complete here…",
attributes: [
.font: NSFont.monospacedSystemFont(ofSize: Self.fontSize, weight: .regular),
.foregroundColor: NSColor.placeholderTextColor
]
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code


// Standard incantation for a wrapping, vertically-growing text view inside a scroll view.
textView.minSize = NSSize(width: 0, height: 0)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: 0,
height: CGFloat.greatestFiniteMagnitude
)

let scrollView = NSScrollView()
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
return scrollView
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
// Nothing to reconcile: the text view owns its content and the real pipeline owns the
// suggestion. Intentionally empty.
}
}
Loading