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.