diff --git a/Sources/GhosttyNSView+Accessibility.swift b/Sources/GhosttyNSView+Accessibility.swift new file mode 100644 index 000000000000..d38af87c1d12 --- /dev/null +++ b/Sources/GhosttyNSView+Accessibility.swift @@ -0,0 +1,164 @@ +import AppKit +import CmuxTerminal + +extension GhosttyNSView { + // MARK: - Accessibility + + /// Expose the terminal surface as an editable accessibility element. + /// Voice input tools frequently target AX text areas for text insertion. + override func isAccessibilityElement() -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .textArea + } + + override func accessibilityHelp() -> String? { + "Terminal content area" + } + + /// The active screen's text. Dictation tools compare this before and + /// after an insertion to confirm the text landed. + override func accessibilityValue() -> Any? { + accessibilityTextValue() + } + + override func accessibilityNumberOfCharacters() -> Int { + (accessibilityTextValue() as NSString).length + } + + override func accessibilityVisibleCharacterRange() -> NSRange { + NSRange(location: 0, length: accessibilityNumberOfCharacters()) + } + + override func accessibilityLine(for index: Int) -> Int { + let content = accessibilityTextValue() as NSString + let end = min(max(index, 0), content.length) + var line = 0 + for offset in 0.. String? { + let content = accessibilityTextValue() as NSString + guard range.location != NSNotFound, + range.location >= 0, + range.length >= 0, + NSMaxRange(range) <= content.length else { return nil } + return content.substring(with: range) + } + + override func setAccessibilityValue(_ value: Any?) { + guard let content = Self.accessibilityCommittedString(value) else { return } + let inject = { + // Diff against what clients recently read, on the main thread + // where the snapshot lives. + let inserted = self.terminalAccessibilityText.insertedText(settingValue: content) + self.insertAccessibilityCommittedText(inserted) + } + if Thread.isMainThread { + inject() + } else { + DispatchQueue.main.async(execute: inject) + } + } + + /// Clients that insert at the caret set the selected text instead of the + /// whole value. A terminal only accepts input at its cursor, so the text + /// is committed there, the same as `setAccessibilityValue(_:)`. + override func setAccessibilitySelectedText(_ text: String?) { + guard let text else { return } + let inject = { + self.insertAccessibilityCommittedText(text) + } + if Thread.isMainThread { + inject() + } else { + DispatchQueue.main.async(execute: inject) + } + } + + override func isAccessibilitySelectorAllowed(_ selector: Selector) -> Bool { + switch selector { + case #selector(setAccessibilityValue(_:)), #selector(setAccessibilitySelectedText(_:)): + return true + default: + return super.isAccessibilitySelectorAllowed(selector) + } + } + + override func accessibilitySelectedTextRange() -> NSRange { + selectedRange() + } + + override func accessibilitySelectedText() -> String? { + guard let snapshot = readSelectionSnapshot() else { return nil } + return snapshot.string.isEmpty ? nil : snapshot.string + } + + private func accessibilityTextValue() -> String { + terminalAccessibilityText.value { + terminalSurface?.readText(region: .active) + } + } + + private static func accessibilityCommittedString(_ value: Any?) -> String? { + switch value { + case let v as NSAttributedString: + return v.string + case let v as String: + return v + default: + return nil + } + } + + /// Commits text from an accessibility client into the terminal. + /// + /// Single-line text keeps typed-input semantics, including a trailing + /// newline sent as Return, so autosuggestions and a dictation tool's + /// "press enter" keep working. Text with a line break before its end goes + /// through the paste path instead, so a bracketed-paste-aware shell or + /// agent receives one multi-line block rather than running each line as + /// its own command. A trailing newline after that block still submits. + private func insertAccessibilityCommittedText(_ content: String) { + guard !content.isEmpty else { return } + +#if DEBUG + cmuxDebugLog("ime.ax.insert len=\(content.count)") +#endif + + let (body, lineBreaks) = TerminalAccessibilityText.splitTrailingLineBreaks(content) + // A cold surface queues the paste but can send the Return at once, so + // multi-line text takes the paste path only on a live runtime. + if TerminalAccessibilityText.containsLineBreak(body), + let terminalSurface, + terminalSurface.hasLiveSurface { + unmarkText() + let payload = TerminalAccessibilityText.pastePayload( + Self.sanitizeExternalCommittedText(body) + ) + if !payload.isEmpty { + terminalSurface.sendText(payload) + } + if !lineBreaks.isEmpty { + withExternalCommittedText { + insertText(lineBreaks, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + } + } else { + withExternalCommittedText { + insertText(content, replacementRange: NSRange(location: NSNotFound, length: 0)) + } + } + + terminalAccessibilityText.scheduleValueChanged(for: self) { [weak self] in + self?.releaseAccessibilityValueChangedFrameDemand() + } + terminalAccessibilityText.invalidate() + retainAccessibilityValueChangedFrameDemand() + } +} diff --git a/Sources/GhosttyNSView+ForeignCommandChord.swift b/Sources/GhosttyNSView+ForeignCommandChord.swift new file mode 100644 index 000000000000..972d7c8d2329 --- /dev/null +++ b/Sources/GhosttyNSView+ForeignCommandChord.swift @@ -0,0 +1,39 @@ +import AppKit + +extension GhosttyNSView { + /// Whether another process posted `event` rather than the keyboard or cmux. + /// + /// Hardware events carry source PID 0, and events cmux synthesizes carry + /// its own PID. Anything else was injected by another app, for example a + /// dictation tool's global hotkey. + static func isKeyEventPostedByAnotherProcess( + _ event: NSEvent, + currentProcessID: pid_t = ProcessInfo.processInfo.processIdentifier + ) -> Bool { + guard let sourceProcessID = event.cgEvent?.getIntegerValueField(.eventSourceUnixProcessID), + sourceProcessID != 0 else { return false } + return sourceProcessID != Int64(currentProcessID) + } + + /// Whether to swallow a Command+Option chord that missed both the menu + /// and every Ghostty binding, instead of typing it into the terminal. + /// + /// Dictation tools can post their own hotkey (Cmd+Option+C while Option + /// is held as push-to-talk) into the focused app. Nothing in cmux handles + /// that chord, so it would reach the program as a stray character. Only + /// chords injected by another process are dropped; the same chord from + /// the keyboard still reaches the terminal. Command chords without Option + /// are left alone so remote-control and automation tools keep working. + func shouldDropForeignUnboundCommandChord(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command), flags.contains(.option), + Self.isKeyEventPostedByAnotherProcess(event) else { return false } +#if DEBUG + cmuxDebugLog( + "key.foreignCommandChord.drop keyCode=\(event.keyCode) " + + "mods=\(flags.rawValue)" + ) +#endif + return true + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index bbca9d54888a..de0bf36d8bf0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3812,6 +3812,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var _renderedFrameFlushScheduled = false private var _pendingRenderedFrameDeliveryReasons: TerminalRenderedFrameDeliveryReasons = [] + private var accessibilityValueChangedFrameDemandRelease: (any RenderDemandRetention)? /// Pane-local frame demand lets a terminal-specific consumer observe a /// late render without enabling notifications on every terminal surface. let localRenderedFrameNotificationDemand = RenderDemandCounter() @@ -3820,6 +3821,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private let keyboardCopyModeRenderedFrameDemand = RenderDemandCounter() nonisolated let selectionAccessibilitySignal = TerminalSelectionAccessibilitySignal() private var selectionAccessibilityNotifier: TerminalSelectionAccessibilityNotifier? + let terminalAccessibilityText = TerminalAccessibilityText() var cellSize: CGSize = .zero private var lastKnownMousePointInView: NSPoint? private let commandClickReleaseRouter = TerminalCommandClickReleaseRouter() @@ -3977,6 +3979,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _scrollbarLock.unlock() } + func retainAccessibilityValueChangedFrameDemand() { + guard accessibilityValueChangedFrameDemandRelease == nil else { return } + accessibilityValueChangedFrameDemandRelease = localRenderedFrameNotificationDemand.retain() + } + + func releaseAccessibilityValueChangedFrameDemand() { + accessibilityValueChangedFrameDemandRelease?.release() + accessibilityValueChangedFrameDemandRelease = nil + } + func enqueueRenderedFrameUpdate( reasons: TerminalRenderedFrameDeliveryReasons ) { @@ -3998,6 +4010,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let reasons = _pendingRenderedFrameDeliveryReasons _pendingRenderedFrameDeliveryReasons = [] + if reasons.contains(.notification), + terminalAccessibilityText.screenDidChange(read: { [weak self] in + self?.terminalSurface?.readText(region: .active) + }) { + releaseAccessibilityValueChangedFrameDemand() + } + if reasons.contains(.keyboardCopyModeCursor), keyboardCopyModeActive, let surface { @@ -6600,73 +6619,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } - // MARK: - Accessibility - - /// Expose the terminal surface as an editable accessibility element. - /// Voice input tools frequently target AX text areas for text insertion. - override func isAccessibilityElement() -> Bool { - true - } - - override func accessibilityRole() -> NSAccessibility.Role? { - .textArea - } - - override func accessibilityHelp() -> String? { - "Terminal content area" - } - - override func accessibilityValue() -> Any? { - // We don't keep a full terminal text snapshot in this layer. - // Expose selected text when available; otherwise provide an empty value - // so AX clients still treat this as an editable text area. - accessibilitySelectedText() ?? "" - } - - override func setAccessibilityValue(_ value: Any?) { - let content: String - switch value { - case let v as NSAttributedString: - content = v.string - case let v as String: - content = v - default: - return - } - - guard !content.isEmpty else { return } - -#if DEBUG - cmuxDebugLog("ime.ax.setValue len=\(content.count)") -#endif - - let inject = { - self.withExternalCommittedText { - self.insertText(content, replacementRange: NSRange(location: NSNotFound, length: 0)) - } - } - if Thread.isMainThread { - inject() - } else { - DispatchQueue.main.async(execute: inject) - } - } - - private func withExternalCommittedText(_ body: () -> T) -> T { + func withExternalCommittedText(_ body: () -> T) -> T { externalCommittedTextDepth += 1 defer { externalCommittedTextDepth -= 1 } return body() } - override func accessibilitySelectedTextRange() -> NSRange { - selectedRange() - } - - override func accessibilitySelectedText() -> String? { - guard let snapshot = readSelectionSnapshot() else { return nil } - return snapshot.string.isEmpty ? nil : snapshot.string - } - func readSelectionSnapshot(surface explicitSurface: ghostty_surface_t? = nil) -> SelectionSnapshot? { guard let surface = explicitSurface ?? self.surface else { return nil } @@ -7029,7 +6987,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return false } - if !shouldRetryMainMenu { lastPerformKeyEvent = nil; keyDown(with: event); return true } + if !shouldRetryMainMenu { + lastPerformKeyEvent = nil + if !shouldDropForeignUnboundCommandChord(event) { keyDown(with: event) } + return true + } if let lastPerformKeyEvent { self.lastPerformKeyEvent = nil if lastPerformKeyEvent == event.timestamp { diff --git a/Sources/TerminalAccessibilityText.swift b/Sources/TerminalAccessibilityText.swift new file mode 100644 index 000000000000..fab747c23ab0 --- /dev/null +++ b/Sources/TerminalAccessibilityText.swift @@ -0,0 +1,240 @@ +import AppKit + +/// The text a terminal surface shows to accessibility clients, plus the rules +/// for turning an accessibility write back into terminal input. +/// +/// Dictation tools (Typeless, Wispr Flow, Superwhisper, Willow) read `AXValue` +/// before and after they insert, and treat an unchanged value as a failed +/// insertion. The value is a short-lived snapshot of the active screen so +/// repeated AX queries in one burst don't each copy the grid out of Ghostty. +@MainActor +final class TerminalAccessibilityText { + /// How long one snapshot answers AX queries before it is read again. + static let snapshotLifetime: TimeInterval = 0.5 + /// Delay before announcing a value change after an AX insertion, so the + /// shell or agent has usually echoed the text by the time clients re-read. + static let valueChangedDelay: TimeInterval = 0.15 + + /// How long a value handed to an AX client remains eligible for edit + /// detection after it was last vended. + static let vendedValueHistoryLifetime: TimeInterval = 30 + /// The total UTF-8 size of values retained for edit detection. + static let vendedValueHistoryByteLimit = 4 * 1024 * 1024 + + private struct VendedValue { + let value: String + var lastVendedAt: TimeInterval + let byteCount: Int + } + + private var snapshot: String? + private var snapshotCapturedAt: TimeInterval = 0 + /// Values recently handed to AX clients, newest last. A client that edits + /// `AXValue` sends back one of these with its insertion spliced in. + var vendedValues: [String] { + vendedValueHistory.map(\.value) + } + private var vendedValueHistory: [VendedValue] = [] + private weak var valueChangedElement: NSView? + private var valueChangedTimer: Timer? + private var valueChangedBaseline: String? + private var valueChangedCompletion: (() -> Void)? + + nonisolated init() {} + + /// Returns the cached snapshot, reading a fresh one when it has expired. + func value( + now: TimeInterval = ProcessInfo.processInfo.systemUptime, + read: () -> String? + ) -> String { + let value: String + if let snapshot, now - snapshotCapturedAt < Self.snapshotLifetime { + value = snapshot + } else { + value = read() ?? "" + snapshot = value + snapshotCapturedAt = now + } + recordVendedValue(value, at: now) + return value + } + + /// Drops the snapshot so the next AX query reads the terminal again. + func invalidate() { + snapshot = nil + } + + /// Waits for the terminal's next screen update before announcing an AX + /// insertion. The timer is only an acknowledgement fallback for input + /// that produces no visible screen echo (password prompts and full-screen + /// applications, for example). + func scheduleValueChanged(for element: NSView, onComplete: (() -> Void)? = nil) { + valueChangedTimer?.invalidate() + valueChangedElement = element + valueChangedBaseline = snapshot ?? vendedValueHistory.last?.value + valueChangedCompletion = onComplete + let timer = Timer(timeInterval: Self.valueChangedDelay, repeats: false) { [weak self] timer in + // This timer is registered only on RunLoop.main below. + MainActor.assumeIsolated { + guard let self, self.valueChangedTimer === timer else { return } + self.valueChangedTimer = nil + self.finishValueChangedNotification() + } + } + valueChangedTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + /// Completes a pending AX notification after a rendered frame has made the + /// terminal's screen text authoritative. Returns whether the pending + /// notification was posted. + @discardableResult + func screenDidChange(read: () -> String?) -> Bool { + guard valueChangedTimer != nil else { return false } + let current = read() ?? "" + guard let baseline = valueChangedBaseline, current != baseline else { return false } + valueChangedTimer?.invalidate() + valueChangedTimer = nil + finishValueChangedNotification() + return true + } + + private func finishValueChangedNotification() { + invalidate() + let element = valueChangedElement + let completion = valueChangedCompletion + valueChangedElement = nil + valueChangedBaseline = nil + valueChangedCompletion = nil + if let element { + NSAccessibility.post(element: element, notification: .valueChanged) + } + completion?() + } + + /// Returns the text an AX client meant to insert when it sets the whole value. + /// + /// Some clients write `AXValue` as a value they read with their text + /// spliced in. Typing that back would paste the screen into the shell, + /// so when `newValue` is an edit of a recently vended value, only the + /// edited middle is returned. Anything else is taken literally, which is + /// how clients that set just the dictated text have always worked. + /// Vended values remain eligible for 30 seconds since their last AX read, + /// including cached reads. History is capped at 4 MiB of UTF-8 text, + /// evicting the least recently vended values first when it exceeds the cap. + /// A single value larger than the cap is not retained. + func insertedText( + settingValue newValue: String, + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> String { + pruneVendedValues(at: now) + // An exact read anywhere in history beats a partial match against a + // newer screen, which would paste the older screen's differing tail. + for allowsPartialMatch in [false, true] { + for vended in vendedValueHistory.reversed() { + if let inserted = Self.insertedText( + settingValue: newValue, + over: vended.value, + allowsPartialMatch: allowsPartialMatch + ) { + return inserted + } + } + } + return newValue + } + + private func recordVendedValue(_ value: String, at now: TimeInterval) { + pruneVendedValues(at: now) + guard !value.isEmpty else { return } + if vendedValueHistory.last?.value == value { + vendedValueHistory[vendedValueHistory.count - 1].lastVendedAt = now + return + } + let byteCount = value.utf8.count + guard byteCount <= Self.vendedValueHistoryByteLimit else { return } + vendedValueHistory.removeAll { $0.value == value } + vendedValueHistory.append(VendedValue(value: value, lastVendedAt: now, byteCount: byteCount)) + var totalBytes = vendedValueHistory.reduce(0) { $0 + $1.byteCount } + while totalBytes > Self.vendedValueHistoryByteLimit, !vendedValueHistory.isEmpty { + totalBytes -= vendedValueHistory.removeFirst().byteCount + } + } + + private func pruneVendedValues(at now: TimeInterval) { + vendedValueHistory.removeAll { now - $0.lastVendedAt >= Self.vendedValueHistoryLifetime } + } + + /// The edited middle of `newValue` when it keeps `currentValue` around one + /// edit, or `nil` when it isn't an edit of `currentValue`. + /// + /// A pure insertion keeps all of `currentValue`. A longer value may also + /// have lost a selection the client replaced, so it counts when most of + /// it survives. Short values need a pure insertion, so a literal that + /// happens to end like a two-character prompt isn't trimmed. With + /// `allowsPartialMatch` false, only a pure insertion counts. + static func insertedText( + settingValue newValue: String, + over currentValue: String, + allowsPartialMatch: Bool = true + ) -> String? { + let old = Array(currentValue.unicodeScalars) + guard !old.isEmpty else { return nil } + let new = Array(newValue.unicodeScalars) + let limit = min(old.count, new.count) + var prefix = 0 + while prefix < limit, old[prefix] == new[prefix] { + prefix += 1 + } + var suffix = 0 + while suffix < limit - prefix, + old[old.count - 1 - suffix] == new[new.count - 1 - suffix] { + suffix += 1 + } + let kept = prefix + suffix + guard kept == old.count || (allowsPartialMatch && old.count >= 64 && kept >= old.count / 2) else { + return nil + } + var inserted = String.UnicodeScalarView() + inserted.append(contentsOf: new[prefix..<(new.count - suffix)]) + return String(inserted) + } + + /// Splits committed text into the part to insert and a trailing run of + /// line breaks that the client sent as a submit. + static func splitTrailingLineBreaks(_ text: String) -> (body: String, lineBreaks: String) { + let scalars = Array(text.unicodeScalars) + var end = scalars.count + while end > 0, scalars[end - 1] == "\n" || scalars[end - 1] == "\r" { + end -= 1 + } + var body = String.UnicodeScalarView() + body.append(contentsOf: scalars[.. Bool { + text.unicodeScalars.contains { $0 == "\n" || $0 == "\r" } + } + + /// Removes control characters other than tab and line breaks from + /// multi-line dictation before it is pasted. Dictated text never needs + /// them, and an ESC inside a bracketed paste could end it early. + static func pastePayload(_ text: String) -> String { + var kept = String.UnicodeScalarView() + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x09, 0x0A, 0x0D: + kept.append(scalar) + case 0x00...0x1F, 0x7F, 0x80...0x9F: + continue + default: + kept.append(scalar) + } + } + return String(kept) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c669487e6694..1e0a13888527 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2058,8 +2058,10 @@ F0A110030000000000000001 /* GhosttyMouseSessionLedgerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A110040000000000000001 /* GhosttyMouseSessionLedgerTests.swift */; }; A11EAF000000000000000000 /* GhosttyNotificationDispatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAF000000000000000001 /* GhosttyNotificationDispatcherTests.swift */; }; D78971000000000000000001 /* GhosttyNotificationScrollRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78971000000000000000002 /* GhosttyNotificationScrollRuntimeBridge.swift */; }; + 09F6D65337EEB5988F365778 /* GhosttyNSView+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C738D190FCB85202992C756 /* GhosttyNSView+Accessibility.swift */; }; B88357000000000000000001 /* GhosttyNSView+ClipboardActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000002 /* GhosttyNSView+ClipboardActions.swift */; }; B88357000000000000000003 /* GhosttyNSView+ClipboardInputSequencing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000004 /* GhosttyNSView+ClipboardInputSequencing.swift */; }; + 972DEB57D4E5CE93A044F413 /* GhosttyNSView+ForeignCommandChord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4257EE224618460A48C8B /* GhosttyNSView+ForeignCommandChord.swift */; }; F0ACC0DE0000000000000001 /* GhosttyNSView+ForkConversationContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE0000000000000002 /* GhosttyNSView+ForkConversationContextMenu.swift */; }; D3571000A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */; }; C75200010000000000000101 /* GhosttyNSView+LinkContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75200010000000000000102 /* GhosttyNSView+LinkContextMenu.swift */; }; @@ -3471,6 +3473,8 @@ 60B6B31B3D982694EFDFF85A /* TeamChangeAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 463126C4EC99FFF15A6DB847 /* TeamChangeAuthClient.swift */; }; 4146C0FDC6AD61A318F4FE9A /* TeamChangeInProgressError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5177F4A60D55EBC0B59312F8 /* TeamChangeInProgressError.swift */; }; 0044CBDD567D9CD29E6A6F12 /* TelemetrySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 257DFD20F0961FE0BDAF70F2 /* TelemetrySettings.swift */; }; + D63161E354D8403AB167AD2A /* TerminalAccessibilityText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E8740439F2BBFC91C571C3 /* TerminalAccessibilityText.swift */; }; + 8EAD3C75402AB9B2CCF95DD6 /* TerminalAccessibilityTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362F5BADDCB2DB6D502F8493 /* TerminalAccessibilityTextTests.swift */; }; A10199010000000000000001 /* TerminalAdaptiveDefaultThemeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10199010000000000000002 /* TerminalAdaptiveDefaultThemeSettings.swift */; }; C10190010000000000000002 /* TerminalAgentPanelInitialTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10190010000000000000001 /* TerminalAgentPanelInitialTitleTests.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; @@ -3630,6 +3634,7 @@ A5C41105A1B2C3D4E5F60718 /* TerminalControllingTTYWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41106A1B2C3D4E5F60718 /* TerminalControllingTTYWaiter.swift */; }; 6190C0126190C0126190C012 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6190C0116190C0116190C011 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift */; }; EC010B01 /* TerminalCustomUploadRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC010B02 /* TerminalCustomUploadRunner.swift */; }; + 6277E32DA3CFCD625C6BD4E0 /* TerminalDictationAccessibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81B9355A408361D86E077820 /* TerminalDictationAccessibilityTests.swift */; }; 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */; }; E5C0F1A0E5C0F1A0E5C0F1A0 /* TerminalFindEscapeRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */; }; F87920000000000000000007 /* TerminalFontConfigurationReconciliationWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87920000000000000000008 /* TerminalFontConfigurationReconciliationWork.swift */; }; @@ -6249,8 +6254,10 @@ F0A110040000000000000001 /* GhosttyMouseSessionLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyMouseSessionLedgerTests.swift; sourceTree = ""; }; A11EAF000000000000000001 /* GhosttyNotificationDispatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyNotificationDispatcherTests.swift; sourceTree = ""; }; D78971000000000000000002 /* GhosttyNotificationScrollRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyNotificationScrollRuntimeBridge.swift; sourceTree = ""; }; + 2C738D190FCB85202992C756 /* GhosttyNSView+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+Accessibility.swift"; sourceTree = ""; }; B88357000000000000000002 /* GhosttyNSView+ClipboardActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+ClipboardActions.swift"; sourceTree = ""; }; B88357000000000000000004 /* GhosttyNSView+ClipboardInputSequencing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+ClipboardInputSequencing.swift"; sourceTree = ""; }; + 50E4257EE224618460A48C8B /* GhosttyNSView+ForeignCommandChord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+ForeignCommandChord.swift"; sourceTree = ""; }; F0ACC0DE0000000000000002 /* GhosttyNSView+ForkConversationContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+ForkConversationContextMenu.swift"; sourceTree = ""; }; D3571001A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+IMEComposition.swift"; sourceTree = ""; }; C75200010000000000000102 /* GhosttyNSView+LinkContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+LinkContextMenu.swift"; sourceTree = ""; }; @@ -7637,6 +7644,8 @@ 463126C4EC99FFF15A6DB847 /* TeamChangeAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TeamChangeAuthClient.swift"; sourceTree = ""; }; 5177F4A60D55EBC0B59312F8 /* TeamChangeInProgressError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamChangeInProgressError.swift; sourceTree = ""; }; 257DFD20F0961FE0BDAF70F2 /* TelemetrySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetrySettings.swift"; sourceTree = ""; }; + 41E8740439F2BBFC91C571C3 /* TerminalAccessibilityText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAccessibilityText.swift; sourceTree = ""; }; + 362F5BADDCB2DB6D502F8493 /* TerminalAccessibilityTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalAccessibilityTextTests.swift"; sourceTree = ""; }; A10199010000000000000002 /* TerminalAdaptiveDefaultThemeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAdaptiveDefaultThemeSettings.swift; sourceTree = ""; }; C10190010000000000000001 /* TerminalAgentPanelInitialTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAgentPanelInitialTitleTests.swift; sourceTree = ""; }; 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; @@ -7796,6 +7805,7 @@ A5C41106A1B2C3D4E5F60718 /* TerminalControllingTTYWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllingTTYWaiter.swift; sourceTree = ""; }; 6190C0116190C0116190C011 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCopyOnSelectManagedConfigLayeringTests.swift; sourceTree = ""; }; EC010B02 /* TerminalCustomUploadRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCustomUploadRunner.swift; sourceTree = ""; }; + 81B9355A408361D86E077820 /* TerminalDictationAccessibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalDictationAccessibilityTests.swift"; sourceTree = ""; }; 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalDirectoryOpenSupport.swift; sourceTree = ""; }; E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalFindEscapeRouting.swift; sourceTree = ""; }; F87920000000000000000008 /* TerminalFontConfigurationReconciliationWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalFontConfigurationReconciliationWork.swift; sourceTree = ""; }; @@ -11303,6 +11313,9 @@ 2DA313712597F71E6015ABBC /* TerminalSSHSessionDetectionTimeoutGate.swift */, 7611B3AEEB664825A7DD8733 /* TerminalSSHSessionDetector+Async.swift */, 61BF07E91B28C5740AD10DD9 /* TerminalSSHSessionDetector+ProcessInfo.swift */, + 2C738D190FCB85202992C756 /* GhosttyNSView+Accessibility.swift */, + 50E4257EE224618460A48C8B /* GhosttyNSView+ForeignCommandChord.swift */, + 41E8740439F2BBFC91C571C3 /* TerminalAccessibilityText.swift */, 68B75C6322ED3E5FFEDEAE09 /* SessionRestorableAgentSnapshot+HibernationLaunchFidelity.swift */, 8F695E641DFA8B05C8818231 /* AgentHibernationTranscriptGuard+BackgroundWork.swift */, 862267A5286E6B91B81484C1 /* CmuxTopSnapshot+AgentBackgroundWork.swift */, @@ -12724,6 +12737,8 @@ 311C986A1E7430418708AAA6 /* MachinesListStatusToolbarRowTests.swift */, 1C38B9AFEEE4FAE6089C250F /* CloudTreeRowToolTipTests.swift */, 6F27EA9972F21B98FB4A7312 /* SurfaceCatalogObservationTests.swift */, + 81B9355A408361D86E077820 /* TerminalDictationAccessibilityTests.swift */, + 362F5BADDCB2DB6D502F8493 /* TerminalAccessibilityTextTests.swift */, 4D426028A0CE6FA90D8EB6E9 /* AgentHibernationLaunchFidelityTests.swift */, 9E6CE8A534885E9CE99D3F84 /* CloudMachineCreatorTests.swift */, C4C4607FC6F461CC8B35343E /* AgentHibernationBackgroundWorkTests.swift */, @@ -14558,8 +14573,10 @@ F0A1100B0000000000000001 /* GhosttyMouseSessionLedgerSession.swift in Sources */, F0A110070000000000000001 /* GhosttyMouseSessionLedgerSurfaceIdentity.swift in Sources */, D78971000000000000000001 /* GhosttyNotificationScrollRuntimeBridge.swift in Sources */, + 09F6D65337EEB5988F365778 /* GhosttyNSView+Accessibility.swift in Sources */, B88357000000000000000001 /* GhosttyNSView+ClipboardActions.swift in Sources */, B88357000000000000000003 /* GhosttyNSView+ClipboardInputSequencing.swift in Sources */, + 972DEB57D4E5CE93A044F413 /* GhosttyNSView+ForeignCommandChord.swift in Sources */, F0ACC0DE0000000000000001 /* GhosttyNSView+ForkConversationContextMenu.swift in Sources */, D3571000A1B2C3D4E5F60718 /* GhosttyNSView+IMEComposition.swift in Sources */, C75200010000000000000101 /* GhosttyNSView+LinkContextMenu.swift in Sources */, @@ -15469,6 +15486,7 @@ 7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */, 4146C0FDC6AD61A318F4FE9A /* TeamChangeInProgressError.swift in Sources */, 0044CBDD567D9CD29E6A6F12 /* TelemetrySettings.swift in Sources */, + D63161E354D8403AB167AD2A /* TerminalAccessibilityText.swift in Sources */, A10199010000000000000001 /* TerminalAdaptiveDefaultThemeSettings.swift in Sources */, 8362A0030000000000000003 /* TerminalCallerTTYBinding.swift in Sources */, 8362A0010000000000000001 /* TerminalCallerTTYResolver.swift in Sources */, @@ -17423,6 +17441,7 @@ C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */, C7A507000000000000004529 /* TaskManagerViewSnapshotBoundaryTests.swift in Sources */, 60B6B31B3D982694EFDFF85A /* TeamChangeAuthClient.swift in Sources */, + 8EAD3C75402AB9B2CCF95DD6 /* TerminalAccessibilityTextTests.swift in Sources */, C10190010000000000000002 /* TerminalAgentPanelInitialTitleTests.swift in Sources */, 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */, 8362B0010000000000000001 /* TerminalCallerTTYResolverTests.swift in Sources */, @@ -17434,6 +17453,7 @@ 9C1BEA3D2E6F49709A71C020 /* TerminalControllerTerminalTextTests.swift in Sources */, A5C41105A1B2C3D4E5F60718 /* TerminalControllingTTYWaiter.swift in Sources */, 6190C0126190C0126190C012 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift in Sources */, + 6277E32DA3CFCD625C6BD4E0 /* TerminalDictationAccessibilityTests.swift in Sources */, 851500000000000000000001 /* TerminalFontZoomSessionPersistenceTests.swift in Sources */, B12715000000000000000005 /* TerminalImageTransferConcurrencyTests+RepeatedPastes.swift in Sources */, B8835A000000000000000005 /* TerminalImageTransferConcurrencyTests+Timeouts.swift in Sources */, diff --git a/cmuxTests/TerminalAccessibilityTextTests.swift b/cmuxTests/TerminalAccessibilityTextTests.swift new file mode 100644 index 000000000000..1fcef5de1bee --- /dev/null +++ b/cmuxTests/TerminalAccessibilityTextTests.swift @@ -0,0 +1,150 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Terminal accessibility text") +struct TerminalAccessibilityTextTests { + private let screen = "~/src main\n$ echo ready\nready\n$ " + private let testNow: TimeInterval = 100 + + /// Vends each value to a fresh model the way AX reads do, a snapshot apart. + private func model(vending values: [String]) -> TerminalAccessibilityText { + let text = TerminalAccessibilityText() + for (offset, value) in values.enumerated() { + _ = text.value(now: testNow + Double(offset), read: { value }) + } + return text + } + + @Test("A value that splices text into what the client read inserts only that text") + func splicedValueYieldsTheInsertion() { + let text = model(vending: [screen]) + #expect(text.insertedText(settingValue: screen + "git status", now: testNow) == "git status") + #expect(text.insertedText(settingValue: "git status" + screen, now: testNow) == "git status") + let middle = screen.index(screen.startIndex, offsetBy: 11) + var spliced = screen + spliced.insert(contentsOf: "hello ", at: middle) + #expect(text.insertedText(settingValue: spliced, now: testNow) == "hello ") + } + + @Test("A value spliced into an older read still inserts only the text after the screen changed") + func staleReadStillYieldsTheInsertion() { + let older = screen + let newer = "ready\n$ \nagent output line one\nagent output line two\n" + let text = model(vending: [older, newer]) + #expect(text.insertedText(settingValue: older + "git status", now: testNow) == "git status") + } + + @Test("An exact older read wins over a newer screen that shares most of its text") + func exactOlderReadBeatsPartialNewerMatch() { + let history = (1...40).map { "build step \($0) done\n" }.joined() + let older = history + "$ " + let newer = history + "warning: cache miss\n$ " + let text = model(vending: [older, newer]) + #expect(text.insertedText(settingValue: older + "git status", now: testNow + 1) == "git status") + } + + @Test("A delayed edit survives more than eight newer screen reads") + func delayedReadSurvivesScreenChurn() { + let newerScreens = (1...10).map { "new screen \($0)\n$ " } + let text = model(vending: [screen] + newerScreens) + #expect(text.insertedText(settingValue: screen + "git status", now: testNow + 10) == "git status") + } + + @Test("A vended value expires after the edit window") + func expiredReadIsLiteral() { + let text = model(vending: [screen]) + let expiredAt = testNow + TerminalAccessibilityText.vendedValueHistoryLifetime + #expect(text.insertedText(settingValue: screen + "git status", now: expiredAt) == screen + "git status") + } + + @Test("A cached AX read renews the edit window without rereading the screen") + func cachedReadRenewsEditWindow() { + let text = model(vending: [screen]) + #expect(text.value(now: testNow + 0.25, read: { "unexpected fresh screen" }) == screen) + _ = text.value(now: testNow + 1, read: { "newer screen\n$ " }) + let originalExpiry = testNow + TerminalAccessibilityText.vendedValueHistoryLifetime + #expect(text.insertedText(settingValue: screen + "git status", now: originalExpiry) == "git status") + #expect(text.insertedText(settingValue: screen + "git status", now: originalExpiry + 0.25) == screen + "git status") + } + + @Test("The UTF-8 byte budget evicts the least recently vended value first") + func byteBudgetEvictsLeastRecentlyVendedValue() { + let byteLimit = TerminalAccessibilityText.vendedValueHistoryByteLimit + let older = String(repeating: "a", count: byteLimit / 2) + let newer = String(repeating: "😀", count: byteLimit / 8) + let text = model(vending: [older, newer, older, "last"]) + #expect(text.vendedValues == [older, "last"]) + #expect(text.vendedValues.reduce(0) { $0 + $1.utf8.count } <= byteLimit) + } + + @Test("A value larger than the history budget does not evict retained reads") + func oversizedValueIsNotRetained() { + let oversized = String(repeating: "a", count: TerminalAccessibilityText.vendedValueHistoryByteLimit + 1) + let text = model(vending: [screen, oversized]) + #expect(text.vendedValues == [screen]) + #expect(text.insertedText(settingValue: screen + "git status", now: testNow + 1) == "git status") + } + + @Test("A value that doesn't keep the text the client read is inserted as is") + func unrelatedValueIsLiteral() { + #expect(model(vending: [screen]).insertedText(settingValue: "hello world", now: testNow) == "hello world") + #expect(model(vending: []).insertedText(settingValue: "$ ", now: testNow) == "$ ") + #expect(model(vending: [screen]).insertedText(settingValue: "ends with a space ", now: testNow) == "ends with a space ") + #expect(model(vending: ["% "]).insertedText(settingValue: "hello ", now: testNow) == "hello ") + } + + @Test("Setting the value the client read inserts nothing") + func unchangedValueInsertsNothing() { + #expect(model(vending: [screen]).insertedText(settingValue: screen, now: testNow) == "") + } + + @Test("Trailing line breaks split off as a submit") + func trailingLineBreaksSplit() { + for (text, body, lineBreaks) in [("one\ntwo\r\n", "one\ntwo", "\r\n"), ("one line", "one line", ""), ("\n", "", "\n")] { + let split = TerminalAccessibilityText.splitTrailingLineBreaks(text) + #expect(split.body == body) + #expect(split.lineBreaks == lineBreaks) + } + } + + @Test("Paste payload keeps text, tabs and line breaks but drops other control characters") + func pastePayloadDropsControls() { + #expect(TerminalAccessibilityText.pastePayload("a\tb\nc\u{1b}[201~d\u{7f}\u{9b}e") == "a\tb\nc[201~de") + } + + @Test("Snapshot answers repeated queries until it expires or is invalidated") + func snapshotCaching() { + let text = TerminalAccessibilityText() + var reads = 0 + let read: () -> String? = { + reads += 1 + return "read \(reads)" + } + #expect(text.value(now: 10, read: read) == "read 1") + #expect(text.value(now: 10.2, read: read) == "read 1") + #expect(text.value(now: 10 + TerminalAccessibilityText.snapshotLifetime, read: read) == "read 2") + text.invalidate() + #expect(text.value(now: 10.6, read: read) == "read 3") + #expect(text.vendedValues == ["read 1", "read 2", "read 3"]) + } + + @Test("Only key events posted by another process count as foreign") + func foreignKeyEventSource() throws { + let hostPID = ProcessInfo.processInfo.processIdentifier + for (sourcePID, isForeign) in [(Int64(0), false), (Int64(hostPID), false), (Int64(1), true)] { + let cgEvent = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 8, keyDown: true)) + cgEvent.flags = [.maskCommand, .maskAlternate] + cgEvent.setIntegerValueField(.eventSourceUnixProcessID, value: sourcePID) + let event = try #require(NSEvent(cgEvent: cgEvent)) + #expect(GhosttyNSView.isKeyEventPostedByAnotherProcess(event) == isForeign) + } + } +} diff --git a/cmuxTests/TerminalDictationAccessibilityTests.swift b/cmuxTests/TerminalDictationAccessibilityTests.swift new file mode 100644 index 000000000000..4cbf699a15ca --- /dev/null +++ b/cmuxTests/TerminalDictationAccessibilityTests.swift @@ -0,0 +1,271 @@ +import AppKit +import CmuxTerminal +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Third-party dictation tools (Typeless, Wispr Flow, Superwhisper, Willow) +/// insert through the terminal's accessibility element and read `AXValue` +/// back to confirm the text landed. +/// +/// Regression coverage for https://github.com/manaflow-ai/cmux/issues/722, +/// https://github.com/manaflow-ai/cmux/issues/4953 and +/// https://github.com/manaflow-ai/cmux/issues/4153. +@MainActor +@Suite("Terminal dictation accessibility", .serialized, .timeLimit(.minutes(2))) +struct TerminalDictationAccessibilityTests { + @Test("AX value exposes the terminal's active screen") + func accessibilityValueShowsScreenText() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let fixture = try DictationTerminalFixture() + defer { fixture.close() } + try await fixture.waitUntilReady() + + let value = try #require(fixture.view.accessibilityValue() as? String) + #expect(value.contains(DictationTerminalFixture.readyMarker)) + let markerRange = (value as NSString).range(of: DictationTerminalFixture.readyMarker) + #expect(fixture.view.accessibilityString(for: markerRange) == DictationTerminalFixture.readyMarker) + #expect(fixture.view.accessibilityNumberOfCharacters() == (value as NSString).length) + } + } + + @Test("Setting AX selected text types it at the cursor") + func selectedTextIsTyped() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let fixture = try DictationTerminalFixture() + defer { fixture.close() } + try await fixture.waitUntilReady() + let recorder = GhosttyKeyPressRecorder() + defer { recorder.stop() } + + fixture.view.setAccessibilitySelectedText("dictated words") + + #expect(recorder.texts == ["dictated words"]) + #expect(recorder.textlessKeyCodes.isEmpty) + } + } + + @Test("Setting AX value to the value read plus an insertion types only the insertion") + func splicedValueTypesOnlyTheInsertion() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let fixture = try DictationTerminalFixture() + defer { fixture.close() } + try await fixture.waitUntilReady() + + let vended = try #require(fixture.view.accessibilityValue() as? String) + try #require(vended.contains(DictationTerminalFixture.readyMarker)) + let recorder = GhosttyKeyPressRecorder() + defer { recorder.stop() } + + fixture.view.setAccessibilityValue(vended + "spliced words") + + #expect(recorder.texts == ["spliced words"]) + #expect(recorder.textlessKeyCodes.isEmpty) + } + } + + @Test("Multi-line AX value arrives as one bracketed paste instead of one command per line") + func multiLineValueIsPasted() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let fixture = try DictationTerminalFixture() + defer { fixture.close() } + try await fixture.waitUntilReady() + let recorder = GhosttyKeyPressRecorder() + defer { recorder.stop() } + + fixture.view.setAccessibilityValue("first line\nsecond line") + + let received = try await fixture.receivedHex() + let expected = Data("\u{1b}[200~first line\nsecond line\u{1b}[201~".utf8) + #expect(received == expected.map { String(format: "%02x", $0) }.joined()) + #expect(!recorder.textlessKeyCodes.contains(36), "No line may be submitted with Return") + } + } + + @Test("A trailing newline after a multi-line AX value submits once, after the paste") + func multiLineValueWithTrailingNewlineSubmitsOnce() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let fixture = try DictationTerminalFixture() + defer { fixture.close() } + try await fixture.waitUntilReady() + let recorder = GhosttyKeyPressRecorder() + defer { recorder.stop() } + + fixture.view.setAccessibilityValue("first line\nsecond line\n") + + let received = try await fixture.receivedHex() + let expected = Data("\u{1b}[200~first line\nsecond line\u{1b}[201~".utf8) + #expect(received == expected.map { String(format: "%02x", $0) }.joined()) + #expect(recorder.textlessKeyCodes == [36], "The trailing newline is one Return") + } + } + + @Test("A Command chord another process posts does not type into the terminal") + func foreignUnboundCommandChordIsDropped() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let fixture = try DictationTerminalFixture() + defer { fixture.close() } + try await fixture.waitUntilReady() + try #require(fixture.window.makeFirstResponder(fixture.view)) + + let keyCodeC: UInt16 = 8 + let keyboardEvent = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command, .option], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: fixture.window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: keyCodeC + )) + // Superwhisper-style hotkey: the same Cmd+Option+C, posted by + // another process (PID 1 is launchd, never this test host). + try #require(!GhosttyNSView.isKeyEventPostedByAnotherProcess(keyboardEvent)) + let postedCGEvent = try #require(keyboardEvent.cgEvent) + postedCGEvent.setIntegerValueField(.eventSourceUnixProcessID, value: 1) + let postedEvent = try #require(NSEvent(cgEvent: postedCGEvent)) + try #require(postedEvent.cgEvent?.getIntegerValueField(.eventSourceUnixProcessID) == 1) + + let recorder = GhosttyKeyPressRecorder() + defer { recorder.stop() } + + #expect(fixture.view.performKeyEquivalentAfterMenuMiss(with: postedEvent)) + #expect( + recorder.pressCount(keyCode: UInt32(keyCodeC)) == 0, + "An unbound chord from another process must not reach the terminal program" + ) + + #expect(fixture.view.performKeyEquivalentAfterMenuMiss(with: keyboardEvent)) + #expect( + recorder.pressCount(keyCode: UInt32(keyCodeC)) == 1, + "The same chord from the keyboard still reaches the terminal" + ) + } + } +} + +/// Records the key presses GhosttyNSView sends to the native surface. +@MainActor +private final class GhosttyKeyPressRecorder { + private(set) var presses: [(keyCode: UInt32, text: String?)] = [] + private let previousObserver: ((ghostty_input_key_s) -> Void)? + + var texts: [String] { presses.compactMap { $0.text } } + var textlessKeyCodes: [UInt32] { presses.filter { $0.text == nil }.map { $0.keyCode } } + + init() { + previousObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { [weak self] keyEvent in + guard keyEvent.action == GHOSTTY_ACTION_PRESS else { return } + let text = keyEvent.text.map { String(cString: $0) } + self?.presses.append((keyCode: keyEvent.keycode, text: text)) + } + } + + func pressCount(keyCode: UInt32) -> Int { + presses.filter { $0.keyCode == keyCode }.count + } + + func stop() { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousObserver + } +} + +/// Hosts a live terminal whose program turns on bracketed paste, prints a +/// marker and records the first paste it reads. +@MainActor +private final class DictationTerminalFixture { + static let readyMarker = "AX_DICTATION_READY" + + let root: URL + let receipt: URL + let surface: TerminalSurface + let window: NSWindow + var view: GhosttyNSView { surface.hostedView.surfaceView } + + init() throws { + _ = NSApplication.shared + root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-ax-dictation-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + receipt = root.appendingPathComponent("receipt.hex") + let receiver = root.appendingPathComponent("receiver.py") + try """ + import os, select, sys, termios, time, tty + old = termios.tcgetattr(0) + try: + tty.setraw(0) + os.write(1, b'\\x1b[?2004h\(Self.readyMarker)\\r\\n') + data = b'' + deadline = time.monotonic() + 8 + while b'\\x1b[201~' not in data and time.monotonic() < deadline: + if select.select([0], [], [], 0.1)[0]: + data += os.read(0, 65536) + with open(sys.argv[1] + '.tmp', 'w') as receipt: + receipt.write(data.hex()) + os.rename(sys.argv[1] + '.tmp', sys.argv[1]) + time.sleep(600) + finally: + termios.tcsetattr(0, termios.TCSADRAIN, old) + """.write(to: receiver, atomically: true, encoding: .utf8) + + surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + initialCommand: "/usr/bin/python3 \(receiver.path.terminalShellEscaped) \(receipt.path.terminalShellEscaped)" + ) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let content = try #require(window.contentView) + let hosted = surface.hostedView + hosted.frame = content.bounds + hosted.autoresizingMask = [.width, .height] + content.addSubview(hosted) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + content.layoutSubtreeIfNeeded() + hosted.setVisibleInUI(true) + hosted.setActive(true) + try #require(window.makeFirstResponder(hosted.surfaceView)) + } + + func waitUntilReady() async throws { + let deadline = ContinuousClock.now + .seconds(15) + while surface.readText(region: .screen)?.contains(Self.readyMarker) != true, + ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + try #require(surface.readText(region: .screen)?.contains(Self.readyMarker) == true) + } + + /// Hex of the bytes the program read up to the end of the first paste, + /// or everything it read in 8 s when no paste ended. + func receivedHex() async throws -> String { + let deadline = ContinuousClock.now + .seconds(15) + while !FileManager.default.fileExists(atPath: receipt.path), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + try #require(FileManager.default.fileExists(atPath: receipt.path), Comment(rawValue: + "No PTY receipt; screen=\(surface.readText(region: .screen) ?? "unavailable")" + )) + return try String(contentsOf: receipt, encoding: .utf8) + } + + func close() { + surface.teardownHostedSurfaceForTesting() + window.orderOut(nil) + try? FileManager.default.removeItem(at: root) + } +}