Skip to content

feat(context): make the live preview a real field the app completes in#655

Merged
FuJacob merged 1 commit into
mainfrom
feat/context-live-preview-real-input
Jun 9, 2026
Merged

feat(context): make the live preview a real field the app completes in#655
FuJacob merged 1 commit into
mainfrom
feat/context-live-preview-real-input

Conversation

@FuJacob

@FuJacob FuJacob commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Summary

The Context pane's live preview was a hand-rolled NSTextView that 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. FocusTracker lifts 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

xcodebuild build -project Cotabby.xcodeproj -scheme Cotabby \
  -destination 'platform=macOS' -derivedDataPath build/DerivedData
# ** BUILD SUCCEEDED **  (full app, on latest main)

xcodebuild test -project Cotabby.xcodeproj -scheme Cotabby \
  -destination 'platform=macOS' -only-testing:CotabbyTests/SelfCaptureGateTests \
  CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO
# ** TEST SUCCEEDED **  Executed 6 tests, 0 failures

swiftlint lint --quiet
# exit 0

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

  • Scoped exception to a safety invariant. FocusTracker previously blocked all capture whenever Cotabby was the focused app. It now allows exactly one element — the live-preview field, keyed on ContextLivePreview.accessibilityIdentifier — and blocks everything else in Cotabby's own windows (search field, Extended Context editor, menus). The decision is a pure helper, SelfCaptureGate, pinned by SelfCaptureGateTests. 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.pbxproj regenerated via XcodeGen (file add/removes: new ContextLivePreviewField, SelfCaptureGate, SelfCaptureGateTests; removed InlineCompletionEditor, LivePreviewModel, LivePreviewModelTests).
  • Removes the now-dead suggestionEngine/configuration threading 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 native NSTextView that the real completion pipeline drives directly. A new SelfCaptureGate type 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.

  • SelfCaptureGate is a pure, AX-free decision type that FocusTracker calls on every poll tick; it fails closed (blocks self-capture) unless the focused element carries the exact sanctioned AX identifier. The @autoclosure wrapping of the AX read skips the IPC entirely for every non-Cotabby app, which is the hot path.
  • ContextLivePreviewField carries no ghost logic and no binding round-trip; updateNSView is intentionally empty — the running app owns the suggestion display, not this view.
  • Deleted: InlineCompletionEditor, LivePreviewModel, LivePreviewModelTests, and the suggestionEngine/configuration props threaded through SettingsContainerView and SettingsCoordinator.

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

Filename Overview
Cotabby/Support/SelfCaptureGate.swift New pure-logic gate for the self-capture invariant; clean and unit-tested, but has a nil==nil Optional equality edge case that could silently misclassify bundle-less apps.
Cotabby/UI/Settings/Components/ContextLivePreviewField.swift Plain NSTextView wrapped for SwiftUI; intentionally carries only the AX identifier and no ghost logic. Missing a placeholder string leaves the field blank on first open.
CotabbyTests/SelfCaptureGateTests.swift Covers all key gate invariants including lazy autoclosure; hardcoded identifier literal instead of referencing ContextLivePreview.accessibilityIdentifier means a string rename would go undetected by tests.
Cotabby/Services/Focus/FocusTracker.swift Self-capture check correctly delegated to SelfCaptureGate; autoclosure wrapping of the AX read avoids the extra IPC on every non-Cotabby poll tick.
Cotabby/Support/AXHelper.swift Minimal additive change: new accessibilityIdentifier helper reads the raw "AXIdentifier" AX attribute, which is the correct undocumented-but-stable way to surface NSView.setAccessibilityIdentifier values.
Cotabby/App/Core/CotabbyAppEnvironment.swift Wires selfCaptureAllowedElementIdentifier using the shared constant from ContextLivePreview; removes the now-dead suggestionEngine property and all downstream threading.
Cotabby/UI/Settings/Panes/ContextPaneView.swift Removes LivePreviewModel and InlineCompletionEditor wiring; replaces with plain ContextLivePreviewField. Status line simplified to just the engine label. Clean reduction.
Cotabby/UI/Settings/SettingsContainerView.swift Removes suggestionEngine/configuration pass-through props; ContextPaneView now needs no injected engine.

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 caret
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(context): make the live preview a r..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

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).
@FuJacob FuJacob merged commit 920db0a into main Jun 9, 2026
4 checks passed
@FuJacob FuJacob deleted the feat/context-live-preview-real-input branch June 9, 2026 07:11
Comment on lines +9 to +10
private let selfBundle = "com.cotabby.app"
private let previewIdentifier = "com.cotabby.settings.context.live-preview"

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 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.

Fix in Codex Fix in Claude Code

Comment on lines +28 to +29
// Not our own process: untouched by this rule.
guard focusedBundleIdentifier == ignoredBundleIdentifier else { return true }

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


// 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant