a73x

docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md

Ref:   Size: 16.6 KiB   History

# Native parity — wheel scrolling

Status: implemented and independently reviewed; CI and functional checks passed.
Final NVIDIA timing check passed; earlier timing misses remain documented.
User acceptance is pending.
Authorized by the user's next-sprint continuation on 2026-09-06.
Opening architecture cleanup and visible-text selection are
implemented and validated; whole text-selection demo acceptance remains pending.
Continue in the existing parity worktree on branch `gui-text-selection`.
This is the first functional slice of git-collab issue `8b16e26b`.

## Sprint goal

Scroll the pane under the pointer with the wheel: shell history at a prompt,
arrow input in an alternate-screen application without mouse reporting, and
terminal mouse reports when an application requests them. Match existing TUI
behavior while keeping each pane independent.

## Order within the overall mouse feature

After visible-text selection, this wheel slice is the next planned sprint item.
Application click/drag forwarding and application clipboard writes follow it.
That slice includes Shift+drag as the required native-selection override; see
the [application mouse acceptance criteria](2026-09-06-native-text-selection.md#required-follow-up-application-mouse-and-clipboard-behavior).
Wheel delivery alone does not complete the overall mouse feature. Preserve native
selection and copy throughout these slices; GUI paste remains deferred.

## Observable acceptance

- Three panes on two daemons, including off-origin and unfocused panes. Wheel
  input affects only the pane under the pointer and does not move keyboard focus.
- At a shell prompt, each whole notch moves three history rows, with bounds at
  the oldest retained history and live output. Return to live output restores
  the cursor and ongoing updates. Typing returns the receiving pane to live view,
  matching the TUI. Fractional wheel events accumulate per pane without leaking
  into another pane; honor SDL's flipped direction flag.
- An alternate-screen app without mouse reporting receives the same arrow-key
  behavior as the TUI, including application cursor-key mode. A mouse-reporting
  app receives wheel reports with correct pane-relative terminal coordinates and
  negotiated encoding. Verify the actual PTY bytes independently.
- Picker/recovery modals and active divider resizing do not leak wheel input to
  underlying sessions. Headers, dividers and empty workspace do not scroll a
  neighbouring pane accidentally.
- Delayed history replies cannot repaint live mode, a replacement attachment or
  a resized viewport. Reconnect, detach and End retain bounded cleanup. The
  authoritative live replica stays independent of the displayed history view.
- Demonstrate real NVIDIA Wayland wheel input at 200% and across the retained
  scale transitions; verify other panes, focus, PTY sizes and shell identities.

Visible-text selection and clipboard copy are delivered by the separate
text-selection sprint; wheel work must preserve them. Clipboard paste, general
app click/drag forwarding, ligatures, font fallback and terminal-wall retirement
remain outside this wheel slice. Horizontal wheel
behavior is outside this initial vertical scrolling slice.

## Opening architecture findings and ownership

### Reuse and ownership map

| Behavior or rule | Existing implementation and callers | Intended owner | Reuse, refactor or add; what can be deleted |
| --- | --- | --- | --- |
| SDL coordinates and event injection | `frame.physicalPoint`, ordinary pointer events and `parseHook` | SDL adapter | Extend with wheel events; no separate test-only scrolling path |
| Pane targeting, modal exclusion, selection cancellation | `interaction.Controller` pointer handlers and shared `client.selection.Drag` | Window-free controller | Reuse content hit testing and attachment checks; per-pane fractional remainder |
| History positioning and row decoding | `Replica.scrollStart`, protocol scrollback request/chunk codecs; TUI and wasm consumers | Shared client pump | Reuse existing wire and grid rows; add bounded request/result ownership without a second replica |
| Displayed view and selection coordinates | `runtime.Live.capture`, `SelectionVersion`, controller selection range | Pump/runtime snapshot boundary | Keep live replica independent; snapshot carries the displayed history origin and freshness |
| Alternate-screen arrow encoding | `keymap.encode`, TUI `sendAltScroll` | Shared client input policy | Reuse key encoding; keep frozen TUI behavior unchanged |
| Application wheel ownership and encoding | `TermModes.appMouse`, `mouse_modes`; TUI forwards existing terminal bytes | Pump and pure client encoder | Decide using admitted mode frames; SDL supplies semantic intent, so a terminal byte parser is unnecessary |
| Real input and independent acceptance | `SelectionRig`, `LifecycleRig`, `wayland_pointer.Pointer` | Retained integration fixtures | Extend established plural-pane fixtures and virtual pointer axis input |

The earlier font-settings cleanup already supplies the useful opening refactor;
the current inspection found no additional cleanup needed before this feature.
Application mouse encodings are checked against the
[xterm mouse protocol](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking).
Selection stays attached to its source text when the viewport moves, including
scrolling offscreen and back or returning through live view. A held drag keeps
its original text anchor and extends using the current pane coordinates. Live redraws preserve the selected range; geometry, screen/connection and
history-watermark changes still invalidate it. Cached history keeps exact
source freshness. These are part of wheel acceptance, not later polish.

`frame.zig` owns SDL events and logical-to-framebuffer conversion through
`physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy;
keep these decisions window-free so native-core tests can cover them.

`runtime.Live.capture` currently copies the pump's live grid only. The GUI
runtime owns the chosen pane view and transfers independent snapshots to the
painter under the existing pump lock. Keep observation hooks passive; assess
extracting touched hooks without turning that into an unrelated refactor.

`client.session_pump` currently has no scrollback mailbox request or chunk
handling. The wire already has `encodeScrollbackReq`, `scrollback_chunk` and
server extraction. `Replica.history_rows` and `scrollStart` supply history
positioning; `wasm_core` and the TUI provide existing reply-decoding examples.
Do not add a second wire protocol or let GUI code access the daemon engine.
Review asynchronous request correlation and live/history snapshot ownership
before implementing the smallest required shared-client extension.

`ClientCore.terminal_modes`, `TermModes.appMouse`, alternate-screen and cursor-key
flags supply app routing policy. SDL already supplies wheel deltas: the TUI's
private `MouseFilter.wheelNotches` parses encoded terminal input and is not a
ready-made SDL helper. Share semantic encoding where justified; keep the frozen
terminal wall behavior unchanged.

Assign Luna the bounded implementation and Terra adversarial review through the
repository sprint skill. The sprint lead owns shared-client integration, real
oracles, validation, recording, webpage, cleanup and commits. Coordinate any
shared wire/lifecycle meaning change across clients.

## Validation and handoff

Follow `docs/native-sprint-workflow.md`: opening and closing cleanup, focused
client/core tests, real plural-pane wheel integration, full CI/native gates and
isolated NVIDIA stress. Build both binaries in ReleaseSafe. Preserve earlier
NVIDIA timing failures and the offscreen-growth limitation; neither is waived.

Finish with a localhost review webpage containing achievements, an actual GUI
video, controls, evidence and limits. Record user demo approval separately from
checks. Publish only if explicitly requested; preserve unrelated services.


## Architecture review before implementation

The user asked explicitly for duplication, hexagonal architecture opportunities
and refactoring. Review decisions:

- Give font settings one owner in `font_options.zig`; both CLI and config
  parsing call its point-size policy. Entry adapters retain their diagnostic
  mapping, and the font backend retains installed-face validation. Sharing a
  helper from `config.zig` alone would remove duplication but put domain policy
  in an input adapter.
- Move font settings and config/theme source ownership to `native_core`, with public aliases in
  `native`. The renderer consumes theme values; window/font libraries are not
  needed to test config parsing, precedence and theme derivation.
- Preserve the existing SDL → controller → runtime → session-pump boundary.
  Add semantic wheel intent at the transport mailbox and interpret it using
  the pump-owned terminal modes, after consuming available frames. Reading modes
  in the GUI and queueing pre-encoded input could race an incoming mode change.
  Verify the ordering with real mode frames and independently observed PTY bytes.
- Keep live/history snapshots behind the pump/runtime boundary. Wheel work should
  narrow snapshot access rather than introduce new GUI reads of pump internals;
  request correlation and view ownership belong in its implementation contract.
- Do not copy the TUI interaction core or mouse byte parser into the GUI. Reuse
  protocol/replica behavior and share only pure semantic encoding when an actual
  second caller needs it. The frozen wall keeps its behavior.
- Extend the existing test input adapter with a real SDL wheel event. A separate
  test-only scroll path would leave actual event routing untested. Broader hook
  extraction is a later package unless this slice exposes a concrete need.

Config and theme line readers look similar but intentionally differ: unknown
config keys are fatal, unknown theme keys warn, missing config is allowed, and a
missing selected theme is fatal. Their shared color/palette parsing is already
centralized. A generic parser framework or filesystem interface is not justified
by the current callers; retain the existing pure parse/merge functions and small
file-loading adapters.


### Opening cleanup evidence

`font_options.zig` now owns the point-size parser and validity rule. Config and
CLI adapters preserve their existing errors; installed-face checks remain with
the font backend. The native core owns font settings, config and theme, and the
native rendering module retains public aliases. Removed the redundant empty
family check after the text parser had already rejected empty values.

Independent review found no behavior/API issue; it caught and removed a stale
point-size comment left above palette parsing. The module split is checked by
actual dependency probes: with `PKG_CONFIG_LIBDIR` set to an empty directory and
`PKG_CONFIG_PATH` empty, `native-core-test` passed all 29 tests, while the full
native build failed on missing SDL3 metadata as expected. This is a build
boundary check, not merely a directory rename.

Logs are in `dist/wheel-opening-cleanup/`. Native build, native unit/core tests
and formatting passed. Full CI (check, terminal integration, agent, throughput)
and final native integration passed with no gate changes. The first native integration launch overlapped CI's temporary
source-ban probe and stopped before compiling; the retry started after the
probe was removed. The original failed log is retained, and the workflow now
calls out this build-serialization requirement.

This refactor preserves appearance behavior and uses the approved appearance
recordings as its visual baseline; no new visible feature or performance claim
is made. The following functional wheel delivery supplies its own real-input
recording and local review page.

## Wheel implementation and closing review

`frame` translates SDL wheel coordinates and modifiers into controller input.
The controller excludes non-content regions and modes, retains fractional
notches by pane attachment, and passes semantic intent through `Runtime.wheel`.
The pump drains available mode frames before interpreting queued wheel input.
Existing keyboard/resize work before a deferred wheel can run between bounded
receive batches; the wheel and following messages retain FIFO order.

Shell scrolling uses the existing scrollback request and grid decoder. The pump
owns one optional history grid and one outstanding request; its cancelled
request remains a tombstone until the reply is drained. New transport attachment
clears that tombstone, but same-wire resync retains it. A two-second timeout
reconnects rather than risking correlation with a later reply. View origin and
grid are captured under the same lock; all selection hit/paint coordinates use
that origin. The live replica remains untouched by history decoding.

The selected distance from live stays constant as output arrives. Output or mode
changes invalidate copying and schedule a new history fetch. The previous view
can remain painted while a reply is pending, but cannot be copied as if current.
This handles ring pruning even when the wire's history-row count remains flat.
Returning to live, resizing, reconnecting and replacing an attachment invalidate
the old view. As before, the wire does not provide an atomic source-version
precondition at the daemon.

Pure wheel encoding lives in `keymap`, reusing its arrow encoder and Zig's UTF-8
encoder. SGR pixels, SGR cells, URXVT, UTF-8 and legacy X10 are supported with
pane-relative coordinates. No runtime dependency, protocol message or terminal
wall feature was added. Closing cleanup removed the hand-written UTF-8 helper
and the old duplicated receive-loop body. Independent review checked ownership,
request cancellation, pending input-buffer transfer, coordinate bounds and
same-wire resync. The existing QUIC batching regression caught a receive-loop
change that delayed mailbox work; the final implementation preserves its
64-frame batches and passes that regression.

A continuously readable stream can delay a wheel waiting for its pending mode
frames and the input ordered after that wheel. A continuously changing history
can likewise postpone a safe selectable refresh. The next shared-client
scheduling change owns investigating a finite protocol ordering boundary if
this is encountered; dropping input or guessing a newer mode is not the fix.


### Delivery evidence

Final CI, client/native tests, full native integration, real Wayland wheel and
clipboard acceptance, and desktop/mobile local video playback/seek checks passed.
The final isolated NVIDIA run passed with frame p99 18.745 ms and sampled
input-to-painted upper bound 65.3 ms (5 ms polling). Earlier current/baseline
runs missed the frame gate; retained evidence does not establish the cause.
The continuous recording is 27 seconds with real Wayland pointer/wheel input,
three panes on two daemons, a separate clipboard reader and real less.

Local review page: http://127.0.0.1:18776/wheel-scrolling/ . The loopback server
is recorded in `dist/wheel-scrolling/server.json`; stop it after acceptance.
No remote publication is required or pending. Publishing is opt-in at the user's
request. Sources, recording and all validation logs remain in the worktree.


### Hands-on feedback: keep the highlight while scrolling

The user expects a completed selection to remain attached to its source text,
including scrolling it offscreen and back. `Pump` now preserves selection
freshness through local viewport requests, history replies and return-to-live.
Input explicitly invalidates selection at the mailbox boundary; terminal output,
modes, geometry and attachment changes retain their existing guards. Existing
absolute selection rows and `Live.view_origin` already provide correct painting
and hit testing, so no new production state or abstraction is needed.

A held drag retains its original anchor; subsequent motion or release resolves
its endpoint against the current viewport. The real Wayland regression covers
both a release on the moved source row and extending across newly visible rows.
The user requested hands-on review instead of a new demo recording for this fix.

Client/native units, the full native integration suite and real NVIDIA Wayland
wheel/copy regressions passed. The repository check gate passed; full CI stopped
on an existing nested-agent exit timeout, which passed on targeted rerun without
changes. Full-run CI success is therefore not claimed for this feedback commit.
Evidence: `dist/wheel-scrolling/selection-preserve-*.log`; details in `RETRO.md`.


Active-output follow-up: live selection now survives counter/redraw frames,
following Ghostty; a cached-history origin remains strict even after returning
live. See the text-selection plan's active-output entry for semantics and limits.