Skip to content
Open
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
3 changes: 3 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ extension SuggestionCoordinator {
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
focusSnapshot: focusModel.snapshot
) {
Expand Down Expand Up @@ -59,6 +60,7 @@ extension SuggestionCoordinator {
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: snapshot,
Expand Down Expand Up @@ -87,6 +89,7 @@ extension SuggestionCoordinator {
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: snapshot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ extension SuggestionCoordinator {
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
screenRecordingGranted: permissionManager.screenRecordingGranted,
focusSnapshot: focusModel.snapshot,
Expand All @@ -86,6 +87,7 @@ extension SuggestionCoordinator {
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
focusSnapshot: focusModel.snapshot
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ extension SuggestionCoordinator {
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals,
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
focusSnapshot: focusSnapshot
)
Expand Down
10 changes: 10 additions & 0 deletions Cotabby/Models/FocusModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ struct FocusedInputSnapshot: Equatable {
let selection: NSRange
let isSecure: Bool

/// True when the resolved field is an xterm.js integrated-terminal surface (VS Code / Cursor /
/// Windsurf terminal, or a browser-hosted web terminal). Set by `FocusSnapshotResolver` from the
/// focused element's `AXDOMClassList`. Lets the availability gate suppress ghost text in the
/// terminal without disabling the editor or Copilot chat, which share the same bundle id and so
/// can't be separated by the app-level terminal blocklist. The initializer default keeps existing
/// call sites compiling unchanged.
let isIntegratedTerminal: Bool

/// Monotonic counter that increments every time polling observes a focused-input identity
/// change.
///
Expand Down Expand Up @@ -189,6 +197,7 @@ struct FocusedInputSnapshot: Equatable {
trailingText: String,
selection: NSRange,
isSecure: Bool,
isIntegratedTerminal: Bool = false,
focusChangeSequence: UInt64 = 0,
focusedURLString: String? = nil,
resolvedFieldStyle: ResolvedFieldStyle? = nil
Expand All @@ -208,6 +217,7 @@ struct FocusedInputSnapshot: Equatable {
self.trailingText = trailingText
self.selection = selection
self.isSecure = isSecure
self.isIntegratedTerminal = isIntegratedTerminal
self.focusChangeSequence = focusChangeSequence
self.focusedURLString = focusedURLString
self.resolvedFieldStyle = resolvedFieldStyle
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ enum AcceptanceGranularity: String, CaseIterable, Codable, Sendable {
struct SuggestionSettingsSnapshot: Equatable, Sendable {
let isGloballyEnabled: Bool
let disabledAppBundleIdentifiers: Set<String>
/// When false (the default), ghost text is suppressed in integrated terminals (VS Code / Cursor
/// xterm.js surfaces). Power users can opt back in. Travels in the snapshot so the availability
/// gate sees the live value alongside the other "where Cotabby runs" rules.
let suggestInIntegratedTerminals: Bool
let selectedEngine: SuggestionEngineKind
let selectedWordCountPreset: SuggestionWordCountPreset
/// When true, the generation pipeline uses `customWordCountRange` for the length budget and
Expand Down
4 changes: 4 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ struct SuggestionSettingsData: Equatable {
var showIndicator: Bool
var showAcceptanceHint: Bool
var disabledAppRules: [DisabledApplicationRule]
/// When false (the default), ghost text is suppressed in integrated terminals (VS Code / Cursor
/// xterm.js surfaces); a terminal's own completion/history conflicts with autocomplete and ghost
/// text overlaps command output. Power users can opt back in.
var suggestInIntegratedTerminals: Bool
var customSuggestionTextColorHex: String?
var ghostTextOpacity: Double
var selectedEngine: SuggestionEngineKind
Expand Down
28 changes: 26 additions & 2 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ final class SuggestionSettingsModel: ObservableObject {
/// Whether the keycap hint (the small pill that teaches the accept key) is drawn after ghost text.
@Published private(set) var showAcceptanceHint: Bool
@Published private(set) var disabledAppRules: [DisabledApplicationRule]
/// Whether Cotabby should suggest inside integrated terminals (VS Code / Cursor xterm.js
/// surfaces). Off by default: a terminal's own completion/history conflicts with ghost text and
/// overlaps command output. Power users who want it can opt back in from the Apps settings pane.
@Published private(set) var suggestInIntegratedTerminals: Bool
@Published private(set) var customSuggestionTextColorHex: String?
@Published private(set) var ghostTextOpacity: Double
@Published private(set) var selectedEngine: SuggestionEngineKind
Expand Down Expand Up @@ -132,6 +136,7 @@ final class SuggestionSettingsModel: ObservableObject {
showIndicator = data.showIndicator
showAcceptanceHint = data.showAcceptanceHint
disabledAppRules = data.disabledAppRules
suggestInIntegratedTerminals = data.suggestInIntegratedTerminals
customSuggestionTextColorHex = data.customSuggestionTextColorHex
ghostTextOpacity = data.ghostTextOpacity
selectedEngine = data.selectedEngine
Expand Down Expand Up @@ -184,6 +189,7 @@ final class SuggestionSettingsModel: ObservableObject {
SuggestionSettingsSnapshot(
isGloballyEnabled: isGloballyEnabled,
disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)),
suggestInIntegratedTerminals: suggestInIntegratedTerminals,
selectedEngine: selectedEngine,
selectedWordCountPreset: selectedWordCountPreset,
isUsingCustomWordCountRange: isUsingCustomWordCountRange,
Expand Down Expand Up @@ -471,6 +477,15 @@ final class SuggestionSettingsModel: ObservableObject {
store.saveGloballyEnabled(enabled)
}

func setSuggestInIntegratedTerminals(_ enabled: Bool) {
guard suggestInIntegratedTerminals != enabled else {
return
}

suggestInIntegratedTerminals = enabled
store.saveSuggestInIntegratedTerminals(enabled)
}

func setApplicationDisabled(
bundleIdentifier: String?,
displayName: String,
Expand Down Expand Up @@ -850,18 +865,27 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding {
$customWordCountLowWords,
$customWordCountHighWords
)
return Publishers.CombineLatest4(primary, $acceptanceGranularity, $extendedContext, customRange)
.map { primaryTuple, granularity, extendedContext, customRangeTuple in
// `extendedContext` shares its outer slot with `suggestInIntegratedTerminals` via a paired
// `CombineLatest` so the new toggle costs no extra top-level slot (the outer is at the cap).
return Publishers.CombineLatest4(
primary,
$acceptanceGranularity,
Publishers.CombineLatest($extendedContext, $suggestInIntegratedTerminals),
customRange
)
.map { primaryTuple, granularity, extendedContextTuple, customRangeTuple in
let (combinedSettings, presentationToggles, profile, timing) = primaryTuple
let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings
let (clipboardContextEnabled, fastModeEnabled, mirrorPreference, typoToggles) = presentationToggles
let (suppressOnTypo, offerCorrections) = typoToggles
let (userName, customRules, responseLanguages) = profile
let (debounce, focusPoll, multiLine, autoAcceptPunctuation) = timing
let (isCustomActive, customLow, customHigh) = customRangeTuple
let (extendedContext, suggestInIntegratedTerminals) = extendedContextTuple
return SuggestionSettingsSnapshot(
isGloballyEnabled: globallyEnabled,
disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)),
suggestInIntegratedTerminals: suggestInIntegratedTerminals,
selectedEngine: engine,
selectedWordCountPreset: wordCountPreset,
isUsingCustomWordCountRange: isCustomActive,
Expand Down
12 changes: 12 additions & 0 deletions Cotabby/Services/Focus/FocusSnapshotResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@ struct FocusSnapshotResolver {
)
}
}
// Recognize an xterm.js integrated terminal (VS Code / Cursor / web terminal) from the
// focused element's DOM classes. The terminal, code editor, and Copilot chat all live in one
// process, so this surface-level signal is the only way to suppress ghost text in the
// terminal while leaving the editor and chat working. Read on the focused element because
// that is exactly where xterm puts the caret (`xterm-helper-textarea`). Computed here — only
// once a real editable field has resolved — so idle/non-editable focus polls don't pay for an
// extra AXDOMClassList round-trip; native apps don't vend the attribute anyway.
let isIntegratedTerminal = TerminalAppDetector.isIntegratedTerminal(
domClassList: AXHelper.stringArrayValue(
for: "AXDOMClassList" as CFString, on: focusedElement) ?? []
)
let context = FocusedInputSnapshot(
applicationName: applicationName,
bundleIdentifier: bundleIdentifier,
Expand All @@ -224,6 +235,7 @@ struct FocusSnapshotResolver {
trailingText: nsValue.substring(from: trailingStart),
selection: contextWindow.selection,
isSecure: resolvedCandidate.isSecure,
isIntegratedTerminal: isIntegratedTerminal,
focusChangeSequence: focusChangeSequence,
focusedURLString: focusedURLString,
resolvedFieldStyle: resolvedFieldStyle
Expand Down
7 changes: 7 additions & 0 deletions Cotabby/Support/AXHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ enum AXHelper {
stringValue(for: "AXIdentifier" as CFString, on: element)
}

/// Reads an array-of-strings AX attribute. Chromium/Electron exposes a web element's CSS
/// classes through `AXDOMClassList` this way; native apps simply don't vend the attribute, so
/// this returns nil for them rather than throwing.
static func stringArrayValue(for attribute: CFString, on element: AXUIElement) -> [String]? {
copyAttributeValue(attribute, on: element) as? [String]
}

static func boolValue(for attribute: CFString, on element: AXUIElement) -> Bool? {
guard let number = copyAttributeValue(attribute, on: element) as? NSNumber else {
return nil
Expand Down
13 changes: 13 additions & 0 deletions Cotabby/Support/SuggestionAvailabilityEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum SuggestionAvailabilityEvaluator {
globallyEnabled: Bool = true,
disabledAppBundleIdentifiers: Set<String> = [],
disabledDomains: Set<String> = [],
suggestInIntegratedTerminals: Bool = false,
inputMonitoringGranted: Bool,
focusSnapshot: FocusSnapshot,
checkCapability: Bool = true
Expand Down Expand Up @@ -38,6 +39,14 @@ enum SuggestionAvailabilityEvaluator {
return "Cotabby is not available in terminal apps."
}

// Integrated terminals (VS Code / Cursor xterm.js) share their app's bundle id with the
// editor and chat, so they slip past the blocklist above. Suppress them here unless the user
// has opted back in, keeping ghost text out of shell prompts and command output while the
// editor and Copilot chat in the same window keep suggesting.
if !suggestInIntegratedTerminals, focusSnapshot.context?.isIntegratedTerminal == true {
return "Cotabby is not available in the integrated terminal."
}

guard inputMonitoringGranted else {
return "Input Monitoring permission is required before Cotabby can react to typing."
}
Expand All @@ -58,13 +67,15 @@ enum SuggestionAvailabilityEvaluator {
globallyEnabled: Bool = true,
disabledAppBundleIdentifiers: Set<String> = [],
disabledDomains: Set<String> = [],
suggestInIntegratedTerminals: Bool = false,
inputMonitoringGranted: Bool,
focusSnapshot: FocusSnapshot
) -> Bool {
disabledReason(
globallyEnabled: globallyEnabled,
disabledAppBundleIdentifiers: disabledAppBundleIdentifiers,
disabledDomains: disabledDomains,
suggestInIntegratedTerminals: suggestInIntegratedTerminals,
inputMonitoringGranted: inputMonitoringGranted,
focusSnapshot: focusSnapshot
) == nil
Expand All @@ -86,6 +97,7 @@ enum SuggestionAvailabilityEvaluator {
globallyEnabled: Bool = true,
disabledAppBundleIdentifiers: Set<String> = [],
disabledDomains: Set<String> = [],
suggestInIntegratedTerminals: Bool = false,
inputMonitoringGranted: Bool,
screenRecordingGranted: Bool,
focusSnapshot: FocusSnapshot,
Expand All @@ -103,6 +115,7 @@ enum SuggestionAvailabilityEvaluator {
globallyEnabled: globallyEnabled,
disabledAppBundleIdentifiers: disabledAppBundleIdentifiers,
disabledDomains: disabledDomains,
suggestInIntegratedTerminals: suggestInIntegratedTerminals,
inputMonitoringGranted: inputMonitoringGranted,
focusSnapshot: focusSnapshot,
checkCapability: false
Expand Down
11 changes: 11 additions & 0 deletions Cotabby/Support/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ struct SuggestionSettingsStore {

private static let isGloballyEnabledDefaultsKey = "cotabbyGloballyEnabled"
private static let disabledAppRulesDefaultsKey = "cotabbyDisabledAppRules"
private static let suggestInIntegratedTerminalsDefaultsKey = "cotabbySuggestInIntegratedTerminals"
private static let showCaretIndicatorDefaultsKey = "cotabbyShowCaretIndicator"
private static let selectedIndicatorModeDefaultsKey = "cotabbySelectedIndicatorMode"
private static let showAcceptanceHintDefaultsKey = "cotabbyShowAcceptanceHint"
Expand Down Expand Up @@ -126,6 +127,10 @@ struct SuggestionSettingsStore {
userDefaults.object(forKey: Self.showCaretIndicatorDefaultsKey) as? Bool ?? true
}
let resolvedShowAcceptanceHint = userDefaults.object(forKey: Self.showAcceptanceHintDefaultsKey) as? Bool ?? true
// Defaults to false so ghost text stays out of terminals out of the box, matching how
// standalone terminal apps are already skipped. Existing installs (no key) get the same.
let resolvedSuggestInIntegratedTerminals =
userDefaults.object(forKey: Self.suggestInIntegratedTerminalsDefaultsKey) as? Bool ?? false
let resolvedCustomSuggestionTextColorHex = Self.normalizedHexString(
userDefaults.string(forKey: Self.customSuggestionTextColorHexDefaultsKey)
)
Expand Down Expand Up @@ -303,6 +308,7 @@ struct SuggestionSettingsStore {
showIndicator: resolvedShowIndicator,
showAcceptanceHint: resolvedShowAcceptanceHint,
disabledAppRules: resolvedDisabledAppRules,
suggestInIntegratedTerminals: resolvedSuggestInIntegratedTerminals,
customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex,
ghostTextOpacity: resolvedGhostTextOpacity,
selectedEngine: resolvedEngine,
Expand Down Expand Up @@ -350,6 +356,7 @@ struct SuggestionSettingsStore {
// sticky on the next launch. Mirrors the resolution above field-for-field.
saveGloballyEnabled(data.isGloballyEnabled)
saveDisabledAppRules(data.disabledAppRules)
saveSuggestInIntegratedTerminals(data.suggestInIntegratedTerminals)
saveShowIndicator(data.showIndicator)
saveShowAcceptanceHint(data.showAcceptanceHint)
saveCustomSuggestionTextColorHex(data.customSuggestionTextColorHex)
Expand Down Expand Up @@ -412,6 +419,10 @@ struct SuggestionSettingsStore {
userDefaults.set(enabled, forKey: Self.isGloballyEnabledDefaultsKey)
}

func saveSuggestInIntegratedTerminals(_ enabled: Bool) {
userDefaults.set(enabled, forKey: Self.suggestInIntegratedTerminalsDefaultsKey)
}

func saveDisabledAppRules(_ rules: [DisabledApplicationRule]) {
guard !rules.isEmpty else {
userDefaults.removeObject(forKey: Self.disabledAppRulesDefaultsKey)
Expand Down
17 changes: 17 additions & 0 deletions Cotabby/Support/TerminalAppDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,21 @@ enum TerminalAppDetector {
guard let bundleIdentifier else { return false }
return terminalBundleIdentifiers.contains(bundleIdentifier)
}

/// DOM class prefix xterm.js stamps on every node of its terminal subtree — most importantly the
/// focusable `xterm-helper-textarea` that receives the caret.
private static let integratedTerminalClassPrefix = "xterm"

/// Whether a focused web element's `AXDOMClassList` marks it as an xterm.js terminal surface.
///
/// VS Code, Cursor, Windsurf, and browser-hosted terminals (ttyd, Jupyter) all render their
/// terminal through xterm.js, so an `xterm`-prefixed class is a reliable, localization-independent
/// signal for "the caret is inside an integrated terminal". This is the piece `isTerminal` can't
/// provide: the editor, Copilot chat, and integrated terminal share one process, so the app-level
/// bundle blocklist can only ever block or allow all three together. Matching the whole `xterm`
/// prefix (not just `xterm-helper-textarea`) keeps detection working if xterm renames its input
/// node or focus lands on a sibling like `xterm-screen`.
static func isIntegratedTerminal(domClassList: [String]) -> Bool {
domClassList.contains { $0.hasPrefix(integratedTerminalClassPrefix) }
}
}
Loading