feat(hints): opt-in auto-refresh that keeps hints live on macOS - #1018
Draft
gabrielecirulli wants to merge 18 commits into
Draft
feat(hints): opt-in auto-refresh that keeps hints live on macOS#1018gabrielecirulli wants to merge 18 commits into
gabrielecirulli wants to merge 18 commits into
Conversation
This was referenced Jul 12, 2026
gabrielecirulli
marked this pull request as draft
July 12, 2026 13:13
Contributor
Author
Contributor
Greptile SummaryThis PR adds opt-in live hint refresh on macOS. The main changes are:
Confidence Score: 5/5This looks safe to merge. No blocking issues found in the changed code.
What T-Rex did
|
| Filename | Overview |
|---|---|
| internal/app/modes/auto_refresh.go | Adds serialized debounce, typing holds, settle backoff, and observer refresh handling for hint mode. |
| internal/app/modes/hints.go | Routes in-mode hint refreshes through the new auto-refresh debounce gate. |
| internal/core/infra/axobserver/platform_darwin.go | Adds the Darwin observer adapter and maps configured notification masks to native AX observers. |
| internal/core/infra/platform/darwin/axobserver_darwin.m | Implements native AX observer registration, callback delivery, and run-loop teardown. |
| internal/config/service.go | Rejects renamed legacy accessibility configuration keys instead of silently ignoring them. |
| internal/core/infra/electron/electron.go | Consolidates per-application accessibility activation and PID-aware attribute caching. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant AX as AX observer
participant D as Debounce timer
participant H as Hint handler
participant S as Hint scanner
AX->>D: UI notification
D->>H: fire after debounce
H->>S: refresh hint collection
S-->>H: updated hints
H->>D: schedule settle recheck
D->>H: bounded backoff refresh
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant AX as AX observer
participant D as Debounce timer
participant H as Hint handler
participant S as Hint scanner
AX->>D: UI notification
D->>H: fire after debounce
H->>S: refresh hint collection
S-->>H: updated hints
H->>D: schedule settle recheck
D->>H: bounded backoff refresh
Reviews (1): Last reviewed commit: "feat(hints): re-scan auto-refresh with b..." | Re-trigger Greptile
gabrielecirulli
force-pushed
the
feat/hints-auto-refresh-config
branch
8 times, most recently
from
July 18, 2026 14:26
7549bd3 to
f0e81b8
Compare
This is the engine for push-based hint auto-refresh, added on its own and completely inert — nothing imports it yet, so it arms no observers and holds no OS resources until a later change drives it. - A dependency-free axnotify package names the accessibility notifications auto-refresh can watch. It is the single source of truth for the valid set, so the config validator and the observer both read it, with no import cycle (the config package cannot import the observer package directly). The set covers structural notifications (created, ui_destroyed, layout_changed, window created/moved/resized, load_complete, menu open/close, focused_ui_element_changed) plus the notifications browsers actually post for web content, where Chromium and Firefox emit no plain "created" event: live region changed/created, expanded_changed, row expanded/collapsed, and element_busy_changed. value_changed is valid but off by default because it fires on every value update (a clock, a progress bar). - A native macOS AXObserver bridge on one dedicated CFRunLoop thread. The Go layer owns lifecycle and holds an opaque per-observer handle; arm, disarm, and release are marshalled onto the run-loop thread, so a release can never race an in-flight callback for that observer. Each observer bounds its synchronous AX calls with a per-app messaging timeout, so a wedged app cannot hang the thread, and disarm skips the unregister IPC for a process that has already exited. The callback forwards only the firing pid and notification name to Go. - A single-owner Go Manager over a Platform interface. Reconcile(targets) arms newly wanted pids, disarms gone ones, and re-arms a pid whose mask changed. The non-darwin backend is an explicit unsupported stub. Tests cover the Manager against a fake platform (arm, disarm, re-arm on mask change, arm-failure retried, callback delivery, close idempotency); the notification vocabulary and the name-to-bit mapping, with drift guards (every name has a bit, every bit maps to a native notification); goleak guards the package; and a darwin soak arms and disarms 1000 times, asserting zero live Core Foundation objects and no run-loop thread at idle after each cycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The axnotify package existed only to expose the observable notification names to configuration, so the config validator and the observer could read one shared list of valid names. Auto-refresh no longer exposes the notification set as a config surface, so that shared vocabulary is no longer needed. Move what the observer still uses into the axobserver package directly: the notification bits, and a fixed DefaultMask that covers every notification except value_changed, which fires on every value update and would wake the observer continuously. The name-to-bit map and MaskFromNames, which only served config parsing, are removed, and the axnotify package is deleted. The drift guard that pinned every notification name to a bit is replaced by one that walks the package's own bits and checks each maps to a native notification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Manager now tracks a single pid: Watch(pid) arms an observer on it, replacing whatever app was watched before, and Unwatch tears it down. The notification selection moves out of Go entirely. The set an observer registers is fixed and lives only in the darwin bridge, which registers every name directly instead of filtering a bit table through a mask. This removes the Go Mask type and its constants, the Target struct, the bridge's AXObserverMask mirror, the NERU_AXNOTIF_* bits, and the bit-to-name table. No runtime behavior changes: the fixed native set equals the only mask ever passed in, and value-change notifications stay unregistered. Also renames the platform hook SetSink to SetChangeHandler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The darwin bridge now owns the whole observer: the watched application (one process at a time), the run-loop thread lifecycle, and the fixed notification set, written as literal names so the set is a compile-time constant. NeruObserverWatch(pid) switches the watched app, building and registering the new observer before tearing down the previous one so the run loop never sits empty mid-switch; watching the already-watched pid is a success no-op; on failure nothing is watched afterward, so a retry starts clean. NeruObserverUnwatch() empties the slot. No handle or pointer crosses the bridge, and the change callback carries only the notification name. Teardown unregisters the same fixed name list registration offers, skipping the IPC for an exited process (kill with signal 0, which delivers nothing and only tests existence) and bailing on the first error that means the app is gone or wedged, so hints exit against a beachballing app costs one messaging timeout instead of one per name. The Go package shrinks to package-level Init, Watch, Unwatch, and Supported over build-tagged platform functions, matching how sibling infra packages dispatch per platform. The Manager type, the Platform interface, per-pid disarm, Close, and the notification-name bookkeeping are gone. The test-only notification synthesizer moves into a test file so it is not shipped API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hints are scanned once when hints mode opens, so they go stale when the focused window changes afterward (a page finishes loading, a menu opens, a panel appears). This wires the AX observer service to a new opt-in setting, [hints.auto_refresh], that re-scans hints when the focused app's UI changes, with no keypress. macOS only for now; on other platforms the observer backend is a stub and the setting is inert. The setting has three keys: enabled (off by default), debounce_ms (how long a burst of changes settles before the re-scan, default 150), and allowed_notifications (which notifications arm an observer). The default list covers the structural notifications plus the web-content signals browsers post when a page changes; value_changed is available but off by default because it fires on every value update. Validation rejects an enabled section whose allowed_notifications is empty or names a notification outside the supported set. The valid names live in the axnotify package added with the observer service, so the config validator and the observer read one source of truth (config cannot import axobserver directly, because the darwin platform package imports config). The refresh paths that re-enter through activateHintModeInternal — a --repeat re-entry, a modifier-passthrough re-scan, a bound `hints` re-launch, a cycle-hint re-scan, and the debounced observer — coalesce there. The debounce lives at that point, as Handler state and methods rather than a separate coordinator: - Leading edge: the first refresh in an idle burst scans immediately, so a manual refresh is never delayed. - Trailing edge: refreshes arriving during the debounce window collapse into a single follow-up scan. - Max-wait cap: a continuously-changing app still refreshes at a bounded rate. While a search query is being typed, a refresh is held and released when the search ends, so the query and its filtered hint set are not swapped out from under the keystrokes. A pending hint-label selection is not held: the --repeat re-scan right after a label is chosen must proceed, or the overlay freezes on the stale filter. The observer callback runs on the AX thread and touches only a leaf mutex, never the mode lock, so it cannot deadlock a teardown that joins that thread. Observers arm on the focused app while hints are open and are disarmed on exit, so an idle neru holds no accessibility resources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An observer-driven scan can catch a web page mid-render and label only part of it. Browsers post an accessibility notification when content starts changing (a focus move, a load) but none when it finishes rendering, and a single-page site loads its content in waves a second or more after the click, so relying on notifications alone leaves the hints stuck on a partial scan. After each observer-driven scan, keep re-scanning on a capped exponential backoff. Each re-scan fingerprints the resulting hint set (a hash of element bounds and roles, from the hints the scan already produced, so it costs no extra accessibility calls). If the set changed the interval restarts dense (250ms) so the next look is soon; if it is stable the interval grows by 1.6x up to a 5s ceiling; the loop stops only after two consecutive stable scans at that ceiling. So it reacts fast while the page is moving, backs off to a slow heartbeat while it looks settled, and rides through the gap between a loading skeleton and its late content. The scan re-draws through the same refresh path a manual refresh uses, so a stable re-scan that changes nothing is not visible. Two ceilings bound a page that never settles: the interval resets on change but the scan count (25) and the elapsed window (30s) since the sequence began do not, so a continuously-changing live page winds down to dormant rather than re-scanning forever, until the next observer event starts a fresh sequence. A manual or --repeat refresh takes priority over a running settle loop: it ends the settle and scans immediately as the leading edge, so selecting a hint while the settle is mid-flight is never deferred behind a pending re-check (which would otherwise freeze the overlay on the selection). The loop runs only on the observer path while hints are open; it is dormant between events. Tests cover the interval growth and clamp, the stop conditions (two stable at the cap, the scan-count ceiling, the window ceiling), the state machine (climb, reset-on-change keeping the global count, stop at the cap, the scan-count ceiling halting a page that keeps changing), and a manual refresh pre-empting the settle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-refresh exposed an allowed_notifications list so a config could choose which accessibility notifications wake a re-scan. That surface is being removed: the observer now watches a fixed set instead. Remove the allowed_notifications field, its default, and its validation, and have the auto-refresh handler use axobserver.DefaultMask. Drop the config and docs that described the list. Enabling auto_refresh now only needs enabled = true; the watched set is not user-configurable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ignature The observer-driven auto-refresh re-scan calls activateHintModeInternal to redraw hints with the session's active settings. That function takes a modifier and a split-word override alongside the existing parameters, so the call passes nil for the modifier to preserve whatever the mode already had and forwards the current split-word setting read from the hint context, matching the other in-place refresh callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The observer manager watches one pid: updateAutoRefreshObservers hands the focused app's pid to Watch, which re-targets on change and no-ops when the pid is already watched, and hints-mode exit tears it down through Unwatch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
activateHintModeInternal takes a hide-on-empty-search override, and the observer-driven re-scan reapplies the session's active settings when it redraws hints. It now captures the session's hide-on-empty-search value and passes it through like the other settings, so a re-scan keeps the behavior the session was opened with. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… callback onObserverChange runs on the observer callback thread holding autoRefreshMu. When a change landed after the settle loop had outlived its window ceiling, it ended the settle through a helper that acquires autoRefreshMu again. Go mutexes are not reentrant, so that call deadlocked the callback thread, and a later teardown joining that thread under the mode lock hung the mode system. The window-ceiling path now clears the settle state through the variant that runs under the already-held autoRefreshMu. A regression test drives a change into a settle loop older than the window ceiling and asserts the callback returns and ends the loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hints auto-refresh ships on: a fresh config and the built-in defaults both carry enabled = true, so hints stay live out of the box and the setting exists to turn the behavior off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hints.auto_refresh timing key is named for what a user observes: the minimum delay before hints refresh after the app changes. The Go field becomes MinRefreshDelayMs and the default constant DefaultAutoRefreshMinDelayMs; the validator message names the new key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Auto Refresh section in CONFIGURATION.md now describes the behavior a user sees: hints stay up to date while hints mode is open, it is on by default, and min_refresh_delay_ms controls how long the app must stop changing before the re-scan. TIPS_TRICKS.md drops the section that told users to turn the feature on and the section on delaying hint refreshes around page loads, since hints now follow page changes on their own. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The settle-backoff mechanism moves into its own file, auto_refresh_settle.go, so auto_refresh.go holds the observer wiring, the debounce gate, and the timer machinery. The observer construction moves out of NewHandler into initAutoRefresh next to the callback it wires. Names now say what things are: the debounce gate is admitHintRefresh (it reports whether the caller may scan now), the enabled check is autoRefreshEnabledLocked (it reads config under the mode lock), and the settle constants spell out their roles (base interval, max interval, growth numerator and denominator, stable scans to stop, max duration). Comments describe the mechanisms in plain language and reference the named constants instead of repeating their values. The struct comment on Handler states why the auto-refresh state lives there: mode values are stateless dispatchers, the observer manager outlives any single activation, and the timers and callback coordinate through the two locks the Handler owns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e type The debounce, the settle recheck, and their shared timer now live on a hintAutoRefresh type with a leaf mutex, replacing the flat fields on the Handler, and the observer sits behind the axobserver package's single watched-app API. Refreshes are held while the user is typing a search query or the label of a multi-match search confirm, a failed or empty auto-refresh scan keeps the session alive instead of exiting, scroll actions re-scan hints so the labels track the moved content, and a config reload that disables auto_refresh disarms the observer. Docs and config comments follow the reworked behavior.
An active hints session now re-scans when the front application switches, when a menu tracking session begins or ends, and when Mission Control activates or deactivates. The front-application switch comes from the Carbon kEventAppFrontSwitched handler installed beside the NSWorkspace observers, and covers focus moving to another regular app, including via a hint click. The menu tracking events come from the two HIToolbox distributed notifications and cover the surfaces that emit no workspace or accessibility event at all: menu bar menus, third-party status item menus, Control Center panels, and the Notification Center panel, on both open and close. All three feed RefreshAfterFocusChange, which records the event as an observed change, so the existing debounce and settle recheck drive the scan, re-resolve the focused app, and re-point the observer. Outside a hints session, or with auto_refresh disabled, the events do nothing. Disclaimer: This code was written by Claude. I steered it toward a solution I thought sensible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An exact label match left the typed label in the input buffer, relying on the follow-up re-activation to clear it via SetHints. When the debounce gate deferred that re-activation, the stale buffer misfiltered the next keystrokes and read as mid-typing, which held every pending auto-refresh indefinitely. The manager now clears the input the moment a match is consumed, and the selection's re-activation clears the pending refresh work first, so the gate admits an immediate scan instead of deferring behind observer chatter. Disclaimer: This code was written by Claude. I steered it toward a solution I thought sensible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gabrielecirulli
force-pushed
the
feat/hints-auto-refresh-config
branch
from
July 24, 2026 20:11
f0e81b8 to
2a88bfc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Disclaimer: Both this code and this PR description were written by AI. I still need to review this description to make sure it is fully correct, and I will do that soon.
Draft: this branch also needs review and testing before it moves forward.
Rationale
Hints are scanned once when hints mode opens, so they go stale when the focused window changes afterward: a page finishes loading, a menu opens, a panel appears. This wires the AX observer service (#1017) to a new opt-in setting that re-scans hints when the focused app's UI changes, with no keypress. macOS only for now; on other platforms the observer backend is a stub and the setting is inert.
What this adds
The setting.
[hints.auto_refresh]has two keys:enabled(off by default) anddebounce_ms(how long a burst of changes settles before the re-scan, default 150). The set of accessibility notifications that wake a re-scan is not configurable: the observer watches a fixedDefaultMaskdefined in theaxobserverpackage (#1017), which covers the structural notifications plus the web-content signals browsers post when a page changes, and omitsvalue_changedbecause it fires continuously.Debounce and coalescing. The refresh paths that re-enter through
activateHintModeInternal(a--repeatre-entry, a modifier-passthrough re-scan, a boundhintsre-launch, a cycle-hint re-scan, and the debounced observer) coalesce there, as Handler state rather than a separate coordinator:While a search query is being typed, a refresh is held and released when the search ends, so the query and its filtered hint set are not swapped out from under the keystrokes. A pending hint-label selection is not held, because the
--repeatre-scan right after a label is chosen must proceed or the overlay freezes on the stale filter.Backoff until the page settles. An observer-driven scan can catch a web page mid-render and label only part of it. Browsers post a notification when content starts changing but none when it finishes, and single-page sites load content in waves a second or more after the click. So after each observer-driven scan, neru keeps re-scanning on a capped exponential backoff. Each re-scan fingerprints the resulting hint set (a hash of element bounds and roles, from hints the scan already produced, so it costs no extra accessibility calls). If the set changed, the interval restarts dense (250ms); if it is stable, the interval grows by 1.6x up to a 5s ceiling; the loop stops after two consecutive stable scans at that ceiling. Two ceilings bound a page that never settles: the scan count (25) and the elapsed window (30s) since the sequence began do not reset on change, so a continuously-changing live page winds down to dormant rather than re-scanning forever, until the next observer event starts a fresh sequence.
A manual or
--repeatrefresh takes priority over a running settle loop: it ends the settle and scans immediately as the leading edge, so selecting a hint while the settle is mid-flight is never deferred behind a pending re-check.Safety. The observer callback runs on the accessibility thread and touches only a leaf mutex, never the mode lock, so it cannot deadlock a teardown that joins that thread. Observers arm on the focused app while hints are open and are disarmed on exit, so an idle neru holds no accessibility resources.
Tests
Cover the debounce (leading, trailing, max-wait, hold-during-search, no-hold-for-label), the backoff interval growth and clamp, the stop conditions (two stable at the cap, the scan-count ceiling, the window ceiling), the state machine, and a manual refresh pre-empting the settle.
Notes
This sits on top of the merged label-persistence fix (#1012). A stable re-scan that changes nothing redraws through the same incremental path, so it is not visible.
Testing the whole feature
This is the tip of the two-part stack (#1017 then this) and sits on top of
main, so it is the branch to run to test the feature end to end. Pull and runfeat/hints-auto-refresh-config(#1018).macOS only. Set this in your neru config:
hints.auto_refresh.enabled = trueis the only switch the feature needs. It refreshes native windows, menus, and browser and Electron web-page content alike: neru wakes the focused app's accessibility tree on focus, so page-content changes reach the observer without any extra setting.debounce_mscan stay at its default.Then open hints mode and watch the labels update as the focused app changes, a page finishes loading, a menu opens, or a panel appears, with no keypress.
Potential improvements
Merge order
This PR and the observer PR are a two-part stack and must merge in order:
feat/ax-observer-service— push-based AX observer service (inert)feat/hints-auto-refresh-config— opt-in hints auto-refresh ← this PRBoth target
main, so until #1017 merges, this PR's diff also shows its commits.Two earlier PRs from the original series have been closed: #1015 (show hints in any Electron app without whitelisting) and #1016 (rename
hints.additional_ax_supporttohints.web_content_hints). Upstream's bundle-type auto-detection (#1035) removed theadditional_ax_support/web_content_hintsmachinery they depended on, so both became unnecessary; this branch was rebased directly ontomainwithout them, and the feature no longer needs any browser-specific setting to reach web-page content.