feat(context): make the live preview a real field the app completes in#655
Conversation
Replace the bespoke ghost-rendering NSTextView editor (which mirrored a SwiftUI binding and reconciled a ghost run inside its own storage, racing with and corrupting live keystrokes) with a plain native field, and let the production focus -> suggestion -> overlay pipeline drive it. FocusTracker lifts its 'never complete in our own UI' rule for this one element, matched by a known AX identifier, so the preview exercises the real engine end to end while every other settings surface stays blocked. The gate decision is a pure, unit-tested helper (SelfCaptureGate).
| private let selfBundle = "com.cotabby.app" | ||
| private let previewIdentifier = "com.cotabby.settings.context.live-preview" |
There was a problem hiding this comment.
Hardcoded identifier decouples test from the constant it protects
previewIdentifier is a literal copy of ContextLivePreview.accessibilityIdentifier rather than a reference to it. Because both the focusedElementIdentifier and sanctionedElementIdentifier arguments in the gate tests receive this same local constant, the tests will keep passing even if the real constant is renamed or the value changed — the two strings will still compare equal and the logic under test looks correct. Using ContextLivePreview.accessibilityIdentifier directly would make the test a compile-time lint on the production value and would catch an accidental string drift.
| // Not our own process: untouched by this rule. | ||
| guard focusedBundleIdentifier == ignoredBundleIdentifier else { return true } |
There was a problem hiding this comment.
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.
| // 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 } |
|
|
||
| // 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) |
There was a problem hiding this comment.
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.
| 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!
Summary
The Context pane's live preview was a hand-rolled
NSTextViewthat mirrored a SwiftUI binding and rendered a gray "ghost" run inside its own editable storage. That reconciliation raced with live keystrokes and corrupted typed text (transposed characters, jumping caret), so the box felt unnatural to type in. This replaces it with a plain, native field and lets the production focus → suggestion → overlay → insertion pipeline complete in it directly, exactly as in any other app.FocusTrackerlifts its "never complete inside our own UI" rule for this one element (matched by a known AX identifier), so the preview is now a genuine end-to-end exercise of the real engine while every other settings surface stays blocked. Net ~440 fewer lines.Validation
Not exercised in a live run: the real overlay rendering a completion inside Cotabby's own settings window (AX caret geometry and overlay layering against our own process). This is the one inherent unknown of the approach. If it does not render, the worst case is a plain text box with no ghost text; completion in every other app is unaffected (see Risk notes).
Linked issues
None.
Risk / rollout notes
FocusTrackerpreviously blocked all capture whenever Cotabby was the focused app. It now allows exactly one element — the live-preview field, keyed onContextLivePreview.accessibilityIdentifier— and blocks everything else in Cotabby's own windows (search field, Extended Context editor, menus). The decision is a pure helper,SelfCaptureGate, pinned bySelfCaptureGateTests. For every app other than Cotabby the focus path is byte-identical, so the general completion path cannot regress from this change; the blast radius is confined to the preview field.project.pbxprojregenerated via XcodeGen (file add/removes: newContextLivePreviewField,SelfCaptureGate,SelfCaptureGateTests; removedInlineCompletionEditor,LivePreviewModel,LivePreviewModelTests).suggestionEngine/configurationthreading that fed the old button-gated "try it" playground.Greptile Summary
This PR replaces the Context pane's hand-rolled ghost-text editor (
InlineCompletionEditor+LivePreviewModel) with a plain nativeNSTextViewthat the real completion pipeline drives directly. A newSelfCaptureGatetype encodes the single exception to Cotabby's "never complete inside its own UI" invariant, keyed on a shared AX identifier, and six unit tests pin that boundary. The net effect is ~440 lines removed and a field that behaves identically to any other app target.SelfCaptureGateis a pure, AX-free decision type thatFocusTrackercalls on every poll tick; it fails closed (blocks self-capture) unless the focused element carries the exact sanctioned AX identifier. The@autoclosurewrapping of the AX read skips the IPC entirely for every non-Cotabby app, which is the hot path.ContextLivePreviewFieldcarries no ghost logic and no binding round-trip;updateNSViewis intentionally empty — the running app owns the suggestion display, not this view.InlineCompletionEditor,LivePreviewModel,LivePreviewModelTests, and thesuggestionEngine/configurationprops threaded throughSettingsContainerViewandSettingsCoordinator.Confidence Score: 4/5
Safe to merge; the blast radius of the self-capture exception is tightly scoped by the AX identifier check and confirmed by dedicated tests.
The core safety invariant — that completions cannot appear in Cotabby's own settings surfaces — is now expressed as a pure, well-tested function, and all production references flow through a single shared constant. The three findings are all style/polish: a nil==nil Optional equality edge case in SelfCaptureGate that could silently misclassify bundle-less apps, a hardcoded identifier string in the tests that wouldn't catch a constant rename, and a missing placeholder in the preview field. None of these affect the normal focus path through any real app target.
SelfCaptureGate.swift for the nil-bundle edge case; SelfCaptureGateTests.swift to reference the shared constant directly.
Important Files Changed
accessibilityIdentifierhelper reads the raw "AXIdentifier" AX attribute, which is the correct undocumented-but-stable way to surface NSView.setAccessibilityIdentifier values.Sequence Diagram
sequenceDiagram participant User as User (typing) participant TV as ContextLivePreviewField (NSTextView) participant AX as AXHelper.accessibilityIdentifier participant Gate as SelfCaptureGate participant FT as FocusTracker (poll tick) participant Pipeline as Suggestion Pipeline User->>TV: Types text FT->>FT: "Poll — focusedApp == Cotabby?" FT->>AX: accessibilityIdentifier(of: focusedElement) AX-->>FT: com.cotabby.settings.context.live-preview FT->>Gate: allowsCapture(bundle, ignored, identifier, sanctioned) Gate-->>FT: true (identifier matches sanctioned) FT->>Pipeline: Proceed with focus snapshot Pipeline-->>TV: Overlay renders ghost text at caretReviews (1): Last reviewed commit: "feat(context): make the live preview a r..." | Re-trigger Greptile