Skip to content

Refresh embedded mouse state when modifiers change#54

Merged
austinywang merged 13 commits into
mainfrom
issue-3557-embedded-stationary-cmd-click
May 19, 2026
Merged

Refresh embedded mouse state when modifiers change#54
austinywang merged 13 commits into
mainfrom
issue-3557-embedded-stationary-cmd-click

Conversation

@austinywang
Copy link
Copy Markdown

@austinywang austinywang commented May 5, 2026

Fixes the Ghostty-side root cause for https://github.com/manaflow-ai/cmux/issues/3557.\n\nThe embedded adapter suppressed same-position cursor callbacks to avoid phantom macOS mouse moves. That also hid stationary modifier transitions from core, so a pointer parked over an OSC 8 hyperlink did not refresh link hover/open state when Command was pressed before click release.\n\nThis keeps same-position suppression only when both coordinates and modifier state are unchanged.


Summary by cubic

Refreshes embedded mouse state when modifiers change so stationary Cmd-clicks on OSC 8 links work in embedded mode. Also fixes a renderer crash, adds a crash report subdirectory option, improves spaced file path link detection, and adds a cursor-line selection API. Addresses cmux issues 3557 and 3369.

  • New Features

    • Configurable crash report path via --crash-report-subdir; exported as build_config.crash_report_subdir and used by crash dir lookup. Validates non-empty, relative paths.
    • Cursor line selection: new C API ghostty_surface_select_cursor_line selects the semantic line under the cursor.
  • Bug Fixes

    • Embedded: track cursor_pos_mods and fire cursorPosCallback on mod changes; skip only when both position and mods are unchanged.
    • Renderer: bound IME preedit catch-up to shaped glyphs; adds a helper and a regression test.
    • URL parsing: bound spaced file path links, handle dotted spaced prefixes, and avoid capturing trailing punctuation/colons; adds tests.

Written for commit 5b0b60b. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • Performance
    • Optimized cursor tracking to detect modifier key changes and avoid redundant input updates when cursor and modifiers are unchanged.
  • Bug Fixes
    • Improved text-input/preedit rendering to better handle cases where shaped cells produce empty tails, reducing misalignment.
  • Tests
    • Added a unit test validating preedit catch-up behavior.

Review Change Stack

The embedded adapter suppressed same-position cursor callbacks to avoid phantom macOS mouse moves, but that also hid stationary modifier transitions from core. Link hover and OSC 8 open state depend on the cursor callback seeing Command/Super, so a pointer parked over a link could never become over_link before release.

Constraint: Preserve phantom same-coordinate move suppression for unchanged pointer state.

Rejected: Add an app-side URL fallback | would duplicate Ghostty link ownership and miss OSC 8 semantics.

Confidence: high

Scope-risk: narrow

Tested: Not run locally; parent cmux XCUITest added for behavior coverage.
The fork main advanced after cmux pinned 22fa801, so the OSC 8 stationary Cmd-click fix is merged forward instead of rewinding the fork. This keeps the parent submodule pointer fast-forwardable from manaflow-ai/ghostty main.

Constraint: Parent cmux submodule pointers must reference commits reachable from the fork main branch.

Confidence: high

Scope-risk: narrow

Tested: git diff origin/main..HEAD -- src/apprt/embedded.zig

Not-tested: GhosttyKit rebuild not run yet
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

Adds Surface.cursor_pos_mods and uses it in the cursor callback to avoid redundant updates when position delta < 1 and modifiers are unchanged. Separately, factors shaper-index advancement into advanceShaperCellIndexToX, replaces an inline loop in rebuildRow, and adds a unit test for empty-tail preedit catch-up.

Changes

Cursor Position Modifier Tracking

Layer / File(s) Summary
Data Shape
src/apprt/embedded.zig
cursor_pos_mods: input.Mods field added to Surface struct to store modifier state alongside cursor position.
Initialization
src/apprt/embedded.zig
Surface.init sets .cursor_pos_mods = {} to initialize the new field.
Logic & State Update
src/apprt/embedded.zig
Cursor callback skips updates when movement is negligible AND modifiers are unchanged; stores both cursor_pos and cursor_pos_mods on update.

Renderer shaper index advance

Layer / File(s) Summary
Helper
src/renderer/generic.zig
Add advanceShaperCellIndexToX to advance shaper_cells_i until shaped-cell x >= target x, handling terminal empty cells.
Integration
src/renderer/generic.zig
rebuildRow preedit catch-up now calls the helper instead of an inline loop.
Test
src/renderer/generic.zig
New test verifies advancing past an empty tail yields shaper_cells_i == shaped_cells.len.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit taps the cursor's lore,
It knows when keys have changed before.
I hop through glyphs and empty tails,
Skipping steps where nothing fails.
Tiny tweaks, the UI hums—hooray! 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly addresses the main change: tracking cursor position modifiers to refresh mouse state when modifiers change, which is the core fix enabling modifier-only transitions for the embedded adapter.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-3557-embedded-stationary-cmd-click

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/apprt/embedded.zig`:
- Around line 923-927: Update the misleading comment above the conditional that
checks cursor movement and modifiers (the if using self.cursor_pos, pos, and
self.cursor_pos_mods.equal(mods)); change it to state that processing is skipped
only when neither the cursor position has changed (within 1 pixel in x and y)
nor the modifier state has changed, i.e., the block suppresses duplicate events
for identical position+modifier state but allows handling when modifiers change
even if position is stationary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a4dd372-2a2e-4add-8b04-1ba0d9e677a3

📥 Commits

Reviewing files that changed from the base of the PR and between 41ab6c5 and ee397e2.

📒 Files selected for processing (1)
  • src/apprt/embedded.zig

Comment thread src/apprt/embedded.zig
Comment on lines 923 to +927
// behavior, we only continue with callback logic if the cursor has
// actually moved.
if (@abs(self.cursor_pos.x - pos.x) < 1 and
@abs(self.cursor_pos.y - pos.y) < 1) return;
@abs(self.cursor_pos.y - pos.y) < 1 and
self.cursor_pos_mods.equal(mods)) return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the cursor-move suppression comment to match the new logic.

The comment says processing continues only when the cursor moves, but the code now also processes stationary modifier transitions. Please update wording to avoid misleading future changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/apprt/embedded.zig` around lines 923 - 927, Update the misleading comment
above the conditional that checks cursor movement and modifiers (the if using
self.cursor_pos, pos, and self.cursor_pos_mods.equal(mods)); change it to state
that processing is skipped only when neither the cursor position has changed
(within 1 pixel in x and y) nor the modifier state has changed, i.e., the block
suppresses duplicate events for identical position+modifier state but allows
handling when modifiers change even if position is stationary.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR extends the same-position cursor-event deduplication in embedded.zig to also track modifier state, so that pressing or releasing a modifier key (e.g. Command) while the cursor is stationary is no longer silently swallowed before reaching core. The root motivation is OSC 8 hyperlink hover/open state not refreshing on modifier-only changes.

  • Adds cursor_pos_mods: input.Mods to Surface (zero-initialized) and stores the latest mods alongside cursor_pos on every unfiltered event.
  • The early-return guard is extended from a position-only check to position unchanged AND mods unchanged, so modifier transitions always propagate to core_surface.cursorPosCallback.

Confidence Score: 4/5

Safe to merge; the change is minimal and well-scoped with no expected regressions for the current macOS platform code.

The dedup guard now calls Mods.equal, which compares all 16 bits of the packed struct including side bits for unpressed modifiers. If the platform ever constructs Mods with inconsistent side bits for unpressed keys, phantom events could slip through the guard. The explanatory comment above the guard also no longer describes the full bypass condition, which is a small maintenance hazard.

src/apprt/embedded.zig — specifically the dedup guard and its associated comment.

Important Files Changed

Filename Overview
src/apprt/embedded.zig Adds cursor_pos_mods field to Surface and extends same-position dedup guard to also compare modifier state, so modifier-only changes (e.g. Cmd key) are no longer silently suppressed when the cursor is stationary.

Sequence Diagram

sequenceDiagram
    participant Platform as macOS Platform
    participant Surface as embedded.Surface
    participant Core as core_surface

    Note over Platform,Core: Before fix: modifier-only change suppressed
    Platform->>Surface: cursorPosCallback(x, y, mods=Cmd)
    Surface->>Surface: Check same pos (< 1px)?
    Note right of Surface: Old: only checked position
    Surface-->>Platform: return (suppressed!)

    Note over Platform,Core: After fix: modifier change passes through
    Platform->>Surface: cursorPosCallback(x, y, mods=Cmd)
    Surface->>Surface: Check pos delta < 1 AND cursor_pos_mods.equal(mods)
    Note right of Surface: mods changed, not equal, don't suppress
    Surface->>Surface: cursor_pos = pos
    Surface->>Surface: cursor_pos_mods = mods
    Surface->>Core: cursorPosCallback(pos, mods)
    Core-->>Surface: ok
Loading

Comments Outside Diff (1)

  1. src/apprt/embedded.zig, line 917-927 (link)

    P2 Stale suppression comment

    The block comment above the guard (lines 917–924) only describes "the cursor has actually moved" as the condition for passing through. It now omits that a modifier-state change also bypasses suppression. A reader seeing only the comment would not understand why the guard now includes cursor_pos_mods.equal(mods), and might remove or simplify it during future maintenance thinking it's redundant.

Reviews (1): Last reviewed commit: "Carry embedded mouse-state fix onto curr..." | Re-trigger Greptile

Comment thread src/apprt/embedded.zig
Comment on lines 925 to +930
if (@abs(self.cursor_pos.x - pos.x) < 1 and
@abs(self.cursor_pos.y - pos.y) < 1) return;
@abs(self.cursor_pos.y - pos.y) < 1 and
self.cursor_pos_mods.equal(mods)) return;

self.cursor_pos = pos;
self.cursor_pos_mods = mods;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mods.equal compares side bits for unpressed modifiers

Mods.equal performs a full u16 bitcast comparison (including the sides sub-field and _padding). The sides bits track left/right identity for each modifier, but their value for an unpressed modifier is logically meaningless. If the macOS platform inconsistently populates side bits for unpressed modifiers across different events (e.g. one call leaves sides.shift = .left, the next leaves it .right while shift=false), the two Mods values would compare unequal even though the semantically visible modifier state is identical. This could cause the dedup to let through phantom events that the original guard was meant to suppress. Using mods.binding().equal(self.cursor_pos_mods.binding()) — which zeroes the side and lock bits — would be a safer anchor for this dedup check.

The Metal crash report points at GenericRenderer(Metal).rebuildRow, and the row preedit catch-up path can advance past the final shaped glyph when preedit covers the only glyph and the row tail is empty. This commit preserves the current behavior behind a small seam and adds the regression that proves the unsafe read before the fix changes it.

Constraint: Regression test must be committed before the fix commit for issue ghostty-org#3369

Confidence: high

Scope-risk: narrow

Tested: zig build test -Demit-macos-app=false -Dtest-filter='renderer rebuild row preedit catch-up tolerates empty tail after covered glyph' fails with index out of bounds

Not-tested: Full renderer/AppKit integration remains for CI after the fix
The row rebuild path skipped IME preedit cells and then advanced through shaped glyph cells as though every terminal cell had a corresponding glyph. Empty cells and other no-glyph cells break that assumption, so the catch-up cursor now stops at the shaped slice boundary and lets the existing next-run logic take over.

Constraint: Fix follows the failing regression commit for issue ghostty-org#3369

Rejected: Handle this in cmux Swift lifecycle | the unsafe read is in Ghostty's renderer row iterator, independent of AppKit surface ownership

Confidence: high

Scope-risk: narrow

Directive: Keep shaped glyph iteration bounded; terminal-cell count and shaped-glyph count are not equivalent

Tested: zig build test -Demit-macos-app=false -Dtest-filter='renderer rebuild row preedit catch-up tolerates empty tail after covered glyph'

Not-tested: Full app rendering and CI matrix pending in cmux PR
… into issue-3557-embedded-stationary-cmd-click
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/renderer/generic.zig (1)

3415-3431: 💤 Low value

Consider expanding test coverage for edge cases.

The test correctly validates the main "empty tail" scenario, but additional test cases would provide more comprehensive coverage:

  • Empty shaped_cells array
  • Target x at the same position as a cell (not just after)
  • Multiple cells requiring advancement
  • Target x before the first cell
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/generic.zig` around lines 3415 - 3431, Add more unit tests
covering edge cases for advanceShaperCellIndexToX: include tests where
shaped_cells is empty (verify shaper_cells_i stays 0), where target x equals a
cell's x (ensure index points to that cell or correct next position per function
contract), where multiple cells must be advanced (create several entries in
shaped_cells and assert final shaper_cells_i), and where target x is before the
first cell (assert shaper_cells_i remains 0). Use the same test pattern as
"renderer rebuild row preedit catch-up tolerates empty tail after covered glyph"
and reference the symbols shaped_cells, shaper_cells_i, and
advanceShaperCellIndexToX to locate where to add these cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/renderer/generic.zig`:
- Around line 3415-3431: Add more unit tests covering edge cases for
advanceShaperCellIndexToX: include tests where shaped_cells is empty (verify
shaper_cells_i stays 0), where target x equals a cell's x (ensure index points
to that cell or correct next position per function contract), where multiple
cells must be advanced (create several entries in shaped_cells and assert final
shaper_cells_i), and where target x is before the first cell (assert
shaper_cells_i remains 0). Use the same test pattern as "renderer rebuild row
preedit catch-up tolerates empty tail after covered glyph" and reference the
symbols shaped_cells, shaper_cells_i, and advanceShaperCellIndexToX to locate
where to add these cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6f1b1fc6-b4cc-492b-b0ba-7951dfbbb723

📥 Commits

Reviewing files that changed from the base of the PR and between ee397e2 and ec09743.

📒 Files selected for processing (1)
  • src/renderer/generic.zig

@austinywang austinywang merged commit 5b0b60b into main May 19, 2026
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