Skip to content

Replace the selection popup with an editable copy preview - #885

Merged
nedtwigg merged 37 commits into
mainfrom
copy-paste-2
Oct 1, 2026
Merged

nedtwigg merged 37 commits into
mainfrom
copy-paste-2

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Oct 1, 2026

Copy link
Copy Markdown
Member

Replaces the Copy Raw / Rewrapped popup with a copy editor that previews the clipboard text before copying. Users can choose Auto / Exact / Spaces / No breaks, expand the selection to words or paragraphs, nudge either edge, and edit individual line breaks.

The editor uses available space across the window while following its pane and terminal reflow, supports touch controls, and shows copy outcomes. Mouse-reporting program drags and OSC 52 copy offers can also open the editor. Specs, shortcuts, tutorial text, and Storybook coverage are updated.

Validation: library typechecking, spec lint and its self-tests, and 281 targeted selection/copy tests pass. The innerdogfood harness starts and renders the app.

nedtwigg and others added 30 commits September 30, 2026 17:30
Two React-drawn mock terminals (Claude Code inline and fullscreen, zsh)
over a pure model of the copy transforms: line-break judgement, chrome
stripping, scope growth, and word nudging.

Prototypes/Copy editor compares three concepts (numbered chooser,
copy-then-switch receipt, editor). Prototypes/Copy editor 2 carries the
chosen one: the editor at full pane width, Auto / Exact / Spaces / No
breaks cycled with f, e and Shift+e for scope, arrows to nudge, and a
clickable mark on every line break.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The storybook Tool renders as agent-browser-screencast, innerdogfood
drops its explicit desktop viewport, and the descriptive comments are
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/⬆︎f bare

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pure reading of the terminal buffer for the copy editor: Auto / Exact /
Spaces / No breaks renderings with per-break judgement and overrides,
decoration stripping, scope expansion to whole words and paragraphs, and
word-wise edge nudging. Cells are read through xterm's wide-character
continuation cells. Not wired to the UI yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mouse-up over a terminal-handled drag opens the copy editor at full pane
width: the clipboard text with a gutter line per clipboard line and a
clickable mark on every line break. Formats are Auto (the old Rewrapped,
now judging each break on its own: soft wraps and margin-split tokens
rejoin, code and list lines keep their breaks), Exact (the old Copy Raw),
Spaces, and No breaks, cycled with f / Shift+F. Scopes expand with e /
Shift+E to whole words (Full URL / Full path across a split) and the
paragraph, drawn dashed by the selection overlay. Arrows nudge the end and
Shift+arrows the start by a word. Enter or the copy chord copies; any
other key closes the editor and reaches the terminal.

The tutorial's Raw / Rewrapped items now check Exact and Auto copies; the
homepage and VS Code README describe the editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Cancel-on-change follows the selection: the lifecycle re-snapshots it
  whenever the store's selection changes, replacing the router's injected
  setter, the nudge's re-arm, and a dead setTerminalSelectionBaseline.
- setSelection writes the copy editor in the same notify (none unless one
  is given), so an editor never outlives its selection; consumers gate on
  copyEditor alone.
- Span carries its block flag; selectionOfSpan inverts spanOfSelection.
- Row text, decoration and paragraph bounds are computed once per row, and
  one decoration rule serves rows and extracted lines.
- The editor renders each format once per scope and reuses it for the
  preview and the duplicate check; a mark passes its own kind to flip.
- Whole-words labels classify edge tokens with the smart-token detector,
  which also shares the new readLineCells cell reader.
- Segments and the Copy button use the design recipes; the preview is
  memoized against the render tick; tests and stories share one fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…editor

A primary mouse drag that reaches the inside program keeps reaching it
untouched, but Dormouse now remembers the cells it crossed. On release
they become a program-owned selection with no outline and a "Press Cmd+C
to copy" hint; the copy chord (Ctrl+C elsewhere) opens the copy editor
over them, outline included. Any other key goes to the program and drops
the shadow, as do a new press, a content change, and the program turning
mouse reporting off. Ctrl+C now intercepts while such a drag waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OSC 52 is still consumed and still never writes the clipboard. The owner's
parser now decodes a write (UTF-8, line endings normalized, other controls
but tab removed, dropped rather than truncated past CLIPBOARD_OFFER_LIMIT;
a `?` read is never answered) into a clipboard offer, sent to the owning
renderer as terminal:clipboardOffer from the sidecar and the VS Code
extension host, and routed by PTY id in Tauri. The webview accepts it only
into a pane holding a shadowed drag, where the copy editor shows it as a
fifth format, "From <program>", last in the f cycle; it is copied only when
the user picks it. Replay re-parses without offers.

security-local.md's terminal-output rule and FAIL IF lines, security.md,
terminal-escapes.md, and transport.md describe the new boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Simplify:
- parseOsc52 reuses decodeBase64; CLIPBOARD_OFFER_LIMIT bounds the base64
  payload under the incomplete-OSC bound, so PTY read splitting never
  changes whether an offer is kept.
- One clearSelection writes every selection-scoped field; isShadowed
  names the shadowed state for the overlay and the keys.
- The program format's lack of scope lives in the model (choosing it
  returns to the selection's scope; e does nothing), not three view checks.
- "From <program>" uses the WATCHING command key, skipping wrappers and
  earlier commands, instead of the display string.
- A shadowed drag measures its end cell once at release; one
  pastDragThreshold serves the router's three threshold checks.
- renderText shares the break text with render; the copy chord label
  lives beside hasCopyModifier.

Fix, found driving the real app:
- Auto judged wraps against at least half the terminal, so `fold -w 50`
  output in a wide pane never joined; the floor is now an absolute 40
  columns (60% of a narrower terminal).
- A selection edge resting on a blank no longer grows into the word
  beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
relay and one-time get one-line descriptions saying what they serve and
how changes reach them; hosted's says its backend needs a restart and
Docker. one-time drops the inline notes the other Tools no longer carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor concept is built (Components/CopyEditor); the prototypes and
their parallel model of the copy heuristics would only drift from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The editor holds the buffer it opened with: preview, copy, and nudge all
  read it, so what is shown is what is copied, and only opening reads the
  terminal. copy-editor reads the leaf registry, so the copy-selection
  wrappers that only looked up a terminal are gone.
- A scope's rows are read and stripped once (readScope) for all four
  formats, cached per scope span, and each format renders in one pass.
- The Whole words label renders only the grown edge tokens, and nothing
  when no edge grew.
- Any input the terminal receives closes the editor or a shadowed drag at
  writeUserInput — Pocket's input bar and paste included — replacing the
  Wall's keydown dismissal list. The editor's bare keys apply in
  passthrough only; the copy chord works in either mode.
- The store owns "the program format needs its offer": a replaced
  selection demotes it, and setCopyEditor refuses it without one.
- stringCopyBuffer, Rendering.lines and the popup-era wording are gone;
  tests and stories share fixtures (fakeXterm, bufferLine,
  finalizedSelection) and one useStorySelection harness; the expanded-scope
  colour lives in design.tsx; the surface merges its border through tv.
- Spec: one dismissal list (§4.5), Auto's rule order matches the code,
  Enter vs Shift+Enter stated, pointers instead of repeats in transport.md
  and shortcuts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A drag's edges were the cells under the pointer, inclusive at both ends,
so releasing just past a word's last character took the next character
too: the end of a selection was usually one character too far, in either
drag direction. Each edge is now the cell boundary nearest the pointer,
as xterm.js's own selection reads it; the earlier edge takes the cell
after its boundary and the later edge the cell before (column order for a
block). The router keeps the drag's two boundaries and re-resolves both
cells on every move and on an Alt flip, replacing setDragAlt; shadowed
program drags resolve the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A resize cleared every selection, so the copy editor closed whenever its
pane changed size. A finalized linewise selection Dormouse owns now
follows its cells: each edge is a cell offset into its logical line, held
by an xterm marker on that line's first row, which reflow never inserts
or deletes. The new position is coordinate math; it is kept only if the
cells there, soft wraps joined and wrap padding skipped, still read as
the selected text, and cancels otherwise, as does a trimmed edge. Block,
program-owned, alternate-buffer, dragging and copy-confirming selections
still cancel. The open editor is rebuilt over a fresh read of the buffer
in its format and same-labeled scope; per-break edits drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A height-only resize reflows nothing, so every break still sits where it
did and the edits made to them still apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy buffer trimmed trailing blanks off every physical row, so a real
space in a soft-wrapped row's last cell was lost: Auto and No breaks joined
`dddd ` + `eeee` into `ddddeeee`, and Whole words grew across the space.
Rows now trim only where their logical line ends, dropping just a wide
character's wrap padding before a soft wrap. A deleted soft wrap puts the
trimmed blanks back and keeps the continuation's leading blanks, which the
joined-line strip used to drop the same way. Exact and Spaces are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The copy flash's timer is keyed to its own flash, not the selection, so a
  nudge or a resize during the flash no longer leaves a stuck checkmark, and
  a resize now follows a selection whose copy is confirming.
- The selection's baseline, anchor, render-time cancel check, and resize
  follow move from terminal-lifecycle.ts into selection-watch.ts
  (`watchSelection`); the lifecycle only wires xterm's events to it.
- `followCopySelection` replaces `refollowCopyEditor` as a copy-editor
  transition, and writes nothing when a resize moved nothing and the
  editor's width held.
- `followReflow` returns a reading-order Span; `ReflowAnchor` is opaque.
- `lineAt` in buffer-cells.ts is the one guarded line reader; the reflow
  module fetches each row once and shares the wrap-padding check.
- Spec 3.4 scopes the rule and cancels any other selection; the tests are
  table-driven, de-duplicated, and pin the watch's render check directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Geometry for the coming copy editor rewrite; nothing renders differently
yet.

- TerminalOverlayDims carries the terminal element's viewport corner,
  read from the rect getTerminalOverlayDims already measures.
- copy-editor-placement.ts places the editor in the window: below, above
  (touch: above first), or the roomier side of the pane at natural size,
  then squished into the roomier of below and above, then the overlay. A
  natural spot is held unless a higher one fits with 24px to spare, and
  every candidate is computed from the new band, so a held spot never
  covers the selection. GAP_PX and MIN_HEIGHT_PX move here.
- rect-tween.ts gains a rect-only tween, and rect-motion.ts drives it
  from rAF: the first target snaps, later ones restart from the displayed
  rect, and settled frames land on whole pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plumbing for moving the copy editor onto document.body. A React portal
still passes events up the React tree, but DOM contains and closest
checks no longer see the portaled subtree as part of its pane.

- dom.ts maps a [data-portal-anchored] root to an anchor left in place
  (setPortalAnchor), and anchoredTarget stands that anchor in for any
  target inside the root. The terminal context's outside press and its
  Escape, the Wall's context key routing, the Tool context-terminal
  check, and the agent-browser key forwarder all test it.
- MobileTerminalUi ignores a press outside the host's DOM, so Gestures
  mode never grabs it and it neither acknowledges the Session nor
  dismisses the keyboard.
- LathHost provides lath.subscribeFrames as LayoutFramesContext, for
  chrome that must follow a pane moving under Lath.
- COPY_EDITOR_Z_INDEX = 55: above the ring, below every modal layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor leaves its pane's clipping box for document.body, so it can
sit below or above the selection over a neighbouring pane, or beside its
own pane, and covers the selection only when nothing else fits.

- CopyEditor portals its root to document.body at COPY_EDITOR_Z_INDEX,
  leaving a hidden anchor in the pane (setPortalAnchor). The root is
  fixed and hidden until first placed; only the rect-motion driver
  writes its geometry, and data-copy-editor-side names the spot taken.
- copy-editor-measure.ts holds the DOM reads: the natural width from a
  w-max probe of every format's longest lines, so f and a flipped mark
  never change it, and the height at a width from a detached copy of
  the preview, cached per rendering.
- Placement runs before paint on a selection, scope, or rendering
  change, so opening lands without travel. Anything else that moves the
  pane is coalesced to one frame: the render tick, Lath layout frames,
  Workspace motion frames, the window and visual viewport, a scroll
  outside the editor, and a resize of the terminal. The editor no longer
  re-renders on the tick.
- It hides while its Wall is transformed, its pane is hidden, or another
  pane is zoomed over it, and it stops a right-click as well as a press
  from reaching the pane.
- CopyEditorPlacement stories check each spot in a real Wall: below over
  a neighbour, above, beside, squished, and the overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- mouse-and-clipboard.md §4.5: the body portal and its layer, the
  desktop and touch ranking, the width rule, re-placing on every
  selection or scope change, easing every move from the displayed rect,
  never showing while the Wall is transformed, and presses inside it
  counting as inside its pane. §7 points the editor's re-placement at
  layout.md's Position tracking. Budget ratcheted to 5100.
- The rationale gains 4.5: why window-wide, why the width cap spans
  every format, restart rather than retarget, hysteresis, layer 55.
- DESIGN.md widens the layout-animation carve-out to every JS per-frame
  tween and describes the copy editor's travel; the FOCUS_MOTION_MS
  comment names it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy text model judged physical rows, so a soft wrap's continuation
row counted as a line of its own. Auto, Paragraph and Whole words now read
logical lines: a row and the rows soft wraps continue it onto.

- Whole words / Full URL / Full path stopped one cell into a crossed row:
  the crossing landed on a blank (the next row's indent, or past the row
  above's end) and the edge-on-a-blank check then ended the growth. A
  token now crosses a deleted break only with text on both sides, onto the
  row above's last cell or the row below's first text cell, and a soft
  wrap's continuation starts at column 0 rather than its line's indent.
- A blank row inside a soft-wrapped line read as a blank line: it bounded
  Paragraph, made Auto keep the breaks around it, and made No breaks drop
  the blanks it should join exactly. A line's trailing blanks are now
  trimmed however many rows they wrap onto.
- A continuation row's leading position counted as an indent, so it broke
  the hanging-indent match and pulled the shared indent to 0; its length
  also counted as a line's, so the fit test kept every break of a reply
  after a pane narrowed. Auto now judges whole lines, so a reflowed reply
  reads as the original does.

The mid-drag smart extension had the same gap: it read only the
pointer's row, so `e` stopped a soft-wrapped URL at the row edge. It now
reads across the soft wraps around the pointer, and its hint carries a
start and end cell.

Exact output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A press inside the body-portaled editor focused its buttons on
document.body, so four key routes needed anchoredTarget to find the
pane again. The editor root now prevents the press's default, except on
the preview's own box so its scrollbar still drags, and its buttons
leave the tab order. Focus stays in the pane's xterm textarea, so the
Wall keyboard, the selection keys, the browser forwarder, and the
context's Escape are back to their plain DOM checks.

setPortalAnchor marks and unmarks the root itself, and anchoredTarget
returns an Element, so its one remaining caller, the context's outside
press, needs no cast.

The spec states the focus rule, and to stay in its word budget its
placement section and rendering notes keep one statement per rule:
the editor's subscriptions become a pointer to layout.md's "Position
tracking", and the rationale drops the hysteresis mechanism sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor and the ring each wired the same five sources that move a
pane without a render: its ResizeObserver, window resize, capture-phase
scroll, Lath frames, and Workspace motion frames. subscribePaneMotion
now holds them for both, with the anchored menu's narrower scroll rule:
only a scroller that contains the pane can move it, and xterm's own
scroll already reaches the editor through the render tick.
subscribeOverlayViewport holds the visual viewport pair the editor and
the anchored menu shared. LayoutFramesContext and the ring's prop take
LathWallEngine's subscribeFrames type.

The editor no longer reads its Wall's inline transform, which is
workspace-motion's private presentation: workspaceInTravel answers it.
Its concealment checks run cheapest first, and a concealed editor that
is already hidden writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor skips placement on a render tick that moved nothing, keyed
on the grid, its box, the viewport, and the measured inputs: the tick
is global, so it fired for every terminal's output. Its height measurer
resizes one hidden copy of the preview React keeps, instead of deep
cloning it for every width. Placement builds the side spots only when
neither natural spot fits or a side is held, and one hold() carries all
three ways of keeping the previous spot. The width probe cuts each line
to what a screen could show, takes its three longest in one pass, and
rebuilds only for a new scope or offer, never a flipped mark.

rect-motion is created once, owns showing and hiding, and drops
dispose and its injected clock; rectsEqual, insetOverlayBounds,
portalToBody, and installFakeFrames replace their copies. The editor
keeps its side only on its root and its inputs in one ref, and its
dismissal listener shares the follow effect's AbortController.

DESIGN.md and the code comments point at the spec for restart-not-
retarget and the editor's layer instead of restating them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`wrapRun` in buffer-cells is now the one walk over soft wraps: the copy
buffer, the smart-token detector, the reflow anchor, and the prompt-line
reader all take a logical line's rows from it, each line read once.
`textWidth` / `textCells` are the one place wrap padding is dropped.

- The copy buffer reads a whole logical line at once, so whether a row
  keeps its trailing blanks is one backward pass instead of a scan per
  row, and each row records its line's top and bottom: `line()` no
  longer walks. `textContinues` and its `translateToString` read are gone.
- `readScope` groups rows by logical line and marks continuation rows,
  so `renderLines` stops asking the buffer. Decoration is judged on a
  logical line's rows joined, as `line()` judges: a box run, bullet, or
  row of box drawing a wrap puts mid-line is text, and a box side wrapped
  onto rows of blanks no longer leaves those blanks behind.
- `wrapWidth`'s two walks are one helper, and a paragraph a break's walks
  read whole shares its width with its other breaks.
- A scope's layout per format (lines, shared indent, Auto's decisions) is
  memoized, so a per-break override render reuses Auto's judgements.

Exact renders hash identically before and after over every fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`detectTokenInBuffer` runs on every drag move. It now reads only the cell
under the pointer when that cell is blank or unchanged since its last
call, and otherwise reads the pointer's row first, going on into a row a
soft wrap joins only while the run of non-whitespace reaches that edge.
Each UTF-16 unit maps to its cell by number, with positions built only
for the token's two ends.

`setHintToken` skips a token equal to the one it holds, so a drag no
longer rebuilds every pane's snapshot per move. `BufferToken` is the one
name for the hint (`TokenHint` and `BufferCell` are gone), built from
`DetectedToken` over `GridPos`, and the extension's direction reads
through `comparePos`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor's natural width covered only its preview lines, so a narrow
pane over a short selection gave a 188px editor whose root clipped the
never-wrapping header and footer: one scope button, the selected format
scrolled away, no count or Copy button.

A chrome probe now lays out the header and footer as wide as any format
shows them (one `*`, the Copy check, the widest count), measured only
when its labels, hints, or count digits change, so `f` never resizes the
editor. Placement takes that width as a floor and a side needs room for
it. In a window narrower than the chrome the key hints and legend clip
first, keeping the segments, the count, and Copy in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A side was taken only when the editor fit it whole and only when it fit
the whole chrome, key hints included, so a tall selection in a split
layout squished into a short strip below or docked over the text with a
full-height column free beside the pane. Squish now ranks below, above,
and both sides by area, a side taking the window's height, and a side
needs only room for the segments, the count, and Copy, measured by a
second chrome probe; the key hints and legend clip first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nedtwigg and others added 4 commits October 1, 2026 11:11
On a phone the Copy button was 40x20, a tap just below the editor landed
on the terminal and cleared the selection, and a copy confirmed with a
12px check that shifted the label before everything closed, so a copy
and a miss looked the same. Over plain http the write failed silently.

- Touch gets a full-width Copy row 44px tall.
- Copy swaps in place to Copied or Couldn't copy, every label laid out
  in one cell; the copied selection fills, and touch holds the flash
  1200 ms with a tap of vibration. A failed write keeps the selection.
- The write falls back to execCommand('copy'), before any await when the
  Clipboard API is missing.
- A touch or pen press within 16px outside the editor is swallowed, with
  its compatibility mousedown and click.
- MobileTerminalUi skips the later events of a press that began in a
  portaled descendant, keyed by pointer id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A copy's flash and its failure notice were two exclusive fields with two
token maps and two timers; they are now one `copyOutcome`, 'copied' or
'failed', on one timer, the newer replacing the older. The tutorial reads
the copied format from the editor instead of the flash. Copy buttons
share the outcome labels.

The `execCommand` fallback writes through a one-shot `copy` listener, so
focus stays put (no xterm focus report, no committed rename), keeping the
hidden textarea only for an engine that fires no `copy`. The theme
debugger, the external-link modal, and the update report copy through it.

The copy fill's reduced-motion gate is the JS one alone, read once per
flash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A near-miss tap was swallowed by three window-capture listeners and a flag
carried from pointerdown to click, verified only on Chromium. On touch the
root now pads the visible surface with a `TOUCH_SLOP_PX` margin, so the
browser targets the editor itself, whose presses are already inert; the
placement rect stays the visible editor. MobileTerminalUi drops its
portaled-pointer set: a portaled press clears the pending tap instead.

One chrome probe replaces the copy that left out the key hints and
legend: measured whole, then with `data-essential` hiding its
`data-chrome-optional` parts, and memoized.

Placement maps each side once to its natural and squished spot, and
skips measuring a side when the editor overflows the window at full
width. The §4.5 rules state the rule once, the mechanism left to code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 76924a5
Status: ✅  Deploy successful!
Preview URL: https://7d7ea8dc.mouseterm.pages.dev
Branch Preview URL: https://copy-paste-2.mouseterm.pages.dev

View logs

nedtwigg and others added 2 commits October 1, 2026 12:30
Alt-or-latched-block was spelled out at four sites; `dragShape` in the
selection store now owns it. Also tightens §5's re-examine rule and the
nudge test's case labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	lib/src/components/MobileTerminalUi.tsx
#	lib/src/lib/platform/fake-adapter.ts
#	lib/src/lib/platform/vscode-adapter.ts
#	scripts/spec-word-budgets.json
#	standalone/src/browser-sidecar-adapter.ts
#	standalone/src/tauri-adapter.ts

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Most findings are inline. Two more:

  • An empty copy reports a failure. Auto renders "" for a frame-only selection (for example a lone ╭──────╮ row) or a whitespace-only one, and Exact does the same for whitespace. writeTextToClipboard returns false on empty text (if (!text) return false;), so copySelection calls failCopy and Copy reads "Couldn't copy" for 1.5 s although no write was attempted. §4.5 ties that message to a failed write. The old copyRaw just withheld the flash.
  • Stale §4.2 references. §4 was renumbered, and §4.2 is now Scopes; the copy chord lives in §4.3. Three places still point at §4.2 for the chord: mouse-and-clipboard.md §8 ("copy keeps the macOS separation instead (§4.2)" and "It gates the copy chord (§4.2)") and the hasCopyModifier doc comment in lib/src/components/wall/keyboard/chords.ts. All three sit outside the diff hunks, so there are no suggestions for them. I can push the fix if you'd like.

lib/src/ tests were not run locally; this review comes from reading the code.

void copySelection(sid);
return consume();
}
if (isCopyChord(e) && isShadowed(mouseState)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On Linux and Windows this captures Ctrl+C until the shadow is dropped, and nothing times it out. Only input through writeUserInput, a press in the same pane, a content change, or the program ending mouse reporting clears it; clicking another pane or waiting does not.

So after an accidental drag of a few pixels in htop, less --mouse, or a mouse-enabled TUI, the user presses Ctrl+C to quit. The first press opens the editor. A second press copies the shadow's text, which overwrites their clipboard. Only after the flash does Ctrl+C reach the program again. §3.8 specifies this, so it is a design call rather than a slip. Still, losing SIGINT to a drag the user may not know they made seems worth a maintainer's decision. Options include expiring the shadow, dropping it on focus leaving the pane, or opening the editor only when Ctrl+C comes soon after the drag.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the specified §3.8 behavior in this change: the copy chord opens an editor over a shadowed program drag, and copies only on a subsequent explicit action. Expiring the shadow or clearing it on focus changes would change that contract and could discard an intentional copy. I have retained the current policy while fixing the implementation defects; the SIGINT tradeoff remains a maintainer design choice.

Comment thread lib/src/components/CopyEditor.tsx Outdated
Comment thread lib/src/lib/copy-text.ts Outdated
Comment thread lib/src/lib/copy-text.ts Outdated
Comment thread lib/src/lib/copy-text.ts Outdated
@nedtwigg

nedtwigg commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

Addressed the implementation findings in 76924a5: preview focus, paragraph boundary expansion, relative indentation from a mid-line start, and nudging an edge past the row's text. Empty copies now return without attempting a write or showing a failure, with regression tests for frame-only and whitespace-only selections. All three copy-chord references now point to §4.3.

Validation: 490 targeted library tests, library typechecking, and spec lint with its self-tests pass. The merge of origin/main also passed standalone and VS Code typechecking and 25 standalone adapter tests.

The shadowed-drag copy-chord policy is retained as specified in §3.8; the inline reply explains that design choice.

@nedtwigg
nedtwigg merged commit 9ef5f46 into main Oct 1, 2026
21 checks passed
@nedtwigg
nedtwigg deleted the copy-paste-2 branch October 1, 2026 19:54

This branch is waiting to be deployed

1 waiting deployment
hosted-preview — 76924a53 Waiting Oct 1, 2026 by nedtwigg via cleanup #628
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants