Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e92b690
test(accessibility): reproduce terminal AX content and focus
austinywang Sep 25, 2026
84dbf74
fix(accessibility): expose focused terminal content
austinywang Sep 25, 2026
154abfe
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
141e811
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
9f7d6d7
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
e4dc1f8
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
64e9e88
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
e076004
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
d2154ac
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
e7bbf3b
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
4297696
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
f0f80a3
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
8bbc069
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 25, 2026
69c4702
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 26, 2026
ff23e13
Preserve terminal accessibility localization
austinywang Sep 26, 2026
1ff69fa
Merge remote-tracking branch 'origin/main' into issue-9563-axfocusedu…
austinywang Sep 26, 2026
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
59 changes: 59 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -557994,6 +557994,65 @@
}
}
}
},
"terminal.accessibility.contentArea.help": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Terminal content area"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "منطقة محتوى الطرفية"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Terminalinhaltsbereich"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Área de contenido de la terminal"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Zone de contenu du terminal"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ターミナルコンテンツ領域"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "터미널 콘텐츠 영역"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "终端内容区域"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "終端機內容區域"
}
}
}
}
},
"version": "1.0"
Expand Down
11 changes: 11 additions & 0 deletions Sources/App/CmuxMainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ final class CmuxMainWindow: NSWindow {
workspaceSwitchSignposts.end(switchInterval)
}

/// Returns the focused terminal surface directly so AX clients can read
/// terminal selection attributes without stopping at the window element.
override var accessibilityFocusedUIElement: Any? {
if let terminalView = firstResponder?.cmuxTerminalFocusOwningGhosttyView(),
terminalView.window === self,
terminalView.isAccessibilityElement() {
return terminalView
}
return super.accessibilityFocusedUIElement
}

/// No content may resize this window past the attached display union. The content view
/// hosts AppKit subtrees whose subviews carry REQUIRED autoresizing-mask
/// constraints, and if any of them is ever laid out oversized, AppKit
Expand Down
97 changes: 92 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3772,6 +3772,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var _renderedFrameFlushScheduled = false
private var _pendingRenderedFrameDeliveryReasons:
TerminalRenderedFrameDeliveryReasons = []
private var accessibilityScreenContentsCache: (value: String, expiresAt: CFTimeInterval)?
/// Pane-local frame demand lets a terminal-specific consumer observe a
/// late render without enabling notifications on every terminal surface.
let localRenderedFrameNotificationDemand = RenderDemandCounter()
Expand Down Expand Up @@ -6325,14 +6326,88 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}

override func accessibilityHelp() -> String? {
"Terminal content area"
String(localized: "terminal.accessibility.contentArea.help", defaultValue: "Terminal content area")
}

override func isAccessibilityFocused() -> Bool {
guard let window, window.isKeyWindow else { return false }
return window.firstResponder?.cmuxTerminalFocusOwningGhosttyView() === self
}

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() ?? ""
accessibilityScreenContents()
}

override func accessibilityNumberOfCharacters() -> Int {
accessibilityScreenContents().utf16.count
}

override func accessibilityVisibleCharacterRange() -> NSRange {
let content = accessibilityScreenContents()
return NSRange(location: 0, length: content.utf16.count)
}

override func accessibilityLine(for index: Int) -> Int {
let content = accessibilityScreenContents()
let prefix = String(decoding: content.utf16.prefix(max(0, index)), as: UTF16.self)
return prefix.reduce(into: 0) { count, character in
if character == "\n" { count += 1 }
}
}

override func accessibilityString(for range: NSRange) -> String? {
let content = accessibilityScreenContents()
guard let swiftRange = Range(range, in: content) else { return nil }
return String(content[swiftRange])
}

override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
guard let plainString = accessibilityString(for: range) else { return nil }
var attributes: [NSAttributedString.Key: Any] = [:]
if let surface,
let fontRaw = ghostty_surface_quicklook_font(surface) {
let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
attributes[.font] = font.takeUnretainedValue()
font.release()
}
return NSAttributedString(string: plainString, attributes: attributes)
}

private func accessibilityScreenContents() -> String {
let now = CACurrentMediaTime()
if let cached = accessibilityScreenContentsCache, cached.expiresAt > now {
return cached.value
}
guard let surface else { return "" }

let selection = ghostty_selection_s(
top_left: ghostty_point_s(
tag: GHOSTTY_POINT_SCREEN,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0
),
bottom_right: ghostty_point_s(
tag: GHOSTTY_POINT_SCREEN,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0
),
rectangle: false
)
var text = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &text) else { return "" }
defer { ghostty_surface_free_text(surface, &text) }

let value: String
if let ptr = text.text, text.text_len > 0 {
let data = Data(bytes: ptr, count: Int(text.text_len))
value = String(decoding: data, as: UTF8.self)
} else {
value = ""
}
accessibilityScreenContentsCache = (value: value, expiresAt: now + 0.5)
return value
}

override func setAccessibilityValue(_ value: Any?) {
Expand Down Expand Up @@ -6427,6 +6502,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {

override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if result {
postAccessibilityFocusChanged()
}
var shouldApplySurfaceFocus = false
if result {
imeConsumedKeyUps.removeAll()
Expand Down Expand Up @@ -6540,6 +6618,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {

override func resignFirstResponder() -> Bool {
let result = super.resignFirstResponder()
if result {
postAccessibilityFocusChanged()
}
if result {
imeConsumedKeyUps.removeAll()
manualNamedKeyConsumedKeyUps.removeAll()
Expand All @@ -6561,6 +6642,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return result
}

private func postAccessibilityFocusChanged() {
guard let window else { return }
NSAccessibility.post(element: window, notification: .focusedUIElementChanged)
NSAccessibility.post(element: NSApp, notification: .focusedUIElementChanged)
}

// For NSTextInputClient - accumulates text during key events
private(set) var keyTextAccumulator: [String]? = nil
private var markedText = NSMutableAttributedString()
Expand Down
90 changes: 90 additions & 0 deletions cmuxTests/TerminalAndGhosttyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,96 @@ final class GhosttyPasteboardHelperTests: XCTestCase {
}
}

@MainActor
final class TerminalAccessibilityRegressionTests: XCTestCase {
private var surfaces: [TerminalSurface] = []

override func tearDown() {
for surface in surfaces {
surface.hostedView.removeFromSuperview()
surface.teardownSurface()
}
surfaces.removeAll()
super.tearDown()
}

func testAccessibilityValueExposesRenderedTerminalContent() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
surfaces.append(surface)

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 400),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }

let hostedView = surface.hostedView
hostedView.frame = window.contentView?.bounds ?? .zero
hostedView.autoresizingMask = [.width, .height]
window.contentView?.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
hostedView.setVisibleInUI(true)
hostedView.setActive(true)
window.contentView?.layoutSubtreeIfNeeded()
hostedView.layoutSubtreeIfNeeded()

XCTAssertTrue(
waitUntil(timeout: 5) { surface.surface != nil },
"Expected a live Ghostty surface before checking accessibility content"
)

let marker = "cmux-ax-content-\(UUID().uuidString)"
XCTAssertTrue(surface.sendText("printf '\\n\(marker)\\n'\n"))

guard let surfaceView = hostedView.surfaceView else {
XCTFail("Expected the hosted Ghostty view")
return
}
XCTAssertTrue(
waitUntil(timeout: 5) {
(surfaceView.accessibilityValue() as? String)?.contains(marker) == true
},
"AXValue should contain text rendered by the terminal"
)
}

func testMainWindowAccessibilityFocusResolvesFocusedTerminalView() {
let window = CmuxMainWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 400),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }

let terminalView = GhosttyNSView(frame: window.contentView?.bounds ?? .zero)
window.contentView?.addSubview(terminalView)
window.makeKeyAndOrderFront(nil)

XCTAssertTrue(window.makeFirstResponder(terminalView))
XCTAssertTrue(window.accessibilityFocusedUIElement as AnyObject? === terminalView)
}

private func waitUntil(timeout: TimeInterval, condition: () -> Bool) -> Bool {
let deadline = ProcessInfo.processInfo.systemUptime + timeout
while ProcessInfo.processInfo.systemUptime < deadline {
if condition() {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
return condition()
}
}

@MainActor
final class TerminalOffscreenStartupTests: XCTestCase {
#if DEBUG
Expand Down
Loading