Skip to content

Add physical-row text read API + host-owned external-hover rendering/diagnostics - #197

Open
YosukeIida wants to merge 17 commits into
manaflow-ai:mainfrom
YosukeIida:cmux/physical-row-read
Open

Add physical-row text read API + host-owned external-hover rendering/diagnostics#197
YosukeIida wants to merge 17 commits into
manaflow-ai:mainfrom
YosukeIida:cmux/physical-row-read

Conversation

@YosukeIida

@YosukeIida YosukeIida commented Aug 10, 2026

Copy link
Copy Markdown

Summary

This branch now carries three related additions for manaflow-ai/cmux#9868 (the Cmd-click wrapped-path resolver):

(A) Physical-row text read API

ghostty_surface_read_text_physical_rows — reads terminal text without unwrapping soft-wrapped row boundaries, so a host that maps a screen row/column back into text doesn't lose row identity when the clicked text soft-wraps. Screen.SelectionString grows an unwrap: bool = true option; every existing caller keeps the historical true behavior.

(B) Host-owned external-hover rendering

ghostty_surface_set_external_link_hover / ghostty_surface_clear_external_link_hover — lets the embedding host claim interactive hover rendering for a resolved link over a physical-row range, instead of Ghostty's own regex-based hover. Ghostty fingerprints and re-validates the supplied text every frame, so a stale/mismatched argument fails safe.

(C) External-hover diagnostics

ghostty_surface_drain_external_hover_diagnostics / ghostty_external_hover_diag_entry_s — fixed-size POD hover-lifecycle tracing ring entries for host-side debugging, gated off by default.

Note on scope history: an earlier commit on this branch described (B) as reviewed-and-rejected, intended to stay host-side only. That didn't hold up against the actual consumer code — cmux's ExternalHoverWorkService / TerminalSurfaceRuntimeTeardownCoordinator genuinely call into (B) and (C), so both are real, in-scope additions here.

Tests

  • Screen.zig: selectionString soft wrap extended with an unwrap = false case, plus hard-newline/blank-row and wrap-boundary (wide char, combining mark) coverage. zig build test -Dtest-filter="Screen:": 261/261 pass.
  • External-hover mechanism + diagnostics: zig build test -Dtest-filter="ExternalHover": 92/92 pass.

Consumer

manaflow-ai/cmux#9868. That PR's submodule pin currently points at this branch's tip pending merge here; it will be re-pinned to the resulting fork-main merge commit once this lands.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added host-controlled link hover support with activation, clearing, pointer-range validation, and transition notifications.
    • Added optional diagnostics for external hover validation and delivery issues.
    • Added text-reading support that preserves soft-wrapped physical row boundaries.
  • Bug Fixes
    • External hover state now clears when the pointer leaves the viewport or valid link ranges.
    • Improved hover replacement, stale-state handling, and bounded retry behavior.
  • Tests
    • Expanded coverage for hover transitions, pointer movement, validation, diagnostics, and physical-row text selection.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3efff62e-6751-4e81-88a6-09753eb798b8

📥 Commits

Reviewing files that changed from the base of the PR and between 90dc81a and 5f563c1.

📒 Files selected for processing (4)
  • include/ghostty.h
  • src/apprt/action.zig
  • src/apprt/embedded.zig
  • src/renderer/Thread.zig
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/apprt/action.zig
  • include/ghostty.h
  • src/apprt/embedded.zig

📝 Walkthrough

Walkthrough

The change adds physical-row text reads and a host-controlled external link-hover lifecycle. It includes validation, pointer invalidation, rendering arbitration, action delivery, acknowledgment handling, diagnostics, and tests.

Changes

External link hover and physical-row text

Layer / File(s) Summary
Text and public API contracts
src/terminal/Screen.zig, src/Surface.zig, src/apprt/embedded.zig, include/ghostty.h, src/apprt/action.zig, src/renderer.zig
Selection reads can preserve physical rows. Public APIs expose external hover actions, ranges, tokens, clearing, and diagnostics.
Hover activation and pointer invalidation
src/renderer/link.zig, src/Surface.zig, src/renderer/State.zig, src/apprt/embedded.zig
External hover claims validate snapshots, ranges, pointer position, viewport bounds, eligibility, and context epochs. Pointer exits invalidate active claims.
Hover rendering arbitration
src/renderer/generic.zig, src/renderer/link.zig
The renderer invalidates stale claims, merges valid external cells into link highlights, and skips regex hover resolution for active overrides.
Transition delivery and acknowledgment
src/renderer/Thread.zig, src/renderer/generic.zig, src/apprt/action.zig
Render paths deliver hover transitions. Acknowledgments update tokens and apply one bounded retry for failed inactive transitions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Mergeability Score: ⚪ Minimal · up to 5f563

No actionable merge-blocking risk remains. The remaining items are a localized test-probe initialization cleanup and the standard formatting pass, with no supplied indication of production impact.

Sequence Diagram(s)

sequenceDiagram
  participant Host
  participant SurfaceAPI
  participant Surface
  participant Renderer
  participant AppRuntime
  Host->>SurfaceAPI: Set external link hover
  SurfaceAPI->>Surface: Validate scope, snapshot, pointer, and ranges
  Surface->>Renderer: Schedule hover render
  Renderer->>Renderer: Revalidate and merge external cells
  Renderer->>AppRuntime: Deliver external_link_hover action
  AppRuntime-->>Renderer: Acknowledge transition
  Renderer-->>Host: Publish or clear hover token
Loading

Suggested reviewers: lawrencecchen, mitchellh

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the physical-row text API and host-owned external-hover rendering and diagnostics added by the pull request.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 2847-2854: Update the documentation for
ghostty_surface_read_text’s cmux variant to remove the claim that every physical
screen row emits its own newline. State instead that using unwrap = false
prevents soft-wrapped rows from being joined, and callers must validate output
row counts before mapping screen coordinates to returned-text offsets; leave the
existing false argument unchanged.
🪄 Autofix

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 Plus

Run ID: 5ed82e88-6684-4872-9389-3d38200dff79

📥 Commits

Reviewing files that changed from the base of the PR and between f76c132 and e196afa436e3e722fb120bc1431ac783fd4aa3b3.

📒 Files selected for processing (4)
  • include/ghostty.h
  • src/Surface.zig
  • src/apprt/embedded.zig
  • src/terminal/Screen.zig

Comment thread src/apprt/embedded.zig
@YosukeIida
YosukeIida force-pushed the cmux/physical-row-read branch from e196afa to 7c8a205 Compare August 10, 2026 13:14
YosukeIida and others added 14 commits August 10, 2026 22:36
…aries

cmux fork addition, reviewed and scoped down from a wider proposal (the
review kept only the physical-row read API — (A) — and explicitly
rejected porting the accompanying ExternalHover mechanism (B), its BFS
candidate search, and its mouse-press replay arbitration; those stay
host-side or out of scope).

`Screen.selectionString` always unwraps soft-wrapped rows into one
logical line — correct for clipboard-style reads, but it means a host
that maps a screen row/column (e.g. from a mouse click) back into the
returned text loses row identity whenever the clicked text soft-wraps
across two physical rows: the two rows collapse into one line and any
row-index-based lookup desyncs.

Adds an `unwrap: bool = true` option to `Screen.SelectionString`,
threaded through `Surface.dumpTextLocked` and the embedded apprt's
`readTextLocked`, defaulting to the historical `true` everywhere except
the one new call site. `ghostty_surface_read_text_physical_rows` is the
only caller that passes `false`; every existing caller
(`ghostty_surface_read_text`, `ghostty_surface_read_selection`, the
inspector's word-selection read) is updated to pass `true` explicitly
and is behaviorally unchanged.

Named `_physical_rows` rather than mirroring an earlier `_no_unwrap`
working name — a negative boolean embedded in a public C symbol name
reads worse than what the API actually guarantees. The contract is
deliberately "does not unwrap soft-wrapped boundaries," not "exactly
one line per physical row": formatters can still represent an
unrendered trailing row or a final newline differently, and this API
doesn't change any of that pre-existing behavior — it only stops
joining wrapped rows into one line. Callers that need a fixed row count
must still validate the split themselves (row-count/pad/fail-closed
policy is the host's responsibility, not this API's).

Tests added to `Screen.zig`:
- Existing `selectionString soft wrap` test extended with an
  `unwrap = false` case proving the wrap boundary now round-trips as a
  newline (`"2EFGH\n3IJ"`) instead of the joined `"2EFGH3IJ"`.
- Hard newlines and leading/inner blank rows survive a selection
  spanning several real (non-wrapped) row boundaries, for both
  `unwrap` values — proving this option only affects soft-wrap joining,
  never a real line break.
- A wide character sitting exactly at the wrap boundary and a combining
  mark composed onto the row's last character both preserve row
  identity across the wrap under `unwrap = false`: neither the wide
  cell's trailing spacer nor the zero-width combining mark shifts which
  physical row later text lands in.

All pre-existing `selectionString`/`dumpText`/clipboard/inspector
callers pass the historical `true` explicitly and their existing tests
are unaffected (`zig build test -Dtest-filter="Screen:"`,
`-Dtest-filter="dumpText"`, `-Dtest-filter="selectionString"`: all
green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…ering

Lets the embedding host (cmux) own interactive hover rendering for a
resolved link, replacing a native regex partial-match hover for exactly
that scope, without racing the render thread or resurrecting stale state.

- `renderer/link.zig`: `PhysicalSnapshotToken` (mouse-independent identity
  over a row scope fixed at mint time, never re-derived from a "current"
  cell) and `HoverActivationToken` (snapshot + pointer cell/mods/epoch at
  mint time). `ExternalHover` destructively invalidates on any token
  mismatch (ABA-safe) and re-projects its cell ranges against current grid
  bounds before rendering.
- `renderer/State.zig`: new `Mouse` fields — `pointer_cell`/`hover_input_epoch`
  (native-hover-outcome-independent, unlike the pre-existing `point`),
  `hover_eligible` (computed once under the mutex by the input path, never
  re-derived by the renderer thread), the `external_hover` state itself, and
  a last-delivered/pending-transition pair for cross-thread handoff.
- `Surface.zig`: `setExternalLinkHover`/`clearExternalLinkHover` mint/release
  a token entirely inside the renderer mutex and never call the apprt
  inline — Ghostty's own `processLinks -> openUrl -> performAction` already
  runs synchronously under this same mutex, so a caller can only ever
  observe the transition after the setter returns. `cursorPosCallback`/
  `modsChanged` bump `hover_input_epoch` only on a real cell or normalized-
  mods change, never sub-cell jitter.
- `renderer/generic.zig`: render-loop priority — OSC8 always invalidates and
  wins outright; otherwise the stored scope is re-fingerprinted from current
  screen content every frame (catches an in-place text rewrite under a
  stationary pointer) and `validateOrInvalidate`d; an active override
  supplies its own cell set and suppresses interactive regex hover (the
  always-on `regex_always` layer is untouched). A transition is queued for
  delivery only on an actual token/active change.
- `renderer/Thread.zig`: `notifyExternalHoverTransition` fetches-and-clears
  the pending transition under a brief separate lock, then calls
  `performAction` after releasing it — mirrors the existing
  `notifySelectionChanged` precedent exactly, reusing `state`/`lockDemand`
  rather than a new lock. Wired into the same 3 call sites
  `notifySelectionChanged` already had.
- `apprt/action.zig`: new `external_link_hover` action carrying only the
  bounded `{token_bits: [4]u64, active: bool}` value — never a path string
  across the C ABI. Updated the `Action.CValue` size sentinel (this is now
  the largest union member).
- `include/ghostty.h` / `apprt/embedded.zig`: new
  `ghostty_surface_set_external_link_hover`/`_clear_external_link_hover`
  exports and the `GHOSTTY_ACTION_EXTERNAL_LINK_HOVER` mirror. The setter
  takes the caller's row scope and physical-row text (same non-unwrapped
  form as `ghostty_surface_read_text_physical_rows`) and returns an opaque
  activation token; the export wrappers do not take the renderer mutex
  themselves since the Surface methods already do.

New tests (all passing): physical snapshot token identity across content/
scope/screen-key change and out-of-bound rejection; hover activation token
identity across pointer cell/mods/epoch; ExternalHover's destructive
invalidation on mismatch; `.set` rejecting out-of-bound ranges; and
`.replaceCells` re-validating ranges against current grid bounds.

This is new API surface with no prior behavior to regress against, so
there is no meaningful failing-test-first split for it (same as the prior
physical-row-read C export); tests are included in this commit and were
green before it landed. `zig build -Demit-macos-app=false` and every
targeted `zig build test -Dtest-filter=...` above are green, including
`ghostty.h Action.Key` (confirms the new enum entry and the updated
`CValue` size sentinel are both correct).

Ghostty side of cmux issue manaflow-ai/cmux#8810.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
ghostty-org#8810)

`zig build test` caught this: `external_link_hover` had been inserted
right after `mouse_over_link`, shifting every later tag's ordinal value by
+1 and breaking "Action.Key preserves the public C ABI" (expected 64,
found 65 for copy_title_to_clipboard). New actions must be appended at
the end per the file's own "GUIDE TO ADDING NEW ACTIONS" comment, exactly
to avoid this. Moved the union field, the Key enum tag, and the matching
ghostty.h entries (tag enum + union field) to the end, after
selection_changed — copy_title_to_clipboard and selection_changed are
back at their pinned 64/65, and external_link_hover is now 66.

zig build test: green (the one pre-existing test-lib-vt failure, "kitty
temporary file medium preserves bool ABI", reproduces identically on the
abcf569 base commit with none of this fork's changes applied — confirmed
via a throwaway worktree — so it is unrelated and pre-existing, not a
regression from this work).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…e range row (ghostty-org#8810)

review-B-wiring-plan.md Blocking 2: range.row is an absolute viewport
row (see replaceCells, which uses it directly as the drawn cell's .y
with no offset by top_row), but .set() never checks it actually falls
within [top_row, top_row + row_count) — the scope the call is claiming.
This test proves the gap; the fix lands in the next commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…hostty-org#8810)

.set() now checks r.row against [top_row, top_row + row_count) alongside
the existing inverted/oversized-range checks. Since range.row is drawn
directly as the cell's .y with no top_row offset (replaceCells), a range
outside its own claimed scope could otherwise get accepted and later draw
an underline on the wrong row once render-time bounds checking alone
happened to still pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…iewport-relative implementation (ghostty-org#8810)

review-B-wiring-plan.md Blocking 2: ghostty.h claimed top_row was an
absolute screen row and range.row was relative to top_row. Neither
matches the actual Zig implementation: Surface.setExternalLinkHover's
bound check and the render loop's re-fingerprint both pin top_row as
.viewport, and replaceCells draws range.row directly with no top_row
offset (i.e. range.row is itself an absolute VIEWPORT row). Following
the wrong header contract would read/underline the wrong row on any
scrolled-away-from-bottom viewport.

Fixed the doc comments to describe the real contract, and added a test
exercising the exact pin(.{.viewport=...}) lookup those call sites use
at a nonzero viewport scroll offset and nonzero top_row, confirming it
tracks the viewport rather than a fixed absolute row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…n ineligible (ghostty-org#8810)

review-B-wiring-plan.md Blocking 6: Surface wrote hover_eligible but
nothing consumed it — the render loop never checked it and the epoch only
bumped on pointer_cell/mods changes, so a selection starting (or mouse
capture beginning) while the pointer sat still over the same cell would
neither invalidate an in-flight token nor stop a new one from being
minted.

- cursorPosCallback now bumps hover_input_epoch when hover_eligible
  itself flips, independent of pointer_cell.
- setExternalLinkHover rejects (returns zero) outright while
  !hover_eligible, mirroring the same gate native link hover already
  respects.
- The render loop's external_active block destructively invalidates on
  !hover_eligible, the same as an OSC8 link taking over.

No dedicated unit test: exercising cursorPosCallback/setExternalLinkHover
end to end needs a full Surface+apprt harness this file has none of (its
existing tests all operate on terminal.Terminal directly); zig build
test and zig build -Demit-macos-app=false are both green with this
change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…n's result (ghostty-org#8810)

review-B-wiring-plan.md Blocking 5 (Ghostty half): Thread.notifyExternalHoverTransition
called performAction and threw the result away with '_ ='. Without
consuming it, the core has no way to know whether the host actually
committed a transition into logical ownership, and final-spec's
last_published/pending_inactive ack table had no implementation at all.

- New State.Mouse fields: external_hover_ack_last_published,
  external_hover_ack_pending_retry, external_hover_ack_retry_attempted.
- externalHoverAckReducer: the pure decision from final-spec's ack table,
  split out so it's unit-testable without a live Thread/Surface/apprt
  (this file has none). active(true) commits lastPublished
  unconditionally on success, leaves it untouched on failure/error (no
  retry — the next real state change re-derives its own outcome).
  inactive(true) clears lastPublished only if it still matches; a failed
  inactive stages exactly one bounded retry via
  external_hover_ack_retry_attempted, never an unconditional resend loop.
- notifyExternalHoverTransition now prefers a genuinely new pending
  transition over a staged retry (and resets retry_attempted when it
  fetches one, since that's real fresh state superseding whatever the
  retry was chasing), calls applyExternalHoverAck with performAction's
  actual result, and only calls wakeup.notify() after releasing the state
  lock.

6 new deterministic tests on externalHoverAckReducer: active mismatch
(false/error) leaves lastPublished untouched, active accepted commits
unconditionally even over a different prior token, inactive mismatch
(succeeding for a token that isn't the current lastPublished) leaves it
untouched, inactive accepted for the matching token clears it, a failed
inactive stages exactly one retry and never re-arms a second time, and a
new active transition is unaffected by a prior retry_attempted flag.

zig build test and zig build -Demit-macos-app=false are both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…hostty-org#8810)

review-B-primitives-final.md Blocking 6: the C export doc comment still
said cell range rows were relative to top_row. ghostty.h and
renderer/link.zig's ExternalHover.set/replaceCells were already fixed to
describe the real absolute-viewport-row contract; this was the third,
still-stale copy. Now all three agree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…nt (ghostty-org#8810)

Real dogfood regression: moving the pointer along a resolved hard-wrapped
path (staying within the SAME registered underline ranges the whole
time) makes the bottom-left indicator flicker between the full external
path and a native regex fragment on every cell crossed.

Root cause: `Surface.cursorPosCallback` bumps `hover_input_epoch` on
every pointer cell change, and `buildHoverActivationToken` hashes that
epoch together with the pointer cell itself into ONE opaque
`HoverActivationToken`. `ExternalHover.validateOrInvalidate` compares
this opaque token by a single memcmp, so it cannot express "the pointer
cell changed but is still inside the same link's ranges, so stay valid"
— any cell change looks identical to a real invalidation.

This test pins the exact repro at the `ExternalHover` unit level: an
override active for a 2-cell-wide range, with the pointer moving from
cell A to cell B (both inside the SAME registered range), must stay
valid. It fails against the current source (`validateOrInvalidate`
returns `false`), confirming the regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…oken/physical/context identity (ghostty-org#8810)

Fixes the regression the previous commit's failing test pins: the
bottom-left hover indicator flickered between the full external path
and a native regex fragment on every cell the pointer crossed while
moving along a stable, still-valid resolved link. Implements
review-flicker-fix-confirm.md's 5 required conditions for approving
candidate (b), verbatim:

1. Input-time range-exit destructive invalidation.
   `Surface.cursorPosCallback`'s `pointer_cell` update and the new
   `Mouse.updateExternalHoverPointerCell` (renderer/State.zig) it calls
   destructively invalidate an active override the moment the pointer
   leaves its registered ranges (or the viewport), rather than waiting
   for the next render frame — mouse events and render frames aren't 1:1
   (they can coalesce), so a render-time-only check can miss an
   A->outside->A sequence within a single frame.

   Also fixes an existing latent bug review found while implementing
   this: the negative-position branch set `pointer_cell = null` but did
   not return, so control fell through to the common path's unconditional
   `pointer_cell = pos_vp` a few lines later — and `posToViewport` clamps
   negative coordinates to `(0, 0)` rather than signaling out-of-bounds,
   silently resurrecting a real cell where `null` should have stuck. Two
   epoch bumps happened to invalidate the token anyway before this fix,
   masking it; removing the cell-driven epoch bump (item 3 below) would
   have exposed it. Fixed by computing `is_out_of_viewport` once and
   guarding the common path's reassignment on `!is_out_of_viewport`.

2. Setter containment guard. `ExternalHover.set` now requires
   `pointer_cell != null && ranges.contains(pointer_cell)`, checked at
   the moment of the call — the pointer can move between the host's
   currentness check and the C-boundary setter call.

3. Activation identity split from validity proof. `ExternalHover` now
   holds `token` (host-visible clear/ack identity only), `physical`
   (fixed scope/content/viewport identity), and `context_epoch` (mods/
   eligibility ABA guard) as separate fields — `validateOrInvalidate`
   independently checks live pointer/ranges containment, physical
   identity, context epoch, and eligibility, instead of reconstructing
   and memcmp'ing one opaque hash that conflated "which cell" with
   "still the same link." `hover_input_epoch` renamed to
   `hover_context_epoch`, no longer bumped on a plain in-bounds cell
   change (range containment decides that now).

4. Viewport identity. `buildPhysicalSnapshotToken` folds in
   `pages.scrollbar()`'s `row_space_revision` and `offset`, at both the
   setter and the render-loop re-fingerprint, so a token minted at one
   scroll position can never validate at a different one — neither
   `ScreenSet.generation` nor `row_space_revision` alone changes on an
   ordinary scroll.

5. No performance claim bundled in. `ExternalHoverWorkService.process`
   (cmux host side) is untouched — this is a correctness-only fix.

`Mouse.updateExternalHoverPointerCell` is a new pure method on the
otherwise plain `Mouse` struct, extracted specifically so the exact
control flow `cursorPosCallback` runs (including the negative-position
clamp fix) is unit-testable without a live `Surface` — there is no
lightweight `Surface` test fixture in this codebase.

14 new tests (review §5, verbatim) beyond the repro test from the
previous commit: 2-row candidate range transitions, setter containment,
mods/eligibility ABA without render-time validation, physical mismatch,
viewport-identity mismatch (offset and revision independently), the
existing stale-clear contract, gap/out-of-scope/viewport-exit
invalidation with the (0,0)-clamp non-resurrection check, and the
A->outside->A input-only ABA case — plus 3 cmux-side integration tests
(same-range moves never transition away from external / real exit
promotes deferred native, no revival without a fresh activation, and
scroll-triggered withdrawal racing core invalidation converging
idempotently) landed separately in the CmuxTerminalCore Swift package.

zig build test: 3350/3366 passed, 0 failed, 16 skipped (pre-existing,
unrelated to this fix). No push, no submodule pointer commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDsDZHZ4aFKQH3SoE6y2du
…ured verdicts (ghostty-org#8810)

Adds bug C diagnostics (hover underline/indicator not showing) per
design-hover-diagnostics-v4-final.md, without changing resolver/reducer/
setter production behavior — every early-return still rejects/accepts
exactly as before; the change is that each one now also returns (and,
for the setter, the caller pushes) a structured reason/verdict instead
of a bare bool.

link.zig:
- New `ExternalHoverDiagSource`/`ExternalHoverDiagReason`/
  `ExternalHoverDiagVerdict` enums, a 16-byte POD `ExternalHoverDiagEntry`
  (event/source/reason/verdict/flags/seq), and a fixed 64-entry
  `ExternalHoverDiagRing` (push/pushUnchecked/drain, no allocation, no
  string formatting — string formation is host-only, after drain
  releases the mutex).
- `externalHoverDiagnosticsEnabled()`: a runtime env-var gate
  (`CMUX_EXTERNAL_HOVER_DIAGNOSTICS=1`), memoized once per process via
  an atomic, present in ALL build modes — deliberately NOT
  `builtin.mode`-gated, since dogfood runs a Debug cmux app against a
  ReleaseFast GhosttyKit.
- `ExternalHover.set` now returns `ExternalHoverDiagReason` (`.none` on
  success) instead of `bool`, and takes a new `event: u64` parameter
  stored into `diagnostic_event` (only when the gate is on — received
  but not stored when off) alongside resetting per-activation verdict-
  suppression bookkeeping.
- `ExternalHover.validateOrInvalidate` now returns
  `ExternalHoverDiagVerdict` and pushes a `source=render` diagnostic
  entry (via new `recordRenderVerdict`, which suppresses a repeated
  identical verdict after the first-for-activation one) BEFORE any
  `invalidate()` call clears the bookkeeping it needs.
- `ExternalHover.invalidateIfPointerLeftRanges` similarly pushes a
  `source=input` entry (`viewportExit`/`pointerNotInRanges`) before
  invalidating.
- Every early-return in both functions reuses the SAME structured value
  for the production accept/reject decision and the diagnostic entry —
  no parallel "diagnostic reason" re-derivation anywhere (the
  correctness guard this design most insists on).

State.zig: `Mouse.external_hover_diag` ring field, threaded through
`updateExternalHoverPointerCell`.

Surface.zig: `setExternalLinkHover` takes `host_event_id: u64`, computes
one structured `ExternalHoverDiagReason` across every one of its own
early checks (zeroRowCount/hoverIneligible/scopeOutOfBounds/
snapshotBuildFailed) plus `ExternalHover.set`'s own, pushes it to the
ring on rejection, and separately pushes `renderQueueFailed` if
`queueRender()` fails right after an accepted setter (the direct reason
no render/ack would otherwise follow).

generic.zig: the render loop's OSC8/eligibility/scope/pin/snapshot-build
checks each now push their own verdict before invalidating, and the
final `validateOrInvalidate` call's returned verdict drives
`external_active` (`== .valid`).

embedded.zig / include/ghostty.h: `ghostty_surface_set_external_link_hover`
gains a `host_event_id` parameter (same signature across Debug/Release/
ReleaseFast); new `ghostty_surface_drain_external_hover_diagnostics`
destructively drains the ring under the renderer mutex into a
caller-provided buffer, returning entries copied and the ring's
monotonic cumulative dropped-count (the host computes its own delta).
Returns 0 immediately, without touching the mutex, when the gate is off.

Tests: existing `ExternalHover`/`Mouse` tests updated for the new
return types; new ring FIFO/wrap/partial-drain/overflow tests (via
`pushUnchecked`, since the gate's process-wide memoization makes
`push` unusable for behavior tests within one test binary), POD size/
alignment + enum raw-value pinning tests, a gate-off no-op test, and a
focused multi-row (nonzero top_row) selection-read test covering hard
newlines, a blank row, and wide/combining glyphs with trim=false/
unwrap=false.

Held for user approval per repo convention: this commit stays local to
`cmux/external-hover` — not pushed, no PR, no parent submodule pointer
update.
…ped_count saturates

Review round2 B6: two follow-up fixes to the (C) ExternalHover diagnostics
ring, left as uncommitted working-tree changes after commit 4e5fb0cd0.

- Surface.zig: setExternalLinkHover's renderQueueFailed push stored its
  failure in .verdict (ExternalHoverDiagVerdict, source=render-only per
  this file's own field contract) instead of .reason
  (ExternalHoverDiagReason, source=setter-only). ABI-shape-consistent
  (both u8) but semantically wrong for a host decoder keying off source.
  The accept/reject decision itself is unchanged.

- renderer/link.zig: ExternalHoverDiagRing.dropped_count used a wrapping
  add (+%=), which could roll a saturated counter back to 0 and read to
  the host as "nothing has ever been dropped" — the opposite of what
  happened. Switched to a saturating add (+|=), plus a boundary test
  pinning the max-value case.

Local commit only, per the standing constraint holding all Ghostty
submodule push/pointer-update actions for explicit user approval.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgHfFt4vvsSdhzKgPvsBcR
…hostty-org#8810 426ms delay investigation

Diagnostics-only, no behavior change. Adds a new flags bit,
external_hover_diag_flag_transition_snapshot (bit 1, independent of
the existing first-for-activation bit 0), and pushes a source=render ring
entry with it set at the exact point generic.zig's render loop creates a
transition value snapshot (state.mouse.external_hover_pending_transition
= ...) -- a different point in the same render-loop pass than the
existing per-frame validation entry recordRenderVerdict already pushes a
few lines earlier. Reuses the existing ExternalHoverDiagRing/gate/push
path unconditionally (push() already checks the diagnostics env var
itself); no new mechanism.

This is one of two new timestamp points a design investigation asked for
to localize a ~426ms delay between setter acceptance and transition
delivery to the host (the left-bottom hover indicator). The other is a
new stage=callbackEntry Swift-side log at the GHOSTTY_ACTION_EXTERNAL_
LINK_HOVER callback entry, committed separately in the parent cmux repo.

Adds a boundary test pinning the new flag's raw value and its
independence/composability with the existing first-for-activation flag.

Local commit only, per the standing constraint holding all Ghostty
submodule push/pointer-update actions for explicit user approval.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgHfFt4vvsSdhzKgPvsBcR
@YosukeIida
YosukeIida force-pushed the cmux/physical-row-read branch from 7c8a205 to 0100e59 Compare August 10, 2026 14:55
@YosukeIida YosukeIida changed the title Add ghostty_surface_read_text_physical_rows: preserve soft-wrap boundaries Add physical-row text read API + host-owned external-hover rendering/diagnostics Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/renderer/Thread.zig (1)

2049-2061: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A transition produced by updateFrame waits for an unrelated wake.

notifyExternalHoverTransition runs before updateFrame. generic.zig sets external_hover_pending_transition inside updateFrame, and it does not notify wakeup. So the transition produced by this frame is delivered only on the next renderer wake.

Surface.setExternalLinkHover queues exactly one render. That render produces the transition and then ends without delivery. Delivery then waits for an unrelated wake, for example the cursor blink timer at up to 600 ms. This matches the delay the diagnostics comment in src/renderer/generic.zig (lines 1716-1728) describes.

Deliver again after updateFrame so a transition produced in this pass leaves in the same pass.

🐛 Proposed fix
     // Update our frame data
     t.updateFrame(t.flags.cursor_blink_visible) catch |err|
         log.warn("error rendering err={}", .{err});
 
+    // `updateFrame` is the only producer of an external-hover transition
+    // (see `generic.zig`), and it cannot call the apprt itself. Deliver
+    // here so a transition produced by this pass is not held until an
+    // unrelated wake (cursor blink, mailbox traffic).
+    t.notifyExternalHoverTransition();
+
     // Draw
     _ = t.drawFrame(false);

Apply the same ordering in renderNow (line 982) and renderNowWithPresentation (line 1006).

🤖 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/Thread.zig` around lines 2049 - 2061, Update the render flow in
the shown renderer path and in renderNow and renderNowWithPresentation so
notifyExternalHoverTransition is called again immediately after updateFrame
completes. Preserve the existing pre-update notification, visibility/realization
guard, and error handling so transitions produced during updateFrame are
delivered in the same render pass.
🧹 Nitpick comments (4)
src/apprt/action.zig (1)

480-487: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider pinning the new tag ordinal in the ABI test.

The comment for external_link_hover states that the tag must stay last so existing ordinals do not shift. The test Action.Key preserves the public C ABI pins only copy_title_to_clipboard and selection_changed. Add the new tag so a future insertion before it fails the test.

♻️ Proposed test addition (outside the selected range, near line 443)
            try std.testing.expectEqual(
                `@as`(c_int, 66),
                `@intFromEnum`(Key.external_link_hover),
            );
🤖 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/action.zig` around lines 480 - 487, Update the `Action.Key
preserves the public C ABI` test to also assert that `Key.external_link_hover`
has ordinal 66, alongside the existing ordinal checks for
`copy_title_to_clipboard` and `selection_changed`, ensuring the tag remains last
without shifting prior values.
include/ghostty.h (1)

1723-1733: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add ABI layout assertions for the external hover structs.

ghostty_external_hover_cell_range_s mirrors renderer/link.zig’s ExternalHoverCellRange, and ghostty_external_hover_diag_entry_s mirrors ExternalHoverDiagEntry; the latter already has Zig compile-time assertions. Add equivalent compile-time size and offset checks here as well to prevent an ABI drift for ghostty_external_hover_cell_range_s.

🤖 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 `@include/ghostty.h` around lines 1723 - 1733, Add compile-time ABI layout
assertions for ghostty_external_hover_cell_range_s, matching renderer/link.zig’s
ExternalHoverCellRange size and field offsets; also add equivalent size and
offset checks for ghostty_external_hover_diag_entry_s consistent with its
existing Zig assertions. Keep the assertions adjacent to the struct definitions
and validate each relevant member’s offsetof value.
src/renderer/generic.zig (1)

1596-1623: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard row_count == 0 where the state is stored.

Line 1618 computes top_row + row_count - 1 on u32 values. If row_count is 0, this underflows and panics in safe builds. Surface.setExternalLinkHover currently rejects row_count == 0 before it calls set, so this path is not reachable today. ExternalHover.set is public and does not enforce the invariant itself, so a second caller would introduce the panic.

Reject row_count == 0 inside ExternalHover.set in src/renderer/link.zig so the invariant lives with the stored state.

🤖 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 1596 - 1623, Update ExternalHover.set
in link.zig to reject row_count == 0 before storing the hover state, preserving
the existing behavior for positive row counts. Keep the invariant at the
state-storage boundary so the rendering logic in generic.zig cannot reach the
top_row + row_count - 1 underflow.
src/renderer/link.zig (1)

2575-2592: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider emitting the spacer tail for wide cells.

putHoverCell (line 137) adds x + 1 for a wide cell so the underline covers both columns. replaceCells draws only the host-supplied columns. If a host reports a wide glyph as one column, that glyph renders half-underlined. Confirm the host contract requires both columns of a wide cell, or clamp/extend here.

🤖 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/link.zig` around lines 2575 - 2592, Update
ExternalHover.replaceCells to ensure wide-cell hover underlines cover the spacer
column, matching putHoverCell’s x + 1 behavior. Either validate and rely on the
host contract that wide ranges include both columns, or extend each eligible
range by one column when needed, clamped to cols, while preserving bounds and
existing range filtering.
🤖 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/renderer/generic.zig`:
- Around line 1700-1735: Update the external-hover transition tracking around
external_hover_last_delivered_token so the inactive transition stores the
current invalidated token identity (zero) as the next comparison token, rather
than retaining the previously delivered active token. Preserve the pending
transition payload as { T, false } while ensuring the condition in this
render-loop block becomes false on subsequent inactive frames until a new
activation.

In `@src/renderer/link.zig`:
- Around line 1760-1795: Update buildPhysicalSnapshotToken and the corresponding
bound test to enforce the joined text limit using a byte budget derived from the
maximum columns and the configured maximum UTF-8 bytes per cell, rather than
treating max_snapshot_row_columns as a byte count. Preserve the column limit
semantics and ensure valid wide or combining-glyph content is not rejected
solely because its UTF-8 representation is larger.

In `@src/Surface.zig`:
- Around line 5584-5594: Update the scope validation in the Reason computation
to avoid adding top_row and row_count; after confirming top_row is within rows,
compare row_count against the remaining rows (rows - top_row). Apply the same
overflow-safe remaining-range comparison in renderer/link.zig’s
ExternalHover.set validation so both checks reject oversized ranges
consistently.
- Around line 5644-5686: Update the queueRender error branch in the external
hover setter to invalidate the newly created activation immediately, return zero
instead of the live hover token, and retain the renderQueueFailed diagnostic.
Keep the successful queueRender path returning
self.renderer_state.mouse.external_hover.token unchanged.

---

Outside diff comments:
In `@src/renderer/Thread.zig`:
- Around line 2049-2061: Update the render flow in the shown renderer path and
in renderNow and renderNowWithPresentation so notifyExternalHoverTransition is
called again immediately after updateFrame completes. Preserve the existing
pre-update notification, visibility/realization guard, and error handling so
transitions produced during updateFrame are delivered in the same render pass.

---

Nitpick comments:
In `@include/ghostty.h`:
- Around line 1723-1733: Add compile-time ABI layout assertions for
ghostty_external_hover_cell_range_s, matching renderer/link.zig’s
ExternalHoverCellRange size and field offsets; also add equivalent size and
offset checks for ghostty_external_hover_diag_entry_s consistent with its
existing Zig assertions. Keep the assertions adjacent to the struct definitions
and validate each relevant member’s offsetof value.

In `@src/apprt/action.zig`:
- Around line 480-487: Update the `Action.Key preserves the public C ABI` test
to also assert that `Key.external_link_hover` has ordinal 66, alongside the
existing ordinal checks for `copy_title_to_clipboard` and `selection_changed`,
ensuring the tag remains last without shifting prior values.

In `@src/renderer/generic.zig`:
- Around line 1596-1623: Update ExternalHover.set in link.zig to reject
row_count == 0 before storing the hover state, preserving the existing behavior
for positive row counts. Keep the invariant at the state-storage boundary so the
rendering logic in generic.zig cannot reach the top_row + row_count - 1
underflow.

In `@src/renderer/link.zig`:
- Around line 2575-2592: Update ExternalHover.replaceCells to ensure wide-cell
hover underlines cover the spacer column, matching putHoverCell’s x + 1
behavior. Either validate and rely on the host contract that wide ranges include
both columns, or extend each eligible range by one column when needed, clamped
to cols, while preserving bounds and existing range filtering.
🪄 Autofix

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 Plus

Run ID: 4d268e62-978c-4cef-8f3f-c3a40844559b

📥 Commits

Reviewing files that changed from the base of the PR and between 7c8a20533120f5574597659945af18d39e300cbc and 0100e59.

📒 Files selected for processing (9)
  • include/ghostty.h
  • src/Surface.zig
  • src/apprt/action.zig
  • src/apprt/embedded.zig
  • src/renderer.zig
  • src/renderer/State.zig
  • src/renderer/Thread.zig
  • src/renderer/generic.zig
  • src/renderer/link.zig

Comment thread src/renderer/generic.zig Outdated
Comment thread src/renderer/link.zig Outdated
Comment thread src/Surface.zig Outdated
Comment thread src/Surface.zig

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/renderer/Thread.zig (1)

3094-3100: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make external_link_hover callback-safe.

The embedded action callback has no general reentrancy restriction, and ghostty_surface_free can tear down the surface during this synchronous call. This path then dereferences self in applyExternalHoverAck, which can use destroyed renderer state or self-join the renderer thread. Defer the acknowledgment through a lifetime-safe owner, or prohibit surface destruction during this callback and document the contract.

🤖 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/Thread.zig` around lines 3094 - 3100, Make the
external_link_hover handling around performAction callback-safe: do not
dereference self in applyExternalHoverAck after the synchronous action, because
the callback may destroy the surface; instead defer the acknowledgment through a
lifetime-safe owner, or enforce and document a surface-lifetime restriction
covering this callback.
🤖 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/renderer/link.zig`:
- Around line 2572-2573: Update the second range check in the surrounding
row-scope logic to explicitly cast r.row to u32 before comparing it with
top_row, preserving the existing row_count boundary behavior and the
.rangeOutOfScope return.

In `@src/renderer/Thread.zig`:
- Around line 1714-1722: Initialize ExternalHoverUpdateOrderProbe with its
default fields before calling updateFrameAndNotifyExternalHover, ensuring
event_count starts at 0. Replace the undefined initialization or explicitly set
event_count before the callback while preserving the existing event-order
assertions.

---

Outside diff comments:
In `@src/renderer/Thread.zig`:
- Around line 3094-3100: Make the external_link_hover handling around
performAction callback-safe: do not dereference self in applyExternalHoverAck
after the synchronous action, because the callback may destroy the surface;
instead defer the acknowledgment through a lifetime-safe owner, or enforce and
document a surface-lifetime restriction covering this callback.
🪄 Autofix

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 Plus

Run ID: de877cf7-7035-40db-a09b-e648368fcd0b

📥 Commits

Reviewing files that changed from the base of the PR and between 0100e59 and 90dc81a.

📒 Files selected for processing (6)
  • include/ghostty.h
  • src/Surface.zig
  • src/apprt/action.zig
  • src/renderer/Thread.zig
  • src/renderer/generic.zig
  • src/renderer/link.zig
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/apprt/action.zig
  • src/renderer/generic.zig
  • include/ghostty.h
  • src/Surface.zig

Comment thread src/renderer/link.zig
Comment on lines +2572 to +2573
if (r.row < top_row) return .rangeOutOfScope;
if (@as(u32, r.row) - top_row >= row_count) return .rangeOutOfScope;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect the mixed-width comparison and the adjacent explicit conversion.
sed -n '2568,2576p' src/renderer/link.zig

Repository: manaflow-ai/ghostty

Length of output: 825


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- relevant declarations and function context ---'
rg -n -C 8 'ExternalHoverCellRange|fn set|top_row|r\.row < top_row' src/renderer/link.zig

printf '%s\n' '--- Zig availability ---'
if command -v zig >/dev/null 2>&1; then
  zig version
  printf '%s\n' '--- formatter check ---'
  zig fmt --check src/renderer/link.zig
  printf '%s\n' '--- standalone mixed-width comparison check ---'
  tmpdir="$(mktemp -d)"
  trap 'rm -rf "$tmpdir"' EXIT
  cat >"$tmpdir/mixed_width.zig" <<'ZIG'
const std = `@import`("std");

pub fn main() void {
    const row: u16 = 1;
    const top_row: u32 = 0;
    if (row < top_row) std.debug.print("{}\n", .{row});
}
ZIG
  if zig build-exe "$tmpdir/mixed_width.zig" -femit-bin="$tmpdir/mixed_width" >/dev/null 2>&1; then
    echo 'mixed-width comparison compiled'
  else
    echo 'mixed-width comparison failed to compile'
  fi
else
  echo 'zig is not available'
fi

Repository: manaflow-ai/ghostty

Length of output: 16733


🌐 Web query:

Zig language reference comparison operators operands integer types u16 u32 compile error

💡 Result:

In Zig, arithmetic and comparison operators do not automatically widen integer types, which is a common source of compile errors when attempting to mix different integer types like u16 and u32 [1][2][3]. ### Comparison Operators and Type Compatibility Zig's comparison operators (e.g., ==, <, >, <=, >=) require the operands to have compatible types [1][2][4]. When you attempt to compare different integer types, such as u16 and u32, the compiler will trigger a compile error because it does not perform implicit type coercion (widening) for these operations [1][5][3]. To compare operands of different integer types, you must explicitly cast one of the operands to match the other using @intCast [3][6]: // Example of fixing a compile error when comparing u16 and u32 var a: u16 = 100; var b: u32 = 200; // if (a < b) // This causes a compile error if (@as(u32, a) < b) { // Correct: explicit cast to u32 //... } ### Peer Type Resolution While Zig uses Peer Type Resolution for many expressions (determining a common type for multiple possible branches or operands), this mechanism does not perform widening for arithmetic or comparison operators to prevent ambiguity [1][5]. If the compiler were to automatically widen types, it could lead to unexpected behavior or hide bugs where a larger type is truncated, which contradicts Zig's philosophy of explicit behavior [5][6]. ### Exceptions and Comptime * Comptime-known values: If one or both operands are compile-time constants (comptime_int), the compiler may allow the comparison if the values can be evaluated and validated at compile time [6][7]. * Nullable types: Comparing optional types (e.g.,?u32) directly with a non-optional integer literal can also result in compile errors, as the types are distinct [8][9]. These must be handled by unwrapping or explicit casting to a compatible type [8]. In summary, if you encounter a compile error involving u16 and u32 in an expression, it is almost certainly because Zig requires you to be explicit about the type conversion rather than implicitly widening the smaller type to the larger one [5][3][6].

Citations:


Cast r.row to u32 before comparing it with top_row.

r.row is u16, and top_row is u32. Zig does not implicitly widen integer operands in comparisons.

Proposed fix
-            if (r.row < top_row) return .rangeOutOfScope;
+            if (`@as`(u32, r.row) < top_row) return .rangeOutOfScope;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (r.row < top_row) return .rangeOutOfScope;
if (@as(u32, r.row) - top_row >= row_count) return .rangeOutOfScope;
if (@as(u32, r.row) < top_row) return .rangeOutOfScope;
if (@as(u32, r.row) - top_row >= row_count) return .rangeOutOfScope;
🤖 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/link.zig` around lines 2572 - 2573, Update the second range
check in the surrounding row-scope logic to explicitly cast r.row to u32 before
comparing it with top_row, preserving the existing row_count boundary behavior
and the .rangeOutOfScope return.

Comment thread src/renderer/Thread.zig
Comment on lines +1714 to +1722
test "external hover transition delivery follows the same successful render pass" {
var state: u8 = 0;
var probe: ExternalHoverUpdateOrderProbe = undefined;
probe.state = &state;
probe.renderer = .{ .probe = &probe };

try updateFrameAndNotifyExternalHover(&probe, false);
try std.testing.expectEqual(@as(usize, 2), probe.event_count);
try std.testing.expectEqual([2]u8{ 1, 2 }, probe.events);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/sh
set -eu

if rg -n 'var probe: ExternalHoverUpdateOrderProbe = undefined' src/renderer/Thread.zig; then
  echo "ExternalHoverUpdateOrderProbe is still initialized with undefined" >&2
  exit 1
fi

Repository: manaflow-ai/ghostty

Length of output: 286


🏁 Script executed:

#!/bin/sh
set -eu

printf '%s\n' '--- relevant test and probe definitions ---'
rg -n -A35 -B15 'ExternalHoverUpdateOrderProbe|updateFrameAndNotifyExternalHover|notifyExternalHoverTransition' src/renderer/Thread.zig
printf '%s\n' '--- surrounding test lines ---'
sed -n '1660,1745p' src/renderer/Thread.zig

Repository: manaflow-ai/ghostty

Length of output: 17206


🏁 Script executed:

#!/bin/sh
set -eu

printf '%s\n' '--- Zig availability ---'
if command -v zig >/dev/null 2>&1; then
  zig version
  printf '%s\n' '--- formatting check ---'
  zig fmt --check src/renderer/Thread.zig
else
  printf '%s\n' 'zig is not available'
fi

printf '%s\n' '--- all probe field writes and reads ---'
rg -n -A18 -B4 'const ExternalHoverUpdateOrderProbe|var probe: ExternalHoverUpdateOrderProbe|probe\.event_count|probe\.events|self\.probe\.event_count' src/renderer/Thread.zig

Repository: manaflow-ai/ghostty

Length of output: 2184


Initialize ExternalHoverUpdateOrderProbe before the first callback.

= undefined bypasses the default event_count = 0. updateFrame uses event_count as an index before the assertions, so the test can fail with an out-of-bounds access. Use an explicit initializer or set event_count before the callback.

🤖 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/Thread.zig` around lines 1714 - 1722, Initialize
ExternalHoverUpdateOrderProbe with its default fields before calling
updateFrameAndNotifyExternalHover, ensuring event_count starts at 0. Replace the
undefined initialization or explicitly set event_count before the callback while
preserving the existing event-order assertions.

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