a73x

docs/superpowers/plans/2026-09-06-native-text-selection.md

Ref:   Size: 19.7 KiB   History

# Native parity — text selection

Status: implemented, independently reviewed and validated; actual GUI demo
recorded. User demo acceptance is pending. User selected text selection as the next
sprint on 2026-09-06; wheel scrolling remains unimplemented and deferred.
Worktree branch: `gui-text-selection` (existing worktree directory retained).

## Goal and trial behavior

Drag across visible terminal text, see the selected cells, and copy the daemon's
text to the desktop clipboard on release or with Ctrl+Shift+C. Reuse the TUI's single-click versus
drag semantics and daemon extraction. A plain click continues to focus a pane.
The selection belongs to its starting pane; crossing another pane must never
select or copy that pane's text. A new press clears the previous highlight.

This slice covers visible text, copy on release and the copy shortcut. Wheel/history navigation,
edge autoscroll, double/triple click, rectangular selection, clipboard paste,
application mouse forwarding and ligatures remain separate work. The existing
GUI does not forward application mouse clicks; this slice preserves that scope.

## Reuse and ownership map

| Behavior or rule | Existing implementation and callers | Intended owner | Reuse, refactor or add; what can be deleted |
| --- | --- | --- | --- |
| Click/drag distinction, direction, pane confinement, inclusive row spans | `src/tui/select.zig`: `Drag`, `Range.span`; `interact.zig` and `wallview.zig` callers | Shared `client.selection` | Move the pure module and its tests to `src/client/selection.zig`; delete the TUI source and update imports. No second drag state machine. |
| Text extraction, soft wraps, hard newlines, UTF-8/graphemes and wide cells | `Engine.extractSelection`, `Server.onSelectionReq`, protocol `SelectionReq`/`SelectionReply` | Daemon engine and existing wire | Reuse unchanged. Never reconstruct copied text from native grid cells. |
| Request ID correlation and reply validation | `ClientCore.beginSelection`/`receiveSelectionReply`; TUI `Core.requestSelection`/`selectionCopy` | Shared client core and session pump | Reuse core matching. Add bounded mailbox request/result ownership to `session_pump`; release borrowed frame text before the frame is freed by copying or transferring into owned storage. Do not import TUI `Core`. |
| Pane identity, lifetime and frozen displayed grid | `runtime.Live.capture`, `Runtime.accepts`, `workspace.Attachment` | Native runtime | Expose selection-relevant snapshot metadata alongside the grid through the existing boundary; associate requests with attachment and displayed coordinates. Avoid new unlocked reads of pump internals. |
| Pointer hit testing, modal routing and cancellation | `interaction.Controller.pointerDown`/`pointerMove`, `focusLost`, `updateGeometry` | Window-free native controller | Use shared drag state with native geometry; add release/copy intent and cancellation. Keep divider drag distinct from text drag. |
| Logical-to-physical input, mouse capture, clipboard IO | `frame.Events`, `physicalPoint`, `physicalSignedAxis`, existing pointer hooks | SDL frame adapter | Extend ordinary mouse routing and use SDL clipboard writing. SDL remains confined to frame. Test hooks observe actual clipboard state and inject existing SDL events. |
| Selection highlight | `quads.rowInstances`, existing themed cell colors | Native painter | Consume a supplied row span; select complete wide glyph cells and retain readable colors. Never mutate the authoritative grid for highlighting. |
| Copy shortcut from hands-on feedback | `frame.interactionKey`, `Controller.keyDown`, selection request in `pointerUp`, `Runtime.requestSelection` | SDL maps the chord; native controller owns copy intent | Extract the existing request action for both release and shortcut. Consume Ctrl+Shift+C even without selection; preserve plain Ctrl+C. No second text cache or clipboard implementation. |

The existing pure selection module already supplies the needed domain rule; a
new selection framework is unnecessary. Moving it to the shared client owner
allows both frontends to consume it without importing either window or terminal
UI libraries. The TUI's OSC 52 size cap is an adapter limit and must not become
the desktop clipboard's cap; the existing wire's 1 MiB bound still applies.

## Observable acceptance

- Three panes across two real daemons, including off-origin panes: forward and
  reverse drags highlight and copy only the starting pane. Headers, dividers,
  blank padding and modal dialogs cannot start a terminal selection.
- A press/release in one cell changes focus without changing clipboard text.
  Copy on release uses exact daemon text for soft wraps, hard newlines, trailing
  spaces, non-ASCII text and wide-cell continuations. A selected wide glyph is
  visibly complete. Other panes retain their text, PTYs and input behavior.
- Ctrl+Shift+C copies the current fresh range without clearing the highlight or
  sending Ctrl+C to the application. With no selection it leaves the clipboard
  and application untouched. Plain Ctrl+C still interrupts an actual foreground
  PTY process. Repeat/release and modal routing retain their existing ownership.
- A new selection, cancellation, resize/DPI transition, focus loss, attachment
  replacement/reconnect or detach cannot allow a delayed reply to overwrite the
  clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior
  contents intact; errors that need action get a bounded notice.
- Live redraws retain the selected range, including when selected text is
  overwritten; copying extracts its current text, following Ghostty. Geometry,
  screen/connection changes and history-watermark changes still invalidate it.
  Cached-history selections retain exact source freshness.
- The painter highlights without changing replica cells. Core policy tests run
  without GUI libraries. Real clipboard reads and actual framebuffer samples
  are the independent integration oracles.
- Real NVIDIA Wayland drag/release at 200%, retained scale transitions, and
  separate raw-output responsiveness gates. Linux evidence does not imply Mac
  acceptance.

## Delivery and retained actions

Luna owns opening shared-selection extraction and feature implementation;
Terra independently reviews correctness and structure. Root owns planning,
integration oracles, final gates, demonstration, documentation and commits.
Opening cleanup is reviewed, checked and committed before feature edits.

Follow `docs/native-sprint-workflow.md`, serializing builds against the source
ban probes in `make check`. Retain logs for full CI, native core/build/units,
native integration, NVIDIA scale and stress. Review production growth and remove
superseded paths before freezing source. Existing NVIDIA frame-time misses and
offscreen growth artifacts remain unresolved renderer follow-ups.

Assess the existing passive frame hooks as they are touched; add only observation
needed for clipboard evidence. Any broader hook extraction requires a concrete
benefit rather than an arbitrary file split. Reuse the existing Rig and recorder
assets with isolated XDG state and compositor addresses.

Deliver a localhost review page with achievements, actual GUI recording,
controls, checks and limitations. Preserve demos awaiting review; tear down accepted
demo routes and owned processes while retaining their artifacts. Record demo approval
only after the user gives it.


## Delivered structure and closing review

`client.selection` is the single gesture model, consumed by both frontends.
`Drag.buttonHeld()` distinguishes capture lifetime from a retained highlight;
the native painter consumes the shared `Span` directly. `SelectionVersion` in
`session_pump` carries sequence, history, session epoch and a revision for
connection, geometry and mode changes. The runtime captures that version under
the same lock as the independent displayed grid. The controller retains the
press version and attachment; it never refreshes an old drag into a new frame.

The pump owns queued-request tickets, cancellation, the deadline and at most one
owned result. It validates the version before sending, while decoding a reply
and when transferring the result. Its single invalidation path clears semantic
correlation and owned text, including requests cancelled before mailbox service.
The SDL adapter writes clipboard text and reports failures; it preserves the
previous clipboard for empty or unrepresentable NUL-containing replies.

Review resolved history-coordinate overflow, neighbour-history subtraction,
held mouse capture, release-position resize regression, stale press versions,
mode/connection invalidation, unlocked timeout access, allocation-failure
ownership, logical-only geometry changes and empty-copy behavior. Root completed
the pump ownership cleanup and focused tests after the first integration pass;
Terra independently reviewed the final version. No new library or wire message
was added. Production growth connects the existing policy and wire to native
input, rendering and clipboard adapters; test code supplies independent failure
and ownership evidence.

The existing protocol has no source-sequence precondition at the daemon.
Observed client-side changes cancel selection conservatively; copying an atomic
historical frame is not claimed. A wire-level version precondition belongs to
any future sprint that strengthens this contract, across all frontend callers.


## Validation and demo evidence

Final source passed full `make ci`, client/native units and native build,
`make native-e2e`, and native core tests with GUI package metadata hidden.
Real offscreen and NVIDIA Wayland selection fixtures cover three panes on two
daemons, daemon-extracted Unicode/wraps, actual framebuffer highlights,
clipboard reads, other-pane/selected-pane output, delayed requests, blank copies,
timeouts and detach without ending the shell. Wayland tests additionally use
real virtual-pointer input and held selection across 100/150/200% transitions.
The retained `native_scale.py` gate also passed. No Mac evidence is claimed.

`make native-stress` passed separately from recording and other GUI fixtures:
frame p99 19,267 us (20,000 limit), sampled input-to-painted upper bound 65.3 ms
(250 limit; up to 5 ms polling overhead). Earlier unexplained NVIDIA misses stay
open; this is one acceptance run, not a speedup claim.

Logs, fixture paths, paired release build evidence, helper source/protocol,
verified agent models/cumulative counters and recording scripts are retained in
`dist/text-selection/`. The maintained `test/wayland_pointer.py` adapts the
retained helper's line protocol; it requires one isolated headless Sway output.
Set `MUXG_TEST_POINTER` to the absolute `dist/text-selection/pointer` binary when
running `test/native_selection.py` on Wayland, and optionally set
`MUXG_TEST_SCALE_OUTPUT=HEADLESS-1` for its held-selection scale scenarios.
The helper sources, generated Wayland protocol and binary live with these
artifacts; compile `pointer.c` and `pointer-protocol.c` with wayland-client if
rebuilding the fixture. SDL-injected events remain the offscreen input path.

The first Wayland test exposed a harness limitation: injected SDL mouse events
cannot provide the input serial needed for compositor clipboard ownership.
The final Wayland gate uses real pointer events and waits for clipboard offers
to reach a separate `wl-paste` client. Initial failed logs are retained. The
first demo's cleanup was interrupted by the recorder's minimum-length check;
root stopped only its three owned GUI/daemon processes and fixed unconditional
fixture cleanup before the successful recording.

Review page source: `docs/demos/native-text-selection.html`. The 24.4-second
continuous NVIDIA 200% recording shows real drags and independent `wl-paste`
output, with no cuts or audio. Private handoff route:
https://charizard.folk-amberjack.ts.net/text-selection/ . The accepted appearance
routes were subsequently removed; their artifacts remain.
Route-specific teardown: `tailscale serve --https=443 --set-path /text-selection off`,
then stop only the page server identified by `dist/text-selection/server.json`.


Private page verification passed HTTPS/byte ranges, desktop/mobile layout,
playback and seeking; all three earlier appearance pages remain reachable.
Only the allowlisted page server remains (PID 3384909, port 18774); owned
browser/compositor fixtures are stopped. The final offscreen path was rechecked
after sharing the Wayland pointer adapter. The retained recording script now
explicitly focuses its owned window before its first pane click.

## Hands-on copy-shortcut feedback

The user found Ctrl+Shift+C was forwarded as terminal Ctrl+C. The SDL adapter now
maps that chord to copy intent, and the native controller consumes it before
terminal input. Both mouse release and keyboard copy share the existing request
queue, ID and freshness handling; clipboard writing and daemon extraction are
unchanged. No new module, dependency or protocol was needed. Pure modifiers keep
the selection; plain Ctrl+C still goes to the PTY. Existing modal/prefix handling
retains precedence. Luna implemented, Terra reviewed, root added the real-PTY
oracle and removed a redundant forwarding helper.

The regression uses a real foreground Python process recording SIGINT to a file.
It verifies copying during a held drag, retaining the released highlight, copying
with no selection, and ordinary Ctrl+C delivery. Adapter tests cover left/right
modifier combinations and excluded Alt/GUI/Mode modifiers. Fixture output and
keyboard events wait for actual highlighted pixels where compositor input and
the passive hook arrive on separate connections. Early race failures are retained
beside final results in `dist/text-selection/copy-*`.

Full CI and native integration passed after the shortcut change, alongside
native/core units and offscreen/Wayland selection checks. The review GUI was
restarted with the same explicit appearance flags; the user confirmed copying.
Updated footage is 26.4 seconds and includes copy before release (SDL-injected
shortcut, real Wayland pointer, independent desktop clipboard read). Previous
performance measurements predate this keyboard follow-up.

The user's application-selection example was Claude Code, not Neovim. Follow-up
parity must distinguish alternate-screen mode from requested mouse reporting:
TUI `Core.forward` already uses `TermModes.appMouse()`. Native application mouse
forwarding and application clipboard effects are both missing; the pump currently
skips `clipboard_set`. GUI paste stays deferred by the user's clarification.

## Required follow-up: application mouse and clipboard behavior

Shift+drag is part of the overall mouse feature, as confirmed by the user; it is
required when adding application mouse forwarding, not an optional later polish.
Wheel scrolling is implemented. The active follow-up is
[application mouse and clipboard writes](2026-09-06-native-application-mouse.md).
The following application mouse/clipboard slice must demonstrate:

- Normal click/drag reaches applications that request mouse reporting, using
  coordinates relative to the starting pane. Alternate-screen mode alone does
  not decide mouse ownership.
- Shift+drag forces mux's native selection and copy even when the application
  requests mouse reporting. No part of that drag reaches the application.
- A gesture keeps its chosen owner through motion and release, including modifier
  changes and crossing another pane; cancellation cannot leave an application
  with a stuck button or copy text from a different pane.
- Attach foot and muxg to the same tmux session with mouse reporting enabled.
  Normal drag in either client must enter tmux's selection/copy path and render
  its selection in both clients; verify tmux's paste buffer independently.
  Shift+drag in muxg remains a local highlight and desktop copy, with no tmux
  mouse reports or paste-buffer mutation. Verify desktop clipboard separately
  from tmux's buffer; a visible shared highlight alone does not prove copying.
- Application-issued clipboard writes reach the desktop through the existing
  validated clipboard-effect path. Verify actual PTY mouse reports and an
  independent clipboard reader, plus a real application's selection/copy flow.

Owner: the planner and implementer of the application mouse/clipboard slice under
parity issue `8b16e26b`. Include the Shift override in that slice's demo and tests
before declaring application mouse parity complete. GUI paste remains deferred.


### Active-output feedback

A Claude counter running inside tmux made selection unusable. The apparent
fullscreen dependency was actually the counter pausing in the smaller window.
A real tmux counter reproduced the cancellation, with ordinary redraws advancing
the replica sequence during a held drag.

Following the user's Ghostty reference, live selection now retains its range
through redraws and copies current daemon text. `SelectionVersion.history`
records whether the selection originated in a cached history view; those
selections still require the exact source sequence, including after returning
live. This supersedes the initial policy of cancelling on every output frame.
The pump remains the sole validity owner at poll, release, send, reply and take.
No renderer, mouse-coordinate, daemon or protocol changes are needed.

Ghostty reference (our pinned revision):
[tracked selections](https://github.com/ghostty-org/ghostty/blob/853183e911b70ff7b61057f52fc7b47ea4934238/src/terminal/Selection.zig)
and [screen-owned selection](https://github.com/ghostty-org/ghostty/blob/853183e911b70ff7b61057f52fc7b47ea4934238/src/terminal/Screen.zig).
Its page pins belong to the daemon's terminal engine; the client has flattened
rows. Existing daemon extraction already uses Ghostty. We follow the redraw
behavior without adding another terminal or per-cell watcher to the client.

The current protocol cannot pin an atomic source snapshot for a remote copy,
or identify scrollback eviction when the history count remains at its cap.
Preserving selection across history eviction/reflow still needs an explicit
source-identity design; it is not claimed by this fix. Hands-on review replaces
a new recording at the user's request.

Validation: full `make ci`, client/native units, full native integration and
actual NVIDIA Wayland selection/wheel checks passed. The counter runs inside
real tmux for the Wayland selection check; eleven selection and ten wheel
checkpoints pass. Evidence: `dist/wheel-scrolling/active-selection-*.log`.


### Outstanding feedback: selection must follow moving text

The user expects a selected line and its highlight to move together when output
scrolls that line upward. Commit `7897f7d` only preserves a live coordinate range
through redraws; it does not track row identity through scrolling inside an
application. Wheel movement of the client's viewport is already handled, but
terminal scroll-region movement, insert/delete lines, reflow and capped-history
eviction require a distinct identity/transform solution. This gap remains open.

Acceptance: select a unique line, let genuine terminal output scroll it upward,
and verify its highlight follows that same line. Text already copied must remain
unchanged; copying again must still address the originally selected line. Cover
normal screen/history and alternate-screen scroll regions, plus offscreen return,
without guessing identity by matching strings (duplicate lines are valid).
Owner: the next selection-tracking slice, using Ghostty's existing engine-owned
pins/scroll semantics and an explicit client/daemon contract where required.

The user's foot/tmux comparison also exposes the already planned application
mouse gap: normal muxg drag is currently local, so tmux cannot render that
selection to other clients. This does not imply that local clipboard extraction
failed. Cross-client visibility, tmux paste buffers, and the desktop clipboard
must be tested as separate outcomes in the application mouse slice above.