Skip to content

feat(hints): opt-in auto-refresh that keeps hints live on macOS - #1018

Draft
gabrielecirulli wants to merge 18 commits into
y3owk1n:mainfrom
gabrielecirulli:feat/hints-auto-refresh-config
Draft

feat(hints): opt-in auto-refresh that keeps hints live on macOS#1018
gabrielecirulli wants to merge 18 commits into
y3owk1n:mainfrom
gabrielecirulli:feat/hints-auto-refresh-config

Conversation

@gabrielecirulli

@gabrielecirulli gabrielecirulli commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

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) and debounce_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 fixed DefaultMask defined in the axobserver package (#1017), which covers the structural notifications plus the web-content signals browsers post when a page changes, and omits value_changed because it fires continuously.

Debounce and coalescing. 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, as Handler state 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, because the --repeat re-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 --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.

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 run feat/hints-auto-refresh-config (#1018).

macOS only. Set this in your neru config:

[hints.auto_refresh]
enabled = true          # re-scan hints when the focused app's UI changes

hints.auto_refresh.enabled = true is 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_ms can 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

  • The fingerprint hashes exact element bounds, so one continuously-animating element on a page keeps the backoff pinned dense until the 25-scan ceiling instead of settling. Quantizing bounds to a coarse grid before hashing would let such a page settle early.
  • On multi-monitor setups the re-scan resolves the target screen from the cursor location, the same as a fresh activation. Moving the cursor to another display during a settle can filter the window's hints off screen for that scan. Pinning a refresh to the screen the session started on would fix it.

Merge order

This PR and the observer PR are a two-part stack and must merge in order:

  1. feat(axobserver): add push-based AX observer service #1017feat/ax-observer-service — push-based AX observer service (inert)
  2. feat(hints): opt-in auto-refresh that keeps hints live on macOS #1018feat/hints-auto-refresh-config — opt-in hints auto-refresh ← this PR

Both 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_support to hints.web_content_hints). Upstream's bundle-type auto-detection (#1035) removed the additional_ax_support / web_content_hints machinery they depended on, so both became unnecessary; this branch was rebased directly onto main without them, and the feature no longer needs any browser-specific setting to reach web-page content.

@gabrielecirulli

Copy link
Copy Markdown
Contributor Author

@greptileai ?

@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds opt-in live hint refresh on macOS. The main changes are:

  • Adds debounced AX observer-driven hint rescans.
  • Adds bounded settle retries for asynchronously rendered content.
  • Adds auto-refresh configuration, validation, defaults, and documentation.
  • Renames browser accessibility settings to hints.web_content_hints.
  • Adds macOS observer bridge support and non-macOS no-op behavior.

Confidence Score: 5/5

This looks safe to merge.

No blocking issues found in the changed code.

T-Rex T-Rex Logs

What T-Rex did

  • Blocked the focused-run attempt, recording the exact command, working directory, full compiler output, and exit code 1 in hints-auto-refresh-modes-01-before.log.
  • Validated the approach with a passing fallback: the hints-auto-refresh-02-after.log records the exact command, working directory, complete verbose output, and exit code 0, and relevant auto-refresh validation tests passed at lines 512–521.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

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
Loading
%%{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
Loading

Reviews (1): Last reviewed commit: "feat(hints): re-scan auto-refresh with b..." | Re-trigger Greptile

@gabrielecirulli
gabrielecirulli force-pushed the feat/hints-auto-refresh-config branch 8 times, most recently from 7549bd3 to f0e81b8 Compare July 18, 2026 14:26
gabrielecirulli and others added 15 commits July 24, 2026 22:09
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>
gabrielecirulli and others added 3 commits July 24, 2026 22:09
…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
gabrielecirulli force-pushed the feat/hints-auto-refresh-config branch from f0e81b8 to 2a88bfc Compare July 24, 2026 20:11
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.

1 participant