Repository navigation
feat(app): anchor the mode flash at the caret and add a screen-border style - #68
Merged
Merged
Conversation
… style The transient mode label now starts at the caret (AXBoundsForRange (loc,1) → (loc-1,1) → text-marker bounds), read only for requests that carry a flash; caret validity lives in the pure layout layer and the persistent badge keeps the element rung. A second presentation style draws an accent frame around the display that holds the focused window with the mode label in its corner, selectable in Settings > General and stored in UserDefaults (onScreenModeIndicatorStyle, default badge). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
An in-flight geometry read can redraw the previous persistent style after Settings hides it.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds caret-anchored mode flashes and a configurable persistent screen-border indicator.
Changes:
- Reads and validates caret geometry with element/window fallbacks.
- Adds badge/screen-border styles with persisted Settings selection.
- Expands layout/controller tests and architecture documentation.
File summaries
| File | Description |
|---|---|
VimAction/ModeIndicatorController.swift |
Coordinates styles, geometry reads, and panels. |
VimAction/ModeIndicatorGeometryReader.swift |
Reads caret bounds through AX fallbacks. |
VimAction/ModeIndicatorLayout.swift |
Adds per-overlay ladders and border layout. |
VimAction/ModeIndicatorPanel.swift |
Shares panel and pill implementations. |
VimAction/ModeIndicatorBorderPanel.swift |
Draws the screen border and label. |
VimAction/SettingsView.swift |
Adds the indicator style picker. |
VimAction/Preferences.swift |
Defines the persisted style preference. |
VimActionTests/ModeIndicatorLayoutTests.swift |
Tests caret and border geometry. |
VimActionTests/ModeIndicatorControllerTests.swift |
Tests style state and persistence. |
.claude/skills/plans/SKILL.md |
Updates active-plan status. |
.claude/skills/plans/references/20260906_onscreen-mode-indicator.md |
Records PR progress and follow-ups. |
.claude/skills/architecture/references/profiles-and-config.md |
Documents the new preference. |
.claude/skills/architecture/references/mode-indicator-overlay.md |
Documents the expanded overlay architecture. |
.claude/skills/architecture/references/app-shell.md |
Documents the Settings picker. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…is frontmost the self-pid branch of `reconcile` hid stale persistent panels but left `token` untouched, so a geometry read already in flight for the previous app could land afterwards and redraw the old badge or border right after settings hid it (copilot review on #68). bump the token, drop the pending request and expire any pending flash there — a flash that missed its moment is dropped, same contract `finish` applies when no anchor exists. `inFlight` stays untouched so `pump()` keeps its one-read-at-a-time guard. `token` is now `private(set)` so the regression can be pinned without a panel or an ax read: the self-pid path is the one trigger that neither reads nor draws. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
dogfooding feedback: some users want the transient flash on every mode change but find a badge that stays on screen during normal/visual too noisy. add a second bool setting next to the on/off toggle — when the persistent layer is off, `presentation` folds `showsBadge` to false for every mode, so everything downstream already behaves like the insert path: nothing stays on screen and anchor events cost zero ax reads. settings gets a "show" picker under the indicator toggle (only when the mode changes / also while in normal or visual mode); the style picker is disabled whenever there is no persistent layer to style. defaults are unchanged — flash plus persistent badge stays the product default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
dogfooding feedback: between "badge near the field" and "whole screen border" there was no way to frame just the focused app window. add `windowBorder` as a third persistent style. - the geometry reader takes `includesWindow` (mirroring `includesCaret`) and reads the focused window rect once whenever the window border asks for it or the element rect is unusable; the request only asks when a persistent window border is actually going to be drawn, so insert and flash-only requests stay at zero extra ax calls. - the pure layer gains `windowBorderLayout`: it frames the window rect itself (no ladder, no fallback) and clamps the corner label first into the anchor screen's visible frame and then into the window, so a window narrower than the label plus insets no longer clips it. the existing screen layout is renamed `screenBorderLayout`. - both border styles share the one `ModeIndicatorBorderPanel`; the controller now remembers which style the panel is showing so a border-to-border switch hides the old shape immediately, the same contract badge-to-border already had. `usesBorderPanel` on the style enum keeps the hide rule exhaustive. - settings lists the new style between badge and screen border. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
…itecture - decisions: new entry for the flash-only option and the focused-window border style (opt-in, defaults unchanged; the 2026-09-06 policy is not superseded) plus the self-pid token invalidation that both rely on. - architecture: mode-indicator-overlay reflects the three settings, `includesWindow`, the shared border panel with `borderPanelStyle`, `screenBorderLayout`/`windowBorderLayout`, the self-pid invariant and the new known limits; profiles-and-config lists the third key; app-shell lists the "show" picker. - plans: pr 3 extension recorded, the token follow-up removed, the remaining device dogfooding handed to the user, tools re-created. - border panel: note why both border styles share one corner radius. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
the indicator grew to a toggle, two pickers and three footnotes, which crowded the behavior section in general and mixed system behavior (launch at login, normal-mode exit) with on-screen display preferences. the color and opacity follow-up will add more options, so give them a home now. this only moves the existing controls into a new "indicator" tab right after general, in the same order with the same disabled conditions. settings keys, wording, defaults (apps as the default tab, general for onboarding), the 460x560 window size and the dock icon hook on the tab view root are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…plan docs - decisions: new entry for the four-tab settings window (general / indicator / apps / about); the 2026-08-09 three-tab decision is superseded only for the tab count, its other rules stay valid. - architecture: app-shell describes the four tabs and the indicator tab contents; mode-indicator-overlay points at the indicator tab. - plans: the tab split recorded as done in pr #68, the "consider a visual tab" note dropped from the color/opacity follow-up, and the indicator tab added to the user's dogfooding checklist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pilyang
added a commit
that referenced
this pull request
Sep 16, 2026
the plan reached its goal: four prs merged (#66 flash, #67 persistent badge, #68 caret anchor and border styles, #70 per-mode colors) and the roadmap item checked. its two open follow-ups moved to github issues so nothing is lost with the plan file: - #71 (cold-launched apps miss observer registration) - #72 (one unreproduced flash placement after a cross-display move) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.
Summary
Third and last feature PR of the on-screen mode indicator plan (PRD §7.7, roadmap Stage 4). Two things, both settled in the 2026-09-06 decisions:
AXSelectedTextRange→AXBoundsForRange (loc,1)→(loc-1,1)at document end →AXSelectedTextMarkerRange+AXBoundsForTextMarkerRange(opaque marker passed straight through). Validity lives in the pure layer (ModeIndicatorLayout.isUsableCaret): height > 0 (width may be 0), not equal to the element rect (marker degeneration at content end), and inside the element rect when that rect is usable. The ladder is now per pill: flash = caret → element → window, persistent badge = element → window (the badge never follows the caret). Chromium browsers yield no caret and fall back to the element rung; no screen-reader mode is forced.visibleFrameso the top and bottom edges are not hidden behind the menu bar and Dock. The flash keeps its caret/element ladder in both styles.Setting:
onScreenModeIndicatorStyle(badgedefault /screenBorder), owned byModeIndicatorControllerlike the toggle (runtime SSOT is the property,didSetpersists and reconciles; unknown or missing value folds to the default). All three panels share one non-activating panel factory; every hide rule for the badge applies to the border through a single helper.Extra AX cost: up to 5 calls per flash on top of the existing element/window reads; anchor and ladder events stay at the previous cost.
Extension (2026-09-15) — from dogfooding feedback
Three more commits on this branch:
7b92d9d, Copilot's review finding). The self-pid branch ofreconcilenow bumpstoken, clearspendingand expires a pending flash, so a read already in flight for the previous app can no longer land after Settings (or a menu-bar click) hid the old badge or border and redraw it.inFlightis left tofinishsopump()keeps its one-read-at-a-time guard.tokenisprivate(set)so the regression is pinned without a panel or an AX read.87e7529). A second Bool setting,onScreenModeIndicatorPersistentEnabled(default on), shown under the indicator toggle as a "Show" picker: Only when the mode changes / Also while in Normal or Visual mode. Off foldsshowsBadgeto false inpresentation, so everything downstream behaves like the Insert path: nothing stays on screen and anchor events cost zero AX reads. The style picker is disabled whenever there is no persistent layer to style. Defaults are unchanged.2d2b738).ModeIndicatorPresentationStyle.windowBorderframes the focused window's own rect (no ladder, no fallback: no window, nothing drawn) with the label inside its top-right corner. The reader takesincludesWindow(mirroringincludesCaret) and reads the window rect once, only when a persistent window border is actually going to be drawn. The corner label is clamped first into the anchor screen'svisibleFrame, then into the window, so a window narrower than the label plus insets no longer clips it. Both border styles share the oneModeIndicatorBorderPanel; the controller remembers which style it is showing (borderPanelStyle) so a border-to-border switch hides the old shape immediately, the same contract badge-to-border already had.borderLayoutis renamedscreenBorderLayout.Decision recorded:
20260914_mode-indicator-optin-flash-only-and-window-border.md(both options are opt-in; the 2026-09-06 policy is not superseded).Extension verification
xcodebuild test -only-testing:VimActionTests: pass. New tests cover the flash-only presentation rows and read coalescing, persistence of the new key (with theobject(forKey:) != nilguard), thewindowBorderraw value and persistence,usesBorderPanel, sixwindowBorderLayoutcases (window over element, nil/degenerate window, label placement, secondary display, menu-bar band, small window) and the self-pid token invalidation.xcodebuild build … CODE_SIGNING_ALLOWED=NO: 0 warnings.i); the window border's panel bounds equal the TextEdit window bounds exactly and follow move and resize; it moves to Slack's window on app switch and back; hidden on minimize and oni, back on un-minimize; correct on the secondary display (negative x). Flash-only in both styles: the flash appears and nothing persists, including after switching apps.Settings: Indicator tab (2026-09-15)
Two more commits move the indicator settings out of General:
3224093). The settings window gains a fourth tab, Indicator (General / Indicator / Apps / About, SF Symboltext.cursor), holding the on-screen indicator toggle, the "Show" picker and the "Indicator style" picker with their footnotes. It is a move only: keys, wording, defaults, the.disabledrules, the fixed 460×560 window, the default Apps tab and the Dock-icon hook on theTabViewroot are unchanged. General > Behavior keeps only Launch at login and Exit Normal mode on ⌘/⌥ shortcuts.ModeIndicatorController,Preferences.swiftand the tests are untouched, since the view only binds to the controller.4e54732). Decision20260915_settings-window-indicator-tab.md(four tabs; partially supersedes the 2026-08-09 three-tab decision, tab count only),app-shell.mdandmode-indicator-overlay.mdupdated, plan updated (the colour/opacity follow-up now adds its options to this tab).Verification:
xcodebuild build … CODE_SIGNING_ALLOWED=NOwith 0 warnings andxcodebuild test -only-testing:VimActionTestspassing, re-run independently by the supervising session. A Developer ID Release build is installed for the remaining hands-on check of the tab.Behaviour notes
visibleFramestill excludes it.loc).Verification
swift test(engine): 101 tests pass.xcodebuild test -only-testing:VimActionTests: pass (new layout tests for the per-pill ladder, caret validity, caret placement, border layout; controller tests for style in the presentation, read coalescing on style switch, style persistence with theobject(forKey:) != nilguard).xcodebuild build … CODE_SIGNING_ALLOWED=NO: 0 warnings.(loc,1); Safari's address bar via(loc-1,1)(the end-of-text caret sat just outside the field rect, so the first variant was rejected as designed); Slack's composer and a Notion block via the text-marker path. Chrome and Arc yield no caret: flash and badge at the element corner.i, back onv; follows the window across displays through the window-moved notification (warm app — a freshly launched app hits the pre-existing AXObserver registration failure already recorded in the plan).Docs
Architecture references updated to the current state:
mode-indicator-overlay.md(per-pill ladder, caret read order and validity rule, border panel and style, hide rules),profiles-and-config.md(new UserDefaults key),app-shell.md(style picker). Plan updated.Extension docs:
mode-indicator-overlay.md(three settings,includesWindow, shared border panel, self-pid invariant, new known limits),profiles-and-config.md(third key),app-shell.md("Show" picker), plus the new decision entry.🤖 Generated with Claude Code
https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721
https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q