From a0cf966a921a7b9a399846a9629d825bb6c26276 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:17:45 -0700 Subject: [PATCH] Skip ghost text in VS Code / Cursor integrated terminals The integrated terminal, code editor, and Copilot chat all share one bundle id (com.microsoft.VSCode), so the app-level TerminalAppDetector blocklist can only block or allow all three together. Detect the terminal at the surface level instead: xterm.js focuses an AXTextField whose AXDOMClassList contains `xterm-helper-textarea`, which the Monaco editor and chat (native-edit-context) never carry. Verified live against VS Code's accessibility tree. FocusSnapshotResolver reads AXDOMClassList on the resolved focused element and records FocusedInputSnapshot.isIntegratedTerminal; SuggestionAvailabilityEvaluator suppresses suggestions and visual-context capture there unless the new suggestInIntegratedTerminals setting is on (default off; toggle in the Apps pane). Editor and Copilot chat keep working; standalone terminal apps are unchanged. Generalizes to Cursor/Windsurf and browser-hosted web terminals (all xterm.js). --- .../SuggestionCoordinator+Input.swift | 3 + .../SuggestionCoordinator+Lifecycle.swift | 2 + .../SuggestionCoordinator+Prediction.swift | 1 + Cotabby/Models/FocusModels.swift | 10 +++ Cotabby/Models/SuggestionEngineModels.swift | 4 ++ Cotabby/Models/SuggestionSettingsData.swift | 4 ++ Cotabby/Models/SuggestionSettingsModel.swift | 28 ++++++++- .../Focus/FocusSnapshotResolver.swift | 12 ++++ Cotabby/Support/AXHelper.swift | 7 +++ .../SuggestionAvailabilityEvaluator.swift | 13 ++++ Cotabby/Support/SuggestionSettingsStore.swift | 11 ++++ Cotabby/Support/TerminalAppDetector.swift | 17 +++++ Cotabby/UI/Settings/Panes/AppsPaneView.swift | 19 ++++++ CotabbyTests/CotabbyTestFixtures.swift | 4 ++ ...SuggestionAvailabilityEvaluatorTests.swift | 62 +++++++++++++++++++ CotabbyTests/TerminalAppDetectorTests.swift | 26 ++++++++ 16 files changed, 221 insertions(+), 2 deletions(-) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index 3fb81f15..c64dad75 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -24,6 +24,7 @@ extension SuggestionCoordinator { globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), + suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, focusSnapshot: focusModel.snapshot ) { @@ -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, @@ -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, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift index 9ef01a6d..8de0639f 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift @@ -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, @@ -86,6 +87,7 @@ extension SuggestionCoordinator { globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), + suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, focusSnapshot: focusModel.snapshot ) { diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index e3773fa2..e9195e9b 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -526,6 +526,7 @@ extension SuggestionCoordinator { globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, disabledDomains: PerDomainDisableSettings.disabledDomains(), + suggestInIntegratedTerminals: settingsSnapshot.suggestInIntegratedTerminals, inputMonitoringGranted: permissionManager.inputMonitoringGranted, focusSnapshot: focusSnapshot ) diff --git a/Cotabby/Models/FocusModels.swift b/Cotabby/Models/FocusModels.swift index 72574ff3..6dfd51aa 100644 --- a/Cotabby/Models/FocusModels.swift +++ b/Cotabby/Models/FocusModels.swift @@ -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. /// @@ -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 @@ -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 diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index d93af8e8..359c6b1b 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -80,6 +80,10 @@ enum AcceptanceGranularity: String, CaseIterable, Codable, Sendable { struct SuggestionSettingsSnapshot: Equatable, Sendable { let isGloballyEnabled: Bool let disabledAppBundleIdentifiers: Set + /// 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 diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 021bf2ea..27596030 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -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 diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index ab136cea..0af117c0 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -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 @@ -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 @@ -184,6 +189,7 @@ final class SuggestionSettingsModel: ObservableObject { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)), + suggestInIntegratedTerminals: suggestInIntegratedTerminals, selectedEngine: selectedEngine, selectedWordCountPreset: selectedWordCountPreset, isUsingCustomWordCountRange: isUsingCustomWordCountRange, @@ -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, @@ -850,8 +865,15 @@ 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 @@ -859,9 +881,11 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { 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, diff --git a/Cotabby/Services/Focus/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/FocusSnapshotResolver.swift index 1be88e04..6cdfe7d7 100644 --- a/Cotabby/Services/Focus/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/FocusSnapshotResolver.swift @@ -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, @@ -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 diff --git a/Cotabby/Support/AXHelper.swift b/Cotabby/Support/AXHelper.swift index 773cdfb2..07cf218a 100644 --- a/Cotabby/Support/AXHelper.swift +++ b/Cotabby/Support/AXHelper.swift @@ -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 diff --git a/Cotabby/Support/SuggestionAvailabilityEvaluator.swift b/Cotabby/Support/SuggestionAvailabilityEvaluator.swift index fa635fef..f39b86d1 100644 --- a/Cotabby/Support/SuggestionAvailabilityEvaluator.swift +++ b/Cotabby/Support/SuggestionAvailabilityEvaluator.swift @@ -11,6 +11,7 @@ enum SuggestionAvailabilityEvaluator { globallyEnabled: Bool = true, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], + suggestInIntegratedTerminals: Bool = false, inputMonitoringGranted: Bool, focusSnapshot: FocusSnapshot, checkCapability: Bool = true @@ -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." } @@ -58,6 +67,7 @@ enum SuggestionAvailabilityEvaluator { globallyEnabled: Bool = true, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], + suggestInIntegratedTerminals: Bool = false, inputMonitoringGranted: Bool, focusSnapshot: FocusSnapshot ) -> Bool { @@ -65,6 +75,7 @@ enum SuggestionAvailabilityEvaluator { globallyEnabled: globallyEnabled, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, + suggestInIntegratedTerminals: suggestInIntegratedTerminals, inputMonitoringGranted: inputMonitoringGranted, focusSnapshot: focusSnapshot ) == nil @@ -86,6 +97,7 @@ enum SuggestionAvailabilityEvaluator { globallyEnabled: Bool = true, disabledAppBundleIdentifiers: Set = [], disabledDomains: Set = [], + suggestInIntegratedTerminals: Bool = false, inputMonitoringGranted: Bool, screenRecordingGranted: Bool, focusSnapshot: FocusSnapshot, @@ -103,6 +115,7 @@ enum SuggestionAvailabilityEvaluator { globallyEnabled: globallyEnabled, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, disabledDomains: disabledDomains, + suggestInIntegratedTerminals: suggestInIntegratedTerminals, inputMonitoringGranted: inputMonitoringGranted, focusSnapshot: focusSnapshot, checkCapability: false diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 7cd50423..b3cc2d35 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -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" @@ -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) ) @@ -303,6 +308,7 @@ struct SuggestionSettingsStore { showIndicator: resolvedShowIndicator, showAcceptanceHint: resolvedShowAcceptanceHint, disabledAppRules: resolvedDisabledAppRules, + suggestInIntegratedTerminals: resolvedSuggestInIntegratedTerminals, customSuggestionTextColorHex: resolvedCustomSuggestionTextColorHex, ghostTextOpacity: resolvedGhostTextOpacity, selectedEngine: resolvedEngine, @@ -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) @@ -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) diff --git a/Cotabby/Support/TerminalAppDetector.swift b/Cotabby/Support/TerminalAppDetector.swift index 2a71ee9a..a9d07a23 100644 --- a/Cotabby/Support/TerminalAppDetector.swift +++ b/Cotabby/Support/TerminalAppDetector.swift @@ -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) } + } } diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index db7f1d32..baa4249f 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -38,6 +38,18 @@ struct AppsPaneView: View { } } + Section("Integrated Terminals") { + Toggle(isOn: suggestInIntegratedTerminalsBinding) { + SettingsRowLabel( + title: "Suggest in Integrated Terminals", + description: "Show ghost text in the VS Code and Cursor integrated terminal. " + + "Off by default so suggestions don't overlap shell prompts and command " + + "output — the code editor and Copilot chat in the same window keep " + + "suggesting either way." + ) + } + } + if !filteredRunningAppSuggestions.isEmpty { Section("Suggestions") { Text("Currently running apps you can disable with one click.") @@ -57,6 +69,13 @@ struct AppsPaneView: View { } } + private var suggestInIntegratedTerminalsBinding: Binding { + Binding( + get: { suggestionSettings.suggestInIntegratedTerminals }, + set: { suggestionSettings.setSuggestInIntegratedTerminals($0) } + ) + } + /// Hide suggestions that are already in the disabled list so the row never shows a /// no-op chip. Recomputed on every redraw because `disabledAppRules` is observed. private var filteredRunningAppSuggestions: [RunningAppSuggestion] { diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index 962089b5..81718bfc 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -25,6 +25,7 @@ enum CotabbyTestFixtures { trailingText: String = "", selection: NSRange? = nil, isSecure: Bool = false, + isIntegratedTerminal: Bool = false, focusChangeSequence: UInt64 = 1 ) -> FocusedInputSnapshot { let resolvedSelection = selection @@ -46,6 +47,7 @@ enum CotabbyTestFixtures { trailingText: trailingText, selection: resolvedSelection, isSecure: isSecure, + isIntegratedTerminal: isIntegratedTerminal, focusChangeSequence: focusChangeSequence ) } @@ -214,6 +216,7 @@ enum CotabbyTestFixtures { static func settingsSnapshot( isGloballyEnabled: Bool = true, disabledAppBundleIdentifiers: Set = [], + suggestInIntegratedTerminals: Bool = false, selectedEngine: SuggestionEngineKind = .llamaOpenSource, selectedWordCountPreset: SuggestionWordCountPreset = .sevenToTwelve, isUsingCustomWordCountRange: Bool = false, @@ -236,6 +239,7 @@ enum CotabbyTestFixtures { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, + suggestInIntegratedTerminals: suggestInIntegratedTerminals, selectedEngine: selectedEngine, selectedWordCountPreset: selectedWordCountPreset, isUsingCustomWordCountRange: isUsingCustomWordCountRange, diff --git a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift index d3678ea8..d521cc26 100644 --- a/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift +++ b/CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift @@ -61,6 +61,68 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { ) } + /// A supported focus snapshot whose field is an xterm.js integrated terminal. Bundle id is a + /// non-terminal app (VS Code shares its bundle with the editor/chat), so only the surface-level + /// `isIntegratedTerminal` flag distinguishes it. + private func makeIntegratedTerminalSnapshot() -> FocusSnapshot { + let context = CotabbyTestFixtures.focusedInputSnapshot( + applicationName: "Code", + bundleIdentifier: "com.microsoft.VSCode", + role: "AXTextField", + isIntegratedTerminal: true + ) + return FocusSnapshot( + applicationName: "Code", + bundleIdentifier: "com.microsoft.VSCode", + capability: .supported, + context: context, + inspection: nil + ) + } + + // MARK: - Integrated terminal gating + + func test_disabledReason_integratedTerminal_suppressedByDefault() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + inputMonitoringGranted: true, + focusSnapshot: makeIntegratedTerminalSnapshot() + ) + + XCTAssertEqual(reason, "Cotabby is not available in the integrated terminal.") + } + + func test_disabledReason_integratedTerminal_allowedWhenOptedIn() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + suggestInIntegratedTerminals: true, + inputMonitoringGranted: true, + focusSnapshot: makeIntegratedTerminalSnapshot() + ) + + XCTAssertNil(reason, "Opting in should let the integrated terminal suggest like any field") + } + + func test_shouldSchedulePrediction_integratedTerminal_falseByDefault_trueWhenOptedIn() { + let snapshot = makeIntegratedTerminalSnapshot() + + XCTAssertFalse( + SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + globallyEnabled: true, + inputMonitoringGranted: true, + focusSnapshot: snapshot + ) + ) + XCTAssertTrue( + SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + globallyEnabled: true, + suggestInIntegratedTerminals: true, + inputMonitoringGranted: true, + focusSnapshot: snapshot + ) + ) + } + // MARK: - disabledReason: exact-string contracts /// If this string ever changes, the menu-bar status copy will silently diff --git a/CotabbyTests/TerminalAppDetectorTests.swift b/CotabbyTests/TerminalAppDetectorTests.swift index c5c09415..5a02311c 100644 --- a/CotabbyTests/TerminalAppDetectorTests.swift +++ b/CotabbyTests/TerminalAppDetectorTests.swift @@ -55,6 +55,32 @@ final class TerminalAppDetectorTests: XCTestCase { XCTAssertFalse(TerminalAppDetector.isTerminal(bundleIdentifier: nil)) } + // MARK: - Integrated terminal (xterm.js DOM class list) + + func test_isIntegratedTerminal_xtermHelperTextarea() { + // The focused input leaf in a VS Code / Cursor terminal — verified live against the real + // AX tree (role AXTextField, class "xterm-helper-textarea"). + XCTAssertTrue(TerminalAppDetector.isIntegratedTerminal(domClassList: ["xterm-helper-textarea"])) + } + + func test_isIntegratedTerminal_xtermPrefixedSibling() { + // Prefix match so focus landing on another xterm node (or an xterm internal rename) still + // counts as a terminal. + XCTAssertTrue(TerminalAppDetector.isIntegratedTerminal(domClassList: ["xterm-screen"])) + } + + func test_isIntegratedTerminal_monacoEditor_isFalse() { + // The VS Code code editor and Copilot chat input — must stay enabled. + XCTAssertFalse(TerminalAppDetector.isIntegratedTerminal(domClassList: ["native-edit-context"])) + XCTAssertFalse( + TerminalAppDetector.isIntegratedTerminal(domClassList: ["monaco-editor", "no-user-select", "mac"]) + ) + } + + func test_isIntegratedTerminal_empty_isFalse() { + XCTAssertFalse(TerminalAppDetector.isIntegratedTerminal(domClassList: [])) + } + // MARK: - Evaluator integration func test_evaluator_blocksTerminalApp() {