Skip to content

Fix VoiceOver QuickNav, stale content, and review-cursor jumps in the terminal - #697

Open
Deon588 wants to merge 1 commit into
gnachman:masterfrom
Deon588:fix/voiceover-quicknav-focus
Open

Fix VoiceOver QuickNav, stale content, and review-cursor jumps in the terminal#697
Deon588 wants to merge 1 commit into
gnachman:masterfrom
Deon588:fix/voiceover-quicknav-focus

Conversation

@Deon588

@Deon588 Deon588 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes three VoiceOver problems when interacting with terminal content, none of which occur in Terminal.app. Reported in https://gitlab.com/gnachman/iterm2/-/issues/12920.

  1. QuickNav navigation does not engage after VO-interacting with the terminal until QuickNav is toggled off and on.
  2. Content goes stale during an active interaction: after Cmd+K and a new command, VoiceOver keeps reading the pre-clear text and cannot reach the new output without re-interacting.
  3. The VoiceOver review cursor jumps to the top of the terminal whenever a command produces output.

Causes and fixes:

  • The terminal advertises an AXDocument attribute and a settable AXValue (settable only because of the no-op setter kept for Full Keyboard Access, Issue 10023). That makes VoiceOver treat the view as more than a plain text area, which wedges QuickNav on interaction. Fixed by hiding both from the accessibility surface while VoiceOver is running via isAccessibilitySelectorAllowed:. Full Keyboard Access is unaffected while VoiceOver is off; when both run at once, hiding AXValue wins because QuickNav breaks otherwise.
  • The announce-new-output path suppresses value-changed notifications during an output burst and posted layout-changed with NSAccessibilityUIElementsKey once output settled. That notification directs VoiceOver to move its cursor to the listed element — the jump to the top — and since no content notification was re-posted at settle, VoiceOver's cached text went stale during interaction. Fixed by replacing the layout-changed with a value-changed + selected-text-changed pair posted after the announcement: the pair makes VoiceOver refresh its cached content and extend its navigable model (value-changed alone does not) without moving the review cursor. Terminal.app posts the same pair for output and never posts layout-changed for it.

Testing: verified with VoiceOver on macOS 26.5.2 — QuickNav engages immediately on interaction; output announcements still speak; the review cursor holds its position when new output arrives and the new output is reachable by navigating down; shell command history at the prompt and deleted-character announcements are unaffected. Release notes updated.

@gnachman

Copy link
Copy Markdown
Owner

Thanks for this, and for the unusually precise writeup. I investigated the same three symptoms independently on master and want to share one finding before it lands, since it may affect the QuickNav (step 1) fix.

I reproduced your attribute-surface approach (drop AXDocument, make AXValue non-settable to VoiceOver via isAccessibilitySelectorAllowed:) and confirmed it fixes the initial QuickNav wedge and preserves Full Keyboard Access spaces (Issue 10023) when VoiceOver is off.

But I hit a residual leak the initial-interaction test does not cover: if you run a second command while still interacting and then press Up/Down, the arrows leak to the shell again (history navigation), even with AXValue hidden. It is essentially step 1 recurring after output.

The debug log shows the trigger is independent of AXValue settability. Right before the leaked arrow:

accessibilityRole -> AXTextArea
accessibilityNumberOfCharacters -> 162
accessibilitySelectedTextRange -> [Empty range at 162]   (caret at end)
Received KeyDown ... keyCode=126   (up arrow, no VO modifier, leaks)

VoiceOver reads us as an editable AXTextArea whose caret just moved, switches into text-editing mode, and defers the arrow as caret movement. The thing that flips it into that mode is the value-changed + selected-text-changed pair that refreshAccessibility already posts whenever the terminal cursor moves during output. Toggling QuickNav off/on clears it because that resets VoiceOver's browse-vs-edit mode, which matches your original step 1 description.

Why I am flagging it here: your steps 4/5 fix posts a value-changed + selected-text-changed pair at settle, which is the same class of notification I saw pulling VoiceOver into caret mode. So it is worth verifying this specific repro on your branch:

  1. VoiceOver + QuickNav on
  2. seq 1 40, VO-interact
  3. navigate up into the output
  4. run another command (e.g. ls)
  5. press Up/Down: do they still move the VO cursor, or leak to the shell?

I tested an approximation on master, not your exact branch, so your full combination (especially the ordering of the announcement vs the notification pair) may behave differently, but we are on the same OS (macOS 26.5.2) so I suspect it reproduces. If it does, the deeper issue is that a focused editable AXTextArea with a caret inherently makes VoiceOver treat arrows as caret keys, and the fix may need to suppress the cursor-follow notifications while VoiceOver is browsing (or move away from one editable AXTextArea toward per-line elements, which is possibly how Terminal.app avoids it).

Happy to pair or dig in the debugger if useful.

… terminal

VoiceOver treats the terminal as more than a plain text area because it advertises an AXDocument attribute and a settable AXValue (settable only because of the no-op setter kept for Full Keyboard Access, Issue 10023); Terminal.app advertises neither. As a result, QuickNav navigation does not engage after the user interacts with the terminal until QuickNav is toggled off and on. Hide both from the accessibility surface while VoiceOver is running via isAccessibilitySelectorAllowed:. The gate preserves Full Keyboard Access when VoiceOver is off; when both run at once, hiding AXValue wins because QuickNav breaks otherwise.

The announce-new-output feature suppresses value-changed notifications during an output burst and posts layout-changed with NSAccessibilityUIElementsKey once output settles. That notification directs VoiceOver to move its cursor to the listed element, relocating the user's review cursor to the top of the terminal on every command; and because nothing re-posts a content notification at settle, VoiceOver keeps reading pre-burst text during an active interaction and cannot reach new output without re-interacting. Replace the layout-changed with a value-changed + selected-text-changed pair posted after the announcement: the pair makes VoiceOver refresh its cached content and extend its navigable model (value-changed alone does not) without moving the review cursor. Terminal.app posts the same pair for output and never posts layout-changed.

Issue 12920
@Deon588
Deon588 force-pushed the fix/voiceover-quicknav-focus branch from 750590f to 446548a Compare July 25, 2026 12:00
@Deon588

Deon588 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful analysis. I ran your exact reproduction steps on this branch (rebased onto 4416dd3 today), macOS 26.5.2, VoiceOver with QuickNav on: seq 1 40, interact with the terminal, navigate up, then ls, then seq 1 20, staying interacted throughout. Bare Up/Down kept moving the VoiceOver cursor after the second and third bursts. There was no leak to shell history and no QuickNav toggle was needed. One VoiceOver cursor effect does occur at settle: focus follows to the newest output (caret at end), which as a VoiceOver user I actually want after running a command. It does not flip VoiceOver into caret mode here.

An AXObserver capture over the whole reproduction steps/flow shows why I think your master test behaved differently. On this branch the only notifications at settle are a single pair of AXValueChanged and AXSelectedTextChanged, and zero AXLayoutChanged. Master still posts AXLayoutChanged with NSAccessibilityUIElementsKey at settle (accessibilityPostOutputLayoutChangedNotification). Per the AppKit docs that notification directs VoiceOver to move its cursor to the listed element, and while diagnosing the jump to top symptom I could reproduce VoiceOver cursor and state disruption from exactly that post. Since your approximation kept master's settle path, I suspect the leak you saw was triggered by AXLayoutChanged rather than by the pair. Would you mind rerunning your sequence on the branch itself?

Two data points from the diagnosis that may save you time. First, Terminal.app does not expose the terminal as separate elements per line: its accessibility surface is a single AXTextArea with zero children and the same parameterized attributes as iTerm's (no text markers). Second, what Terminal does differently, per an AXObserver capture, is preserve the caret VoiceOver sets in AXSelectedTextRange across output (it held the review position while content grew), whereas iTerm snaps the reported caret to the shell cursor on every move. If an edit mode flip does show up in some configuration, holding the caret VoiceOver set rather than suppressing notifications looks like the principled direction.

If it still reproduces for you on the branch, I would take you up on pairing. One thing to note: I think we might be in different time zones, I am based in South Africa, so UTC+2. Could differences in VoiceOver settings (for example “Synchronize keyboard focus and VoiceOver cursor”) explain the divergence?

On a side note: it would be amazing to get this landed. With these fixes iTerm2 becomes, for the first time a genuinely usable terminal for blind users, a real alternative to macOS Terminal with all of iTerm2's power on top. That would mean a lot to me and to other VoiceOver users...

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants