diff --git a/AGENTS.md b/AGENTS.md index d1e5cafb9..4e0c8ed13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ A spec is the accurate reference for the current code: it states the invariants - **`docs/specs/terminal-escapes.md`** — Registry of every escape sequence parsed, answered, or ignored, each row pointing at its owning spec. Read before touching OSC/CSI parsing. - **`docs/compatible-agents.md`** — Public agent guide plus the hidden recovery contract: shutdown capture, detection, single-use records, and cold-restore execution. - **`docs/specs/transport.md`** — Adapter-agnostic webview ↔ host protocol: PTY lifecycle and buffering, reconnection, message contracts, persisted-session types, the invariants every adapter honors. -- **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned selection, copy (Raw / Rewrapped), paste tiers, smart URL/path extension, the mouse-ownership state matrix. +- **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned selection, the copy editor (Auto / Exact / Spaces / No breaks, expand, per-break marks), paste tiers, smart URL/path extension, the mouse-ownership state matrix. - **`docs/specs/theme.md`** — The two-layer CSS variable strategy, consumed-token resolver, terminal color contract, theme debugger. - **`docs/specs/dor-cli.md`** — The `dor` CLI on every Dormouse terminal's `PATH`: bundling and env contract, `spawnAndCapture` rules, control-socket plumbing, the Surface handle model, the command set. - **`docs/specs/dor-browser.md`** — The browser surface: `BrowserPanel` with swappable `renderMode`, browser chrome, the agent-browser stack, the iframe proxy and CSP boundaries. diff --git a/DESIGN.md b/DESIGN.md index ee5a2f1c2..a9dbef058 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -179,9 +179,9 @@ Flat by default. Pane headers, doors, the baseboard, and terminal panes carry ze Shadows appear only on **raised surfaces that float above content**: popovers, tooltips, dialogs. They are ambient, not structural; they say "I am temporary and on top," not "I have weight." ### Shadow Vocabulary -- **Popover** (`box-shadow: var(--tw-shadow-md)`): tooltips (`PopupButtonRow`), selection popup, illegal-rename warning, terminal-pane header tooltips. +- **Popover** (`box-shadow: var(--tw-shadow-md)`): tooltips (`PopupButtonRow`), illegal-rename warning, terminal-pane header tooltips. - **Dialog** (`box-shadow: var(--tw-shadow-lg)`): kill-confirm sheet, TODO alert dialog. -- **Modal** (`box-shadow: var(--tw-shadow-2xl)`): theme picker dropdown (when expanded), theme debugger, theme store dialog. +- **Modal** (`box-shadow: var(--tw-shadow-2xl)`): theme picker dropdown (when expanded), theme debugger, theme store dialog, copy editor. - **Inset hairline** (`box-shadow: inset 0 0 0 1px var(--color-focus-ring)` / `var(--color-border)`): mobile UI segmented controls. Used instead of `border` when the surface needs a 1px stroke that does not shift layout on state change. ### Named Rules @@ -267,6 +267,9 @@ The selection ring around the focused pane in command mode is an SVG with `strok #### Focus Ring Travel & Header Crossfade When selection moves between panes/doors, the focus ring **glides** to the new target over 220ms (`FOCUS_MOTION_MS`, half the pane-motion duration) on the house curve `cubic-bezier(0.22, 1, 0.36, 1)`, and the source/destination pane headers crossfade their active/inactive palette over the same 220ms (`HEADER_PALETTE_TRANSITION_CLASS` in `design.tsx`), so the two read as one gesture. The ring's rect is a per-frame JS tween (`rect-tween.ts`), not a CSS transition; same-identity re-measures (sash drag, window resize, animator frames) snap 1:1, and a pane↔door move lerps the corner radii so the shape never pops. Reduced motion nulls both: the ring snaps and the header palette swaps instantly. +#### Copy Editor Travel +The copy editor's moves and resizes ease on the focus ring's duration and curve (`FOCUS_MOTION_MS`, `rect-tween.ts` driven by `rect-motion.ts`); `docs/specs/mouse-and-clipboard.md` §4.5 owns when. + ## 6. Do's and Don'ts ### Do: @@ -287,7 +290,7 @@ When selection moves between panes/doors, the focus ring **glides** to the new t - **Don't** introduce a `text-muted` color inside an active or inactive pane header. Header-internal text inherits the header foreground; muting inside it breaks the focus signal. - **Don't** use rounded SaaS cards, gradient accents, gradient text, or glassmorphism. PRODUCT.md names these directly: "Generic SaaS (rounded cards, gradients, startup illustrations)," "Electron bloat (Slack — heavy, slow-feeling, too much chrome)." - **Don't** use hacker-aesthetic green-on-black, terminal-cliché Matrix tints, or any color that signals "this is a programmer tool." The user's theme decides what color this tool is. -- **Don't** animate layout properties (`width`, `height`, `top`, `left`, `padding`) **with a CSS transition**. Pane transitions use `clip-path` and `opacity` deliberately so layout measurements stay valid mid-animation. The one carve-out is a JS tween that writes true per-frame values on a `pointer-events: none` overlay (the Lath animator; the focus ring's `rect-tween`): it moves through real intermediate geometry every frame rather than letting the browser interpolate an opaque box, so measurements stay valid — a CSS `transition: top/left/width/height` does not qualify and stays banned. +- **Don't** animate layout properties (`width`, `height`, `top`, `left`, `padding`) **with a CSS transition**. Pane transitions use `clip-path` and `opacity` deliberately so layout measurements stay valid mid-animation. The one carve-out is a JS tween that writes true intermediate geometry every frame (the Lath animator on the leaves; the `rect-tween` that carries the focus ring and the copy editor) rather than letting the browser interpolate an opaque box, so measurements stay valid mid-flight — a CSS `transition: top/left/width/height` does not qualify and stays banned. - **Don't** add an emoji, mascot, or illustration to chrome. PRODUCT.md is explicit: "Overly playful (too many animations, emojis, mascots)." - **Don't** gate app chrome on `window.alert` / `confirm` / `prompt`. Native dialogs are not dependable in the desktop webview: the theme uninstall was gated on `confirm` and silently did nothing there, because the call returned without ever showing a dialog. Whether a given webview suppresses the panel or never implements it, a control gated on one cannot be trusted to run. Use `ModalFrame`, or make the action a single click when it is cheap and reversible. The marketing website is exempt — it only ever runs in a real browser, where `ShareUrlButton`'s `prompt` is a reasonable last-resort clipboard fallback. - **Don't** wrap things in containers. Most surfaces don't need one; the host's sidebar already is the container. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 771fb6c62..921da3f04 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -66,7 +66,7 @@ Source of truth: `useHeldWhile` in `lib/src/components/wall/preview-transition.t **Must open the terminal context from terminal header, body, and command-mode `a` and `>` entry points.** Browser-only Surfaces and Doors have no context. Tool context displays its primary terminal; `docs/specs/terminal-context.md` → Tool context owns that composition. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. -**Must render one context per Wall in a stable Wall-level overlay**, with a theme-derived edge and raised shadow. Anchor it to the invoking source and follow its painted bounds without resizing panes or remounting the helper. Outside pointer press and explicit close dismiss it. +**Must render one context per Wall in a stable Wall-level overlay**, with a theme-derived edge and raised shadow. Anchor it to the invoking source and follow its painted bounds without resizing panes or remounting the helper. Outside pointer press and explicit close dismiss it; its copy editors count as inside (`anchoredTarget` in `lib/src/lib/dom.ts`). **Must choose placement on opening and retain its side while usable.** Never reposition in response to terminal output. Minimized panes do not count; zoom uses single-pane placement. @@ -255,7 +255,7 @@ Each Wall renders one Workspace's Content and Baseboard (doors). Standalone moun - **Must mount every Workspace's Wall in one grid cell**, inactive Walls `inert`, then `visibility:hidden` after their fade and never `display:none` (rationale). - **Must preserve mounted leaves across switches**: no re-seed, no re-parent, no leaf unmount, and no `resumeTerminal` / `restoreTerminal`; the only mount work is the terminal reattach below, which replays nothing, so I8 holds by construction (`lib/src/components/WorkspaceWindow.test.tsx`). - **A hidden Wall's terminals hold no element and no GL context**: completion of the outgoing fade runs `unmountElement` on every terminal pane, exactly as minimize does ([Renderer](#renderer)); activation runs `mountElement` and fits through the [Animations](#animations) gate, so an unchanged grid sends no PTY resize (`lib/src/components/TerminalPane.test.tsx`). Browser Surfaces keep their live documents (rationale). -- **A hidden Wall consumes no window input**: every listener that dispatches, forwards, or `preventDefault`s window input is gated on `active`, keyboard and custom events alike, so a hidden Wall neither mounts chrome nor refits a detached element in answer to one. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation and a terminal's selection popup (rationale): a staged prompt survives the switch and is answered only where the user can see it. +- **A hidden Wall consumes no window input**: every listener that dispatches, forwards, or `preventDefault`s window input is gated on `active`, keyboard and custom events alike, so a hidden Wall neither mounts chrome nor refits a detached element in answer to one. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation and a terminal's copy editor (rationale): a staged prompt survives the switch and is answered only where the user can see it. - **Exactly one Wall answers a `dor` request**, chosen by `docs/specs/dor-cli.md` → "Handle Model". Every Wall registers a handle, a bare one under `DEFAULT_WORKSPACE_ID`, so the router always finds one. - **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). **The deadline refuses rather than reporting clean**, and the walk re-reads membership until nothing is left, so a Surface born behind it is closed too. - **A closing Workspace takes no new Surfaces**: while `closeAll` walks, this Wall answers every Surface-creating `dor` verb with an error (`docs/specs/dor-cli.md` → "Handle Model"), **rechecked after any host round trip the verb makes before creating** (`CREATING_CONTROL_METHODS` in `lib/src/components/wall/use-dor-control.ts`; `lib/src/components/WorkspaceWindow.test.tsx`). @@ -420,11 +420,11 @@ Source of truth: `lib/src/lib/ring-geometry.ts`. ### Position tracking -Each pane body registers its DOM element in a `paneElements` Map on mount and removes it on unmount (`usePaneChrome`); the overlay resolves the enclosing Lath leaf (`[data-lath-leaf]`) via `resolvePaneElement`, so the ring covers header + body. Doors are registered by the `Baseboard` through `DoorElementsContext` (`[data-door-id]`), **only the *visible* subset** — an overflowed door has no element to measure. +Each pane body registers its DOM element in a `paneElements` Map while mounted (`usePaneChrome`); the overlay resolves the enclosing Lath leaf (`[data-lath-leaf]`) via `resolvePaneElement`, so the ring covers header + body. Doors are registered by the `Baseboard` through `DoorElementsContext` (`[data-door-id]`), **only the *visible* subset** — an overflowed door has no element to measure. -Re-measures on: selection change, target resize, scroll, window resize, Workspace changes, every Lath store commit, and each Lath animation frame. **Must hold the last painted frame when the target is missing, detached, or zero-sized**, including stale Door observer notifications during restore. Pinned by `restores from the last painted Door through a %s target` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`. +Re-measures on: selection change, target resize, an ancestor's scroll, window resize, Workspace changes, every Lath store commit, and each Lath animation frame. **Must hold the last painted frame when the target is missing, detached, or zero-sized**, including stale Door observer notifications during restore. Pinned by `restores from the last painted Door through a %s target` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`. -Source of truth: `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`; `resolvePaneElement` in `lib/src/components/wall/resolve-pane-element.ts`; `WindowFocusedContext` in `lib/src/components/wall/wall-context.tsx`, which the overlay reads and the Wall fills from `useWindowFocused` in `lib/src/components/wall/use-window-focused.ts`. +Source of truth: `lib/src/components/wall/WorkspaceSelectionOverlay.tsx`; `subscribePaneMotion` in `lib/src/components/wall/pane-motion.ts`; `resolvePaneElement` in `lib/src/components/wall/resolve-pane-element.ts`; `WindowFocusedContext` in `lib/src/components/wall/wall-context.tsx`, which the overlay reads and the Wall fills from `useWindowFocused` in `lib/src/components/wall/use-window-focused.ts`. ## Spatial navigation diff --git a/docs/specs/mobile-terminal-ui.md b/docs/specs/mobile-terminal-ui.md index c8c71a1ab..1697be8f6 100644 --- a/docs/specs/mobile-terminal-ui.md +++ b/docs/specs/mobile-terminal-ui.md @@ -101,6 +101,9 @@ Event routing, by mode: alone so it still reaches the terminal, and real mouse pointers fall through untouched. (rationale) +**Never treat a portaled descendant as pane content**: a press outside the +host's DOM starts no touch mode, tap, or keyboard dismissal. + **Must release a tracked Mouse-mode press on pointerup or cancel even after leaving Mouse mode.** Cancellation releases on the last target. @@ -110,7 +113,7 @@ the pointer, and **must never reach xterm or the pane** for focus, selection, or pane interaction. **Non-primary mouse buttons are ignored**, so their browser or host behavior continues. -Source of truth: `TOUCH_MODES` and `paneMouseOverride` in +Source of truth: `TOUCH_MODES`, `paneMouseOverride`, and `isPortaledTarget` in `lib/src/components/MobileTerminalUi.tsx`; per-pane wiring in `lib/src/remote/pocket-app/PocketWall.tsx` and `website/src/components/PocketTerminalExperience.tsx`. diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index be1dc9d4c..4891d1f37 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -64,6 +64,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1). ### 3.1 Initiating a Selection +- **A selection edge is the cell boundary nearest the pointer**, as xterm.js's own selection reads it: the earlier edge (reading order, or column order for a block) takes the cell after its boundary, the later edge the cell before (rationale). Source of truth: `dragCells` in `lib/src/lib/drag-cells.ts`, pinned by its test. - **Must begin selection after a click-and-drag crosses ~4px**; plain clicks shift pane focus or activate hyperlinks. **Must capture mouse presses on xterm’s screen immediately** (rationale); that capture, and the plain click it must not break, are pinned by `lib/src/lib/terminal-mouse-router.test.ts`. - On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored. - The selection draws as a single perimeter outline tracing the union of selected cells (§7 owns rendering). Color is `--color-focus-ring` (`docs/specs/theme.md`), with a hardcoded cornflower-blue final fallback in `SelectionOverlay.tsx`. @@ -87,13 +88,17 @@ A small hint sits adjacent to an in-progress selection — below when dragging d A selection is anchored to the characters under it, not to screen coordinates: stored in absolute buffer rows (scrollback + viewport). - **Pure scroll** — vertical translation with no character changes — carries the selection along; coordinate math only, no matching. -- **Content change:** any change to a cell the finalized selection overlaps cancels it immediately; repaints elsewhere on screen are irrelevant. A text snapshot taken at finalize is compared on each xterm render; **never add a partial-match or content-tracking heuristic** — cancel-on-change is the rule (§9.1). -- **Terminal resize** counts as a content change and cancels any active selection. +- **Content change:** any change to a cell the finalized selection overlaps cancels it immediately; repaints elsewhere on screen are irrelevant. A text snapshot, retaken whenever the selection is finalized or moved (§4.3), is compared on each xterm render; **never add a partial-match or content-tracking heuristic** — cancel-on-change is the rule (§9.1). +- **Terminal resize** carries a finalized linewise selection Dormouse owns, in the normal buffer, through xterm's reflow (rationale). Its editor stays open in the same format and same-labeled scope, else As selected; per-break edits drop unless the width held. + - **Must cancel if an edge's line was trimmed or its cells no longer read as the selected text** (rationale). + - **Must cancel any other.** + +Source of truth: `anchorSelection` and `followReflow` in `lib/src/lib/selection-reflow.ts`, pinned by `lib/src/lib/selection-reflow.test.ts`; `watchSelection` in `lib/src/lib/selection-watch.ts` and `followCopySelection` in `lib/src/lib/copy-editor.ts`, pinned by `a terminal resize` in `lib/src/lib/terminal-lifecycle.selection.test.ts`. ### 3.5 Selection in the Live Region vs. Scrollback - **Scrollback selection is always available**, whatever the reporting or override state; live-region availability follows §6.1's matrix. -- **Crossing the boundary:** a drag beginning in scrollback and continuing into the live region is a single continuous selection. A drag beginning in the live region under mouse reporting, with no override, goes to the inside program instead. +- **Crossing the boundary:** a drag beginning in scrollback and continuing into the live region is a single continuous selection. A drag beginning in the live region under mouse reporting, with no override, goes to the inside program instead, shadowed (§3.8). ### 3.6 During a Drag @@ -103,55 +108,135 @@ Source of truth: `lib/src/components/wall/keyboard/handle-mouse-selection-keys.t ### 3.7 Ending a Selection -- Releasing the button ends the drag and fixes the selection; the popup (§4) appears. -- It persists until something ends it: a completed copy, a content change (§3.4), **Esc**, or a click outside (§4.3). -- **A new mouse-down in the terminal content area replaces any existing selection immediately** and dismisses its popup. +- Releasing the button ends the drag and fixes the selection; the copy editor (§4) opens. +- It persists until the editor is dismissed (§4.5). +- **A new mouse-down in the terminal content area replaces any existing selection immediately**, its editor with it. + +### 3.8 Drags the Inside Program Owns + +A primary mouse drag that reaches the inside program (§6.1) is **shadowed**: its events reach the program untouched, and on release the cells it crossed become a linewise selection the program owns. + +- **Must never consume, delay, or reorder a shadowed drag's events**; only a press that crosses the drag threshold counts, so a program click shadows nothing. +- It draws no outline, only a `Press Cmd+C to copy` hint (Ctrl+C on non-macOS): the program paints its own highlight. +- **The copy chord opens the copy editor over it** (§4), outline included. Any input the program receives drops the shadow (§4.5), as does the program ending mouse reporting. +- Touch never shadows; a touch drag over a reporting program takes §6.1's rows. + +Source of truth: `finishProgramDrag` in `lib/src/lib/terminal-mouse-router.ts`, pinned by `lib/src/lib/terminal-mouse-router.test.ts`; the chord in `handleMouseSelectionKeys` in `lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts`. --- -## 4. Selection Popup +## 4. Copy Editor + +Mouse-up over a terminal-handled drag opens the **copy editor**, as does the copy chord over a shadowed one (§3.8): the text a copy would produce, every line break the selection crossed marked (rationale). + +### 4.1 Formats + +| Format | Clipboard text | +|---|---| +| **Auto** (opens here) | Decoration stripped, each break judged on its own (§4.1.1). | +| **Exact** | As displayed: selected rows joined by `\n`, each trimmed of trailing whitespace — soft-wrapped rows included. | +| **Spaces** | Decoration stripped, blank lines dropped, every break one space, continuation indents removed. | +| **No breaks** | As Spaces, every break deleted. | + +**Decoration** is a frame-only line (dropped), a leading or trailing run of box drawing (`U+2500–U+259F`, Box Drawing and Block Elements), and a TUI's leading bullet (`⏺`, `⎿`, `●`). **Must read cells through xterm's wide-character continuation cells.** **A deleted soft wrap joins its rows exactly**, keeping a blank on either side of it. **Every judgement reads a soft wrap's rows as one line**, indented as its first row (rationale). **Never rewrap a block-shape selection**: it is a rectangular slab, so Auto reads it as Exact, and it has no wider scope (§4.2) and no edge keys (§4.3). + +Source of truth: `render` in `lib/src/lib/copy-text.ts`, pinned by `lib/src/lib/copy-text.test.ts`. + +#### 4.1.1 Auto -A finalized selection gets a popup of action buttons anchored where §3.3's drag hint sat. +Each break between consecutive rows is, in this order: + +1. **Deleted** if the next row is a true soft wrap (xterm's `isWrapped`). +2. **Kept** if either line is blank, the next starts a list item, this one ends in `;` `{` `}` or the next starts with `)` `}` `]`, or the next line's indent is not this line's hanging indent. +3. **Kept** if the paragraph's longest line is under 40 columns (60% of a narrower terminal), or the next line's first word would have fit on this line within that longest line (rationale). +4. **Deleted** if this line fills that width and its last word is token-shaped (a URL or path character, or 16+ token characters) — a token split at the margin. +5. Otherwise **one space**. + +**Must trim leading/trailing blank lines, collapse blank runs, and remove shared indent**, keeping relative indent. **Must use full row indentation for mid-line starts.** + +Source of truth: `autoBreak` in `lib/src/lib/copy-text.ts`. + +### 4.2 Scopes + +**Must offer distinct scopes containing the drag, narrowest first.** + +| Scope | Covers | +|---|---| +| **As selected** | The drag (opens here). | +| **Whole words** | Each edge grown over its token, across any row break Auto deletes; an edge on a blank grows nothing. Named **Full URL** or **Full path** when the §5.1 detector classifies a grown edge token so. | +| **Paragraph** | The lines between blank lines, frame-only lines, and box sides (`│ ┃ ║`); an edge on a boundary never crosses it. | + +The selection overlay draws a wider scope dashed around the outline; the preview marks every cell outside the drag. Source of truth: `computeScopes` in `lib/src/lib/copy-text.ts`. + +### 4.3 Keys + +| Key | Effect | +|---|---| +| `Cmd+C` (Ctrl+C on non-macOS), with or without Shift | Copy what the editor shows, in either mode. | +| `e` / `Shift+E` | Next wider / narrower scope, stopping at either end. | +| `f` / `Shift+F` | Next / previous format, wrapping, in table order (§4.1), then the program's own copy (§4.6). | +| `←` `→` / `Shift+←` `→` | Move the end / start one word; from whitespace, land on the adjacent word. Clamp past-text edges before stepping. A row boundary ends a word. Returns to As selected, keeping the format. | +| `Enter` | Copy, as the chord does. | +| `Esc` | Close and cancel the selection. | -### 4.1 Copy Buttons +**Every key but the copy chord is the editor's in passthrough only**; command mode keeps its own. Any other key goes to the terminal, which closes the editor (§4.5). **Intercept Ctrl+C only while the editor is open or a shadowed drag waits for it** (§3.8); otherwise it reaches the inside program (SIGINT for shells, app-defined for TUIs). A selection a TUI makes from the keyboard (vim visual mode, less search highlight) is neither, and does not change that routing. The editor's hints write Shift and the arrows as the mobile compass rose does (`⬆︎` `◀` `▶`), and touch shows none. -Source of truth: `lib/src/components/SelectionPopup.tsx` (Copy Raw, Copy Rewrapped, platform-dependent shortcut labels). +Source of truth: `handleMouseSelectionKeys` in `lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts`, pinned by its test; the transitions in `lib/src/lib/copy-editor.ts`, pinned by `lib/src/lib/copy-editor.test.ts`. -#### 4.1.1 Copy Raw +### 4.4 Preview and Marks -**Must preserve displayed row breaks and decorative characters**, trimming trailing whitespace on each selected row; soft-wrapped rows also get `\n`. Source of truth: `extractSelectionText` in `lib/src/lib/selection-text.ts`. +- One gutter-numbered row per clipboard line; leading whitespace shows as `·`. +- Every break is a mark — `⏎` kept, `␣` one space, `⌁` deleted. **A click cycles it keep → space → none**, and the format then reads `Auto*`. A scope or format change, or a nudge, discards those edits. +- A format whose text an earlier one already gives is dimmed. -#### 4.1.2 Copy Rewrapped +### 4.5 Placement and Dismissal -Copies with two transformations applied (`lib/src/lib/rewrap.ts`): +- **Must render into `document.body` at `COPY_EDITOR_Z_INDEX`**: above the selection ring, below every `MODAL_LAYERS` value (rationale). +- It takes the first spot holding it whole in the window, less `OVERLAY_VIEWPORT_MARGIN_PX`. All but the last stay clear of the **band**, the rows of the selection and its scope: -1. **Drop frame-only lines**, and **strip leading/trailing runs of box-drawing characters** (`U+2500–U+259F`, both Box Drawing and Block Elements) from each remaining line. -2. **Group remaining lines into paragraphs** at blank lines: lines within a paragraph join with a single space (unwrapping display wrapping), paragraphs join with `\n\n`. + | Rank | Desktop | Touch | + |---|---|---| + | 1 | Below the band | Above, clear of the thumb that ended the drag | + | 2 | Above the band | Below | + | 3 | Beside the pane, the roomier side | The same | + | 4 | Squished into whichever of below, above, and the two sides gives the most area, if 120px tall (rationale) | The same | + | 5 | Over the band at the pane's bottom, at most 60% of the window | The same | -**Never rewrap a block-shape selection** — it is an intentionally rectangular slab, so Copy Rewrapped falls back to its raw text. +- **Must be at least as wide as the pane and the chrome** (header and footer, never wrapped, measured at their widest in any format so `f` never resizes the editor), widening with the space to the longest line in any format of the scope, clamped to the window or the side's room (rationale). Above and below may cover neighbors. +- **Must clip the key hints and legend before the segments, the count, or Copy; a side needs room only for those three** (rationale). +- **Must re-place on every selection or scope change; never keep a spot that covers the selection while another fits.** A held spot, natural or squished, yields only to one better by `HYSTERESIS_PX` (rationale). +- **Must ease every move, restarting from the displayed rect; opening snaps**, as does any move under `motionIsInstant()` (rationale). **Must follow its pane at most once a frame** (`docs/specs/layout.md` → "Position tracking"). +- **Never show while its Wall travels**, its pane hidden, or another pane zoomed over it. +- **Never take focus**; only an actual scrollbar press keeps its default (rationale). Presses inside it count as inside its pane (`anchoredTarget`); its `mousedown` and `contextmenu` never reach the pane. +- **Must give the touch editor a `TOUCH_SLOP_PX` hit margin** (rationale) **and `touch-action: manipulation`.** +- **Esc**, a click outside the editor, a content change or a resize it cannot follow (§3.4), a confirmed copy, or **any input the terminal receives** — typing, a paste, Pocket's input bar — dismisses it and cancels the selection. Source of truth: `writeUserInput` in `lib/src/lib/terminal-lifecycle.ts`, pinned by `lib/src/lib/terminal-lifecycle.selection.test.ts`. +- **Must flash only after a successful clipboard write, and only for the selection copied**: Copy reads ✓ Copied, never moving, and the copied selection fills, pulsing unless `motionIsInstant()`; after `COPY_FLASH_MS` (700 ms), `TOUCH_COPY_FLASH_MS` (1200 ms) on touch, the selection clears, however it moved meanwhile (rationale). Canceling clears the flash immediately. +- **Must leave empty copies idle without writing.** **Must say a failed write failed**: Copy reads Couldn't copy for `COPY_FAILED_MS` (1500 ms) and the selection stays for a retry. Without the Clipboard API, or refused by it, the write first falls back to `execCommand('copy')` (rationale). -### 4.2 Keyboard Shortcuts +Source of truth: `placeCopyEditor` in `lib/src/lib/copy-editor-placement.ts`, pinned by `lib/src/lib/copy-editor-placement.test.ts`; `createRectMotion` in `lib/src/components/rect-motion.ts`; `anchoredTarget` in `lib/src/lib/dom.ts`; `CopyEditor` in `lib/src/components/CopyEditor.tsx`, pinned by `moves off a wider scope whose band reaches it`, `lays its chrome probe out the same in every format`, `eases a move from the displayed rect, after opening snapped`, `hides while its Wall travels, and shows again when the travel ends`, and `never takes focus from the pane, but leaves its scrollbar its drag` in `lib/src/components/CopyEditor.test.tsx` and the plays in `lib/src/stories/CopyEditorPlacement.stories.tsx`; `copySelection` in `lib/src/lib/copy-selection.ts`, pinned by `lib/src/lib/copy-editor.test.ts`; `writeTextToClipboard` in `lib/src/lib/clipboard.ts`, pinned by `lib/src/lib/clipboard-write.test.ts`; the fill in `SelectionOverlay` in `lib/src/components/SelectionOverlay.tsx`, pinned by `lib/src/components/SelectionOverlay.test.tsx`; `TOUCH_SLOP_PX` in `lib/src/components/CopyEditor.tsx`, pinned by `CopyEditor: touch slop` in `lib/src/components/CopyEditor.test.tsx`. -With an active, finalized terminal selection, popup focused or not: **Cmd+C** (Ctrl+C on non-macOS) triggers Copy Raw, **Cmd+Shift+C** (Ctrl+Shift+C) triggers Copy Rewrapped. +### 4.6 The Program's Own Copy (OSC 52) -**Intercept Ctrl+C as Copy Raw only while a terminal selection is active.** With none it is forwarded to the inside program as usual (SIGINT for shells, app-defined for TUIs). An in-program selection a TUI maintains itself (vim visual mode, less search highlight) is **not** a terminal selection and does not change that routing. +An `OSC 52` clipboard write from the inside program is never the clipboard. It becomes an **offer** the editor can show (rationale): -### 4.3 Dismissing the Popup +1. The owner's parser decodes the base64 as UTF-8, turns `\r\n` and `\r` into `\n`, and removes every other control character but tab. **Must drop, never truncate, a payload over `CLIPBOARD_OFFER_LIMIT` base64 characters** (rationale); a `?` read is never answered, and an empty or malformed write offers nothing. The sequence is consumed either way. +2. The host sends it to the owning renderer as `terminal:clipboardOffer` (`docs/specs/transport.md`); replay re-parses output without offers. +3. **Must accept an offer only into a pane whose selection the program owns** (§3.8), shadowed or open in the editor, the latest replacing any earlier; it goes with that selection. +4. The editor then offers a fifth format, **From ** (the running command as WATCHING keys it, `docs/specs/alert.md`, else `program`), last in `f` order. **It has no scope**: choosing it returns to As selected, and `e` does nothing while it shows. Its marks still flip, and a nudge returns to Auto. **Never write an offer to the clipboard except as that format, chosen and copied by the user.** -- **Esc**, or a click outside the selection, dismisses the popup and cancels the selection. -- **Must flash only after a successful clipboard write, and only for the selection copied**: swap the active button's shortcut text for a checkmark for ~700 ms, then clear the selection. Failed writes retain it for retry; canceling clears the flash immediately. Touch shows the checkmark without a shortcut label. Pinned by `lib/src/components/SelectionPopup.test.tsx`. +Source of truth: `parseOsc52` and `CLIPBOARD_OFFER_LIMIT` in `lib/src/lib/terminal-protocol.ts`, pinned by `lib/src/lib/terminal-protocol.test.ts`; `offerProgramCopy` in `lib/src/lib/mouse-selection.ts`, pinned by `lib/src/lib/mouse-selection.test.ts`; `editorFormats` in `lib/src/lib/copy-editor.ts`. --- ## 5. Smart Extension (URL / Path Detection) -Offered **mid-drag**, alongside the Alt block modifier (§3.2–§3.3): each drag update re-examines the cell under the cursor for a URL- or path-shaped token, and offers **e** to extend the selection over the whole token. +**Must re-examine the URL/path token under the cursor on every drag update, never reusing an answer for an unchanged cell.** Offer **e** to extend over it, alongside Alt (§3.2–§3.3). ### 5.1 Detection -A token is whitespace-delimited. Trailing characters unlikely to be part of it — `.`, `,`, `;`, `:`, `!`, `?`, single quotes, double quotes — are stripped from its end, along with unmatched closing brackets (`)`, `]`, `}`, `>`); matched pairs are preserved. **Strip before pattern matching, never after** (rationale). +A token is whitespace-delimited and **runs on across soft wraps**. Trailing characters unlikely to be part of it — `.`, `,`, `;`, `:`, `!`, `?`, single quotes, double quotes — are stripped from its end, along with unmatched closing brackets (`)`, `]`, `}`, `>`); matched pairs are preserved. **Strip before pattern matching, never after** (rationale). -**Must map detection offsets through xterm cells**, preserving wide characters, combining marks, and multi-codepoint emoji. Source of truth: `detectTokenInBufferLine` in `lib/src/lib/smart-token.ts`, pinned by `lib/src/lib/smart-token.test.ts`. +**Must map detection offsets through xterm cells**, preserving wide characters, combining marks, and multi-codepoint emoji, skipping wrap padding. Source of truth: `detectTokenInBuffer` in `lib/src/lib/smart-token.ts`, pinned by `lib/src/lib/smart-token.test.ts`. Source of truth: `PATTERNS` in `lib/src/lib/smart-token.ts` — the detected shapes in priority order, error locations (`:line[:col]`) ahead of the generic path patterns. The generic patterns require an anchor (`~/`, `/`, `./`, `../`, or a drive letter), so a bare relative path like `src/foo.ts` qualifies only in its error-location form. @@ -162,8 +247,9 @@ A second line on the block-selection hint names the detected kind — URL or pat ### 5.3 Extension Action - **e** during a drag, while the hint is visible, extends the selection over the full detected token: the anchor is preserved, the far end moves to the token boundary away from it. The drag then continues normally — movement updates the selection from the new boundary, Alt still toggles block shape. -- **e** with no qualifying token is consumed (per §3.6) but extends nothing, and has no effect once the drag has ended (once the popup has appeared, §4); on release the selection is finalized at whatever boundaries the drag, `e`-extensions included, produced. -- **Only this single extension step is offered** — no multi-level extension, no "open URL" action (§9.1). +- **e** with no qualifying token is consumed (per §3.6) but extends nothing; once the drag has ended, `e` is the editor's expand instead (§4.3). On release the selection is finalized at whatever boundaries the drag, `e`-extensions included, produced. +- **Only this single extension step is offered mid-drag**, and no "open URL" action (§9.1); the editor's scopes are the wider steps (§4.2). +- **Must preserve extension on keys that leave the shape unchanged.** Pinned by `lib/src/lib/terminal-mouse-router.test.ts`. --- @@ -176,7 +262,7 @@ Where a drag goes; **Terminal** means the terminal's own selection. | Program requests mouse | Override | Live-region drag | Scrollback drag | |---|---|---|---| | No | — | Terminal | Terminal | -| Yes | No | Inside program | Terminal | +| Yes | No | Inside program, shadowed (§3.8) | Terminal | | Yes | Temporary | Terminal, ends on mouse-up | Terminal | | Yes | Sticky | Terminal | Terminal | @@ -199,9 +285,9 @@ Source of truth: `terminalOwnsEvent` in `lib/src/lib/terminal-mouse-router.ts`, **Must keep selection and hint updates from rerendering pane headers or override banners** (rationale). -- **Must render outlines, hints, and popups above the cell grid**, isolated from inside-program output and redraws; header icons and banners remain persistent chrome. +- **Must render outlines and hints above the cell grid**, isolated from inside-program output and redraws; header icons and banners remain persistent chrome. - **Geometry comes from the *measured* xterm cell grid** (`cellWidth`/`cellHeight`/`gridLeft`/`gridTop`), never element-width ÷ cols, so the outline stays aligned across xterm's internal padding. -- **Must remeasure both overlay and popup on every shared render tick** (scroll, resize, output), even when the selection is unchanged; the popup dismisses if the selection is canceled. Pinned for the popup by `lib/src/components/SelectionPopup.test.tsx`. +- **Must remeasure the overlay and the editor on every shared render tick** (scroll, resize, output), even when the selection is unchanged (the editor: §4.5). Source of truth: `lib/src/lib/selection-text.ts` (extraction and normalization), `lib/src/lib/selection-geometry.ts` (perimeter construction), `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx` and `MouseOverrideBanner` in `lib/src/components/wall/MouseOverrideBanner.tsx` — tested in `lib/src/components/wall/mouse-chrome.test.tsx`. @@ -215,7 +301,7 @@ Source of truth: `lib/src/lib/selection-text.ts` (extraction and normalization), ### 8.2 Paste Keybindings -**`Cmd/Ctrl (+Shift) + V` — all four combinations, on every platform — are intercepted and paste** (`hasPasteModifier`); copy keeps the macOS separation instead (§4.2). The price: the raw control byte `0x16` (readline `quoted-insert`, vim literal-next) never reaches the program by this key — §8.3 is the escape hatch. (rationale) +**`Cmd/Ctrl (+Shift) + V` — all four combinations, on every platform — are intercepted and paste** (`hasPasteModifier`); copy keeps the macOS separation instead (§4.3). The price: the raw control byte `0x16` (readline `quoted-insert`, vim literal-next) never reaches the program by this key — §8.3 is the escape hatch. (rationale) Source of truth: `lib/src/components/wall/keyboard/chords.ts`. @@ -225,7 +311,7 @@ Because Ctrl+V is intercepted everywhere, a literal control character goes in th ### 8.4 Platform Detection -**`IS_MAC` (`lib/src/lib/platform/index.ts`) is computed once at startup** from `navigator.userAgentData.platform`, else `navigator.platform`, matched against `/Mac|iPhone|iPad/i`. It gates the copy chord (§4.2), every platform-dependent label, and the app's own macOS chrome (the AppBar's traffic-light inset, the VS Code workbench chord map) — **the paste chord alone is platform-independent** (§8.2). +**`IS_MAC` (`lib/src/lib/platform/index.ts`) is computed once at startup** from `navigator.userAgentData.platform`, else `navigator.platform`, matched against `/Mac|iPhone|iPad/i`. It gates the copy chord (§4.3), every platform-dependent label, and the app's own macOS chrome (the AppBar's traffic-light inset, the VS Code workbench chord map) — **the paste chord alone is platform-independent** (§8.2). ### 8.5 Bracketed Paste @@ -308,13 +394,12 @@ Not implemented today; they may be added in response to user feedback. - Auto-scroll during a drag that reaches the viewport edge. - Double-click to select word, triple-click to select line. -- Copy modes beyond Raw and Rewrapped (strip ANSI, strip line numbers, strip prompts, join hyphenated line-breaks). -- Contextual popup actions (Open URL, Open in `$EDITOR`, Copy hash). -- Multi-level `e` extension (token → line → paragraph). +- More formats (strip line numbers, strip prompts, join hyphenated line-breaks, Markdown rebuilt from styling) and scopes (a command's output from OSC 133 marks, a TUI message). +- Contextual editor actions (Open URL, Open in `$EDITOR`, Copy hash). - A "quiet mode" setting to suppress hints for experienced users. - Content-matching selection tracking when the underlying content changes (today: cancel-on-change). - Keyboard activation of the mouse icon and banner buttons. -- Refining the Copy Rewrapped heuristics based on dogfooding. +- Refining Auto's heuristics based on dogfooding. ### 9.2 Paste diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index 51279d5bd..0bfefa58b 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -4,10 +4,60 @@ ## 3.1 Initiating a Selection +**Why edges are the nearest boundary.** Edges were once the cell under the pointer, inclusive at both ends. People aim between characters, and a release just past a word's last character lands in the next cell, so the end of a selection was usually one character too far while the start looked right (reported 2026-09-30, both drag directions). Nearest-boundary edges are what xterm.js's `MouseService` uses for its own selection (`ceil((x + cellWidth / 2) / cellWidth)`, 1-based). + **Why mouse capture targets xterm’s screen.** Reproduced with Codex CLI 0.153.4 in the Chromium innerdogfood harness (2026-09): Codex emitted valid OSC 8 links and xterm recognized their targets. Capturing on Dormouse's wrapper at pointerdown retargeted mouseup outside xterm's screen and cleared its hovered link, preventing activation. Capturing on xterm’s screen keeps the release on the link-handler path. Deferring capture until the drag threshold restored clicks too, but lost a selection whose first coalesced movement left the iframe; early screen capture preserves both behaviors. An innerdogfood iframe probe starting 2 px inside the top edge and moving directly to −30 px delivered the move and release to `.xterm-screen` and finalized the selection (Chromium, 2026-09). **How a mouse-up outside the iframe still reaches us.** Capture is taken on mouse-down, and Chromium delivers the captured `pointerup` across the frame boundary even when the button comes up over host chrome. Engines that do not honor cross-frame capture deliver nothing, so a window `mousemove` reporting `buttons === 0` stands in for the missed mouse-up: a pointer still holding the button reports `buttons === 1`, so the heal cannot fire mid-drag, but it does need the pointer to re-enter the frame. Against double-finalizing, the captured-pointerup path defers to a macrotask and stands down if the compatibility mouseup for an *inside* release arrives first. +## 3.4 Selection Follows Content + +**Why a marker on the logical line's first row.** Checked in `@xterm/xterm` 6.1.0-beta.304 (`Buffer.ts`, `BufferReflow.ts`, 2026-09): narrowing inserts a logical line's new continuation rows after its last row and fires the insert there, and widening deletes its emptied continuation rows; neither touches the first row, so a marker on it survives and shifts with whatever reflowed above. A marker on a continuation row is disposed when widening empties that row. Trimming the top disposes a marker whose row goes, and its line then reads -1, which xterm's `getLine` resolves cyclically to the newest line of a full ring. + +**Why offsets skip wrap padding.** A wide character that does not fit a row's last cell wraps whole, leaving that cell blank, so reflow adds and removes the blank as the wrap points move. + +**Why the text check is not a matching heuristic.** It never searches: the new position is coordinate math from the markers, as a scroll is, and the comparison only keeps or cancels the result. It catches what coordinates cannot see. xterm leaves the cursor's own logical line unreflowed, so narrowing truncates a wrapped prompt line. Writes still queued when the resize arrives are parsed synchronously first, with no render between to cancel on. The comparison joins soft wraps, skips wrap padding, and trims each logical line's trailing blanks, which are all a reflow alone changes, so a pure reflow always passes. + +## 4. Copy Editor + +The editor replaced a two-button Copy Raw / Copy Rewrapped popup. Three shapes were prototyped side by side as Storybook stories (2026-09, since deleted; commit `8139a27eb`): a numbered chooser, copy-then-show-a-receipt, and this editor. The editor won because restating the selection at full width with every break visible is what lets a user see why a paste came out wrong. Its keys are letters rather than digits: a receipt that took digits after a copy collided with TUIs that answer prompts by number (Claude Code's `1` / `2` / `3` permission menu), and `e` already meant "extend" mid-drag (§5). + +## 4.1 Formats + +**Why a soft wrap's rows are one line.** Narrowing a pane, as a split does, makes xterm reflow every row wider than the new width onto soft-wrapped continuation rows, and §3.4 keeps an open editor through the resize. Judged by rows, the Claude reply fixture narrowed from 80 to 60 columns kept every break of its first paragraph: each continuation row (` the final flush`) brought its own indent, which missed the next row's hanging indent, and its own length, which always left room for the next word. The same rows put the shared indent at 0, keeping the code block indented, because a continuation row starts wherever the wrap fell. A blank continuation row, a run of spaces as wide as the pane, read as a paragraph break and split Paragraph and Auto there. Judged by lines, the narrowed reply reads as the original does (`reads a reply the terminal narrowed` in `copy-text.test.ts`, 2026-10). The trade: a program's single long line that soft-wraps is judged as a pane wide enough to show it would judge it, as its paragraph's widest line and so wrapped, where by rows its short continuation row usually kept the break after it. + +## 4.5 Placement and Dismissal + +**Why window-wide.** Inside its pane's `overflow-hidden` box the editor could only take the pane's width, above or below the selection, and with neither side 120px tall it docked over the very text being checked: a short pane, or a selection near its edge, hid what the user was copying (Ned, 2026-09). On `document.body` it can spill over a neighboring pane or sit beside its own, so covering the selection is a last resort instead of the common case in a split layout. A portal still bubbles React events to the pane, which is why it stops its own `mousedown` and `contextmenu`; DOM `contains` and `closest` checks do not follow a portal, which is what `anchoredTarget` restores. + +**Why it never takes focus.** A focused button on `document.body` sent the editor's keys from outside every pane, so each key route keyed on the pane's DOM (the Wall's terminal-context routing, the selection keys, a Tool browser's forwarder, the context's Escape) needed `anchoredTarget`. Keeping focus where the drag left it needs none. The cost: a drag over the preview's text selects nothing. A scrollbar press targets the preview's box and needs its default drag. The preview therefore checks the hit position and overflow: padding and a reserved gutter without overflow also target that box, but keeping their default blurred xterm and lost subsequent typing (reviewed 2026-10). + +**Why the width cap spans every format.** Spaces and No breaks join lines, so the current format's longest line changes with `f`, and a width taken from it alone would resize the box on every press. The widest line any format shows is the one width that cycling never changes; a scope change or a nudge changes the text itself, so it may. + +**Why the chrome sets a floor.** The header and footer never wrap and the root clips them. Sized to a 19-column pane's short line, the editor was 188px wide in a window with about 1100px free: it showed one scope button, scrolled the selected format out of view, and lost the count and Copy (measured 2026-10). Their probe reserves what `f` or a copy would add, one `*`, the widest Copy label, and the widest count, for the same reason the cap spans every format. With key hints the chrome is about 520px, wider than a 480px window, so there the window clamps it and the hints and legend give way. + +**Why sides squish, by area, and need only the controls.** Probed in a 1440×900 window (2026-10), with a side accepted only when the editor fit it whole and only when it fit the chrome (about 522px with key hints): two columns over a 50-line preview squished 284px tall below, with a 704×876 side free; the middle of three 480px columns squished below, its 464px sides refused for the chrome; a 1000px pane over a tall selection docked the overlay over the text, with 424px free beside it. Ranking squishes by width × height compares a short full-width strip against a full-height column directly; the hysteresis is a strip of `HYSTERESIS_PX` rows at the held spot's width so that, between below and above, it is the height rule it replaced. A side narrower than the chrome loses only what a narrow window already clips, the key hints and legend. + +**Why a touch hit margin.** Driving the Pocket composition with real CDP touch at 390×844 (2026-10), a tap about 8px below the editor landed on the terminal, whose Select-mode press cleared the selection: the editor vanished with nothing copied, which looked exactly like a copy closing it. Retargeting to the nearest control could copy or change format unasked. Swallowing the press took three window-capture listeners and a flag carried across `pointerdown`, the compatibility `mousedown`, and the click (Chromium suppressed the canceled press's `mousedown` but still delivered its click), and was verified only on Chromium. A margin the editor owns makes every engine target the editor itself, whose presses are already inert. The trade: a mouse on Pocket gets the margin too, and desktop, where a click just outside is a deliberate dismissal, gets none. + +**Why the confirmation fills the selection, and lasts longer on touch.** In the same run the confirmation was a 12px check for 700 ms, inserted before the label so "Copy" jumped sideways, under the finger that tapped it. The label now swaps in place and the selection fills, where the eye is rather than under the finger; touch holds it 1200 ms, long enough to read once the finger lifts. Desktop keeps 700 ms: the chord or a click leaves the eye on the editor, and a longer hold only delays the terminal. + +**Why the `execCommand` fallback.** Over plain http (Pocket on a LAN origin) `navigator.clipboard` is undefined, and the write returned false with no flash and no message, indistinguishable from a missed tap. `execCommand('copy')` still works there, but only within the user activation, so the missing-API path runs before any await; after a refusal the activation may have lapsed and the fallback is best effort. It writes through a one-shot `copy` listener because selecting a hidden textarea moves focus: xterm's blur sends the program a focus report, and an inline rename commits. WebKit may fire no `copy` without a selection, so the textarea remains for that case. + +**Why restart rather than retarget.** The ring's `retargetRingTween` keeps the old clock and swaps the destination, which suits a ring converging on one moving target. The editor's destination jumps between spots (below to above, a squish changing sides), and a new destination late on the old clock is reached almost at once, so the box jumps. Restarting from the displayed rect on a fresh clock keeps every frame continuous: a restart at time t samples the same rect the old tween showed at t (`lib/src/lib/rect-tween.test.ts`). + +**Why hysteresis.** Output, scrolling, and resizes move the band a few pixels at a time, and with two spots near their limits a strict ranking flips the editor back and forth. A held spot gives way to a better one only with `HYSTERESIS_PX` (24) to spare. + +**Why layer 55.** Above the ring (50), so the ring never crosses it, and above the in-Wall terminal context it can spill over. `MODAL_LAYERS.app` (60) would tie with `SettingsDialog`, and a tie falls back to insertion order, which `docs/specs/layout.md` → "Selection overlay" forbids; at 55, Settings and anchored menus cover it by value. + +## 4.6 The Program's Own Copy (OSC 52) + +Before the editor, `OSC 52` was consumed and ignored, because a program that can write the clipboard can plant a command a later paste runs. TUIs that own the mouse copy their own selection this way (Claude Code's fullscreen mode, tmux with `set-clipboard`), and theirs is often the better text: the program knows the source it rendered, Markdown backticks included. An offer shown in full, gated on the user's own drag, and copied only by an explicit choice keeps the clipboard the user's to write. The payload limit sits under the parser's 16,384-unit incomplete-OSC bound because a larger one would make the answer depend on where the PTY split its reads: a payload arriving whole could pass while the same payload split across reads was discarded with every other oversized OSC. About 12 KB of text is enough for a selection. + +## 4.1.1 Auto + +The fit test reads a greedy wrapper correctly by construction: a wrapper at width W only breaks where the next word would push past W, and the paragraph's longest row is at most W, so every wrapped break also fails the test against that row. A break the test calls intentional therefore never comes from a greedy wrap. The 40-column floor exists for paragraphs of short rows, whose longest row says nothing about a wrap width: without it `Hello` / `World`, or a list of short names, read as one wrapped line. It is absolute rather than relative to the terminal because text is often wrapped far narrower than the pane: a first cut that floored at half the terminal kept every break of `fold -w 50` output in a 200-column pane (found driving the real app, 2026-09). + ## 5.1 Detection **Why trailing punctuation is stripped before the patterns run.** Terminal output puts tokens inside sentences: `Error at src/foo.ts:42.` ends in a period no path pattern matches, so matching first leaves nothing to trim afterward. Stripped first it becomes `src/foo.ts:42`, which the error-location pattern recognizes — the `:line[:col]` digits are not trailing punctuation and survive. Matched bracket pairs are exempt for the mirror case: `https://en.wikipedia.org/wiki/Foo_(bar)` really does end in `)`, and trimming truncates the URL. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index b363c132b..cc4adb00a 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -10,7 +10,7 @@ The attacker is any program writing to a PTY. **Must bound retained output by representation**: `TerminalProtocolParser` semantic values by code points and control stripping, an incomplete semantic OSC at 16,384 code units, and ImageAddon data by encoded bytes, decoded pixels, and FIFO storage (`docs/specs/terminal-escapes.md` -> "Parsing location", "Inline graphics"). -**Never let untrusted PTY output write the clipboard or access a file**: consume `OSC 52`, `OSC 50`, and unsupported `OSC 1337`. **Inline images carry their own bytes**: no path is resolved, ImageAddon dropping any non-`inline=1` transfer (`docs/specs/terminal-escapes.md` -> "Inline graphics"). +**Never let untrusted PTY output write the clipboard or access a file**: consume `OSC 50` and unsupported `OSC 1337`; consume `OSC 52`, which only offers its text to the copy editor over the user's own drag, copied when the user picks it (`docs/specs/mouse-and-clipboard.md` §4.6). **Inline images carry their own bytes**: no path is resolved, ImageAddon dropping any non-`inline=1` transfer (`docs/specs/terminal-escapes.md` -> "Inline graphics"). **An `OSC 8` hyperlink opens only after a confirmation dialog**, except a local `file:` link whose display text names its target, which previews through the @@ -36,7 +36,8 @@ shell-integration scripts — the parser scans raw bytes and cannot defend it **Must confine output to the screen, Session state, and bounded terminal reports** — rendered text/images, alerts, titles, prompt/command boundaries, CWD, and `OSC 8` — except a running designated Tool's OSC 367 `open`, gated below. **The PTY-boundary parser writes exactly three answer families**: `OSC 10/11/12 ; ?` color, `OSC 99` capability, `CSI > q` device. xterm.js and ImageAddon answer cursor, device, focus, size, and graphics reports (`docs/specs/terminal-escapes.md` -> "Report filtering on the input side"). -- **FAIL IF** `isKnownUnsupportedIterm2Osc` in `lib/src/lib/terminal-protocol.ts` stops consuming `OSC 52`, or a parse site stops running `TerminalProtocolParser` before `pty:data` leaves it (rationale). Pinned by `lib/src/lib/terminal-protocol.test.ts`. +- **FAIL IF** `TerminalProtocolParser` in `lib/src/lib/terminal-protocol.ts` stops consuming `OSC 52` or `OSC 50`, or a parse site stops running it before `pty:data` leaves it (rationale). Pinned by `lib/src/lib/terminal-protocol.test.ts`. +- **FAIL IF** an `OSC 52` payload can reach the clipboard except as the copy editor's program format the user chose and copied, is retained unbounded or with control characters other than newline and tab, or is accepted into a pane with no shadowed drag: `parseOsc52` and `CLIPBOARD_OFFER_LIMIT` in `lib/src/lib/terminal-protocol.ts`, `offerProgramCopy` in `lib/src/lib/mouse-selection.ts`, `copySelection` in `lib/src/lib/copy-selection.ts`. Pinned by `lib/src/lib/terminal-protocol.test.ts`, `lib/src/lib/mouse-selection.test.ts`, and `lib/src/lib/copy-editor.test.ts`. - **FAIL IF** a value the parser retains stops being bounded and control-stripped before storage, or a new one arrives without a limit — `TITLE_LIMIT`, `BODY_LIMIT`, `COMMAND_LINE_LIMIT` and `sanitizeText` in `lib/src/lib/terminal-protocol.ts`, `MAX_CWD_LENGTH` and `boundedCwdValue` in `lib/src/lib/terminal-state.ts`. `COMMAND_LINE_LIMIT` binds *after* the `\xNN` unescape, a 4x bound before it (rationale). - **FAIL IF** an `OSC 8` activation reaches an adapter's `openExternal` without the confirmation dialog, or the dialog renders an open action for a **deceptive** verdict: `linkHandler` in `lib/src/lib/terminal-lifecycle.ts`, `classifyDisplayMatch` in `lib/src/lib/external-links.ts`, the render branches in `lib/src/components/ExternalLinkModal.tsx`. Pinned by `lib/src/lib/external-links.test.ts` and `lib/src/components/ExternalLinkModalHost.test.tsx`; the host also rejects a deceptive confirmation (rationale). - **FAIL IF** an `OSC 8` activation reaches the preview path when its display text is not a whole-component suffix of its decoded target, or the host opens a `file:` URL whose host is not empty, `localhost`, or this machine: `localFileLinkPreviewPath` in `lib/src/lib/external-links.ts`, `activateTerminalLink` in `lib/src/lib/terminal-link-activation.ts`, `resolveLocalToolTarget` in `lib/src/host/tool-input.ts`. Pinned by `lib/src/lib/external-links.test.ts`, `lib/src/lib/terminal-link-activation.test.ts`, and `local file URLs` in `lib/src/host/tool-input.test.ts`. diff --git a/docs/specs/security.md b/docs/specs/security.md index ea6134d9e..5d95d60bd 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -39,7 +39,7 @@ last column means nothing cheaper does. | Guarantee | Rule | Pinned by | | --- | --- | --- | -| **A program printing to your terminal cannot write your clipboard, read a file, or steal focus.** It can raise an alert, set a title, or mark a prompt; an OSC 8 link requires confirmation unless it is a local file link naming its target, which previews, and a deceptive link has no open action. | [Terminal output](./security-local.md#terminal-output) | `lib/src/lib/terminal-protocol.test.ts`, `lib/src/lib/external-links.test.ts`, `lib/src/lib/terminal-link-activation.test.ts`, `lib/src/components/ExternalLinkModalHost.test.tsx` | +| **A program printing to your terminal cannot write your clipboard, read a file, or steal focus.** Its `OSC 52` copy is only an offer the copy editor shows. It can raise an alert, set a title, or mark a prompt; an OSC 8 link requires confirmation unless it is a local file link naming its target, which previews, and a deceptive link has no open action. | [Terminal output](./security-local.md#terminal-output) | `lib/src/lib/terminal-protocol.test.ts`, `lib/src/lib/external-links.test.ts`, `lib/src/lib/terminal-link-activation.test.ts`, `lib/src/components/ExternalLinkModalHost.test.tsx` | | **A page in a browser pane cannot forge a host message.** In VS Code every host message carries a per-boot token it cannot read, and the standalone adapters have no inbox for it to post to. | [Browser panes](./security-local.md#browser-panes) | `lib/src/lib/platform/vscode-adapter.test.ts` | | **Only your own account can drive your terminals through `dor`.** The socket sits in a directory only you can open, and its token never crosses the wire. | [The dor control socket](./security-local.md#the-dor-control-socket) | `standalone/sidecar/dor-control-server.test.js` | | **A loopback listener grants a stranger nothing it could not get from the upstream directly.** | [Loopback Listeners](./security-local.md#loopback-listeners) | `scripts/loopback-lint.mjs` | diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 2944e0db0..67ac11085 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -56,10 +56,10 @@ Both modes, ahead of the passthrough gate, and only on a terminal **selected** S |-----|--------|-------------| | `e` | Extend to token | Mid-drag only: extend the selection to the full URL/path token at the cursor; consumed but inert with no token. | | `Alt` (hold) | Block / linewise | Block (rectangular) rather than linewise, live through the drag; touch latches it with a double-tap-then-drag. | -| `Esc` | Cancel selection | Cancel the in-progress drag, or clear a finalized selection while its popup is up. | -| *(any other key)* | — | Swallowed during a terminal-handled drag, never reaching the inside program (`docs/specs/mouse-and-clipboard.md` §3.6). | -| `⌘C` (macOS) / `Ctrl+C` (others) | Copy raw | Copy the selection as-is; requires a finalized selection. | -| `⌘⇧C` (macOS) / `Ctrl+Shift+C` (others) | Copy rewrapped | Copy the selection rewrapped for single-line display. | +| `Esc` | Cancel selection | Cancel the in-progress drag, or in passthrough close the copy editor and clear its selection. | +| *(any other key)* | — | Swallowed during a terminal-handled drag, never reaching the inside program (`docs/specs/mouse-and-clipboard.md` §3.6); with the copy editor open, closes it and reaches the terminal. | +| `⌘C` (macOS) / `Ctrl+C` (others) | Copy | Copy editor: copy what it shows (`⇧` optional); over a program-owned drag, open it. | +| `e` / `⇧E`, `f` / `⇧F`, `←` `→` / `⇧←` `⇧→`, `↵` | Copy editor | Passthrough only: scope, format, edges, copy (`docs/specs/mouse-and-clipboard.md` §4.3). | | `⌘V` / `⌘⇧V` / `Ctrl+V` / `Ctrl+Shift+V` | Paste | Paste into the terminal; the `Ctrl` variants are intercepted on every platform, macOS included. | On macOS `Ctrl+C` still reaches the running program; a literal `0x16` needs the shell's `quoted-insert` (`Ctrl+Q`) (`docs/specs/mouse-and-clipboard.md` §8.3). @@ -103,5 +103,5 @@ The standalone host contributes no chords; `docs/specs/standalone.md` owns its n - `lib/src/components/wall/chrome-keyboard-lease.ts`, `lib/src/lib/workspace-ui-store.ts` — the strip's keyboard suppression and rename/confirmation state - `lib/src/lib/vscode-keybindings.ts` — the workbench mirror allowlist - `lib/src/lib/terminal-mouse-router.ts` — live Alt tracking during a drag -- `lib/src/components/SelectionPopup.tsx`, `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/wall/InlineEditInput.tsx` — the popover/dialog handlers +- `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/wall/InlineEditInput.tsx` — the popover/dialog handlers - `lib/src/components/wall/agent-browser-surface-controller.ts` — browser key forwarding and the edit-chord bridge diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index b5cd3ed81..d736c81f0 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -34,7 +34,7 @@ Source of truth: `oscDispositionAt` in `lib/src/lib/terminal-protocol.ts`, `boun ### `pty:data` strip semantics -**Supported semantic sequences are consumed and never re-emitted** — empty or unparseable payloads, unrecognized `OSC 1337` subcommands and `OSC 50` / `OSC 52` included. **`OSC 8` and the recognized ImageAddon `OSC 1337` forms are the exceptions**: they stay in `pty:data` so xterm.js owns hyperlink regions and inline graphics. Dormouse supplies only the hyperlink activation handler. Every other OSC family passes through unchanged, so xterm.js handles standard behavior Dormouse does not model. +**Supported semantic sequences are consumed and never re-emitted** — empty or unparseable payloads, unrecognized `OSC 1337` subcommands, `OSC 50`, and `OSC 52` included. **`OSC 8` and the recognized ImageAddon `OSC 1337` forms are the exceptions**: they stay in `pty:data` so xterm.js owns hyperlink regions and inline graphics. Dormouse supplies only the hyperlink activation handler. Every other OSC family passes through unchanged, so xterm.js handles standard behavior Dormouse does not model. **`textData` is the same chunk with every string-control payload removed**, for consumers reading output as text; every other control is left for `stripTerminalControls`. The webview receives them apart: `pty:data` (the stripped output; feeds xterm.js), `terminal:semanticEvents` (normalized CWD / prompt-command / title events; feeds `TerminalPaneState`), and `terminal:toolEvents` (OSC 367, [dor-tool.md](dor-tool.md#osc-367)). **Notification-derived state never travels as `pty:data`**: the parse site feeds its own process's `AlertManager`. @@ -71,7 +71,7 @@ Replay (`pty:replay`) is the raw stream requiring re-parse: **the webview runs a | `OSC 1337 ; ReportCellSize ST` | iTerm2 cell-size query; passed through and answered by the owner's ImageAddon. | [Inline graphics](#inline-graphics) | | `OSC 1337 ; ST` | Unsupported iTerm2 extension; consumed and ignored. | This spec | | `OSC 50 ; ST` | Unsupported dynamic font change; consumed and ignored. | This spec | -| `OSC 52 ; ; ST` | Unsupported clipboard write; consumed and ignored — untrusted PTY output cannot write the user's clipboard. | This spec | +| `OSC 52 ; ; ST` | Clipboard write: consumed, and only offered to the copy editor over a shadowed drag; a `?` read is never answered. | [mouse-and-clipboard.md](mouse-and-clipboard.md) §4.6 | **A `BEL` that terminates an OSC is part of that sequence, never a bell**; the `BEL` row covers a standalone one, parsed and stripped at the same boundary. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 2dea9e253..113e02cd2 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -201,6 +201,7 @@ Transport constraints: | Host → webview | `pty:data` | PTY output after state-driving supported OSCs are parsed/stripped; `OSC 8` and ImageAddon's inline-image `OSC 1337` forms are preserved for xterm.js, routed only to the owning router. **Carries an optional `textData`** (string-control payloads removed, for the prompt heuristic), **omitted when it would equal `data`**. | | Host → webview | `terminal:semanticEvents` | Normalized CWD / prompt-command / title events the owner's parser derived, in stream order. | | Host → webview | `terminal:toolEvents` | Ordered Tool announcements, state, and command-start resets (`docs/specs/dor-tool.md` → OSC 367). | +| Host → webview | `terminal:clipboardOffer` | `text`: one decoded `OSC 52` write, an offer the copy editor may show (`docs/specs/mouse-and-clipboard.md` §4.6). | | Webview → host | `pty:spawn` | `options.alert`: a cold-restored pane's persisted alert state (`docs/specs/alert.md` → Public State). | | Webview → host | `dormouse:themeColors` (VS Code) / `pty_theme_colors` (standalone) | Resolved foreground / background / cursor, so the owner's parser can answer OSC 10/11/12. | | Host → webview | `pty:replay` | Buffered raw output since spawn; the webview runs a one-shot parser over it, the only re-parse there is. | diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 25e885783..c1f086e5a 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -33,7 +33,7 @@ Browser-side xterm alt-screen behind `FakePtyAdapter`, **never Node `terminal-ki - Desktop `SiteHeader` at top, `themeAware` so `--vscode-*` variables drive its chrome, **carrying no controls**: **the page must restore its own theme** with `useRestoredTheme(WEBSITE_DEFAULT_THEME_ID)` (`website/src/lib/website-theme.ts`), which also declares the host fallback the Settings picker re-resolves through (rationale). `th-theme` walks the user to the Wall's Settings dialog (`docs/specs/theme.md` → "Where the user picks a theme"); Pocket renders the `compact` picker in the keyboard reserve or in the desktop marketing header. - `
` is a flex container so Wall's `flex-1 min-h-0` root gets a real height. -- `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`). **Must seed its three-pane L-shape as an explicit Lath snapshot** — `restoredLathLayout` from `DESKTOP_PLAYGROUND_LAYOUT` — never the synchronous `initialPaneIds` path (rationale); `website/src/lib/playground-desktop-layout.test.ts` pins it. `DESKTOP_PANES` in the same file owns each seed's id, command, and title; **`tut-boxed` is the Copy Rewrapped + `cp-override` target** (rationale). **Titles are seeded as pending shell opts** (`setPendingShellOpts(id, { title })`) before the Wall mounts; the lib pins each at first spawn, after the pane's state reset, and a user-pin outranks the engine fallback (`docs/specs/terminal-state.md` → "Header Derivation"). +- `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`). **Must seed its three-pane L-shape as an explicit Lath snapshot** — `restoredLathLayout` from `DESKTOP_PLAYGROUND_LAYOUT` — never the synchronous `initialPaneIds` path (rationale); `website/src/lib/playground-desktop-layout.test.ts` pins it. `DESKTOP_PANES` in the same file owns each seed's id, command, and title; **`tut-boxed` is the Auto-copy + `cp-override` target** (rationale). **Titles are seeded as pending shell opts** (`setPendingShellOpts(id, { title })`) before the Wall mounts; the lib pins each at first spawn, after the pane's state reset, and a user-pin outranks the engine fallback (`docs/specs/terminal-state.md` → "Header Derivation"). Every visible pane gets a `TutorialShell` via `PlaygroundShellRegistry`. **`ensureShell` must stay idempotent** — `paneAdded` covers every pane that becomes visible, and `FakePtyAdapter.onPtySpawn` covers the seed panes again, auto-launching each seed's command exactly once (rationale). The page’s `startProgram` factory dispatches: `tut` → `TutRunner`, `ascii-splash`/`splash` → `AsciiSplashRunner`, `changelog` → `ChangelogRunner`. **Spawned terminals use `SCENARIO_SHELL_PROMPT`; seed panes get an empty scenario**, so no delayed `user@dormouse:~$` write lands inside a runner's alt-screen. diff --git a/docs/specs/tutorial.rationale.md b/docs/specs/tutorial.rationale.md index e4334cc61..3a09b59fb 100644 --- a/docs/specs/tutorial.rationale.md +++ b/docs/specs/tutorial.rationale.md @@ -16,7 +16,7 @@ **Why the desktop layout is an explicit Lath seed.** The synchronous `initialPaneIds` path creates its leaves before the later ones have measured geometry, so it cannot reliably choose alternating split axes — the L-shape comes out however the measurements land. A valid Lath snapshot fixes the shape, and with it the one vertical and one horizontal divider. -**Why `tut-boxed` is the copy target.** Its wrapped detail lines exercise the Copy Rewrapped path, and its TUI captures the mouse, the state `cp-override` exists to demonstrate. Pocket's `pocket-changelog` session is there for the same two reasons. +**Why `tut-boxed` is the copy target.** Its wrapped detail lines exercise the copy editor's Auto format, and its TUI captures the mouse, the state `cp-override` exists to demonstrate. Pocket's `pocket-changelog` session is there for the same two reasons. **Why `ensureShell` runs from two directions.** `paneAdded` covers splits, restores, dor surfaces and the seed ids alike, but cannot auto-launch the seed commands: that has to happen at spawn, exactly once. `FakePtyAdapter.onPtySpawn` is the spawn-time hook that does, and it necessarily overlaps the seed ids `paneAdded` already announced — hence idempotence rather than a split of responsibilities. @@ -44,4 +44,4 @@ **What supplies the mouse-capturing text.** Both neighbor panes, `ascii-splash` and `changelog`; why `changelog` is also the copy target: [Layout](#layout). -**Coverage audit, against `mouse-and-clipboard.md`'s section numbers as of 2026-09.** Exercisable: §§1–2 (mouse reporting + override), §§3.1–3.3 (drag, block shape, block hint), §§3.6–3.7 (drag keys + popup), §§4.1–4.3 (raw/rewrapped copy, shortcuts, dismissal). Partial: §3.4 exposes change/resize cancellation but not pure scroll; §3.5 lacks enough scrollback; §8.2 writes paste chords to the fake PTY, whose shell ignores bracket markers. Missing: §§3.3 and 5 lack smart tokens and therefore `e` extension; §8.5 lacks a scenario that enables bracketed paste. Auto-scroll during a drag and right-click paste are deferred in the implementation ([§9. Future](mouse-and-clipboard.md#9-future)), not Playground gaps. +**Coverage audit, against `mouse-and-clipboard.md`'s section numbers as of 2026-09.** Exercisable: §§1–2 (mouse reporting + override), §§3.1–3.3 (drag, block shape, block hint), §§3.6–3.7 (drag keys + popup), §§4.1–4.3 (Raw / Rewrapped copy, shortcuts, dismissal; since replaced by the copy editor). Partial: §3.4 exposes change/resize cancellation but not pure scroll; §3.5 lacks enough scrollback; §8.2 writes paste chords to the fake PTY, whose shell ignores bracket markers. Missing: §§3.3 and 5 lack smart tokens and therefore `e` extension; §8.5 lacks a scenario that enables bracketed paste. Auto-scroll during a drag and right-click paste are deferred in the implementation ([§9. Future](mouse-and-clipboard.md#9-future)), not Playground gaps. diff --git a/dormouse.yml b/dormouse.yml index b8fa53427..818db0888 100644 --- a/dormouse.yml +++ b/dormouse.yml @@ -1,79 +1,45 @@ -# Dor Tools for this repo (docs/specs/dor-tool.md). -# -# `dor tool ` runs one of these in a pane that grows a browser once the -# command starts serving. Repo-controlled, so Dormouse asks you to approve this -# directory once before it will run anything here. -# -# The comment directly above each entry is its description in -# `dor tool --list`: say what the Tool is for and anything its fields do not. browser: default_viewport: desktop tools: - # The lib component catalog (Storybook), for a human to look at. + # HMR dev server for our storybook storybook: run: pnpm storybook - # Storybook never announces, so autobind: frame the one port it opens. If it - # ever opened a second, Dormouse would show that instead of guessing. + render: agent-browser-screencast port: auto - # Scoped to the checkout: parallel worktrees each get their own Storybook, - # and each frames the port it actually bound (6006, then 6007, ...). Without - # $PROJECT_ROOT both worktrees would share one key and the second would - # reveal the first instead of starting. prespawn_dedupe: [storybook, $PROJECT_ROOT] - # Dormouse Standalone running in a browser against a real sidecar, for - # debugging the app itself. Drive the page with the command it prints. + # spawns a standalone Dormouse with the UI backed by agent-browser (frontend HMR, static backend) innerdogfood: run: pnpm innerdogfood - # A real browser rather than an iframe, so an agent can drive the harness - # with `dor agent-browser --surface surface:N `. render: agent-browser-screencast - viewport: desktop - # The bridge and Vite each bind an OS-assigned port. Announce Vite's actual - # port via OSC 367 so Dormouse selects the UI from that pair of listeners. port: announced prespawn_dedupe: [innerdogfood, $PROJECT_ROOT] - # The marketing site and public docs (dormouse.sh), with hot reload. + # HMR for playground / marketing / docs at dormouse.sh website: run: pnpm dev:website - # Agents check docs pages here, so a browser they can drive. render: agent-browser-screencast - # Vite moves to the next free port when 5173 is taken. port: auto prespawn_dedupe: [website, $PROJECT_ROOT] - # A selfhost Relay serving Pocket, for testing remote control locally. - # Enroll a Burrow with the URL it prints. + # Selfhost Relay serving Pocket, for testing remote control locally (static build of both, restart to see changes) relay: run: pnpm dev:relay - # Pocket signs in with passkeys and a session cookie, which an iframe drops. render: agent-browser-screencast - # The dev Relay binds an OS-assigned port and derives its origin from it. port: auto prespawn_dedupe: [relay, $PROJECT_ROOT] - # The Hosted accounts frontend with email sign-in; read the codes at - # /api/dev/emails on the printed origin. Needs Docker running. + # HMR for the frontend of hosted.dormouse.sh (backend needs a restart; needs Docker for Postgres) hosted: run: pnpm dev:hosted - # Email sign-in sets a session cookie, which an iframe drops. render: agent-browser-screencast - # One listener serves Vite and auth on an OS-assigned port; Postgres runs in - # Docker, outside this process tree. port: auto prespawn_dedupe: [hosted, $PROJECT_ROOT] - # The one-time rendezvous and phone page on loopback, for trying a one-time - # connection end to end: build a Burrow with the variables it prints, then - # open a link from its Settings. Port 8787 (or PORT), fixed because that - # Burrow build bakes the origin in, so one checkout at a time. + # One-time connection rendezvous + phone page on localhost:8787 (Worker reloads on save, page rebuilds on save, no HMR); fixed port, so one checkout at a time one-time: run: pnpm dev:one-time - # A real browser, so an agent can drive the phone page at a phone viewport. render: agent-browser-screencast - # Wrangler's inspector binds a second port in the same tree, so the loop - # announces the page's port and path via OSC 367 once the Worker answers. port: announced prespawn_dedupe: [one-time, $PROJECT_ROOT] diff --git a/lib/src/components/CopyEditor.test.tsx b/lib/src/components/CopyEditor.test.tsx new file mode 100644 index 000000000..74c39403e --- /dev/null +++ b/lib/src/components/CopyEditor.test.tsx @@ -0,0 +1,663 @@ +/** + * @vitest-environment jsdom + */ +import { act, StrictMode, type ReactElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/copy-selection', () => ({ copySelection: vi.fn() })); +vi.mock('../lib/platform', () => ({ IS_MAC: true })); +// The registry barrel boots xterm; the editor only reads the measured grid. +vi.mock('../lib/terminal-registry', () => ({ getTerminalOverlayDims: vi.fn() })); +// jsdom lays nothing out: the editor's natural size is each test's to set. +vi.mock('./copy-editor-measure', () => ({ measureNaturalWidth: vi.fn(), measureWidth: vi.fn(), createHeightMeasurer: vi.fn() })); +import { cfg } from '../cfg'; +import { copySelection } from '../lib/copy-selection'; +import { followCopySelection, openCopyEditor } from '../lib/copy-editor'; +import { CLAUDE_REPLY, fakeXterm } from '../lib/copy-text-fixtures'; +import { anchoredTarget } from '../lib/dom'; +import { + __resetMouseSelectionForTests, + beginDrag, + bumpRenderTick, + endDrag, + failCopy, + flashCopy, + getMouseSelectionState, + offerProgramCopy, + setSelection, +} from '../lib/mouse-selection'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; +import { CopyEditor, TOUCH_SLOP_PX } from './CopyEditor'; +import { createHeightMeasurer, measureNaturalWidth, measureWidth } from './copy-editor-measure'; +import { TouchUiContext } from './touch-ui-context'; +import { installFakeFrames } from './motion-test-utils'; +import { createWorkspaceMotion } from './workspace-motion'; +import { LayoutFramesContext, WorkspaceActiveContext, WorkspaceIdContext, ZoomedIdContext } from './wall/wall-context'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +// A forty-row grid of 10px cells in an 800×400 pane at (100, 50), in jsdom's +// 1024×768 window: the usable viewport is 12..1012 × 12..756. Rows 2..5 band +// y 70..110, so below starts at 114, and the pane's left plus the gap is 104. +const DIMS = { + cols: 80, + rows: 40, + viewportY: 0, + baseY: 10, + elementLeft: 100, + elementTop: 50, + elementWidth: 800, + elementHeight: 400, + cellWidth: 10, + cellHeight: 10, + gridLeft: 0, + gridTop: 0, +}; + +let container: HTMLDivElement; +let root: Root; +let dims: typeof DIMS; +/** The editor's measured size: its longest line, its header and footer, their + * controls alone, and its height at any width. */ +let natural: { width: number; chrome: number; essential: number; height: number }; +const terminal = fakeXterm(CLAUDE_REPLY); + +const frames = installFakeFrames(); +let previousAnimate: boolean; +let resizeObservers: Set<() => void>; + +/** Advance the clock and run the frames queued as of now. */ +const frame = (ms = 16) => frames.advance(ms); + +const editor = () => document.body.querySelector('[data-copy-editor-for="term-1"]'); +/** The visible editor, inside any touch slop. */ +const surface = () => editor()!.querySelector('[data-copy-editor-surface]')!; +/** What the editor shows, its inert probes left out. */ +const text = () => Array.from(editor() ? surface().children : [], (part) => (part.hasAttribute('inert') ? '' : part.textContent)).join(''); +/** A shown button by its label: its accessible name, else its text. */ +const button = (label: string) => Array.from(editor()!.querySelectorAll('button')) + .find((b) => (b.getAttribute('aria-label') ?? b.textContent) === label && !b.closest('[inert]'))!; +/** The Copy button, and the label it shows of the ones it stacks. */ +const copyButton = () => surface().querySelector(':scope > div:not([inert]) [data-copy-state]')!; +const shownLabel = () => Array.from(copyButton().querySelectorAll('span > span')).find((l) => !l.classList.contains('invisible'))?.textContent; +/** The probe laying out the header and footer. */ +const chromeProbe = () => editor()!.querySelector('[data-chrome-probe]')!; +const side = () => editor()?.dataset.copyEditorSide; +const box = () => { + const { left, top, width, height } = editor()!.style; + return { left, top, width, height }; +}; +const px = (left: number, top: number, width: number, height: number) => ({ left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px` }); + +function render(ui: ReactElement = ): void { + act(() => root.render(ui)); +} + +/** A finalized drag over rows `r0`..`r1`, opened as mouse-up opens it. */ +function drag(r0: number, c0: number, r1: number, c1: number): void { + act(() => { + beginDrag('term-1', { row: r0, col: c0, altKey: false, startedInScrollback: false }); + setSelection('term-1', { ...getMouseSelectionState('term-1').selection!, endRow: r1, endCol: c1 }); + endDrag('term-1'); + openCopyEditor('term-1', terminal); + }); +} + +beforeEach(() => { + __resetMouseSelectionForTests(); + dims = { ...DIMS }; + natural = { width: 300, chrome: 200, essential: 150, height: 150 }; + vi.mocked(getTerminalOverlayDims).mockImplementation(() => dims); + vi.mocked(measureNaturalWidth).mockImplementation(() => natural.width); + vi.mocked(measureWidth).mockImplementation((_, probe) => (probe.hasAttribute('data-essential') ? natural.essential : natural.chrome)); + vi.mocked(createHeightMeasurer).mockImplementation(() => () => natural.height); + vi.mocked(copySelection).mockReset(); + + // Position assertions read settled rects; the easing case turns motion back on. + previousAnimate = cfg.layout.animate; + cfg.layout.animate = false; + resizeObservers = new Set(); + vi.stubGlobal('ResizeObserver', class { + private readonly fire: () => void; + constructor(callback: ResizeObserverCallback) { + this.fire = () => callback([], this as unknown as ResizeObserver); + } + observe(): void { resizeObservers.add(this.fire); } + unobserve(): void {} + disconnect(): void { resizeObservers.delete(this.fire); } + }); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + cfg.layout.animate = previousAnimate; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('CopyEditor: opening and placement', () => { + it('stays closed over a selection nobody opened it for', () => { + act(() => setSelection('term-1', { startRow: 0, startCol: 0, endRow: 1, endCol: 5, shape: 'linewise', dragging: false, startedInScrollback: false })); + render(); + expect(editor()).toBeNull(); + }); + + it('opens below the selection on document.body, placed before any frame', () => { + drag(2, 25, 5, 27); + render(); + expect(editor()!.parentElement).toBe(document.body); + expect(container.contains(editor())).toBe(false); + expect(side()).toBe('below'); + // At least the pane's width less the gaps, though its lines are narrower. + expect(box()).toEqual(px(104, 114, 792, 150)); + expect(editor()!.style.visibility).toBe('visible'); + expect(frames.pending).toBe(0); + }); + + it('grows to its longest line, held inside the window', () => { + natural.width = 950; + drag(2, 25, 5, 27); + render(); + expect(box()).toEqual(px(62, 114, 950, 150)); + }); + + it('widens past a narrow pane and its lines to show its header and footer whole', () => { + // A 19-column pane over a short line. + dims.elementWidth = 190; + Object.assign(natural, { width: 120, chrome: 460 }); + drag(2, 25, 2, 30); + render(); + expect(box()).toEqual(px(104, 84, 460, 150)); + }); + + it('opens above when below has no room', () => { + dims.elementTop = 350; + drag(30, 0, 31, 5); + render(); + expect(side()).toBe('above'); + expect(box()).toEqual(px(104, 496, 792, 150)); + }); + + it.each([ + // Pane x 400..600: 408px of room to its right, 384 to its left. + { left: 400, expected: 'right', x: 604, width: 300 }, + // Pane x 600..800: 208px to its right, 584 to its left. + { left: 600, expected: 'left', x: 296, width: 300 }, + ])('takes the roomier side of the pane beside a tall selection ($expected)', ({ left, expected, x, width }) => { + Object.assign(dims, { elementLeft: left, elementTop: 20, elementWidth: 200, elementHeight: 720, cellHeight: 18 }); + drag(1, 0, 38, 5); + render(); + expect(side()).toBe(expected); + expect(box()).toEqual(px(x, 38, width, 150)); + }); + + it('takes a side narrower than its chrome, which needs only its controls', () => { + // Pane x 400..600: 408px of room to its right, short of the 500px chrome. + Object.assign(dims, { elementLeft: 400, elementTop: 20, elementWidth: 200, elementHeight: 720, cellHeight: 18 }); + Object.assign(natural, { chrome: 500, essential: 260 }); + drag(1, 0, 38, 5); + openCopyEditor('term-1', fakeXterm(Array(40).fill('a whole paragraph'))); + render(); + expect(side()).toBe('right'); + expect(box()).toEqual(px(604, 38, 408, 150)); + // Measured again with the key hints and legend hidden, which leaves its + // controls, then put back. + expect(chromeProbe().hasAttribute('data-essential')).toBe(false); + expect(chromeProbe().className).toContain('[&[data-essential]_[data-chrome-optional]]:hidden'); + const essential = chromeProbe().cloneNode(true) as HTMLElement; + for (const optional of essential.querySelectorAll('[data-chrome-optional]')) optional.remove(); + expect(essential.textContent).toContain('Paragraph'); + expect(essential.textContent).toContain('lines'); + expect(essential.textContent).toContain('Copy'); + expect(essential.textContent).not.toContain('kept'); + expect(essential.textContent).not.toContain('['); + }); + + it('squishes into the roomier of below and above when neither holds it whole', () => { + Object.assign(dims, { elementLeft: 0, elementTop: 0, elementWidth: 1024 }); + natural.height = 700; + drag(10, 0, 11, 5); + render(); + expect(side()).toBe('squish-below'); + expect(box()).toEqual(px(12, 124, 1000, 632)); + }); + + it('docks over the selection at the pane bottom when no spot has room', () => { + Object.assign(dims, { elementLeft: 0, elementTop: 0, elementWidth: 1024, elementHeight: 760, cellHeight: 19 }); + drag(0, 0, 39, 5); + render(); + expect(side()).toBe('overlay'); + expect(box()).toEqual(px(12, 602, 1000, 150)); + }); + + it('prefers above on touch, clear of the thumb, with no key hints', () => { + drag(20, 0, 20, 40); + render(); + expect(side()).toBe('above'); + // Placed at 104, 96, 792 × 150, the touch slop around it. + const slop = TOUCH_SLOP_PX; + expect(box()).toEqual(px(104 - slop, 96 - slop, 792 + 2 * slop, 150 + 2 * slop)); + expect(text()).not.toContain('[f]'); + }); + + it('moves off a wider scope whose band reaches it', () => { + // 20px rows from y 330: As selected bands rows 2..3, Paragraph rows 2..5, + // and a 320px editor fits below the first but only above the second. + Object.assign(dims, { elementTop: 330, rows: 20, cellHeight: 20 }); + natural.height = 320; + drag(2, 30, 3, 20); + render(); + expect(side()).toBe('below'); + act(() => button('Paragraph').click()); + const { scopes, scope } = getMouseSelectionState('term-1').copyEditor!; + expect(scopes[scope].label).toBe('Paragraph'); + expect(side()).toBe('above'); + expect(box()).toEqual(px(104, 46, 792, 320)); + }); +}); + +describe('CopyEditor: following its pane', () => { + it('re-places on the render tick, once a frame', () => { + drag(2, 25, 5, 27); + render(); + dims.viewportY = 2; + act(() => { bumpRenderTick(); bumpRenderTick(); }); + expect(frames.pending).toBe(1); + expect(editor()!.style.top).toBe('114px'); + frame(); + expect(editor()!.style.top).toBe('94px'); + }); + + it('skips placement on a tick that moved nothing', () => { + const heightAt = vi.fn(() => natural.height); + vi.mocked(createHeightMeasurer).mockImplementation(() => heightAt); + drag(2, 25, 5, 27); + render(); + const placed = heightAt.mock.calls.length; + act(() => bumpRenderTick()); + frame(); + expect(heightAt.mock.calls.length).toBe(placed); + dims.viewportY = 2; + act(() => bumpRenderTick()); + frame(); + expect(heightAt.mock.calls.length).toBeGreaterThan(placed); + expect(editor()!.style.top).toBe('94px'); + }); + + it('re-places on a Lath layout frame', () => { + const frames = new Set<(settled: boolean) => void>(); + const subscribe = (cb: (settled: boolean) => void) => { + frames.add(cb); + return () => { frames.delete(cb); }; + }; + drag(2, 25, 5, 27); + render(); + dims.elementTop = 80; + frames.forEach((cb) => cb(false)); + frame(); + expect(editor()!.style.top).toBe('144px'); + }); + + it('stays open through a resize and re-places against the reflowed pane', () => { + drag(2, 25, 5, 27); + render(); + dims.elementWidth = 600; + resizeObservers.forEach((fire) => fire()); + frame(); + expect(box()).toEqual(px(104, 114, 592, 150)); + // The reflow carried the selection two rows down (spec §3.4). + act(() => followCopySelection('term-1', fakeXterm(CLAUDE_REPLY, { cols: 60 }), { start: { row: 4, col: 25 }, end: { row: 7, col: 27 }, block: false })); + expect(getMouseSelectionState('term-1').copyEditor).not.toBeNull(); + expect(editor()!.style.top).toBe('134px'); + }); + + it('eases a move from the displayed rect, after opening snapped', () => { + cfg.layout.animate = true; + drag(2, 25, 5, 27); + render(); + expect(editor()!.style.top).toBe('114px'); + dims.viewportY = 2; + act(() => bumpRenderTick()); + frame(0); + frame(110); + const top = parseFloat(editor()!.style.top); + expect(top).toBeGreaterThan(94); + expect(top).toBeLessThan(114); + frame(220); + expect(editor()!.style.top).toBe('94px'); + }); + + it('hides while its Wall travels, and shows again when the travel ends', () => { + drag(2, 25, 5, 27); + render(
); + // The Workspace's own presentation motion transforms the Wall and reports each frame. + const travel = createWorkspaceMotion(container.querySelector('[data-workspace-wall]')!, 'ws'); + expect(editor()!.style.visibility).toBe('visible'); + void travel.collapse(); + frame(); + expect(editor()!.style.visibility).toBe('hidden'); + expect(side()).toBeUndefined(); + travel.expand(true); + frame(); + expect(editor()!.style.visibility).toBe('visible'); + expect(side()).toBe('below'); + travel.dispose(); + }); + + it('re-places on a window resize and a scroll outside it, never its own scroll', () => { + drag(2, 25, 5, 27); + render(); + dims.viewportY = 2; + editor()!.querySelector('.overflow-auto')!.dispatchEvent(new Event('scroll')); + expect(frames.pending).toBe(0); + document.dispatchEvent(new Event('scroll')); + frame(); + expect(editor()!.style.top).toBe('94px'); + dims.viewportY = 0; + window.dispatchEvent(new Event('resize')); + frame(); + expect(editor()!.style.top).toBe('114px'); + }); + + it('hides under another pane’s zoom, not its own', () => { + drag(2, 25, 5, 27); + const zoomed = (id: string) => ( + +
+
+ ); + render(zoomed('other')); + expect(editor()!.style.visibility).toBe('hidden'); + render(zoomed('term-1')); + expect(editor()!.style.visibility).toBe('visible'); + }); + + it('hides while its pane is hidden, as a parked leaf or a Tool’s other face is', () => { + drag(2, 25, 5, 27); + const pane = (visibility?: 'hidden') =>
; + render(pane()); + expect(editor()!.style.visibility).toBe('visible'); + render(pane('hidden')); + act(() => bumpRenderTick()); + frame(); + expect(editor()!.style.visibility).toBe('hidden'); + }); + + it('survives StrictMode’s second run of its effects', () => { + drag(2, 25, 5, 27); + render(); + expect(box()).toEqual(px(104, 114, 792, 150)); + expect(container.contains(anchoredTarget(button('Copy')))).toBe(true); + dims.viewportY = 2; + act(() => bumpRenderTick()); + frame(); + expect(editor()!.style.top).toBe('94px'); + }); +}); + +describe('CopyEditor: presses inside it belong to its pane', () => { + it('never takes focus from the pane, but leaves its scrollbar its drag', () => { + drag(2, 2, 5, 27); + render(); + const pressed = (target: Element, offsetX = 0, offsetY = 0) => { + const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + Object.defineProperties(down, { offsetX: { value: offsetX }, offsetY: { value: offsetY } }); + act(() => { target.dispatchEvent(down); }); + return down.defaultPrevented; + }; + expect(pressed(button('Exact'))).toBe(true); + expect(pressed(button('␣'))).toBe(true); + const preview = editor()!.querySelector('.overflow-auto')!; + Object.defineProperties(preview, { + clientWidth: { value: 100 }, clientHeight: { value: 40 }, + scrollWidth: { value: 100, configurable: true }, scrollHeight: { value: 40, configurable: true }, + }); + expect(pressed(preview, 20, 30)).toBe(true); + // A stable gutter without overflow is blank space, not a scrollbar. + expect(pressed(preview, 105, 20)).toBe(true); + Object.defineProperty(preview, 'scrollHeight', { value: 80 }); + expect(pressed(preview, 20, 30)).toBe(true); + expect(pressed(preview, 105, 20)).toBe(false); + Object.defineProperty(preview, 'scrollWidth', { value: 200 }); + expect(pressed(preview, 20, 45)).toBe(false); + const buttons = Array.from(editor()!.querySelectorAll('button')); + expect(buttons.filter((b) => b.tabIndex !== -1)).toEqual([]); + expect(getMouseSelectionState('term-1').selection).not.toBeNull(); + }); + + it('dismisses on a press outside, not inside, and keeps inside presses from the pane', () => { + const paneDown = vi.fn(); + const paneMenu = vi.fn(); + drag(2, 2, 5, 27); + render(
); + // DOM containment checks see the editor where its anchor sits. + expect(container.contains(anchoredTarget(button('Copy')))).toBe(true); + act(() => { button('Copy').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + act(() => { editor()!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); }); + expect(getMouseSelectionState('term-1').selection).not.toBeNull(); + expect(paneDown).not.toHaveBeenCalled(); + expect(paneMenu).not.toHaveBeenCalled(); + act(() => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + expect(getMouseSelectionState('term-1').selection).toBeNull(); + expect(editor()).toBeNull(); + }); +}); + +describe('CopyEditor: preview and controls', () => { + it('previews Auto with a mark on every break', () => { + drag(2, 2, 5, 27); + render(); + expect(text()).toContain('final flush␣of the output'); + expect(text()).toContain('1 line'); + }); + + it('switches format from the segment and dims a format that adds nothing', () => { + drag(2, 2, 5, 27); + render(); + expect(button('Spaces').className).toContain('opacity-50'); + act(() => button('Exact').click()); + expect(getMouseSelectionState('term-1').copyEditor?.format).toBe('exact'); + expect(text()).toContain('4 lines'); + }); + + it('flips one break from its mark and marks the format edited', () => { + drag(2, 2, 5, 27); + render(); + act(() => button('␣').click()); + expect(getMouseSelectionState('term-1').copyEditor?.overrides).toEqual({ 0: 'none' }); + expect(button('Auto*')).toBeDefined(); + }); + + it('measures its width once per scope, never for a format or a flipped mark', () => { + drag(2, 27, 3, 5); + render(); + const measured = vi.mocked(measureNaturalWidth).mock.calls.length; + act(() => button('Exact').click()); + act(() => button('Auto').click()); + act(() => button('␣').click()); + expect(vi.mocked(measureNaturalWidth).mock.calls.length).toBe(measured); + act(() => button('Whole words').click()); + expect(vi.mocked(measureNaturalWidth).mock.calls.length).toBe(measured + 1); + }); + + it('lays its chrome probe out the same in every format', () => { + drag(2, 2, 5, 27); + render(); + const measured = vi.mocked(measureWidth).mock.calls.length; + const laidOut = chromeProbe().textContent; + // A `*` on one format and the widest count, Exact's four lines. + expect(laidOut).toContain('Auto*'); + expect(laidOut).toContain('4 lines'); + expect(chromeProbe().querySelector('svg')).not.toBeNull(); + act(() => button('␣').click()); + act(() => button('Exact').click()); + act(() => button('No breaks').click()); + expect(chromeProbe().textContent).toBe(laidOut); + expect(vi.mocked(measureWidth).mock.calls.length).toBe(measured); + }); + + it('probes each format’s three longest lines, cut to what the screen could show', () => { + const innerWidth = window.innerWidth; + // 40px of window at the narrowest cell leaves 18 cells a line. + Object.defineProperty(window, 'innerWidth', { value: 40, configurable: true }); + try { + drag(7, 0, 11, 79); + render(); + } finally { + Object.defineProperty(window, 'innerWidth', { value: innerWidth, configurable: true }); + } + const probe = editor()!.querySelector('.w-max')!; + const [, exact, , joined] = Array.from(probe.children); + const rows = (format: Element) => Array.from(format.children, (row) => ({ n: row.firstElementChild!.textContent, text: row.lastElementChild!.textContent! })); + // Rows 9, 7 and 10 of the reply, heaviest first. + expect(rows(exact).map((r) => r.n)).toEqual(['3', '1', '4']); + expect(rows(exact).every((r) => r.text.length <= 18)).toBe(true); + expect(rows(joined)).toEqual([{ n: '1', text: 'The fix is to awai' }]); + }); + + it('expands from the scope segment and shows what it added', () => { + drag(2, 27, 3, 5); + render(); + act(() => button('Whole words').click()); + expect(getMouseSelectionState('term-1').copyEditor?.scope).toBe(1); + expect(text()).toContain('expanded'); + }); + + it('copies from the button', () => { + drag(2, 2, 5, 27); + render(); + act(() => button('Copy').click()); + expect(copySelection).toHaveBeenCalledWith('term-1', { touch: false }); + }); + + it('gives touch a full-width Copy row a thumb tall, with no key hints', () => { + drag(2, 2, 5, 27); + render(); + expect(copyButton().className).toContain('h-11'); + expect(copyButton().className).toContain('w-full'); + // A row of its own, under the legend and the count. + expect(copyButton().parentElement!.lastElementChild).toBe(copyButton()); + expect(copyButton().previousElementSibling!.textContent).toContain('kept'); + expect(editor()!.className).toContain('touch-manipulation'); + act(() => copyButton().click()); + expect(copySelection).toHaveBeenCalledWith('term-1', { touch: true }); + }); +}); + +describe('CopyEditor: the program’s own copy', () => { + it('takes the width an offer adds on the next tick', () => { + act(() => { + setSelection('term-1', { startRow: 2, startCol: 25, endRow: 5, endCol: 27, shape: 'linewise', dragging: false, startedInScrollback: false, owner: 'program' }); + openCopyEditor('term-1', terminal); + }); + render(); + expect(box().width).toBe('792px'); + natural.width = 950; + act(() => offerProgramCopy('term-1', 'the program’s own, wider copy')); + act(() => bumpRenderTick()); + frame(); + expect(box().width).toBe('950px'); + // Its offer kept, a flipped mark changes no format's widest line. + const measured = vi.mocked(measureNaturalWidth).mock.calls.length; + act(() => button('␣').click()); + expect(vi.mocked(measureNaturalWidth).mock.calls.length).toBe(measured); + }); + + it('offers it last as "From " and previews its text, scope set aside', () => { + act(() => { + setSelection('term-1', { startRow: 2, startCol: 2, endRow: 5, endCol: 27, shape: 'linewise', dragging: false, startedInScrollback: false, owner: 'program' }); + offerProgramCopy('term-1', '**The flake** comes from a race'); + openCopyEditor('term-1', terminal); + }); + render(); + act(() => button('From program').click()); + expect(getMouseSelectionState('term-1').copyEditor?.format).toBe('program'); + expect(text()).toContain('**The flake** comes from a race'); + }); +}); + +describe('CopyEditor: flash', () => { + beforeEach(() => { + drag(5, 2, 5, 12); + render(); + }); + + it('dismisses immediately when the selection is canceled during the flash', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + act(() => flashCopy('term-1')); + act(() => setSelection('term-1', null)); + expect(editor()).toBeNull(); + }); + + it.each([ + { outcome: 'copied', label: 'Copied' }, + { outcome: 'failed', label: 'Couldn’t copy' }, + ] as const)('says $label in place, every label laid out', ({ outcome, label }) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const labels = () => Array.from(copyButton().querySelectorAll('span > span'), (l) => l.textContent); + expect(shownLabel()).toBe('Copy'); + expect(labels()).toEqual(['Copy', 'Copied', 'Couldn’t copy']); + act(() => (outcome === 'copied' ? flashCopy('term-1') : failCopy('term-1'))); + expect(shownLabel()).toBe(label); + expect(copyButton().getAttribute('aria-label')).toBe(label); + expect(copyButton().querySelector('span > span:not(.invisible) svg') !== null).toBe(outcome === 'copied'); + expect(labels()).toEqual(['Copy', 'Copied', 'Couldn’t copy']); + }); + + it('keeps a newer copied selection for its own confirmation duration', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + act(() => flashCopy('term-1')); + act(() => vi.advanceTimersByTime(400)); + drag(9, 4, 9, 20); + act(() => flashCopy('term-1')); + act(() => vi.advanceTimersByTime(300)); + expect(getMouseSelectionState('term-1').selection?.startRow).toBe(9); + act(() => vi.advanceTimersByTime(400)); + expect(getMouseSelectionState('term-1').selection).toBeNull(); + }); +}); + +describe('CopyEditor: touch slop', () => { + it('pads the editor on touch with a margin that is the editor’s own, and desktop with none', () => { + const paneDown = vi.fn(); + drag(2, 25, 5, 27); + render(
); + expect(editor()!.style.padding).toBe(`${TOUCH_SLOP_PX}px`); + // A press on the margin, past the visible surface, lands on the editor: + // never the pane's, never a dismissal, never a control. + const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + act(() => { editor()!.dispatchEvent(down); }); + act(() => { editor()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(down.defaultPrevented).toBe(true); + expect(paneDown).not.toHaveBeenCalled(); + expect(copySelection).not.toHaveBeenCalled(); + expect(getMouseSelectionState('term-1')).toMatchObject({ selection: { endRow: 5 }, copyEditor: { format: 'auto' } }); + render(); + expect(editor()!.style.padding).toBe('0px'); + }); +}); + +describe('CopyEditor: hidden Workspace', () => { + it('renders nothing and swallows no window input, keeping the selection for the way back', () => { + drag(0, 0, 1, 10); + render( + + + , + ); + expect(editor()).toBeNull(); + // The dismissal listener is a capture-phase window listener: answering it + // would take a click from the visible Workspace (docs/specs/layout.md -> + // "Workspaces"). + act(() => { window.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + expect(getMouseSelectionState('term-1').selection).not.toBeNull(); + render(); + expect(editor()).not.toBeNull(); + }); +}); diff --git a/lib/src/components/CopyEditor.tsx b/lib/src/components/CopyEditor.tsx new file mode 100644 index 000000000..61a0a30c0 --- /dev/null +++ b/lib/src/components/CopyEditor.tsx @@ -0,0 +1,626 @@ +import { clsx } from 'clsx'; +import { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type CSSProperties, type ReactNode, type Ref } from 'react'; +import { CheckIcon } from '@phosphor-icons/react'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + getMouseSelectionSnapshot, + setSelection, + subscribeToMouseSelection, + subscribeToRenderTick, + type CopyEditorState, + type CopyOutcome, + type Selection, +} from '../lib/mouse-selection'; +import { spanOfSelection, type BreakKind, type EditorFormat, type Piece, type Rendering, type Span } from '../lib/copy-text'; +import { duplicateFormats, editorFormats, editorRendering, flipCopyBreak, formatRenderings, isEdited, setCopyFormat, setCopyScope, type FormatRenderings } from '../lib/copy-editor'; +import { placeCopyEditor, selectionBand, type CopyEditorSide } from '../lib/copy-editor-placement'; +import { copySelection } from '../lib/copy-selection'; +import { setPortalAnchor } from '../lib/dom'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; +import { getRunningCommandWatchKey } from '../lib/terminal-state-store'; +import { overlayViewportBounds, subscribeOverlayViewport } from '../lib/ui-geometry'; +import { COPY_CHORD_LABEL } from './wall/keyboard/chords'; +import { COPY_EDITOR_Z_INDEX, COPY_EXPANDED_TEXT_CLASS, COPY_OUTCOME_LABEL, modalActionButton, modalSurface, popupButton, portalToBody, Shortcut } from './design'; +import { createHeightMeasurer, measureNaturalWidth, measureWidth, type CopyEditorParts } from './copy-editor-measure'; +import { createRectMotion, type RectMotion } from './rect-motion'; +import { TouchUiContext } from './touch-ui-context'; +import { workspaceInTravel } from './workspace-motion'; +import { subscribePaneMotion } from './wall/pane-motion'; +import { LayoutFramesContext, WorkspaceActiveContext, WorkspaceIdContext, ZoomedIdContext } from './wall/wall-context'; + +/** Shift, and the arrow keys, as the mobile compass rose writes them + * (`lib/src/lib/mobile-gesture-menu.ts`). */ +const SHIFT = '⬆︎'; +const LEFT = '◀'; +const RIGHT = '▶'; + +const FORMAT_NAMES: Record, string> = { + auto: 'Auto', + exact: 'Exact', + spaces: 'Spaces', + joined: 'No breaks', +}; + +const FORMAT_BLURBS: Record = { + auto: 'each line break judged on its own', + exact: 'as displayed', + spaces: 'every line break becomes one space', + joined: 'every line break deleted', + program: 'the text the program itself copied', +}; + +/** The running program, wrappers and earlier commands skipped, for its own + * copy's label (spec §4.6). */ +const programName = (terminalId: string) => getRunningCommandWatchKey(terminalId) ?? 'program'; + +const MARK_GLYPH: Record = { keep: '⏎', space: '␣', none: '⌁' }; +const MARK_TITLE: Record = { + keep: 'Line break kept', + space: 'Line break became one space', + none: 'Line break deleted, joining the two sides', +}; + +/** The lines of each format the width probe lays out. */ +const PROBE_LINES = 3; +/** Under any monospace cell the editor's type renders, so a probed line cut to + * a window's width in these still fills it. */ +const PROBE_MIN_CELL_PX = 4; +const PROBE_MARGIN_CELLS = 8; + +/** On touch, the root's margin around the visible editor (spec §4.5). */ +export const TOUCH_SLOP_PX = 16; + +/** Geometry and, after mounting, visibility are the motion driver's alone: + * React sets neither, so a render never undoes a frame. */ +const ROOT_STYLE: CSSProperties = { position: 'fixed', zIndex: COPY_EDITOR_Z_INDEX, visibility: 'hidden' }; +const SURFACE_STYLE: CSSProperties = { contain: 'layout paint' }; + +/** A stable gutter: a classic scrollbar appearing never narrows the lines. */ +const PREVIEW_CLASS = 'min-h-10 flex-1 overflow-auto bg-app-bg [scrollbar-gutter:stable]'; +const PREVIEW_LINES_CLASS = 'py-1 font-mono text-sm leading-[18px] text-foreground'; + +/** + * The copy editor over a finalized selection (docs/specs/mouse-and-clipboard.md + * §4): the text a copy would produce, with a mark on every line break it + * crossed, placed anywhere in the window (§4.5). Keys are handled by the Wall + * (`handle-mouse-selection-keys.ts`); this owns the pointer. + */ +export function CopyEditor({ terminalId }: { terminalId: string }) { + const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); + // A hidden Workspace consumes no window input (docs/specs/layout.md → + // "Workspaces"); the selection stays in the store for the way back. + const workspaceActive = useContext(WorkspaceActiveContext); + const { selection, copyEditor, copyOutcome, programCopy } = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; + if (!workspaceActive || !copyEditor || !selection) return null; + return ; +} + +/** What placement reads, as last rendered and measured. */ +interface Inputs { + selection: Selection; + scope: Span; + touch: boolean; + zoomedId: string | null; + workspaceId: string | null; + /** The longest line across every format of the scope. */ + naturalWidth: number; + /** The header and footer whole, as wide as any format shows them. */ + chromeWidth: number; + /** Their segments, count, and Copy whole, the key hints and legend left out. */ + essentialWidth: number; + heightAt: (width: number) => number; + /** Everything the last placement read, so a tick that moved nothing skips + * it; null while hidden. */ + placed: readonly unknown[] | null; +} + +/** True while the pane is out of sight, which the editor on `document.body` + * does not inherit, cheapest check first: another pane zoomed over it, which + * a terminal context floats above; its Wall in Workspace travel, a + * presentation the editor does not follow; or the pane hidden, as a parked + * leaf or a Tool's other face hides it. */ +function concealed(anchor: Element, { zoomedId, workspaceId }: Inputs): boolean { + if (zoomedId !== null + && anchor.closest('[data-lath-leaf]')?.getAttribute('data-lath-leaf') !== zoomedId + && !anchor.closest('[data-terminal-context]')) return true; + if (workspaceId !== null && workspaceInTravel(workspaceId)) return true; + return getComputedStyle(anchor).visibility === 'hidden'; +} + +const noFlip = () => {}; +const noHeight = () => 0; +const sameKey = (a: readonly unknown[], b: readonly unknown[]) => a.every((v, n) => v === b[n]); + +/** Mounted while the editor is open, so closing drops its motion and side. */ +const OpenCopyEditor = memo(function OpenCopyEditor({ terminalId, selection, editor, copyState, programCopy }: { + terminalId: string; + selection: Selection; + editor: CopyEditorState; + copyState: CopyState; + programCopy: string | null; +}) { + const touchUi = useContext(TouchUiContext); + const zoomedId = useContext(ZoomedIdContext); + const workspaceId = useContext(WorkspaceIdContext); + const subscribeLayoutFrames = useContext(LayoutFramesContext); + const scope = editor.scopes[editor.scope].span; + + // Each scope's formats are rendered once and cached on it (`copy-editor.ts`), + // so `f`, the duplicate check, the width probe, and the copy all reuse them. + // They read only the scope's buffer and span, so a flipped mark keeps them. + const rendering = useMemo(() => editorRendering(editor, programCopy), [editor, programCopy]); + const renderings = useMemo(() => formatRenderings(editor, programCopy), [editor.buffer, scope, programCopy]); + const sameAs = useMemo(() => duplicateFormats(renderings), [renderings]); + const onFlip = useCallback((index: number, kind: BreakKind) => flipCopyBreak(terminalId, index, kind), [terminalId]); + const most = useMemo(() => mostCounted(renderings), [renderings]); + + const edited = isEdited(editor); + const lines = lineCount(rendering.text); + const fromProgram = editor.format === 'program'; + const expanded = editor.scope > 0; + const nudgeable = selection.shape !== 'block'; + const formats = editorFormats(programCopy); + const program = programCopy === null ? null : programName(terminalId); + const formatName = (f: EditorFormat) => (f === 'program' ? `From ${program}` : FORMAT_NAMES[f]); + // The widest count any format, or a flipped mark past them, gives. + const widestCount = `${Math.max(most.lines, lines)} lines · ${Math.max(most.ch, rendering.text.length)} ch`; + + const anchorRef = useRef(null); + const rootRef = useRef(null); + const surfaceRef = useRef(null); + const headerRef = useRef(null); + const previewRef = useRef(null); + const footerRef = useRef(null); + const widthProbeRef = useRef(null); + const chromeProbeRef = useRef(null); + const heightProbeRef = useRef(null); + const inputs = useRef({ selection, scope, touch: touchUi, zoomedId, workspaceId, naturalWidth: 0, chromeWidth: 0, essentialWidth: 0, heightAt: noHeight, placed: null }); + const motionRef = useRef(null); + motionRef.current ??= createRectMotion({ + // `r` is the visible editor; on touch the root pads it with the slop. + write: (r) => { + const slop = inputs.current.touch ? TOUCH_SLOP_PX : 0; + const style = rootRef.current!.style; + style.padding = `${slop}px`; + style.left = `${r.left - slop}px`; + style.top = `${r.top - slop}px`; + style.width = `${r.width + 2 * slop}px`; + style.height = `${r.height + 2 * slop}px`; + }, + show: (visible) => rootRef.current?.style.setProperty('visibility', visible ? 'visible' : 'hidden'), + }); + const motion = motionRef.current; + + const parts = (): CopyEditorParts => ({ + surface: surfaceRef.current!, + header: headerRef.current!, + preview: previewRef.current!, + footer: footerRef.current!, + widthProbe: widthProbeRef.current!, + heightProbe: heightProbeRef.current!, + }); + + /** Hide the editor and forget its side, so the next placement starts fresh. */ + const conceal = useCallback(() => { + const root = rootRef.current; + if (!root || root.dataset.copyEditorSide === undefined) return; + motion.hide(); + delete root.dataset.copyEditorSide; + inputs.current.placed = null; + }, [motion]); + + /** Place the editor against the selection and pane as they are now. */ + const recompute = useCallback(() => { + const root = rootRef.current; + const anchor = anchorRef.current; + if (!root || !anchor) return; + const at = inputs.current; + const dims = concealed(anchor, at) ? null : getTerminalOverlayDims(terminalId); + if (!dims || dims.rows === 0 || dims.elementWidth === 0) { + conceal(); + return; + } + const viewport = overlayViewportBounds(); + const key = [ + at.selection, at.scope, at.touch, at.naturalWidth, at.chromeWidth, at.essentialWidth, at.heightAt, + dims.viewportY, dims.rows, dims.cellHeight, dims.gridTop, dims.elementLeft, dims.elementTop, dims.elementWidth, dims.elementHeight, + viewport.left, viewport.top, viewport.right, viewport.bottom, + ]; + if (at.placed && sameKey(at.placed, key)) return; + at.placed = key; + const { side, rect } = placeCopyEditor({ + viewport, + pane: { left: dims.elementLeft, top: dims.elementTop, width: dims.elementWidth, height: dims.elementHeight }, + band: selectionBand(dims, [spanOfSelection(at.selection), at.scope]), + naturalWidth: at.naturalWidth, + chromeWidth: at.chromeWidth, + essentialWidth: at.essentialWidth, + naturalHeight: at.heightAt, + touch: at.touch, + previous: (root.dataset.copyEditorSide as CopyEditorSide | undefined) ?? null, + }); + root.dataset.copyEditorSide = side; + motion.setTarget(rect); + }, [terminalId, motion, conceal]); + + useLayoutEffect(() => { + const unmap = setPortalAnchor(rootRef.current!, anchorRef.current!); + return () => { + conceal(); + unmap(); + }; + }, [conceal]); + + // The width shows every format's longest line whole, so `f` and a flipped + // mark never change it; a scope, a nudge, or an offer may. + useLayoutEffect(() => { + inputs.current.naturalWidth = measureNaturalWidth(parts()); + }, [renderings]); + + // The chrome probe reads only these, none of them the format: `f` never + // re-measures it, and a flipped mark only past every format's count. Whole, + // then with its key hints and legend hidden, which is all a side needs. + const chromeDeps = [editor.scopes, expanded, program, touchUi, nudgeable, widestCount]; + useLayoutEffect(() => { + const surface = surfaceRef.current!; + const probe = chromeProbeRef.current!; + inputs.current.chromeWidth = measureWidth(surface, probe); + probe.setAttribute('data-essential', ''); + inputs.current.essentialWidth = measureWidth(surface, probe); + probe.removeAttribute('data-essential'); + }, chromeDeps); + + useLayoutEffect(() => { + inputs.current.heightAt = createHeightMeasurer(parts()); + }, [rendering]); + + // Before paint, so opening lands placed with no travel, and every selection, + // scope, or text change re-places it at once. + useLayoutEffect(() => { + Object.assign(inputs.current, { selection, scope, touch: touchUi, zoomedId, workspaceId }); + recompute(); + }, [selection, scope, rendering, touchUi, zoomedId, workspaceId, recompute]); + + // Anything else that moves the pane or the band, coalesced to one placement + // a frame: output and scrolling, the pane's own motion, the window. And a + // press anywhere outside the editor dismisses it. + useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; + let frame: number | null = null; + const schedule = () => { + frame ??= requestAnimationFrame(() => { + frame = null; + recompute(); + }); + }; + const unsubscribes = [ + subscribeToRenderTick(schedule), + subscribePaneMotion(anchorRef.current?.parentElement, schedule, subscribeLayoutFrames), + subscribeOverlayViewport(schedule), + () => { if (frame !== null) cancelAnimationFrame(frame); }, + ]; + for (const unsubscribe of unsubscribes) signal.addEventListener('abort', unsubscribe); + window.addEventListener('mousedown', (ev) => { + const target = ev.target as HTMLElement | null; + if (!target?.closest(`[data-copy-editor-for="${terminalId}"]`)) setSelection(terminalId, null); + }, { capture: true, signal }); + return () => controller.abort(); + }, [recompute, subscribeLayoutFrames, terminalId]); + + /** A key hint, which touch never shows. */ + const hint = (node: ReactNode) => (touchUi + ? null + : {node}); + + // The header and footer never wrap. The chrome probe lays them out as wide + // as any format shows them: a `*` on one format, the widest count, and the + // Copy button, whose labels are stacked to its widest. In a window or side + // narrower than that, the key hints and the legend (`data-chrome-optional`) + // clip first, so the segments, the count, and Copy still show. + const header = (ref: Ref | undefined, starred: EditorFormat | null) => ( +
+ {/* The program's own copy has no scope of Dormouse's to expand. */} +
+ setCopyScope(terminalId, i)} + items={editor.scopes.map((s, i) => ({ id: i, label: s.label }))} + /> + {editor.scopes.length > 1 && hint(<>e expand {SHIFT}e shrink)} +
+
+ setCopyFormat(terminalId, f)} + items={formats.map((f) => ({ + id: f, + label: `${formatName(f)}${f === starred ? '*' : ''}`, + dim: !!sameAs[f], + title: sameAs[f] ? `${FORMAT_BLURBS[f]}: same text as ${formatName(sameAs[f]!)}` : FORMAT_BLURBS[f], + }))} + /> + {hint(<>f {SHIFT}f)} +
+
+ ); + // On touch, Copy is a full-width row of its own, a thumb's height. + const footer = (ref: Ref | undefined, count: string, state: CopyState) => { + const copy = ( + + ); + return ( +
+
+ + {MARK_GLYPH.keep} kept + {MARK_GLYPH.space} space + {MARK_GLYPH.none} joined + {expanded && abc expanded} + + {nudgeable && hint(<>{LEFT}{RIGHT} end {SHIFT}{LEFT}{RIGHT} start)} + {count} + {hint({COPY_CHORD_LABEL})} + {!touchUi && copy} +
+ {touchUi && copy} +
+ ); + }; + + // Rendered again only when what it measures, or the format, changes. + const chromeProbe = useMemo(() => ( +
+ {header(undefined, formats[0])} + {footer(undefined, widestCount, 'idle')} +
+ ), [...chromeDeps, editor.format]); + + // On touch the root pads the visible surface with a `TOUCH_SLOP_PX` margin + // (spec §4.5): a press that just misses lands on the editor itself, so the + // handlers below and the outside-press check make it inert, and no control + // sits under it. + const root = ( +
{ + e.stopPropagation(); + const preview = previewRef.current; + const onScrollbar = e.target === preview && preview !== null && ( + (preview.scrollHeight > preview.clientHeight && e.nativeEvent.offsetX >= preview.clientWidth) + || (preview.scrollWidth > preview.clientWidth && e.nativeEvent.offsetY >= preview.clientHeight) + ); + if (!onScrollbar) e.preventDefault(); + }} + onContextMenu={(e) => e.stopPropagation()} + > +
+ {header(headerRef, edited ? editor.format : null)} +
+ +
+ {footer(footerRef, `${lines} ${lines === 1 ? 'line' : 'lines'} · ${rendering.text.length} ch`, copyState)} +
+ +
+ {chromeProbe} +
+ +
+
+
+ ); + + // In `document.body`, free of the pane's clipping and of any Workspace's + // stacking context; the anchor keeps its presses inside the pane for DOM + // containment checks (`anchoredTarget`). + return ( + <> +