From b09e39e078e69d62304294f0c39788d34052397a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 15:08:01 -0700 Subject: [PATCH 01/38] Initial spec. --- docs/specs/mouse-and-clipboard.md | 343 ++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/specs/mouse-and-clipboard.md diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md new file mode 100644 index 0000000..12b1931 --- /dev/null +++ b/docs/specs/mouse-and-clipboard.md @@ -0,0 +1,343 @@ +# Terminal Mouse and Clipboard Behavior Specification + +## Overview + +This document specifies the mouse-handling and clipboard (copy and paste) behavior for a mouse-friendly terminal emulator intended to serve both new and experienced users across macOS, Linux, and Windows. The core design goal is to make text selection, copying, pasting, and mouse-driven interaction with TUI programs coexist cleanly, with visible state and predictable transitions between modes. + +## Background: The Two Mouse Regimes + +At any moment, mouse events in the terminal belong to one of two consumers: + +1. **The terminal itself.** Drags paint a selection on the terminal surface; clicks position a cursor or interact with terminal chrome. This is the default. +2. **The running application inside the terminal.** When a program emits a mouse-reporting escape sequence (e.g. `\e[?1000h`, `\e[?1002h`, `\e[?1003h`, with optional `\e[?1006h` SGR encoding), the terminal forwards mouse events to the program as input. Programs such as `tmux`, `vim`, `less`, and `htop` use this. The terminal's own selection behavior becomes unreachable while mouse reporting is active. + +The terminal must make the current regime visible, provide a way for the user to override it when they want to select text, and preserve useful selection actions (copy, copy-rewrapped, extend-to-URL, etc.) across both regimes. + +## Terminology + +- **Live region:** the portion of the terminal showing the active screen buffer (what the running program is currently drawing). +- **Scrollback:** the history of previously-drawn content above the live region. +- **Mouse reporting:** the state in which the inside program has requested and is receiving mouse events. +- **Override:** a state in which the terminal intercepts mouse events for selection purposes even though the inside program has requested mouse reporting. +- **Mouse icon:** a header indicator showing the current mouse regime. + +--- + +## 1. The Mouse Icon (Header Indicator) + +### 1.1 Visibility + +- When the inside program has **not** requested mouse reporting: no icon is shown. +- When the inside program **has** requested mouse reporting: a **Mouse icon** is shown in the terminal header. +- When the user has activated an override: the Mouse icon is replaced by a **No-Mouse icon** in the same header location. + +**Implementation note:** The Mouse icon is rendered as `CursorClickIcon`. The No-Mouse icon is rendered as `ProhibitIcon` composited on top of the same `CursorClickIcon` (not as a separate replacement glyph), so the two states share a consistent base and the override state reads visually as "cursor, but prohibited." + +### 1.2 Hover Text + +- Mouse icon hover text: `TUI is intercepting mouse commands. Click to override.` +- No-Mouse icon hover text: `You're overriding the TUI's mouse capture. Click to restore.` + +### 1.3 Click Behavior + +- Clicking the **Mouse icon** activates a **temporary override** (see §2). +- Clicking the **No-Mouse icon** ends the override immediately and restores mouse reporting to the inside program. + +--- + +## 2. Override State + +### 2.1 Temporary Override + +Activated by clicking the Mouse icon. While the temporary override is active: + +- Mouse events are handled by the terminal, not forwarded to the inside program. +- The Mouse icon is replaced with the No-Mouse icon. +- A banner appears next to the No-Mouse icon reading: + `Temporary mouse override until mouse-up. [Make permanent] [Cancel]` +- The override persists until the **next mouse-up event inside the terminal content area** (live region or scrollback). The click on the No-Mouse icon itself, or on the banner's buttons, does **not** count as that mouse-up. +- After that mouse-up, the override automatically ends: mouse reporting is restored to the inside program, the banner is dismissed, and the icon reverts to the Mouse icon. + +### 2.2 Making the Override Permanent + +- Clicking **[Make permanent]** in the banner converts the temporary override into a permanent one. +- The banner is dismissed. +- The No-Mouse icon remains visible with its "click to restore" hover text. +- The override persists until the user clicks the No-Mouse icon, or until the inside program stops requesting mouse reporting. + +### 2.3 Canceling the Temporary Override + +- Clicking **[Cancel]** in the banner ends the override immediately. +- The banner is dismissed, mouse reporting is restored, and the icon reverts to the Mouse icon. + +### 2.4 No Keyboard Activation + +The mouse icon, No-Mouse icon, and banner buttons are mouse-only. They are not keyboard-activatable. + +### 2.5 Edge Case: No Drag After Override + +If the user activates an override and then never performs a mouse action, the override remains in place indefinitely (temporary overrides simply wait for their terminating mouse-up). This is acceptable; no timeout is implemented. + +--- + +## 3. Selection Behavior + +Selection is available whenever the terminal is handling mouse events — that is, whenever mouse reporting is not active, or an override is in effect, or the drag originates in scrollback (see §3.6). + +### 3.1 Initiating a Selection + +- A click-and-drag in the terminal content area begins a selection. +- The selection is rendered by the terminal in a compositor layer **above** the cell grid, not by writing into the grid. This avoids conflicts with programs redrawing the screen. + +### 3.2 Selection Shapes + +- **Linewise (default):** click-and-drag selects text in reading order, wrapping from end-of-line to start-of-next-line. +- **Block (rectangular):** hold **Alt** (Option on macOS) during the drag to select a rectangular region. +- The selection shape updates live as Alt is pressed and released during the drag: pressing Alt mid-drag converts the current selection to block; releasing Alt converts it back to linewise. + +### 3.3 Selection Hint Text + +While a drag is in progress, a small hint is displayed above the selection: + +- `Hold Alt for block selection` + +The hint is always shown during an active drag. It does not fade with use. + +When a URL or path token is detected near the current drag position, an additional extension hint (`Press e to select the full URL` / `Press e to select the full path`) is shown alongside it. See §5 for full details. + +### 3.4 Selection Follows Content + +The selection is anchored to the characters under it, not to screen coordinates. + +- **Pure scroll:** if content scrolls (translates vertically with no character changes), the selection scrolls with it. This is coordinate math only; no matching is required. +- **Content change:** if any character within the selection's coordinate range changes, the selection is immediately canceled. The user can try again. +- There is no partial-match or content-tracking heuristic. Cancel-on-change is the rule. + +### 3.5 Selection in the Live Region vs. Scrollback + +- **Live region:** selection is available only when mouse reporting is off, or an override is in effect. +- **Scrollback:** selection is **always** available, regardless of mouse reporting or override state. The override state of the Mouse icon is irrelevant for drags that originate in scrollback. +- **Crossing the boundary:** a drag that begins in scrollback and continues into the live region is allowed and produces a single continuous selection. A drag that begins in the live region while mouse reporting is active (with no override) is forwarded to the inside program, not treated as a selection. + +### 3.6 Ending a Selection + +- Releasing the mouse button ends the drag and fixes the selection. +- The selection popup (§4) appears. +- The selection persists until the user acts on it (copy, extend, etc.), clicks elsewhere to dismiss it, presses **Esc**, or the underlying content changes. + +--- + +## 4. Selection Popup + +When a selection is finalized, a popup appears near the selection with action buttons. + +### 4.1 Copy Buttons + +The popup always shows two primary copy buttons: + +- `[Cmd+C] Copy Raw` +- `[Cmd+Shift+C] Copy Rewrapped` + +On non-macOS platforms, the labels show `Ctrl` and `Ctrl+Shift` respectively. + +#### 4.1.1 Copy Raw + +Copies the selected text to the system clipboard exactly as it appears in the terminal cells, including hard line breaks and any box-drawing or decorative characters. + +#### 4.1.2 Copy Rewrapped + +Copies the selected text with two transformations applied: + +1. **Unwrap hard line breaks** that appear to be display wrapping rather than intentional paragraph breaks. (Heuristics for distinguishing the two are implementation-defined and expected to evolve; a reasonable starting point is to unwrap single newlines inside what appears to be a flowing paragraph and preserve blank lines as paragraph separators.) +2. **Strip box-drawing characters** that form UI chrome around text (e.g. `┌`, `─`, `│`, `└`, `╭`, `═`, etc.) when they appear to be part of a textbox, table border, or decorative frame rather than intentional content. + +### 4.2 Keyboard Shortcuts + +While a selection is active: + +- **Cmd+C** (Ctrl+C on non-macOS) triggers Copy Raw. +- **Cmd+Shift+C** (Ctrl+Shift+C on non-macOS) triggers Copy Rewrapped. + +These shortcuts work whether or not the popup is visible and whether or not the user has clicked on it. The shortcuts take precedence over any shell interrupt behavior while a selection is active. + +### 4.3 Dismissing the Popup + +- Pressing **Esc** dismisses the popup and cancels the selection. +- Clicking outside the selection dismisses the popup and cancels the selection. +- Performing a copy action dismisses the popup but leaves the selection visible briefly (implementation-defined; a short fade is reasonable). + +### 4.4 Extensibility + +The popup is designed to accommodate additional copy modes in the future (e.g. strip ANSI codes, strip line numbers, strip prompt markers). These will appear as additional buttons or within a `...` overflow menu. The initial implementation ships with only Copy Raw and Copy Rewrapped. + +--- + +## 5. Smart Extension (URL / Path Detection) + +Smart extension is offered **mid-drag**, in parallel with the Alt block-selection modifier (§3.2–§3.3). During an active drag, the terminal continuously examines the characters at and immediately around the current selection for a URL-shaped or path-shaped token; if one is detected, a hint is shown above the selection inviting the user to press **e** to extend to the full token. + +### 5.1 Detection + +While a drag is in progress, the terminal continuously examines the characters at and immediately around the current selection for a URL-shaped or path-shaped token. A token is whitespace-delimited and matches one of: + +- A URL (e.g. `https?://...`, `file://...`). +- An absolute path (e.g. `/...`, `~/...`). +- A relative path (e.g. `./...`, `../...`). +- A Windows-style path (e.g. `C:\...`). +- An error location pattern (e.g. `file.ext:line`, `file.ext:line:col`). + +A token qualifies for extension only if it is not already fully covered by the current selection. + +### 5.2 Mid-Drag Hint + +When a qualifying token is detected during a drag, a hint is shown above the selection, alongside the existing `Hold Alt for block selection` hint: + +- `Press e to select the full URL` (for URLs) +- `Press e to select the full path` (for paths and error locations) + +The hint appears and disappears live as the drag moves into and out of qualifying tokens. If no qualifying token is present at the current drag position, no extension hint is shown. + +### 5.3 Extension Action + +- Pressing **e** during a drag, while the hint is visible, immediately extends the selection to cover the full detected token. +- After extension, the drag continues normally: further mouse movement updates the selection from the extended boundary, and the Alt modifier continues to toggle block-selection shape. +- If **e** is pressed when no qualifying token is present, the keypress is ignored. +- Pressing **e** has no effect after the drag has ended (i.e. once the popup has appeared, §4). Extension is a mid-drag action only. + +### 5.4 Interaction with Selection Completion + +When the user releases the mouse button, the selection is finalized at whatever boundaries the drag (including any `e`-extensions) produced. The popup (§4) then appears with the standard copy actions operating on the final selection. + +### 5.5 Simplicity Bound + +The initial implementation offers only the single extension step described above. There is no multi-level extension (token → line → paragraph) and no "open URL" or "open in editor" action in the popup. These may be added later. + +--- + +## 6. Interaction Summary + +### 6.1 State Matrix + +| Inside program requests mouse | Override active | Drag in live region goes to... | Drag in scrollback goes to... | +|-------------------------------|-----------------|--------------------------------|-------------------------------| +| No | — | Terminal (selection) | Terminal (selection) | +| Yes | No | Inside program | Terminal (selection) | +| Yes | Temporary | Terminal (selection), ends on mouse-up | Terminal (selection) | +| Yes | Permanent | Terminal (selection) | Terminal (selection) | + +### 6.2 Header Icon States + +| Condition | Icon shown | Banner shown | +|-----------------------------------------------------------|---------------|--------------------------------------------------------| +| Inside program does not request mouse reporting | None | None | +| Inside program requests mouse, no override | Mouse | None | +| Temporary override active | No-Mouse | `Temporary mouse override until mouse-up. [Make permanent] [Cancel]` | +| Permanent override active | No-Mouse | None | + +--- + +## 7. Rendering Notes + +- The selection highlight is rendered in a compositor layer above the cell grid. +- The header icon and banner are part of persistent terminal chrome and are not affected by inside-program redraws. +- The selection popup is rendered above the cell grid and anchored to the selection; it should reposition if the selection moves due to scroll, and dismiss if the selection is canceled. +- All hint text (`Hold Alt for block selection`, `Press e to select the full URL`, etc.) is rendered by the terminal above the cell grid and does not interfere with the inside program's output. + +--- + +## 9. Paste Behavior + +### 9.1 Overview + +Paste is the inverse of copy: the terminal reads the system clipboard and writes the content to the PTY as if it had been typed. Paste keystrokes are **intercepted by the terminal**, not forwarded to the inside program. The inside program only receives the pasted bytes (optionally wrapped in bracketed-paste markers; see §9.5). + +Paste behavior differs by platform to match each OS's native convention. + +### 9.2 Paste Keybindings + +#### 9.2.1 macOS + +| Keystroke | Behavior | +|----------------|----------------------------------------------------| +| **Cmd+V** | Terminal intercepts and performs a bracketed paste. | +| **Cmd+Shift+V**| Terminal intercepts and performs a bracketed paste. (Alias for Cmd+V.) | +| **Ctrl+V** | Not intercepted. Forwarded to the inside program as the raw control byte `0x16`. | + +macOS users have a clean separation: Cmd is the paste modifier, Ctrl is passed through to the program. No escape hatch is needed. + +#### 9.2.2 Windows and Linux + +| Keystroke | Behavior | +|-----------------|----------------------------------------------------| +| **Ctrl+V** | Terminal intercepts and performs a bracketed paste (default). | +| **Ctrl+Shift+V**| Terminal intercepts and performs a bracketed paste. (Alias for Ctrl+V, matches convention from Linux terminals and Windows Terminal.) | + +Because Ctrl+V is needed as both the paste shortcut (user expectation from every other app) and as the raw control byte `0x16` (for shell `quoted-insert`, vim literal-next, etc.), Ctrl+V is always intercepted and the raw byte is not sent to the inside program by this key. Users needing to send `0x16` can use the mechanism in §9.3. + +### 9.3 Sending `0x16` on Windows and Linux (Ctrl+Q) + +Users needing to insert a literal control character at a shell prompt can use the existing readline feature: press **Ctrl+Q**, then the desired key. This is a feature of bash, zsh, fish, and other readline-aware shells; the terminal does nothing special to enable it. This handles the most common occasional use case (inserting a literal Tab, Esc, or other control byte in the shell) without requiring any terminal-level escape hatch. + +This mechanism is documentation-only from the terminal's perspective: it works because the shell already supports it. No equivalent is provided for programs that do not support Ctrl+Q-style `quoted-insert` (e.g. vim insert mode, where `0x16` is the default literal-next key and has been taken over by paste). See §10.2 for deferred alternatives. + +### 9.4 Platform Detection + +The terminal detects its platform at startup and configures paste keybindings accordingly. There is no "pretend to be macOS on Linux" mode or equivalent; each platform gets its native convention by default. + +### 9.5 Bracketed Paste + +All pastes performed by the terminal are **bracketed** when the inside program has opted in via `\e[?2004h`: + +- The terminal writes `\e[200~`, followed by the clipboard content, followed by `\e[201~`, to the PTY. +- If the inside program has not opted in (or has opted out via `\e[?2004l`), the content is written without brackets. + +This is standard xterm behavior and is mandatory. It allows shells and TUIs to distinguish pasted content from typed input (e.g. to not execute newlines immediately, to highlight pasted text, or to confirm before running pasted commands). + +### 9.6 Paste Content + +The initial implementation pastes plain text only: + +- If the clipboard contains text, that text is written to the PTY. +- If the clipboard contains a file URL (e.g. from Finder or Explorer), the path is written to the PTY as text. This is the standard behavior across terminals and enables the file-attachment workflows used by Claude Code and similar tools. +- If the clipboard contains non-text content with no text or file-URL representation (e.g. a raw screenshot image from the system screenshot tool), the paste is a no-op. A brief notification may be shown: `Clipboard contains no pasteable content.` + +Richer paste behavior — such as image/file detection with a paste popup, content-aware transformations (strip trailing whitespace, normalize line endings, convert smart quotes), paste history, credential detection, and preview thumbnails — is out of scope for the initial implementation. See §10. + +### 9.7 Right-Click and Menu Paste + +- **Right-click menu:** the terminal's context menu includes a **Paste** item that performs the same bracketed paste as the keyboard shortcut. +- **Edit menu:** on macOS, the standard **Edit → Paste** menu item is wired to the same action. + +These provide a mouse-driven path for users who don't know or don't remember the keyboard shortcut. + +--- + +## 10. Out of Scope for Initial Implementation + +The following were considered and explicitly deferred: + +### 10.1 Mouse and Selection + +- Additional copy modes beyond Raw and Rewrapped (strip ANSI, strip line numbers, strip prompts, join hyphenated line-breaks). +- Contextual actions in the popup (Open URL, Open in $EDITOR, Copy hash). +- Multi-level `e` extension (token → line → paragraph). +- Fading or suppressing hint text as the user becomes experienced. +- A "quiet mode" setting to suppress chrome for experienced users. +- Content-matching selection tracking when the underlying content changes (current behavior is cancel-on-change). +- Keyboard activation of the mouse icon and banner buttons. +- Timeout behavior when a temporary override is activated but never used. + +### 10.2 Paste + +- A settings toggle to disable Ctrl+V interception on Windows and Linux (making Ctrl+V send `0x16` to the inside program and leaving Ctrl+Shift+V as the sole paste shortcut). Intended for power users who work predominantly in vim, Emacs, or other programs where `0x16`-as-literal-next is a frequent action. Deferred from the initial implementation. +- A paste popup (parallel to the copy popup) for previewing or transforming paste content before it is committed. +- Paste content transformations (strip trailing whitespace, normalize line endings, convert smart quotes, etc.). +- Image paste: detecting image data on the clipboard and offering to paste it as a file path (saved to a temp file) or as inline base64-encoded data. +- File paste beyond plain file-URL-as-text: offering to paste file contents as text, thumbnails in a preview, etc. +- Paste history (a buffer of recent pastes accessible via a shortcut or menu). +- Credential-shaped content detection and warnings. +- Multi-line paste confirmation dialogs ("this paste contains newlines and will execute immediately"). +- A "literal next keystroke" terminal-level shortcut (Ctrl+Alt+V or similar) to send `0x16` or other control bytes in programs that don't support Ctrl+Q-style `quoted-insert`. +- A Ctrl+V-pastes toggle on macOS (macOS users almost never want this, so it is not exposed unless requested). + +These may be revisited based on user feedback after the initial implementation ships. \ No newline at end of file From 9c77f8e1e90e0f45b19f26b46b8d6c29e24ca795 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 15:42:09 -0700 Subject: [PATCH 02/38] Update the spec. --- docs/specs/mouse-and-clipboard.md | 60 +++++++++++++++++++------------ 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 12b1931..4bf1ca4 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -55,7 +55,7 @@ Activated by clicking the Mouse icon. While the temporary override is active: - The Mouse icon is replaced with the No-Mouse icon. - A banner appears next to the No-Mouse icon reading: `Temporary mouse override until mouse-up. [Make permanent] [Cancel]` -- The override persists until the **next mouse-up event inside the terminal content area** (live region or scrollback). The click on the No-Mouse icon itself, or on the banner's buttons, does **not** count as that mouse-up. +- The override persists until the **next mouse-up event inside the terminal content area** (live region or scrollback) that is paired with a prior mouse-down in the same area. The click on the No-Mouse icon itself, the banner's buttons, and any "orphan" mouse-up from a drag that started outside the terminal do **not** count as that mouse-up. - After that mouse-up, the override automatically ends: mouse reporting is restored to the inside program, the banner is dismissed, and the icon reverts to the Mouse icon. ### 2.2 Making the Override Permanent @@ -82,7 +82,7 @@ If the user activates an override and then never performs a mouse action, the ov ## 3. Selection Behavior -Selection is available whenever the terminal is handling mouse events — that is, whenever mouse reporting is not active, or an override is in effect, or the drag originates in scrollback (see §3.6). +Selection is available whenever the terminal is handling mouse events — that is, whenever mouse reporting is not active, or an override is in effect, or the drag originates in scrollback (see §3.5). ### 3.1 Initiating a Selection @@ -110,7 +110,8 @@ When a URL or path token is detected near the current drag position, an addition The selection is anchored to the characters under it, not to screen coordinates. - **Pure scroll:** if content scrolls (translates vertically with no character changes), the selection scrolls with it. This is coordinate math only; no matching is required. -- **Content change:** if any character within the selection's coordinate range changes, the selection is immediately canceled. The user can try again. +- **Content change:** if any cell overlapped by the selection changes, the selection is immediately canceled. Repaints outside the selected cells (e.g. a status line, clock, or progress bar elsewhere on screen) are irrelevant and do not cancel the selection. +- **Terminal resize:** a resize counts as a content change and cancels any active selection. - There is no partial-match or content-tracking heuristic. Cancel-on-change is the rule. ### 3.5 Selection in the Live Region vs. Scrollback @@ -119,11 +120,17 @@ The selection is anchored to the characters under it, not to screen coordinates. - **Scrollback:** selection is **always** available, regardless of mouse reporting or override state. The override state of the Mouse icon is irrelevant for drags that originate in scrollback. - **Crossing the boundary:** a drag that begins in scrollback and continues into the live region is allowed and produces a single continuous selection. A drag that begins in the live region while mouse reporting is active (with no override) is forwarded to the inside program, not treated as a selection. -### 3.6 Ending a Selection +### 3.6 During a Drag + +- **Auto-scroll:** if the mouse reaches the top or bottom edge of the viewport during a drag, the terminal scrolls in that direction at a modest rate so the selection can extend beyond the visible region. Auto-scroll stops when the cursor moves back inside the viewport or the mouse button is released. +- **Keyboard routing:** while a terminal-handled drag is in progress, the terminal consumes keystrokes relevant to the drag — **Alt** for block-selection shape (§3.2), **e** for smart extension (§5), **Esc** to cancel the drag and any in-progress selection. All other keystrokes are ignored and **not** forwarded to the inside program for the duration of the drag. Normal keyboard routing resumes when the mouse button is released. + +### 3.7 Ending a Selection - Releasing the mouse button ends the drag and fixes the selection. - The selection popup (§4) appears. - The selection persists until the user acts on it (copy, extend, etc.), clicks elsewhere to dismiss it, presses **Esc**, or the underlying content changes. +- Starting a new drag (mouse-down in the terminal content area) immediately replaces any existing selection with the new one; the previous popup is dismissed. --- @@ -153,12 +160,12 @@ Copies the selected text with two transformations applied: ### 4.2 Keyboard Shortcuts -While a selection is active: +While the terminal has an active selection: - **Cmd+C** (Ctrl+C on non-macOS) triggers Copy Raw. - **Cmd+Shift+C** (Ctrl+Shift+C on non-macOS) triggers Copy Rewrapped. -These shortcuts work whether or not the popup is visible and whether or not the user has clicked on it. The shortcuts take precedence over any shell interrupt behavior while a selection is active. +These shortcuts work whether or not the popup is visible and whether or not the user has clicked on it. The precedence rule is narrow: Ctrl+C is intercepted as Copy Raw **only** when a terminal selection is active. With no terminal selection, Ctrl+C is forwarded to the inside program as usual (SIGINT for shells, app-defined behavior for TUIs). An in-program selection maintained by a TUI (e.g. vim visual mode, less search highlight) is **not** a terminal selection for this purpose and does not change Ctrl+C routing. ### 4.3 Dismissing the Popup @@ -186,6 +193,8 @@ While a drag is in progress, the terminal continuously examines the characters a - A Windows-style path (e.g. `C:\...`). - An error location pattern (e.g. `file.ext:line`, `file.ext:line:col`). +Trailing characters that are unlikely to be part of the token — `.`, `,`, `;`, `:`, `!`, `?`, and single/double quotes — are stripped from the detected token's end before it is offered for extension. Unmatched closing brackets (`)`, `]`, `}`, `>`) are also stripped, but matched pairs are preserved (e.g. `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its trailing `)`). + A token qualifies for extension only if it is not already fully covered by the current selection. ### 5.2 Mid-Drag Hint @@ -202,7 +211,9 @@ The hint appears and disappears live as the drag moves into and out of qualifyin - Pressing **e** during a drag, while the hint is visible, immediately extends the selection to cover the full detected token. - After extension, the drag continues normally: further mouse movement updates the selection from the extended boundary, and the Alt modifier continues to toggle block-selection shape. - If **e** is pressed when no qualifying token is present, the keypress is ignored. +- Pressing **e** a second time when the same token is already fully covered by the selection is a no-op (per §5.1's qualification rule). - Pressing **e** has no effect after the drag has ended (i.e. once the popup has appeared, §4). Extension is a mid-drag action only. +- Per §3.6, the `e` keystroke (and all others) is consumed by the terminal during a terminal-handled drag and is not forwarded to the inside program. ### 5.4 Interaction with Selection Completion @@ -245,17 +256,17 @@ The initial implementation offers only the single extension step described above --- -## 9. Paste Behavior +## 8. Paste Behavior -### 9.1 Overview +### 8.1 Overview -Paste is the inverse of copy: the terminal reads the system clipboard and writes the content to the PTY as if it had been typed. Paste keystrokes are **intercepted by the terminal**, not forwarded to the inside program. The inside program only receives the pasted bytes (optionally wrapped in bracketed-paste markers; see §9.5). +Paste is the inverse of copy: the terminal reads the system clipboard and writes the content to the PTY as if it had been typed. Paste keystrokes are **intercepted by the terminal**, not forwarded to the inside program. The inside program only receives the pasted bytes (optionally wrapped in bracketed-paste markers; see §8.5). Paste behavior differs by platform to match each OS's native convention. -### 9.2 Paste Keybindings +### 8.2 Paste Keybindings -#### 9.2.1 macOS +#### 8.2.1 macOS | Keystroke | Behavior | |----------------|----------------------------------------------------| @@ -265,26 +276,26 @@ Paste behavior differs by platform to match each OS's native convention. macOS users have a clean separation: Cmd is the paste modifier, Ctrl is passed through to the program. No escape hatch is needed. -#### 9.2.2 Windows and Linux +#### 8.2.2 Windows and Linux | Keystroke | Behavior | |-----------------|----------------------------------------------------| | **Ctrl+V** | Terminal intercepts and performs a bracketed paste (default). | | **Ctrl+Shift+V**| Terminal intercepts and performs a bracketed paste. (Alias for Ctrl+V, matches convention from Linux terminals and Windows Terminal.) | -Because Ctrl+V is needed as both the paste shortcut (user expectation from every other app) and as the raw control byte `0x16` (for shell `quoted-insert`, vim literal-next, etc.), Ctrl+V is always intercepted and the raw byte is not sent to the inside program by this key. Users needing to send `0x16` can use the mechanism in §9.3. +Because Ctrl+V is needed as both the paste shortcut (user expectation from every other app) and as the raw control byte `0x16` (for shell `quoted-insert`, vim literal-next, etc.), Ctrl+V is always intercepted and the raw byte is not sent to the inside program by this key. Users needing to send `0x16` can use the mechanism in §8.3. -### 9.3 Sending `0x16` on Windows and Linux (Ctrl+Q) +### 8.3 Sending `0x16` on Windows and Linux (Ctrl+Q) Users needing to insert a literal control character at a shell prompt can use the existing readline feature: press **Ctrl+Q**, then the desired key. This is a feature of bash, zsh, fish, and other readline-aware shells; the terminal does nothing special to enable it. This handles the most common occasional use case (inserting a literal Tab, Esc, or other control byte in the shell) without requiring any terminal-level escape hatch. -This mechanism is documentation-only from the terminal's perspective: it works because the shell already supports it. No equivalent is provided for programs that do not support Ctrl+Q-style `quoted-insert` (e.g. vim insert mode, where `0x16` is the default literal-next key and has been taken over by paste). See §10.2 for deferred alternatives. +This mechanism is documentation-only from the terminal's perspective: it works because the shell already supports it. No equivalent is provided for programs that do not support Ctrl+Q-style `quoted-insert` (e.g. vim insert mode, where `0x16` is the default literal-next key and has been taken over by paste). See §9.2 for deferred alternatives. -### 9.4 Platform Detection +### 8.4 Platform Detection The terminal detects its platform at startup and configures paste keybindings accordingly. There is no "pretend to be macOS on Linux" mode or equivalent; each platform gets its native convention by default. -### 9.5 Bracketed Paste +### 8.5 Bracketed Paste All pastes performed by the terminal are **bracketed** when the inside program has opted in via `\e[?2004h`: @@ -293,7 +304,7 @@ All pastes performed by the terminal are **bracketed** when the inside program h This is standard xterm behavior and is mandatory. It allows shells and TUIs to distinguish pasted content from typed input (e.g. to not execute newlines immediately, to highlight pasted text, or to confirm before running pasted commands). -### 9.6 Paste Content +### 8.6 Paste Content The initial implementation pastes plain text only: @@ -301,9 +312,9 @@ The initial implementation pastes plain text only: - If the clipboard contains a file URL (e.g. from Finder or Explorer), the path is written to the PTY as text. This is the standard behavior across terminals and enables the file-attachment workflows used by Claude Code and similar tools. - If the clipboard contains non-text content with no text or file-URL representation (e.g. a raw screenshot image from the system screenshot tool), the paste is a no-op. A brief notification may be shown: `Clipboard contains no pasteable content.` -Richer paste behavior — such as image/file detection with a paste popup, content-aware transformations (strip trailing whitespace, normalize line endings, convert smart quotes), paste history, credential detection, and preview thumbnails — is out of scope for the initial implementation. See §10. +Richer paste behavior — such as image/file detection with a paste popup, content-aware transformations (strip trailing whitespace, normalize line endings, convert smart quotes), paste history, credential detection, and preview thumbnails — is out of scope for the initial implementation. See §9. -### 9.7 Right-Click and Menu Paste +### 8.7 Right-Click and Menu Paste - **Right-click menu:** the terminal's context menu includes a **Paste** item that performs the same bracketed paste as the keyboard shortcut. - **Edit menu:** on macOS, the standard **Edit → Paste** menu item is wired to the same action. @@ -312,11 +323,11 @@ These provide a mouse-driven path for users who don't know or don't remember the --- -## 10. Out of Scope for Initial Implementation +## 9. Out of Scope for Initial Implementation The following were considered and explicitly deferred: -### 10.1 Mouse and Selection +### 9.1 Mouse and Selection - Additional copy modes beyond Raw and Rewrapped (strip ANSI, strip line numbers, strip prompts, join hyphenated line-breaks). - Contextual actions in the popup (Open URL, Open in $EDITOR, Copy hash). @@ -326,8 +337,10 @@ The following were considered and explicitly deferred: - Content-matching selection tracking when the underlying content changes (current behavior is cancel-on-change). - Keyboard activation of the mouse icon and banner buttons. - Timeout behavior when a temporary override is activated but never used. +- Double-click to select word and triple-click to select line. These are standard terminal conventions and will almost certainly be added in a follow-up; they are deferred from the initial implementation to keep the first cut minimal. +- Concrete rules for Copy Rewrapped's unwrap / box-drawing-strip heuristics (§4.1.2). The spec intentionally leaves these implementation-defined; finalizing a first-cut ruleset and its test cases is tracked as a follow-up rather than open-ended work. -### 10.2 Paste +### 9.2 Paste - A settings toggle to disable Ctrl+V interception on Windows and Linux (making Ctrl+V send `0x16` to the inside program and leaving Ctrl+Shift+V as the sole paste shortcut). Intended for power users who work predominantly in vim, Emacs, or other programs where `0x16`-as-literal-next is a frequent action. Deferred from the initial implementation. - A paste popup (parallel to the copy popup) for previewing or transforming paste content before it is committed. @@ -339,5 +352,6 @@ The following were considered and explicitly deferred: - Multi-line paste confirmation dialogs ("this paste contains newlines and will execute immediately"). - A "literal next keystroke" terminal-level shortcut (Ctrl+Alt+V or similar) to send `0x16` or other control bytes in programs that don't support Ctrl+Q-style `quoted-insert`. - A Ctrl+V-pastes toggle on macOS (macOS users almost never want this, so it is not exposed unless requested). +- Middle-click paste / X11 PRIMARY selection integration on Linux (auto-copy on selection and middle-click paste of PRIMARY, distinct from the CLIPBOARD used by Ctrl+C / Ctrl+V). Standard on many Linux terminals and frequently expected by Linux power users, but deferred to keep the initial clipboard model single-buffer and cross-platform consistent. These may be revisited based on user feedback after the initial implementation ships. \ No newline at end of file From 7e9dd439b26387dc34dba0c87db09b65bdcb3fec Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:14:54 -0700 Subject: [PATCH 03/38] Add a plan. --- docs/plans/mouse-and-clipboard-plan.md | 506 +++++++++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 docs/plans/mouse-and-clipboard-plan.md diff --git a/docs/plans/mouse-and-clipboard-plan.md b/docs/plans/mouse-and-clipboard-plan.md new file mode 100644 index 0000000..816e17f --- /dev/null +++ b/docs/plans/mouse-and-clipboard-plan.md @@ -0,0 +1,506 @@ +# Implementation Plan: Mouse and Clipboard + +Source spec: `docs/specs/mouse-and-clipboard.md`. Read it first — the spec is the contract; this plan is the build order and the technical approach. + +## Overview + +Build terminal-owned text selection, copy (Raw and Rewrapped), bracketed paste, smart URL/path extension, and a mouse-reporting override UI on top of xterm.js. The work lives almost entirely in `lib/`, touches `Pond.tsx` for keyboard interception and header chrome, and introduces one new runtime module (`mouse-selection.ts`) plus a small overlay component. xterm's built-in selection and mouse forwarding stay disabled for the cells we manage; we own mouse and the relevant keystrokes directly at the DOM level and use xterm only as the character grid + renderer. Detection of the inside program's mouse-reporting and bracketed-paste regimes uses xterm's public `terminal.modes` plus parser hooks. + +## Technical Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Selection ownership** | Terminal fully owns selection at the DOM layer; xterm's built-in selection is disabled. | The spec requires block shapes, compositor-layer highlight, cross-boundary (scrollback→live) drags, auto-scroll, and content-anchored cancel-on-change. xterm's API can't express all of that, and mixing its selection with ours causes double-highlight bugs. Owning it end-to-end is cleaner than fighting xterm's selection state machine. | +| **Mouse regime detection** | Read `terminal.modes.mouseTrackingMode` (public) and also install a `parser.registerCsiHandler` pair for DECSET/DECRST `?1000/?1002/?1003/?1006/?2004` that returns `false` (doesn't consume) so we observe changes synchronously with the escape sequence rather than polling. | The `modes` getter is the spec-intended public API but has no change event; the parser hook gives change notification without forking xterm. Returning `false` keeps xterm's own handling intact. | +| **Where the mouse/override state lives** | New module `lib/src/lib/mouse-selection.ts` with a `Map` paralleling `terminal-registry.ts`. Exposes a subscription API (same `subscribeTo*`/`get*Snapshot` shape as the existing session-state API) so React can `useSyncExternalStore` it. | Matches the existing pattern for per-terminal state (`getSessionStateSnapshot` in `terminal-registry.ts`). Keeping it in a separate file keeps `terminal-registry.ts` from growing further and is easy to unit-test without DOM. | +| **Mouse event interception point** | Attach capture-phase `mousedown`/`mousemove`/`mouseup`/`wheel` listeners directly to `entry.element` (the persistent div xterm renders into) inside `setupTerminalEntry` in `terminal-registry.ts`. Decide per-event whether to forward to xterm (let it bubble) or handle ourselves (call `stopPropagation`). | `entry.element` is the right scope: listeners follow the terminal through reparenting and don't leak across panes. Capture phase gives us first shot before xterm's own listeners. | +| **Keyboard interception point (Cmd+C, Cmd+V, etc.)** | Extend the existing window-level capture keydown handler in `Pond.tsx` (the one around line 1575 that already handles the LCmd→RCmd gesture). In `passthrough` mode it currently short-circuits — add the selection/paste shortcuts as the first check before that short-circuit. | Reuses the single keyboard entry point that already handles "capture before xterm." Avoids a second global listener. Also makes the shortcut behavior identical in command and passthrough mode, which the spec requires. | +| **Selection rendering** | Absolute-positioned `
` overlay child of `entry.element`, one highlight rectangle per selected row (linewise) or one rectangle total (block). Cell→pixel math uses xterm's public `terminal.cols`, `terminal.rows`, and the measured element bounding rect (`getBoundingClientRect()`) so cell width/height = `element.clientWidth / cols`, `element.clientHeight / rows`. | Public API only, no `_core` internals. The highlight div is pointer-events:none so it doesn't eat the drag. Re-render on scroll, resize, and selection change. | +| **Scrollback coordinate model** | Track the selection as `{ startRow, startCol, endRow, endCol, shape }` in **absolute buffer rows** (including scrollback), not viewport rows. Use `terminal.buffer.active.viewportY` to translate to viewport on render. | Matches the spec's "selection follows content on scroll." Pure-scroll becomes free (the translation naturally updates); cancel-on-change compares absolute-row cell contents between renders. | +| **Content-change detection (cancel-on-change, §3.4)** | On `terminal.onRender`, diff the cells in the selection's absolute-row range against a cached snapshot taken at selection start. Only cancel when a diff lands inside the rectangle, not elsewhere. | Cheap (bounded to selection size) and satisfies the spec's narrow rule. | +| **Clipboard API** | `navigator.clipboard.writeText(...)` from the webview directly. No platform-adapter round trip. Read is not needed for MVP paste (we write clipboard contents to the PTY — read is needed too; use `navigator.clipboard.readText()`). | VSCode webviews and Tauri webviews both expose the async Clipboard API. Keeps the adapter surface minimal. If a platform lacks permission we fall back with a toast. | +| **Platform detection (§8.4)** | `navigator.platform` / `navigator.userAgentData` at startup — test for macOS with `/Mac|iPhone|iPad/`. Store result in a small `isMac` helper in `lib/src/lib/platform/`. | All three host environments (standalone, VSCode webview, website) run in a real browser/webview, so this works uniformly. No host round-trip needed. | +| **Bracketed-paste toggling** | Read `terminal.modes.bracketedPasteMode` at paste time; wrap with `\e[200~` / `\e[201~` when true. No per-terminal cached flag needed since `modes` is already authoritative. | Simpler than mirroring state and guaranteed consistent with what the inside program last set. | +| **Copy Rewrapped heuristics** | Initial rules shipped as the first cut (see §4.1.2): (a) unwrap single newlines inside runs of non-blank lines where the previous line ends with a non-whitespace, non-sentence-terminator char and the next starts with lowercase/continuation; (b) strip leading/trailing runs of box-drawing Unicode (`U+2500–U+257F`, `U+2550–U+256C`, etc.) when they occupy full-width runs. Lives in `lib/src/lib/rewrap.ts` with a pure-function API and table-driven tests. | The spec leaves this implementation-defined but still requires *something* to ship. A standalone pure module makes it easy to iterate heuristics without touching the selection plumbing. | +| **Selection hint / popup positioning** | Render as children of `entry.element`, absolute-positioned using the same cell-pixel math. Clamp to viewport. | Co-located with the overlay so it follows the terminal on reparent (detach/reattach) without extra work. | +| **Storybook coverage** | One new story file per visual component: `MouseHeaderIcon.stories.tsx`, `SelectionPopup.stories.tsx`, `SelectionOverlay.stories.tsx` (rename the existing "selection ring" story file to `PaneSelectionRing.stories.tsx` to free the name). | Matches the project's existing Storybook-first component workflow. Chromatic already in CI. | + +## Story-by-Story Plan + +### Story A.1: Per-terminal mouse state module + +**What to build:** +A new module that owns the mouse-reporting / override / selection state for each terminal, plus a React subscription surface. + +**Files to create/modify:** +- `lib/src/lib/mouse-selection.ts` (new) — `MouseSelectionState` type, Map keyed by terminal id, `getSnapshot`/`subscribe`/`setMouseReporting`/`setOverride`/`setSelection`/etc., mirroring the shape of the session-state API in `terminal-registry.ts:63-91`. +- `lib/src/lib/mouse-selection.test.ts` (new) — state-machine tests (no DOM). + +**Approach:** +- State shape: `{ mouseReporting: 'none' | 'x10' | 'vt200' | 'drag' | 'any', bracketedPaste: boolean, override: 'off' | 'temporary' | 'permanent', selection: Selection | null, hintToken: TokenHint | null }`. +- Follow the exact pattern of `subscribeToSessionStateChanges` / `getSessionStateSnapshot` / cached-snapshot invalidation from `terminal-registry.ts`. The API will be consumed via `useSyncExternalStore`. +- No DOM imports; this module is pure state. + +**Test approach:** +- Unit tests for each state transition: mouse-reporting on/off, override on → temporary → permanent → off, override auto-end when program stops requesting mouse reporting. +- Verify snapshot cache invalidation on every change (listener called exactly once per change). + +**Risk/complexity:** Low. + +**Dependencies:** None. + +--- + +### Story A.2: Mouse-regime and bracketed-paste detection + +**What to build:** +Wire xterm's DEC private mode sequences into the mouse-selection store so every terminal reports its current regime. + +**Files to create/modify:** +- `lib/src/lib/terminal-registry.ts` — in `setupTerminalEntry` (around line 307), after `terminal.open(element)`, register parser hooks for `CSI ? ... h` and `CSI ? ... l` that call `mouseSelection.setMouseReporting` / `setBracketedPaste` for params `1000, 1002, 1003, 1006, 2004`. Handlers return `false` so xterm still processes them. On terminal dispose, dispose the hook registrations. +- `lib/src/lib/terminal-registry.alarm.test.ts` and/or a new `terminal-registry.mouse.test.ts` — verify hooks fire on DECSET/DECRST sequences written to the terminal. + +**Approach:** +- Use `terminal.parser.registerCsiHandler({ final: 'h', prefix: '?' }, params => { ...; return false })` and the matching `'l'` variant (xterm's `IFunctionIdentifier` accepts `prefix` and `final`). +- After every change, also read `terminal.modes.mouseTrackingMode` and `terminal.modes.bracketedPasteMode` as the authoritative source (belt-and-suspenders; the mode getter is the one the rest of the system queries). +- When `mouseReporting` transitions from non-`none` → `none`, auto-end any active override and snapshot the state (§2). + +**Test approach:** +- Feed a test terminal `\x1b[?1000h`, `\x1b[?1006h`, `\x1b[?2004h`, verify `mouseSelection.getState(id)` reflects each change. +- Feed `\x1b[?1000l` after an override was active — verify override auto-cleared. + +**Risk/complexity:** Low-Medium — need to confirm parser hooks fire even when xterm has its own handler (spec says they do, but verify). + +**Dependencies:** A.1. + +--- + +### Story A.3: Platform detection helper + +**What to build:** +A tiny `isMac()` / `modKeyIsMeta` helper that paste/copy code keys off. + +**Files to create/modify:** +- `lib/src/lib/platform/index.ts` — add `export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)`. +- Any consumers import from `../platform`. + +**Approach:** Constant, computed once at module load. No tests needed. + +**Risk/complexity:** Low. + +**Dependencies:** None. + +--- + +### Story B.1: Mouse / No-Mouse header icon + +**What to build:** +The header indicator (§1) with its two states, hover text, and click behavior. + +**Files to create/modify:** +- `lib/src/components/Pond.tsx` — inside `TerminalPaneHeader` (line 525), add a new icon button slot rendered between the title and the alarm bell, using the same `HeaderActionButton` wrapper and phosphor icons. Use `CursorClickIcon` for the base; composite `ProhibitIcon` on top for the override state (the spec calls this out explicitly). Hidden when `mouseReporting === 'none'` and no override is active. +- `lib/src/stories/MouseHeaderIcon.stories.tsx` (new) — four stories: reporting-off, reporting-on, temporary-override, permanent-override. + +**Approach:** +- Subscribe to `mouseSelection` via `useSyncExternalStore` the same way `TerminalPaneHeader` already subscribes to session state (line 531). +- Click handler calls into `mouseSelection.setOverride('temporary')` / `mouseSelection.setOverride('off')`. +- Use existing `HeaderActionButton` so tooltip and styling match the alarm bell. +- Tooltip text comes verbatim from spec §1.2. + +**Test approach:** +- Storybook visual coverage (Chromatic picks it up). +- No new unit tests — state transitions are already covered by A.1. + +**Risk/complexity:** Low. + +**Dependencies:** A.1, A.2. + +--- + +### Story B.2: Temporary-override banner + +**What to build:** +The banner next to the No-Mouse icon with `[Make permanent]` and `[Cancel]` buttons (§2). + +**Files to create/modify:** +- `lib/src/components/Pond.tsx` — add a `TempOverrideBanner` component rendered next to the header icon; visible only when `override === 'temporary'`. +- `lib/src/lib/mouse-selection.ts` — add the "temporary override ends on next terminal-area mouse-up" logic. The banner click buttons route to `setOverride('permanent')` / `setOverride('off')`. The mouse-up logic lives in the mouse-event listeners wired up in C.1. + +**Approach:** +- Banner reuses Tailwind chrome tokens (`bg-surface-raised`, `text-foreground`, etc.); no new color choices. +- The spec's orphan-mouse-up rule (§2.1) is handled in C.1 where mouse events originate. + +**Test approach:** +- Storybook for both banner states. +- Unit test in `mouse-selection.test.ts`: override='temporary' + simulated mouse-up → override='off'; orphan mouse-up (no prior mouse-down) → override remains. + +**Risk/complexity:** Low. + +**Dependencies:** B.1. + +--- + +### Story C.1: Mouse event router + +**What to build:** +The DOM-level mouse listener on `entry.element` that decides, per event, whether the terminal or the inside program handles it — per the state matrix in spec §6.1. + +**Files to create/modify:** +- `lib/src/lib/terminal-registry.ts` — in `setupTerminalEntry`, add capture-phase `mousedown`/`mousemove`/`mouseup`/`wheel` listeners on `element`. Route to `mouse-selection.ts`'s `handleMouseEvent(id, ev)`. Register cleanup in the existing `cleanup` function (line 363). +- `lib/src/lib/mouse-selection.ts` — add `handleMouseEvent` that implements the state matrix: terminal-handled drags update selection, mouse-reported events are left alone to bubble to xterm, scrollback drags are always terminal-handled. +- Use `terminal.buffer.active.viewportY` to map mouse Y → absolute buffer row; cell X from `event.offsetX / cellWidth`. + +**Approach:** +- Before doing anything, classify the event: was it in scrollback (row < viewportY), in the live region, or on chrome? +- For events we handle, call `stopPropagation()` and update selection state. For events xterm should handle, do nothing (xterm's own listeners fire on bubble). +- Disable xterm's own right-click-selects-word (`terminal.options.rightClickSelectsWord = false`) and selection (there is no direct option; setting a custom theme where selectionBackground is transparent is one workaround, but the cleaner approach is `terminal.options.selectionBlacklist`? — **see Open Questions**). + +**Test approach:** +- Unit tests in `mouse-selection.test.ts` using synthetic event fixtures and a stub terminal that exposes `{ cols, rows, buffer: { active: { viewportY } } }`. +- Case coverage: terminal-handled drag in live region with reporting off, program-forwarded event with reporting on and no override, scrollback drag with reporting on (always terminal), cross-boundary drag. + +**Risk/complexity:** Medium — the interaction with xterm's own selection is the main unknown. See Open Questions. + +**Dependencies:** A.1, A.2. + +--- + +### Story C.2: Selection overlay rendering + +**What to build:** +The compositor-layer highlight that shows the current selection (§3.1, §7). + +**Files to create/modify:** +- `lib/src/components/SelectionOverlay.tsx` (new) — component that reads current selection for a given terminal id (via `useSyncExternalStore` on `mouse-selection.ts`) and renders absolute-positioned rectangles. +- `lib/src/components/TerminalPane.tsx` — add the overlay as a sibling of the xterm container inside the mount div. +- `lib/src/stories/SelectionOverlay.stories.tsx` — replace existing (which is actually the pane-selection ring; rename that to `PaneSelectionRing.stories.tsx` and free this name) with real text-selection overlay stories. + +**Approach:** +- Cell dimensions: measure once per resize via `terminal.element.clientWidth / terminal.cols` (via the registry's `element` and a ResizeObserver already in TerminalPane). +- Linewise shape: render N rectangles, one per row, clipped at selection's first/last columns for the first and last rows. +- Block shape: single rectangle. +- Use `--vscode-terminal-selectionBackground` as the fill color (already a CSS variable on the terminal theme — see `terminal-registry.ts:255`). +- Re-render on scroll, resize, and selection change. + +**Test approach:** +- Storybook: full-row linewise, mid-line linewise, block, multi-row block, selection that crosses scroll boundary. +- No unit tests — this is pure visual geometry; rely on Chromatic. + +**Risk/complexity:** Medium — cell-math + viewport-scroll coordination has edge cases (half-cells at viewport edges, wide characters). + +**Dependencies:** C.1. + +--- + +### Story C.3: Drag shapes + hint text + +**What to build:** +Linewise by default, block when Alt held; the `Hold Alt for block selection` hint above the drag (§3.2, §3.3). + +**Files to create/modify:** +- `lib/src/lib/mouse-selection.ts` — track `event.altKey` during each drag move; update `selection.shape` live so releasing Alt mid-drag reverts. +- `lib/src/components/SelectionOverlay.tsx` — render the hint tooltip above the active selection. Reuse `text-xs` Tailwind class (no fixed pixels — per memory). + +**Approach:** +- Add a window-level `keydown`/`keyup` listener during the drag only (attached in `handleMouseEvent` at drag-start, removed at drag-end) to catch Alt changes that happen while the mouse isn't moving. + +**Test approach:** +- Storybook: drag-in-progress linewise with hint, drag-in-progress block with hint. + +**Risk/complexity:** Low. + +**Dependencies:** C.1, C.2. + +--- + +### Story C.4: Selection follows content + +**What to build:** +Pure-scroll translates the selection; cell-change cancels it; resize cancels it (§3.4). + +**Files to create/modify:** +- `lib/src/lib/mouse-selection.ts` — add a `terminal.onRender(...)` and `terminal.onResize(...)` subscription per entry. On render, snapshot the selected cells and compare with the previous snapshot; on mismatch, call `setSelection(null)`. On resize, unconditionally `setSelection(null)`. + +**Approach:** +- Snapshot = packed string of cell characters over the selection's absolute-row range. Small (bounded by selection size), fast to compare. +- The selection model already uses absolute row numbers (Story C.1) so pure scroll is free — no update needed. + +**Test approach:** +- Unit test with a stub terminal that exposes `buffer.active.getLine(row).translateToString(col, endCol)`: mutate a cell inside the selection → canceled; mutate a cell outside → still active; scroll → still active. + +**Risk/complexity:** Medium — we're relying on `onRender` firing after each buffer change, which it does in xterm, but double-check for batched updates. + +**Dependencies:** C.1, C.2. + +--- + +### Story C.5: Auto-scroll during drag + +**What to build:** +If the drag reaches the top/bottom edge of the viewport, auto-scroll in that direction (§3.6). + +**Files to create/modify:** +- `lib/src/lib/mouse-selection.ts` — in the drag-move handler, when `event.offsetY < EDGE_PX` or `> height - EDGE_PX`, start a `setInterval` that calls `terminal.scrollLines(±1)`. Clear on move back into viewport or on mouse-up. + +**Approach:** +- `EDGE_PX = cellHeight * 0.75` or so. Scroll rate: one line per ~50ms. +- Continue updating the selection's `endRow`/`endCol` as the terminal scrolls (the user's mouse is stationary but the buffer is moving under it). + +**Test approach:** +- Manual in Storybook — hard to unit test reliably. + +**Risk/complexity:** Low. + +**Dependencies:** C.1, C.2. + +--- + +### Story C.6: Cross-boundary drags and scrollback override + +**What to build:** +Drags starting in scrollback are always terminal-handled regardless of mouse reporting; drags that cross from scrollback into the live region stay terminal-handled (§3.5). + +**Files to create/modify:** +- `lib/src/lib/mouse-selection.ts` — at mousedown, record `startedInScrollback = startRow < viewportY`. If true, capture all subsequent move/up events for this drag even if mouse reporting is on. + +**Approach:** +- Already mostly covered by the mouse router (C.1); this is a specific branch in the classifier. + +**Test approach:** +- Unit test: mousedown in scrollback with reporting on → drag is terminal-handled; mousedown in live region with reporting on → event forwarded to program. + +**Risk/complexity:** Low. + +**Dependencies:** C.1. + +--- + +### Story D.1: Selection popup + +**What to build:** +The popup near the completed selection with `[Cmd+C] Copy Raw` and `[Cmd+Shift+C] Copy Rewrapped` (§4). + +**Files to create/modify:** +- `lib/src/components/SelectionPopup.tsx` (new) — positioned via the same cell-pixel math as the overlay; renders at the end of the selection or clamped to viewport. +- `lib/src/stories/SelectionPopup.stories.tsx` (new). +- Labels switch on `IS_MAC` (A.3): Cmd vs Ctrl. + +**Approach:** +- Appears on mouse-up (§3.7). Dismissed on Esc, click-outside, or content change. +- Use `text-xs`, existing surface/border tokens — no new design language. + +**Test approach:** +- Storybook. + +**Risk/complexity:** Low. + +**Dependencies:** C.1, C.2. + +--- + +### Story D.2: Copy Raw + Copy Rewrapped + +**What to build:** +The two copy actions, both as buttons and as Cmd+C / Cmd+Shift+C shortcuts. + +**Files to create/modify:** +- `lib/src/lib/clipboard.ts` (new) — `copyRaw(text)` and `copyRewrapped(text)`, both wrap `navigator.clipboard.writeText`. +- `lib/src/lib/rewrap.ts` (new) — pure-function Rewrapped transform with the heuristics from Technical Decisions. Plus `lib/src/lib/rewrap.test.ts` with a fixtures table (each case = input, expected output, short rationale). +- `lib/src/lib/mouse-selection.ts` — add `getSelectedText(id, { rewrap: boolean })` that reads cells via `terminal.buffer.active.getLine(row).translateToString(...)`. +- `lib/src/components/Pond.tsx` — in the existing keydown handler (~line 1575), add an **early branch** before both the Meta gesture and the passthrough short-circuit: if there's an active terminal selection and the event is Cmd/Ctrl+C (possibly +Shift), call copy, preventDefault, return. Spec §4.2 explicit: this intercept applies only when `mouse-selection.getState(id).selection` is non-null. + +**Approach:** +- Raw: join cell rows with `\n`, preserving as-is. +- Rewrapped: run through `rewrap.ts`. +- Block-shape selection: always copied as rectangular slab, one row per line, no rewrapping even in "rewrapped" mode (block mode is inherently structural). + +**Test approach:** +- `rewrap.test.ts` table-driven tests covering: paragraph unwrap, preserve blank-line separators, strip box-drawing frame, preserve inline box chars that aren't UI chrome, leave code blocks alone. +- Unit test for the selection-text extraction against a stub buffer. + +**Risk/complexity:** Medium — rewrap heuristics are the squishy bit. Start conservative (only unwrap when highly confident) and expand based on feedback. + +**Dependencies:** D.1. + +--- + +### Story D.3: Popup dismissal + +**What to build:** +Esc and click-outside dismiss the popup and cancel the selection (§4.3). + +**Files to create/modify:** +- `lib/src/components/SelectionPopup.tsx` — `useEffect` that attaches `keydown`-Esc and `mousedown`-outside listeners while the popup is open. +- The existing Pond.tsx keydown handler already handles Esc in various places; make sure it does not conflict. (It currently only handles Esc inside kill-confirmation and rename input.) + +**Approach:** +- After a successful copy, the popup dismisses but the selection remains briefly (spec: "implementation-defined; a short fade is reasonable"). Use a 400ms CSS fade on the overlay rects + popup. + +**Test approach:** +- Storybook: post-copy fading state. + +**Risk/complexity:** Low. + +**Dependencies:** D.1. + +--- + +### Story E.1: URL / path token detection + +**What to build:** +Pure function that, given a buffer row and a cursor cell column, returns the token under/around it if one matches URL / path / error-location patterns (§5.1). + +**Files to create/modify:** +- `lib/src/lib/smart-token.ts` (new) — `detectTokenAt(line: string, col: number): Token | null` with regex patterns for each shape, plus the trailing-punctuation stripping rule from the spec. +- `lib/src/lib/smart-token.test.ts` — table-driven tests covering every listed pattern plus the trailing-punctuation rules (`https://x.com.` → strip `.`, `https://en.wikipedia.org/wiki/Foo_(bar)` → keep `)`, etc.). + +**Approach:** +- Start from the current column, expand left and right until whitespace. +- Match the whitespace-delimited candidate against the pattern list; return first match or null. +- Strip trailing punctuation per spec. + +**Test approach:** +- Extensive table — this is the most error-prone code in the feature. + +**Risk/complexity:** Medium — getting the regexes right and handling trailing punctuation cleanly. + +**Dependencies:** None. + +--- + +### Story E.2: Mid-drag extension hint and action + +**What to build:** +While dragging, poll the detected token; show the `Press e to select the full URL/path` hint; handle `e` to extend (§5.2, §5.3). + +**Files to create/modify:** +- `lib/src/lib/mouse-selection.ts` — on each drag-move, call `detectTokenAt` at the current cursor cell and store it on the state as `hintToken`. +- `lib/src/components/SelectionOverlay.tsx` — render the hint next to the existing "Hold Alt for block selection" hint when `hintToken` is non-null. +- `lib/src/components/Pond.tsx` — in the keydown handler's early branch (same place as Cmd+C in D.2), handle `e` during an active drag: if `hintToken` present, expand selection to cover the token's cells. +- Per spec §3.6, keystrokes during a drag are consumed by the terminal and not forwarded to the program. Add that as part of the same early branch: while a drag is active, stopPropagation + preventDefault on all keys except the ones we explicitly handle (Alt, e, Esc). + +**Approach:** +- After extension, drag continues from the extended boundary (the mouse-move updates are computed from `selection.end`, not the mouse's cell-at-extension-time). +- Second `e` while the same token is fully covered = no-op (already guaranteed by detection's "not already fully covered" rule, §5.1). + +**Test approach:** +- Storybook: mid-drag with URL hint, mid-drag with path hint, post-extension state. +- Unit test: `e` with no hint is a no-op; `e` with hint extends; `e` again is a no-op. + +**Risk/complexity:** Medium — coordinating mid-drag keystroke handling with the window-level keydown listener. + +**Dependencies:** C.1, C.3, E.1. + +--- + +### Story F.1: Bracketed paste on macOS (Cmd+V) + +**What to build:** +Cmd+V and Cmd+Shift+V intercept → clipboard read → bracketed write to PTY (§8.2.1, §8.5, §8.6). + +**Files to create/modify:** +- `lib/src/lib/clipboard.ts` — add `async function doPaste(terminalId: string)`: read via `navigator.clipboard.readText()`, check `terminal.modes.bracketedPasteMode`, wrap with `\e[200~`/`\e[201~` if on, write via `getPlatform().writePty(terminalId, data)`. +- `lib/src/components/Pond.tsx` — in the keydown handler, early branch: on macOS, Cmd+V and Cmd+Shift+V → doPaste. Ctrl+V is NOT intercepted on macOS (forwarded as `0x16`). +- File-URL handling (§8.6): if the clipboard contains only a file URL (`file://...`), paste the path portion. `navigator.clipboard.read()` (different API) exposes multiple MIME types; start with just `text/plain` for MVP since it covers the common case. File-URL-as-text is handled by the OS (macOS Finder puts it on the text clipboard too). + +**Approach:** +- If clipboard read returns empty/null, show a brief toast: `Clipboard contains no pasteable content.` (spec §8.6). Use an existing toast pattern if any; otherwise a 2-second inline banner. + +**Test approach:** +- Unit test: mock `navigator.clipboard.readText` + `terminal.modes.bracketedPasteMode` + `platform.writePty`; verify the three cases (bracketed on, bracketed off, empty). + +**Risk/complexity:** Medium — clipboard permission handling can differ across webview hosts. + +**Dependencies:** A.3. + +--- + +### Story F.2: Bracketed paste on Windows/Linux (Ctrl+V, Ctrl+Shift+V) + +**What to build:** +Ctrl+V and Ctrl+Shift+V both paste on Windows/Linux (§8.2.2). + +**Files to create/modify:** +- `lib/src/components/Pond.tsx` — in the keydown handler early branch, on non-Mac intercept Ctrl+V and Ctrl+Shift+V. Both do the same thing. + +**Approach:** +- Same `doPaste` function from F.1. +- No path exists for sending `0x16` from Ctrl+V on these platforms (spec §8.3: documentation-only use of shell `Ctrl+Q` `quoted-insert`). + +**Test approach:** +- Unit test alongside F.1. + +**Risk/complexity:** Low. + +**Dependencies:** F.1. + +--- + +### Story F.3: Right-click and Edit menu paste + +**What to build:** +Context menu Paste item on right-click, and (macOS only) Edit → Paste (§8.7). + +**Files to create/modify:** +- Right-click: in the mouse router (C.1), a `contextmenu` event on `entry.element` when **no** mouse reporting is active shows a minimal context menu with a Paste entry that calls `doPaste`. Use an existing popover/menu pattern if one exists; otherwise a small positioned `
    ` in a new `TerminalContextMenu.tsx`. +- Edit menu: this is out of the webview's reach directly — it requires a host integration. In Tauri (`standalone/`), register an Edit menu item. In VSCode (`vscode-ext/`), register a command. **See Open Questions** — likely deferred to follow-up since the spec calls out macOS Edit menu specifically and both hosts need separate wiring. + +**Approach:** +- Start with the right-click Paste, which lives in `lib/` and works in all three environments. +- Defer the OS Edit menu wiring (document in spec's §9 or in a new follow-up story). + +**Test approach:** +- Storybook for the context menu. + +**Risk/complexity:** Medium — Edit menu wiring in two hosts is non-trivial. + +**Dependencies:** F.1. + +--- + +### Story G.1: Spec compliance sweep + +**What to build:** +Pass over the shipped feature against the spec, update the spec if implementation diverged (per AGENTS.md: "When updating code covered by a spec, update the spec to match"), and register this spec in `AGENTS.md`. + +**Files to create/modify:** +- `AGENTS.md` — add `docs/specs/mouse-and-clipboard.md` to the spec list with a one-line summary and a list of files this spec covers (`mouse-selection.ts`, `SelectionOverlay.tsx`, `SelectionPopup.tsx`, `clipboard.ts`, `rewrap.ts`, `smart-token.ts`, the relevant parts of `Pond.tsx` and `terminal-registry.ts`). +- `docs/specs/mouse-and-clipboard.md` — reconcile any drift. + +**Risk/complexity:** Low. + +**Dependencies:** All prior stories. + +--- + +## Shared Patterns + +- **Per-terminal state:** follow the `subscribeToSessionStateChanges` / `getSessionStateSnapshot` / cached-snapshot pattern from `terminal-registry.ts:63-91` for any new React-subscribable state. Always invalidate the cached snapshot before notifying listeners. +- **React consumption:** `useSyncExternalStore` — matches the existing pattern (`Pond.tsx:531`). +- **Keyboard interception:** extend the single window-level capture-phase `keydown` listener in `Pond.tsx:1575`; do not add parallel global listeners. +- **Mouse interception:** on `entry.element` via capture-phase listeners registered in `setupTerminalEntry`; tear down in the existing `cleanup` closure. +- **Tailwind:** only scale classes (`text-xs`, `text-sm`, etc.) — no arbitrary `px` values (per project convention). +- **Phosphor icons:** use existing imports from `@phosphor-icons/react`; `CursorClickIcon` + `ProhibitIcon` for mouse icon states. +- **Theme tokens:** use the CSS variables already wired in `terminal-registry.ts:247` (`--vscode-terminal-selectionBackground` etc.) — no hardcoded colors. +- **Tests:** Vitest at `.test.ts` next to the source; table-driven for anything regex/heuristic-heavy. Storybook + Chromatic for visuals. + +## Open Questions + +- **How to cleanly disable xterm's built-in text selection?** There's no `options.disableSelection` flag. Candidates: (a) set `selectionBackground: 'transparent'` in the theme so xterm's selection is invisible while ours paints on top — the cheapest but leaves xterm's state machine running, which may fire `onSelectionChange` events we don't want; (b) intercept mousedown at capture phase and `preventDefault()` before xterm sees it, which should suppress xterm's selection from starting at all. Plan: try (b) first; fall back to (a) if xterm reacts to mousedown on the document level rather than the element. **If neither works**, we file an upstream feature request and ship with (a). +- **Host-level Edit → Paste menu integration (§8.7).** Wiring Tauri's native menu and VSCode's command palette are distinct efforts outside `lib/`. Proposal: ship right-click Paste in F.3; open follow-up tasks for the two hosts separately. Decide before starting F.3. +- **Wide characters (CJK, emoji) in cell-math for the overlay.** xterm renders them as 2 cells but they're 1 character. The overlay math assumes uniform cell width; wide characters will under-highlight by half a cell unless we ask xterm per cell via `buffer.active.getLine(row).getCell(col).getWidth()`. Decide whether the MVP highlight is "uniform cell grid" (wrong for CJK but simple) or "per-cell width" (correct but more code). Recommend uniform for MVP; open follow-up for correctness. +- **VSCode webview clipboard permissions.** VSCode webviews may not grant `navigator.clipboard.readText` by default; may need `webviewOptions.enableCommandUris` or a postMessage round-trip to the extension host. Verify in dogfood before committing to the client-side `readText` approach. If broken, add a `PlatformAdapter.readClipboard()` method and route through the extension host for VSCode. +- **Concrete starting heuristics for Copy Rewrapped.** Technical Decisions above lists a first cut; confirm with dogfood on real terminal output (logs, `less` buffers, `cat` of a `.md` file, `npm install` output) before locking the rule set. Tracked in D.2's test fixtures. + +## Proposed Story Ordering + +A.1 → A.2 → A.3 → B.1 → B.2 → C.1 → C.2 → C.3 → C.4 → C.5 → C.6 → D.1 → D.2 → D.3 → E.1 → E.2 → F.1 → F.2 → F.3 → G.1. + +Each phase is independently valuable and testable: +- **After A:** per-terminal mouse state is observable (no UI yet). +- **After B:** header icon + banner work; no selection yet. +- **After C:** full selection experience works; no copy yet. +- **After D:** copy works end-to-end. +- **After E:** smart extension works. +- **After F:** paste works end-to-end. +- **After G:** spec is reconciled and registered. From 5cacb09fe470e5091e433976c9f6e78b07c457df Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:18:06 -0700 Subject: [PATCH 04/38] Implement story A.1: per-terminal mouse selection state store New module `lib/src/lib/mouse-selection.ts` holds mouse-reporting, bracketed-paste, override, selection, and hint-token state per terminal. Mirrors the subscribeToSessionStateChanges / getSnapshot pattern in terminal-registry.ts so it plugs into useSyncExternalStore. Enforces two spec rules at the store level: override can't be activated while mouse reporting is off, and mouse reporting going to none auto-ends any active override. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/mouse-selection.test.ts | 195 ++++++++++++++++++++++++++++ lib/src/lib/mouse-selection.ts | 147 +++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 lib/src/lib/mouse-selection.test.ts create mode 100644 lib/src/lib/mouse-selection.ts diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts new file mode 100644 index 0000000..a393c13 --- /dev/null +++ b/lib/src/lib/mouse-selection.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + __resetMouseSelectionForTests, + getMouseSelectionSnapshot, + getMouseSelectionState, + removeMouseSelectionState, + setBracketedPaste, + setHintToken, + setMouseReporting, + setOverride, + setSelection, + subscribeToMouseSelection, + type Selection, + type TokenHint, +} from './mouse-selection'; + +afterEach(() => { + __resetMouseSelectionForTests(); +}); + +describe('mouse-selection: default state', () => { + it('returns the default state for an unknown id', () => { + expect(getMouseSelectionState('missing')).toEqual(DEFAULT_MOUSE_SELECTION_STATE); + }); + + it('default state has mouse reporting off, override off, no selection', () => { + expect(DEFAULT_MOUSE_SELECTION_STATE).toEqual({ + mouseReporting: 'none', + bracketedPaste: false, + override: 'off', + selection: null, + hintToken: null, + }); + }); +}); + +describe('mouse-selection: state setters', () => { + it('setMouseReporting updates the mode', () => { + setMouseReporting('a', 'vt200'); + expect(getMouseSelectionState('a').mouseReporting).toBe('vt200'); + + setMouseReporting('a', 'any'); + expect(getMouseSelectionState('a').mouseReporting).toBe('any'); + }); + + it('setBracketedPaste toggles the flag', () => { + setBracketedPaste('a', true); + expect(getMouseSelectionState('a').bracketedPaste).toBe(true); + setBracketedPaste('a', false); + expect(getMouseSelectionState('a').bracketedPaste).toBe(false); + }); + + it('setSelection stores a selection', () => { + const sel: Selection = { startRow: 5, startCol: 3, endRow: 5, endCol: 10, shape: 'linewise', dragging: false }; + setSelection('a', sel); + expect(getMouseSelectionState('a').selection).toBe(sel); + + setSelection('a', null); + expect(getMouseSelectionState('a').selection).toBeNull(); + }); + + it('setHintToken stores a hint', () => { + const hint: TokenHint = { kind: 'url', row: 1, startCol: 0, endCol: 20, text: 'https://example.com' }; + setHintToken('a', hint); + expect(getMouseSelectionState('a').hintToken).toBe(hint); + + setHintToken('a', null); + expect(getMouseSelectionState('a').hintToken).toBeNull(); + }); + + it('removeMouseSelectionState drops all state for an id', () => { + setMouseReporting('a', 'vt200'); + setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 5, shape: 'linewise', dragging: false }); + removeMouseSelectionState('a'); + expect(getMouseSelectionState('a')).toEqual(DEFAULT_MOUSE_SELECTION_STATE); + }); +}); + +describe('mouse-selection: override rules', () => { + it('cannot activate override while mouse reporting is off', () => { + setOverride('a', 'temporary'); + expect(getMouseSelectionState('a').override).toBe('off'); + setOverride('a', 'permanent'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); + + it('can activate override while mouse reporting is on', () => { + setMouseReporting('a', 'vt200'); + setOverride('a', 'temporary'); + expect(getMouseSelectionState('a').override).toBe('temporary'); + + setOverride('a', 'permanent'); + expect(getMouseSelectionState('a').override).toBe('permanent'); + }); + + it('can always deactivate an override', () => { + setMouseReporting('a', 'vt200'); + setOverride('a', 'temporary'); + setOverride('a', 'off'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); + + it('mouse reporting going to none auto-ends the override', () => { + setMouseReporting('a', 'vt200'); + setOverride('a', 'temporary'); + setMouseReporting('a', 'none'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); + + it('mouse reporting going to none auto-ends a permanent override too', () => { + setMouseReporting('a', 'drag'); + setOverride('a', 'permanent'); + setMouseReporting('a', 'none'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); +}); + +describe('mouse-selection: subscription', () => { + it('subscribe + setMouseReporting notifies listeners', () => { + const listener = vi.fn(); + subscribeToMouseSelection(listener); + setMouseReporting('a', 'vt200'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('setting the same value does not notify', () => { + setMouseReporting('a', 'vt200'); + const listener = vi.fn(); + subscribeToMouseSelection(listener); + setMouseReporting('a', 'vt200'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('setBracketedPaste, setOverride, setSelection, setHintToken all notify', () => { + setMouseReporting('a', 'vt200'); // prerequisite for override activation + const listener = vi.fn(); + subscribeToMouseSelection(listener); + + setBracketedPaste('a', true); + setOverride('a', 'temporary'); + setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 1, shape: 'linewise', dragging: true }); + setHintToken('a', { kind: 'path', row: 0, startCol: 0, endCol: 5, text: '/tmp' }); + + expect(listener).toHaveBeenCalledTimes(4); + }); + + it('unsubscribe stops notifications', () => { + const listener = vi.fn(); + const unsub = subscribeToMouseSelection(listener); + unsub(); + setMouseReporting('a', 'vt200'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('removeMouseSelectionState notifies', () => { + setMouseReporting('a', 'vt200'); + const listener = vi.fn(); + subscribeToMouseSelection(listener); + removeMouseSelectionState('a'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('removing a state that was never set does not notify', () => { + const listener = vi.fn(); + subscribeToMouseSelection(listener); + removeMouseSelectionState('never-existed'); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('mouse-selection: snapshot caching', () => { + it('returns the same snapshot reference between changes', () => { + setMouseReporting('a', 'vt200'); + const s1 = getMouseSelectionSnapshot(); + const s2 = getMouseSelectionSnapshot(); + expect(s1).toBe(s2); + }); + + it('invalidates the snapshot after a change', () => { + setMouseReporting('a', 'vt200'); + const s1 = getMouseSelectionSnapshot(); + setMouseReporting('a', 'any'); + const s2 = getMouseSelectionSnapshot(); + expect(s1).not.toBe(s2); + }); + + it('snapshot contains state for every known id', () => { + setMouseReporting('a', 'vt200'); + setMouseReporting('b', 'any'); + const snap = getMouseSelectionSnapshot(); + expect(snap.get('a')?.mouseReporting).toBe('vt200'); + expect(snap.get('b')?.mouseReporting).toBe('any'); + }); +}); diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts new file mode 100644 index 0000000..b506632 --- /dev/null +++ b/lib/src/lib/mouse-selection.ts @@ -0,0 +1,147 @@ +/** + * Per-terminal mouse and selection state. + * + * Owns the data that the mouse-and-clipboard feature needs: + * - Which mouse-reporting regime the inside program requested. + * - Whether bracketed paste is on. + * - Whether the user has activated an override (temporary / permanent). + * - The current text selection and the current smart-extension hint. + * + * Exposes a `useSyncExternalStore`-compatible subscription API. Pure state, + * no DOM dependencies — safe to unit-test. + */ + +export type MouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'; +export type OverrideState = 'off' | 'temporary' | 'permanent'; +export type SelectionShape = 'linewise' | 'block'; + +export interface Selection { + /** Absolute buffer row (scrollback + viewport), 0-indexed. */ + startRow: number; + /** Cell column at the drag anchor. */ + startCol: number; + /** Absolute buffer row. */ + endRow: number; + /** Cell column at the current drag position (or release position). */ + endCol: number; + shape: SelectionShape; + /** True while the user is still dragging; false once the mouse is released. */ + dragging: boolean; +} + +export interface TokenHint { + kind: 'url' | 'path'; + /** Absolute buffer row the token occupies. */ + row: number; + startCol: number; + /** Exclusive. */ + endCol: number; + text: string; +} + +export interface MouseSelectionState { + mouseReporting: MouseTrackingMode; + bracketedPaste: boolean; + override: OverrideState; + selection: Selection | null; + hintToken: TokenHint | null; +} + +export const DEFAULT_MOUSE_SELECTION_STATE: MouseSelectionState = Object.freeze({ + mouseReporting: 'none', + bracketedPaste: false, + override: 'off', + selection: null, + hintToken: null, +}) as MouseSelectionState; + +const states = new Map(); +const listeners = new Set<() => void>(); +let cachedSnapshot: Map | null = null; + +function notify(): void { + cachedSnapshot = null; + listeners.forEach((l) => l()); +} + +function ensure(id: string): MouseSelectionState { + let s = states.get(id); + if (!s) { + s = { ...DEFAULT_MOUSE_SELECTION_STATE }; + states.set(id, s); + } + return s; +} + +export function subscribeToMouseSelection(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getMouseSelectionSnapshot(): Map { + if (cachedSnapshot) return cachedSnapshot; + cachedSnapshot = new Map(states); + return cachedSnapshot; +} + +export function getMouseSelectionState(id: string): MouseSelectionState { + return states.get(id) ?? DEFAULT_MOUSE_SELECTION_STATE; +} + +export function setMouseReporting(id: string, mode: MouseTrackingMode): void { + const s = ensure(id); + if (s.mouseReporting === mode) return; + s.mouseReporting = mode; + // Per spec §1.1 / §2: when the inside program stops requesting mouse reporting, + // any active override is no longer meaningful. End it. + if (mode === 'none' && s.override !== 'off') { + s.override = 'off'; + } + notify(); +} + +export function setBracketedPaste(id: string, on: boolean): void { + const s = ensure(id); + if (s.bracketedPaste === on) return; + s.bracketedPaste = on; + notify(); +} + +export function setOverride(id: string, override: OverrideState): void { + const s = ensure(id); + if (s.override === override) return; + // Override only makes sense while the inside program is requesting mouse + // reporting. Ignore attempts to activate it otherwise. + if (override !== 'off' && s.mouseReporting === 'none') return; + s.override = override; + notify(); +} + +export function setSelection(id: string, selection: Selection | null): void { + const s = ensure(id); + if (s.selection === null && selection === null) return; + s.selection = selection; + notify(); +} + +export function setHintToken(id: string, hint: TokenHint | null): void { + const s = ensure(id); + if (s.hintToken === null && hint === null) return; + s.hintToken = hint; + notify(); +} + +export function removeMouseSelectionState(id: string): void { + if (!states.has(id)) return; + states.delete(id); + notify(); +} + +/** Test-only helper. Do not use in application code. */ +export function __resetMouseSelectionForTests(): void { + states.clear(); + listeners.clear(); + cachedSnapshot = null; +} From 9935135c0e7a8d4c900f8947ffa39bad31d3a02c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:21:13 -0700 Subject: [PATCH 05/38] Implement story A.2: observe mouse-reporting and bracketed-paste DECSET modes New `mouse-mode-observer.ts` registers parser hooks on every xterm terminal that fire on DECSET (CSI ? ... h) and DECRST (CSI ? ... l), returning false so xterm still handles the sequence. A queueMicrotask then syncs our store from `terminal.modes.mouseTrackingMode` and `terminal.modes.bracketedPasteMode` once xterm's own handler has run. terminal-registry wires the observer into setupTerminalEntry and calls removeMouseSelectionState in destroyTerminal. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/mouse-mode-observer.test.ts | 128 ++++++++++++++++++++ lib/src/lib/mouse-mode-observer.ts | 38 ++++++ lib/src/lib/terminal-registry.alarm.test.ts | 8 ++ lib/src/lib/terminal-registry.ts | 7 ++ 4 files changed, 181 insertions(+) create mode 100644 lib/src/lib/mouse-mode-observer.test.ts create mode 100644 lib/src/lib/mouse-mode-observer.ts diff --git a/lib/src/lib/mouse-mode-observer.test.ts b/lib/src/lib/mouse-mode-observer.test.ts new file mode 100644 index 0000000..057f59b --- /dev/null +++ b/lib/src/lib/mouse-mode-observer.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; +import { attachMouseModeObserver } from './mouse-mode-observer'; +import { __resetMouseSelectionForTests, getMouseSelectionState } from './mouse-selection'; + +afterEach(() => { + __resetMouseSelectionForTests(); + vi.restoreAllMocks(); +}); + +interface MockDisposables { + setHandlers: Array<() => boolean>; + resetHandlers: Array<() => boolean>; +} + +function buildMockTerminal(): { terminal: Terminal; modes: { mouseTrackingMode: string; bracketedPasteMode: boolean }; handlers: MockDisposables } { + const handlers: MockDisposables = { setHandlers: [], resetHandlers: [] }; + const modes = { mouseTrackingMode: 'none' as const, bracketedPasteMode: false }; + const parser = { + registerCsiHandler(id: { prefix?: string; final?: string }, cb: () => boolean) { + if (id.prefix === '?' && id.final === 'h') { + handlers.setHandlers.push(cb); + } else if (id.prefix === '?' && id.final === 'l') { + handlers.resetHandlers.push(cb); + } + return { dispose: vi.fn() }; + }, + }; + const terminal = { parser, modes } as unknown as Terminal; + return { terminal, modes, handlers }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('attachMouseModeObserver', () => { + it('registers one DECSET and one DECRST handler', () => { + const { terminal, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + expect(handlers.setHandlers).toHaveLength(1); + expect(handlers.resetHandlers).toHaveLength(1); + }); + + it('DECSET handler returns false so xterm still processes the sequence', () => { + const { terminal, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + expect(handlers.setHandlers[0]()).toBe(false); + expect(handlers.resetHandlers[0]()).toBe(false); + }); + + it('syncs mouseReporting after a DECSET fires (modes updated in the mock)', async () => { + const { terminal, modes, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + + // Simulate xterm processing `\e[?1000h`: our handler fires, then xterm's + // builtin updates modes. We emulate by flipping the mode before the + // microtask runs. + handlers.setHandlers[0](); + modes.mouseTrackingMode = 'vt200'; + + await flushMicrotasks(); + expect(getMouseSelectionState('a').mouseReporting).toBe('vt200'); + }); + + it('syncs bracketedPaste after a DECSET fires', async () => { + const { terminal, modes, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + + handlers.setHandlers[0](); + modes.bracketedPasteMode = true; + + await flushMicrotasks(); + expect(getMouseSelectionState('a').bracketedPaste).toBe(true); + }); + + it('syncs mouseReporting to none after DECRST', async () => { + const { terminal, modes, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + + // Enable first + handlers.setHandlers[0](); + modes.mouseTrackingMode = 'any'; + await flushMicrotasks(); + expect(getMouseSelectionState('a').mouseReporting).toBe('any'); + + // Then disable + handlers.resetHandlers[0](); + modes.mouseTrackingMode = 'none'; + await flushMicrotasks(); + expect(getMouseSelectionState('a').mouseReporting).toBe('none'); + }); + + it('dispose tears down both handlers', () => { + const { terminal } = buildMockTerminal(); + const mockDispose1 = vi.fn(); + const mockDispose2 = vi.fn(); + const realParser = terminal.parser; + (terminal as unknown as { parser: unknown }).parser = { + registerCsiHandler(_id: unknown, _cb: unknown) { + return realParser === terminal.parser + ? { dispose: mockDispose1 } + : { dispose: mockDispose2 }; + }, + }; + + // Simpler: build a fresh mock with explicit disposables + const disposables: Array<{ dispose: ReturnType }> = []; + const term2 = { + parser: { + registerCsiHandler() { + const d = { dispose: vi.fn() }; + disposables.push(d); + return d; + }, + }, + modes: { mouseTrackingMode: 'none', bracketedPasteMode: false }, + } as unknown as Terminal; + + const observer = attachMouseModeObserver('a', term2); + observer.dispose(); + + expect(disposables).toHaveLength(2); + expect(disposables[0].dispose).toHaveBeenCalledOnce(); + expect(disposables[1].dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/lib/src/lib/mouse-mode-observer.ts b/lib/src/lib/mouse-mode-observer.ts new file mode 100644 index 0000000..d136870 --- /dev/null +++ b/lib/src/lib/mouse-mode-observer.ts @@ -0,0 +1,38 @@ +import type { Terminal, IDisposable } from '@xterm/xterm'; +import { setBracketedPaste, setMouseReporting } from './mouse-selection'; + +/** + * Wire an xterm terminal's mouse-tracking and bracketed-paste modes into the + * mouse-selection store. + * + * Installs CSI handlers for DECSET (`CSI ? ... h`) and DECRST (`CSI ? ... l`) + * that return false, letting xterm's built-in handler still process the + * sequence. After xterm updates its internal state we sync our store from + * the public `terminal.modes` getters in a microtask (the parser handler + * runs synchronously before xterm's, so `modes` isn't updated yet when our + * callback first fires). + */ +export function attachMouseModeObserver(id: string, terminal: Terminal): IDisposable { + const sync = () => { + queueMicrotask(() => { + setMouseReporting(id, terminal.modes.mouseTrackingMode); + setBracketedPaste(id, terminal.modes.bracketedPasteMode); + }); + }; + + const onSet = terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, () => { + sync(); + return false; + }); + const onReset = terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, () => { + sync(); + return false; + }); + + return { + dispose() { + onSet.dispose(); + onReset.dispose(); + }, + }; +} diff --git a/lib/src/lib/terminal-registry.alarm.test.ts b/lib/src/lib/terminal-registry.alarm.test.ts index 934a24b..6706332 100644 --- a/lib/src/lib/terminal-registry.alarm.test.ts +++ b/lib/src/lib/terminal-registry.alarm.test.ts @@ -18,6 +18,14 @@ vi.mock('@xterm/xterm', () => { private dataListeners = new Set<(data: string) => void>(); private resizeListeners = new Set<(size: { cols: number; rows: number }) => void>(); + parser = { + registerCsiHandler: () => ({ dispose: () => {} }), + }; + modes = { + mouseTrackingMode: 'none' as const, + bracketedPasteMode: false, + }; + loadAddon(): void {} open(): void {} diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 64e8c1d..b5d66c2 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -5,6 +5,8 @@ import type { SessionStatus } from './activity-monitor'; import { TODO_OFF, isSoftTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; import type { AlarmStateDetail } from './platform/types'; import type { PersistedAlarmState } from './session-types'; +import { attachMouseModeObserver } from './mouse-mode-observer'; +import { removeMouseSelectionState } from './mouse-selection'; export type { SessionStatus } from './activity-monitor'; export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; @@ -360,11 +362,15 @@ function setupTerminalEntry(id: string): TerminalEntry { getPlatform().resizePty(id, cols, rows); }); + // Observe DECSET/DECRST for mouse-reporting and bracketed-paste modes. + const mouseModeObserver = attachMouseModeObserver(id, terminal); + const cleanup = () => { getPlatform().offPtyData(handleData); getPlatform().offPtyExit(handleExit); inputDisposable.dispose(); resizeDisposable.dispose(); + mouseModeObserver.dispose(); }; const entry: TerminalEntry = { @@ -528,6 +534,7 @@ export function destroyTerminal(id: string): void { entry.element.remove(); entry.terminal.dispose(); registry.delete(id); + removeMouseSelectionState(id); notifySessionStateListeners(); } From 508c768f9f594ee95fe155c1df8a08f9b8fbc965 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:21:51 -0700 Subject: [PATCH 06/38] Implement story A.3: IS_MAC platform detection helper Used downstream for choosing Cmd vs Ctrl paste/copy keybindings. Guards against undefined navigator so it's safe in node test envs. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/platform/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/src/lib/platform/index.ts b/lib/src/lib/platform/index.ts index 4798a23..a379fd7 100644 --- a/lib/src/lib/platform/index.ts +++ b/lib/src/lib/platform/index.ts @@ -16,6 +16,17 @@ export { SCENARIO_FAST_OUTPUT, } from './fake-scenarios'; +/** + * True when running on macOS. Used to pick native keyboard conventions + * (Cmd vs Ctrl for copy/paste, etc.). Computed once at module load. + */ +export const IS_MAC: boolean = (() => { + if (typeof navigator === 'undefined') return false; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? ''; + return /Mac|iPhone|iPad/i.test(platform); +})(); + let adapter: PlatformAdapter | null = null; /** Set an externally-created platform adapter (e.g. TauriAdapter from standalone). */ From d0d6408a263838a632849d93baf0c5b7a7e227a7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:27:13 -0700 Subject: [PATCH 07/38] Implement story B.1: mouse/no-mouse header icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TerminalPaneHeader now subscribes to the mouse-selection store and renders a CursorClickIcon between the title and the alarm bell whenever the inside program has requested mouse reporting. When an override is active, a Prohibit glyph is composited on top and the tooltip/aria-label switch to the "restore" wording from spec §1.2. Clicking toggles a temporary override on, or ends any active override. Four Storybook states: hidden, reporting on, temporary override, permanent override. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 35 ++++++- lib/src/stories/MouseHeaderIcon.stories.tsx | 109 ++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 lib/src/stories/MouseHeaderIcon.stories.tsx diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 49a926a..d4b396f 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -14,7 +14,13 @@ import { createPortal } from 'react-dom'; import { TerminalPane } from './TerminalPane'; import { Baseboard } from './Baseboard'; import { tv } from 'tailwind-variants'; -import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon } from '@phosphor-icons/react'; +import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon, CursorClickIcon, ProhibitIcon } from '@phosphor-icons/react'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + getMouseSelectionSnapshot, + setOverride as setMouseOverride, + subscribeToMouseSelection, +} from '../lib/mouse-selection'; import { type AlarmButtonActionResult, clearSessionAttention, @@ -529,8 +535,16 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const zoomed = useContext(ZoomedContext); const windowFocused = useContext(WindowFocusedContext); const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot); + const mouseStates = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); const actions = useContext(PondActionsContext); const sessionState = sessionStates.get(api.id) ?? DEFAULT_SESSION_UI_STATE; + const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; + const showMouseIcon = mouseState.mouseReporting !== 'none'; + const inOverride = mouseState.override !== 'off'; + const mouseIconTooltip = inOverride + ? "You're overriding the TUI's mouse capture. Click to restore." + : 'TUI is intercepting mouse commands. Click to override.'; + const mouseIconAriaLabel = inOverride ? 'Restore mouse capture' : 'Override mouse capture'; const isSelected = selectedId === api.id; const showSelectedHeader = mode === 'passthrough' && isSelected && windowFocused; const isRenaming = renamingId === api.id; @@ -610,6 +624,25 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { onClick={(e) => { e.stopPropagation(); actions.onStartRename(api.id); }} >{api.title} )} + {showMouseIcon && ( + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setMouseOverride(api.id, inOverride ? 'off' : 'temporary'); + }} + ariaLabel={mouseIconAriaLabel} + tooltip={mouseIconTooltip} + > + + + {inOverride && ( + + )} + + + )} {}, + onDetach: () => {}, + onAlarmButton: () => 'noop', + onToggleTodo: () => {}, + onSplitH: () => {}, + onSplitV: () => {}, + onZoom: () => {}, + onClickPanel: () => {}, + onStartRename: () => {}, + onFinishRename: () => {}, + onCancelRename: () => {}, +}; + +function MouseIconStoryFrame({ + mouseReporting = 'none' as MouseTrackingMode, + override = 'off' as OverrideState, + title = 'build-server', + mode = 'command' as PondMode, + width = 360, +}: { + mouseReporting?: MouseTrackingMode; + override?: OverrideState; + title?: string; + mode?: PondMode; + width?: number; +}) { + useEffect(() => { + __resetMouseSelectionForTests(); + setMouseReporting(SESSION_ID, mouseReporting); + if (override !== 'off') setOverride(SESSION_ID, override); + return () => { + __resetMouseSelectionForTests(); + }; + }, [mouseReporting, override]); + + const mockApi = { id: SESSION_ID, title } as unknown as Parameters[0]['api']; + + return ( + + + + +
    +
    + [0]['containerApi']} + params={{}} + tabLocation={'header' as Parameters[0]['tabLocation']} + /> +
    +
    +
    +
    +
    +
    + ); +} + +const meta: Meta = { + title: 'Components/MouseHeaderIcon', + component: MouseIconStoryFrame, + argTypes: { + mouseReporting: { control: 'radio', options: ['none', 'x10', 'vt200', 'drag', 'any'] }, + override: { control: 'radio', options: ['off', 'temporary', 'permanent'] }, + mode: { control: 'radio', options: ['command', 'passthrough'] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Hidden: Story = { + args: { mouseReporting: 'none', override: 'off' }, +}; + +export const ReportingOn: Story = { + args: { mouseReporting: 'vt200', override: 'off' }, +}; + +export const TemporaryOverride: Story = { + args: { mouseReporting: 'vt200', override: 'temporary' }, +}; + +export const PermanentOverride: Story = { + args: { mouseReporting: 'any', override: 'permanent' }, +}; From fb786024a1c5fedb24f0c1c29fad05ce676243d9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:29:08 -0700 Subject: [PATCH 08/38] Implement story B.2: temporary-override banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portal banner anchored below the No-Mouse icon shows while the override is temporary, with Make-permanent and Cancel buttons that toggle the store. Kept in sync with scroll/resize like TodoAlarmDialog. Per spec §2.4 the banner is mouse-only. The existing TemporaryOverride story in MouseHeaderIcon.stories.tsx now exercises the banner as well. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 99 ++++++++++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 17 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index d4b396f..76215a1 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -208,6 +208,61 @@ function HeaderActionButton({ // --- Alarm context menu (right-click on bell) --- +/** + * Portal banner shown while a temporary mouse-capture override is active. + * Positioned below a given anchor element (the No-Mouse icon) and kept in + * sync with scroll/resize. Spec §2.1 / §2.4: mouse-only, no keyboard. + */ +function MouseOverrideBanner({ + anchor, + onMakePermanent, + onCancel, +}: { + anchor: HTMLElement; + onMakePermanent: () => void; + onCancel: () => void; +}) { + const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + + useLayoutEffect(() => { + const update = () => { + const r = anchor.getBoundingClientRect(); + setPos({ x: r.left, y: r.bottom + 4 }); + }; + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [anchor]); + + if (!pos) return null; + + return createPortal( +
    e.stopPropagation()} + role="status" + > + Temporary mouse override until mouse-up. + + +
    , + document.body, + ); +} + function clampOverlayPosition({ left, top, width, height }: { left: number; top: number; @@ -549,6 +604,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const showSelectedHeader = mode === 'passthrough' && isSelected && windowFocused; const isRenaming = renamingId === api.id; const tabRef = useRef(null); + const [mouseIconAnchor, setMouseIconAnchor] = useState(null); const suppressAlarmClickRef = useRef(false); const [tier, setTier] = useState('full'); const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null); @@ -625,23 +681,32 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { >{api.title} )} {showMouseIcon && ( - e.stopPropagation()} - onClick={(e) => { - e.stopPropagation(); - setMouseOverride(api.id, inOverride ? 'off' : 'temporary'); - }} - ariaLabel={mouseIconAriaLabel} - tooltip={mouseIconTooltip} - > - - - {inOverride && ( - - )} - - +
    + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setMouseOverride(api.id, inOverride ? 'off' : 'temporary'); + }} + ariaLabel={mouseIconAriaLabel} + tooltip={mouseIconTooltip} + > + + + {inOverride && ( + + )} + + +
    + )} + {mouseIconAnchor && mouseState.override === 'temporary' && ( + setMouseOverride(api.id, 'permanent')} + onCancel={() => setMouseOverride(api.id, 'off')} + /> )} Date: Fri, 17 Apr 2026 16:32:53 -0700 Subject: [PATCH 09/38] Implement story C.1: mouse event router (observe-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds beginDrag / updateDrag / endDrag / isDragging state helpers to mouse-selection.ts and wires capture-phase mousedown/mousemove/ mouseup listeners in setupTerminalEntry. Classifies each click per spec §6.1 state matrix: terminal owns the drag if mouse reporting is off, an override is active, or the mousedown originated in scrollback. Otherwise the event is left alone to bubble to xterm and forward to the inside program. This story intentionally does NOT stopPropagation — we observe events and build selection state while xterm's default visible selection still works. C.2 adds the overlay and takes over event handling fully. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/mouse-selection.test.ts | 108 +++++++++++++++++++- lib/src/lib/mouse-selection.ts | 64 ++++++++++++ lib/src/lib/terminal-registry.alarm.test.ts | 10 ++ lib/src/lib/terminal-registry.ts | 63 +++++++++++- 4 files changed, 241 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts index a393c13..1bcd7ee 100644 --- a/lib/src/lib/mouse-selection.test.ts +++ b/lib/src/lib/mouse-selection.test.ts @@ -2,8 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_MOUSE_SELECTION_STATE, __resetMouseSelectionForTests, + beginDrag, + endDrag, getMouseSelectionSnapshot, getMouseSelectionState, + isDragging, removeMouseSelectionState, setBracketedPaste, setHintToken, @@ -11,6 +14,7 @@ import { setOverride, setSelection, subscribeToMouseSelection, + updateDrag, type Selection, type TokenHint, } from './mouse-selection'; @@ -52,7 +56,7 @@ describe('mouse-selection: state setters', () => { }); it('setSelection stores a selection', () => { - const sel: Selection = { startRow: 5, startCol: 3, endRow: 5, endCol: 10, shape: 'linewise', dragging: false }; + const sel: Selection = { startRow: 5, startCol: 3, endRow: 5, endCol: 10, shape: 'linewise', dragging: false, startedInScrollback: false }; setSelection('a', sel); expect(getMouseSelectionState('a').selection).toBe(sel); @@ -71,7 +75,7 @@ describe('mouse-selection: state setters', () => { it('removeMouseSelectionState drops all state for an id', () => { setMouseReporting('a', 'vt200'); - setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 5, shape: 'linewise', dragging: false }); + setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 5, shape: 'linewise', dragging: false, startedInScrollback: false }); removeMouseSelectionState('a'); expect(getMouseSelectionState('a')).toEqual(DEFAULT_MOUSE_SELECTION_STATE); }); @@ -139,7 +143,7 @@ describe('mouse-selection: subscription', () => { setBracketedPaste('a', true); setOverride('a', 'temporary'); - setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 1, shape: 'linewise', dragging: true }); + setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 1, shape: 'linewise', dragging: true, startedInScrollback: false }); setHintToken('a', { kind: 'path', row: 0, startCol: 0, endCol: 5, text: '/tmp' }); expect(listener).toHaveBeenCalledTimes(4); @@ -169,6 +173,104 @@ describe('mouse-selection: subscription', () => { }); }); +describe('mouse-selection: drag lifecycle', () => { + it('beginDrag creates a selection anchored at the starting cell', () => { + beginDrag('a', { row: 5, col: 10, altKey: false, startedInScrollback: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel).not.toBeNull(); + expect(sel).toMatchObject({ + startRow: 5, + startCol: 10, + endRow: 5, + endCol: 10, + shape: 'linewise', + dragging: true, + startedInScrollback: false, + }); + }); + + it('beginDrag with altKey starts in block shape', () => { + beginDrag('a', { row: 0, col: 0, altKey: true, startedInScrollback: false }); + expect(getMouseSelectionState('a').selection?.shape).toBe('block'); + }); + + it('beginDrag replaces an existing selection (spec §3.7)', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 2, col: 5, altKey: false }); + endDrag('a'); + + beginDrag('a', { row: 8, col: 3, altKey: false, startedInScrollback: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel?.startRow).toBe(8); + expect(sel?.dragging).toBe(true); + }); + + it('updateDrag moves the end of an active drag', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 4, col: 12, altKey: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel?.endRow).toBe(4); + expect(sel?.endCol).toBe(12); + }); + + it('updateDrag flips shape live as Alt is pressed / released (spec §3.2)', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 4, col: 12, altKey: true }); + expect(getMouseSelectionState('a').selection?.shape).toBe('block'); + updateDrag('a', { row: 4, col: 12, altKey: false }); + expect(getMouseSelectionState('a').selection?.shape).toBe('linewise'); + }); + + it('updateDrag is a no-op after endDrag', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + endDrag('a'); + updateDrag('a', { row: 9, col: 9, altKey: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel?.endRow).toBe(0); + expect(sel?.endCol).toBe(0); + expect(sel?.dragging).toBe(false); + }); + + it('updateDrag with no change does not notify', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 3, col: 5, altKey: false }); + const listener = vi.fn(); + subscribeToMouseSelection(listener); + updateDrag('a', { row: 3, col: 5, altKey: false }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('endDrag freezes the selection but does not clear it', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 3, col: 5, altKey: false }); + endDrag('a'); + const sel = getMouseSelectionState('a').selection; + expect(sel?.dragging).toBe(false); + expect(sel?.endRow).toBe(3); + expect(sel?.endCol).toBe(5); + }); + + it('endDrag is a no-op when no drag is active', () => { + const listener = vi.fn(); + subscribeToMouseSelection(listener); + endDrag('a'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('isDragging reflects the drag state', () => { + expect(isDragging('a')).toBe(false); + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + expect(isDragging('a')).toBe(true); + endDrag('a'); + expect(isDragging('a')).toBe(false); + }); + + it('beginDrag with startedInScrollback=true preserves the flag', () => { + beginDrag('a', { row: 2, col: 0, altKey: false, startedInScrollback: true }); + expect(getMouseSelectionState('a').selection?.startedInScrollback).toBe(true); + }); +}); + describe('mouse-selection: snapshot caching', () => { it('returns the same snapshot reference between changes', () => { setMouseReporting('a', 'vt200'); diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index b506632..a36799c 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -27,6 +27,12 @@ export interface Selection { shape: SelectionShape; /** True while the user is still dragging; false once the mouse is released. */ dragging: boolean; + /** + * True when the drag originated in scrollback. Scrollback-origin drags are + * always handled by the terminal regardless of the inside program's mouse + * reporting (spec §3.5). + */ + startedInScrollback: boolean; } export interface TokenHint { @@ -126,6 +132,64 @@ export function setSelection(id: string, selection: Selection | null): void { notify(); } +/** + * Begin a new drag. Replaces any existing selection (spec §3.7: starting a + * new drag in the terminal content area replaces the existing selection). + */ +export function beginDrag( + id: string, + args: { row: number; col: number; altKey: boolean; startedInScrollback: boolean }, +): void { + const s = ensure(id); + s.selection = { + startRow: args.row, + startCol: args.col, + endRow: args.row, + endCol: args.col, + shape: args.altKey ? 'block' : 'linewise', + dragging: true, + startedInScrollback: args.startedInScrollback, + }; + notify(); +} + +/** + * Update an in-progress drag. No-op if no drag is active or the drag has + * already been released. The shape can flip live as Alt is pressed / released + * (spec §3.2). + */ +export function updateDrag( + id: string, + args: { row: number; col: number; altKey: boolean }, +): void { + const s = ensure(id); + const sel = s.selection; + if (!sel || !sel.dragging) return; + const shape: SelectionShape = args.altKey ? 'block' : 'linewise'; + if (sel.endRow === args.row && sel.endCol === args.col && sel.shape === shape) return; + s.selection = { ...sel, endRow: args.row, endCol: args.col, shape }; + notify(); +} + +/** + * Finalize the drag. Selection remains but is no longer in the dragging + * state. Subsequent mouse moves are ignored until a new drag starts. No-op + * if no drag is active. + */ +export function endDrag(id: string): void { + const s = ensure(id); + const sel = s.selection; + if (!sel || !sel.dragging) return; + s.selection = { ...sel, dragging: false }; + notify(); +} + +/** True if a drag is currently in progress. */ +export function isDragging(id: string): boolean { + const s = states.get(id); + return !!s?.selection?.dragging; +} + export function setHintToken(id: string, hint: TokenHint | null): void { const s = ensure(id); if (s.hintToken === null && hint === null) return; diff --git a/lib/src/lib/terminal-registry.alarm.test.ts b/lib/src/lib/terminal-registry.alarm.test.ts index 6706332..cf33aa7 100644 --- a/lib/src/lib/terminal-registry.alarm.test.ts +++ b/lib/src/lib/terminal-registry.alarm.test.ts @@ -137,6 +137,12 @@ class MockElement { } this.parentElement = null; } + + addEventListener(): void {} + removeEventListener(): void {} + getBoundingClientRect(): DOMRect { + return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect; + } } type PlatformModuleWithMock = typeof platformModule & { __fakePlatform: FakePtyAdapter }; @@ -228,6 +234,10 @@ describe('terminal-registry alarm behavior', () => { return 0; }); vi.stubGlobal('MutationObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('window', { + addEventListener: () => {}, + removeEventListener: () => {}, + }); }); afterEach(() => { diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index b5d66c2..c8464c5 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -6,7 +6,14 @@ import { TODO_OFF, isSoftTodo, type TodoState, type AlarmButtonActionResult } fr import type { AlarmStateDetail } from './platform/types'; import type { PersistedAlarmState } from './session-types'; import { attachMouseModeObserver } from './mouse-mode-observer'; -import { removeMouseSelectionState } from './mouse-selection'; +import { + beginDrag, + endDrag, + getMouseSelectionState, + isDragging, + removeMouseSelectionState, + updateDrag, +} from './mouse-selection'; export type { SessionStatus } from './activity-monitor'; export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; @@ -365,12 +372,66 @@ function setupTerminalEntry(id: string): TerminalEntry { // Observe DECSET/DECRST for mouse-reporting and bracketed-paste modes. const mouseModeObserver = attachMouseModeObserver(id, terminal); + // Mouse event router. Capture phase so we see events before xterm's own + // handlers. For now we only OBSERVE — we update our selection state but + // don't stopPropagation, so xterm's default selection still shows. Once + // the overlay lands (story C.2) we'll fully take over by stopping events. + const computeCell = (ev: MouseEvent): { row: number; col: number; startedInScrollback: boolean } => { + const rect = element.getBoundingClientRect(); + const cellWidth = rect.width / terminal.cols; + const cellHeight = rect.height / terminal.rows; + const offsetX = Math.max(0, ev.clientX - rect.left); + const offsetY = Math.max(0, ev.clientY - rect.top); + const col = Math.min(terminal.cols - 1, Math.max(0, Math.floor(offsetX / cellWidth))); + const viewportRow = Math.min(terminal.rows - 1, Math.floor(offsetY / cellHeight)); + const absRow = terminal.buffer.active.viewportY + viewportRow; + const startedInScrollback = absRow < terminal.buffer.active.baseY; + return { row: absRow, col, startedInScrollback }; + }; + + const onMouseDown = (ev: MouseEvent) => { + if (ev.button !== 0) return; // only left-click starts a selection + const state = getMouseSelectionState(id); + const cell = computeCell(ev); + // Per spec §3.5 and §6.1: + // - reporting off: terminal handles + // - reporting on + override active: terminal handles + // - reporting on + no override + scrollback-origin: terminal handles + // - reporting on + no override + live region: inside program handles + const terminalOwns = + state.mouseReporting === 'none' + || state.override !== 'off' + || cell.startedInScrollback; + if (!terminalOwns) return; + beginDrag(id, { + row: cell.row, + col: cell.col, + altKey: ev.altKey, + startedInScrollback: cell.startedInScrollback, + }); + }; + const onWindowMouseMove = (ev: MouseEvent) => { + if (!isDragging(id)) return; + const cell = computeCell(ev); + updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); + }; + const onWindowMouseUp = (_ev: MouseEvent) => { + if (!isDragging(id)) return; + endDrag(id); + }; + element.addEventListener('mousedown', onMouseDown, true); + window.addEventListener('mousemove', onWindowMouseMove, true); + window.addEventListener('mouseup', onWindowMouseUp, true); + const cleanup = () => { getPlatform().offPtyData(handleData); getPlatform().offPtyExit(handleExit); inputDisposable.dispose(); resizeDisposable.dispose(); mouseModeObserver.dispose(); + element.removeEventListener('mousedown', onMouseDown, true); + window.removeEventListener('mousemove', onWindowMouseMove, true); + window.removeEventListener('mouseup', onWindowMouseUp, true); }; const entry: TerminalEntry = { From 1ed3c365e9a3bec21ea7e01d13c7ebfeb45488c1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:36:15 -0700 Subject: [PATCH 10/38] Implement story C.2: selection overlay rendering New SelectionOverlay component renders the current selection as absolute-positioned rectangles above the xterm grid: one per row for linewise, one total for block. Uses the page's --vscode-terminal-selectionBackground for the fill color so it matches the theme. Terminal overlay dimensions (cols, rows, viewportY, baseY, element size) are exposed via getTerminalOverlayDims. A global renderTick bumped by terminal.onRender / onResize lets the overlay re-measure and reposition on scroll or resize without each pane needing its own subscription plumbing. computeRects is tested exhaustively for normalization, multi-row linewise, block, and viewport clipping. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/SelectionOverlay.test.ts | 123 ++++++++++++++++ lib/src/components/SelectionOverlay.tsx | 147 ++++++++++++++++++++ lib/src/components/TerminalPane.tsx | 7 +- lib/src/lib/mouse-selection.ts | 28 ++++ lib/src/lib/terminal-registry.alarm.test.ts | 4 + lib/src/lib/terminal-registry.ts | 36 +++++ 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 lib/src/components/SelectionOverlay.test.ts create mode 100644 lib/src/components/SelectionOverlay.tsx diff --git a/lib/src/components/SelectionOverlay.test.ts b/lib/src/components/SelectionOverlay.test.ts new file mode 100644 index 0000000..4bb8796 --- /dev/null +++ b/lib/src/components/SelectionOverlay.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { __testing } from './SelectionOverlay'; +import type { Selection } from '../lib/mouse-selection'; + +const { normalize, computeRects } = __testing; + +function sel(overrides: Partial): Selection { + return { + startRow: 0, + startCol: 0, + endRow: 0, + endCol: 0, + shape: 'linewise', + dragging: false, + startedInScrollback: false, + ...overrides, + }; +} + +describe('normalize', () => { + it('forward linewise selection passes through', () => { + const n = normalize(sel({ startRow: 2, startCol: 3, endRow: 5, endCol: 8 })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8, shape: 'linewise' }); + }); + + it('reversed linewise selection swaps start/end', () => { + const n = normalize(sel({ startRow: 5, startCol: 8, endRow: 2, endCol: 3 })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8, shape: 'linewise' }); + }); + + it('block selection normalizes min/max independently', () => { + const n = normalize(sel({ + startRow: 5, startCol: 8, endRow: 2, endCol: 3, shape: 'block', + })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8, shape: 'block' }); + }); +}); + +describe('computeRects: linewise', () => { + const cellWidth = 10; + const cellHeight = 20; + + it('single-row selection → one rect', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 5, endRow: 0, endCol: 15 }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([{ top: 0, left: 50, width: 100, height: 20 }]); + }); + + it('multi-row linewise: first row trimmed, middle rows full, last row trimmed', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 5, endRow: 2, endCol: 10 }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toHaveLength(3); + expect(rects[0]).toEqual({ top: 0, left: 50, width: (80 - 5) * 10, height: 20 }); + expect(rects[1]).toEqual({ top: 20, left: 0, width: 800, height: 20 }); + expect(rects[2]).toEqual({ top: 40, left: 0, width: 100, height: 20 }); + }); + + it('scrollback-only selection above viewport → no rects', () => { + // viewportY=50 means visible rows are 50..73. Selection in rows 10..20 + // is entirely above the viewport. + const rects = computeRects( + sel({ startRow: 10, startCol: 0, endRow: 20, endCol: 10 }), + 80, 50, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([]); + }); + + it('selection clipped to viewport start', () => { + // viewportY=10 (rows 10..33). Selection 5..15 → only rows 10..15 visible. + const rects = computeRects( + sel({ startRow: 5, startCol: 0, endRow: 15, endCol: 40 }), + 80, 10, 24, cellWidth, cellHeight, + ); + // First visible row is row 10 — that's a "middle" row (not the original + // startRow), so full-width. + expect(rects[0]).toEqual({ top: 0, left: 0, width: 800, height: 20 }); + // Last rect is the original endRow (15) in reading order. + expect(rects[rects.length - 1]).toEqual({ top: (15 - 10) * 20, left: 0, width: 400, height: 20 }); + }); + + it('selection with equal start/end columns renders nothing on that row', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 10, endRow: 0, endCol: 10 }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([]); + }); +}); + +describe('computeRects: block', () => { + const cellWidth = 10; + const cellHeight = 20; + + it('block selection is a single rect', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 3, endRow: 2, endCol: 8, shape: 'block' }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toHaveLength(1); + expect(rects[0]).toEqual({ top: 0, left: 30, width: (8 - 3 + 1) * 10, height: (2 - 0 + 1) * 20 }); + }); + + it('block selection clipped to viewport', () => { + const rects = computeRects( + sel({ startRow: 5, startCol: 3, endRow: 15, endCol: 8, shape: 'block' }), + 80, 10, 24, cellWidth, cellHeight, + ); + expect(rects[0].top).toBe(0); + expect(rects[0].height).toBe((15 - 10 + 1) * 20); + }); + + it('block selection entirely in scrollback → no rect', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 0, endRow: 5, endCol: 5, shape: 'block' }), + 80, 100, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([]); + }); +}); diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx new file mode 100644 index 0000000..45388aa --- /dev/null +++ b/lib/src/components/SelectionOverlay.tsx @@ -0,0 +1,147 @@ +import { useSyncExternalStore, type CSSProperties } from 'react'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + getMouseSelectionSnapshot, + getRenderTick, + subscribeToMouseSelection, + subscribeToRenderTick, + type Selection, +} from '../lib/mouse-selection'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; + +interface Rect { + top: number; + left: number; + width: number; + height: number; +} + +/** + * Normalize a selection so start comes before end in reading order. + * For block shape we normalize min/max on both axes. + */ +function normalize(sel: Selection): { r0: number; c0: number; r1: number; c1: number; shape: Selection['shape'] } { + if (sel.shape === 'block') { + return { + r0: Math.min(sel.startRow, sel.endRow), + c0: Math.min(sel.startCol, sel.endCol), + r1: Math.max(sel.startRow, sel.endRow), + c1: Math.max(sel.startCol, sel.endCol), + shape: 'block', + }; + } + // Linewise: compare in reading order. + const before = + sel.startRow < sel.endRow || (sel.startRow === sel.endRow && sel.startCol <= sel.endCol); + return { + r0: before ? sel.startRow : sel.endRow, + c0: before ? sel.startCol : sel.endCol, + r1: before ? sel.endRow : sel.startRow, + c1: before ? sel.endCol : sel.startCol, + shape: 'linewise', + }; +} + +function computeRects( + sel: Selection, + cols: number, + viewportY: number, + rows: number, + cellWidth: number, + cellHeight: number, +): Rect[] { + const n = normalize(sel); + + const viewportStart = viewportY; + const viewportEnd = viewportY + rows; + + if (n.shape === 'block') { + const top = Math.max(viewportStart, n.r0); + const bottom = Math.min(viewportEnd - 1, n.r1); + if (top > bottom) return []; + const left = n.c0; + const right = n.c1; + return [{ + top: (top - viewportStart) * cellHeight, + left: left * cellWidth, + width: (right - left + 1) * cellWidth, + height: (bottom - top + 1) * cellHeight, + }]; + } + + // Linewise — one rect per visible row in [r0..r1]. + const rects: Rect[] = []; + const firstRow = Math.max(viewportStart, n.r0); + const lastRow = Math.min(viewportEnd - 1, n.r1); + for (let r = firstRow; r <= lastRow; r++) { + let c0 = 0; + let c1 = cols; + if (r === n.r0) c0 = n.c0; + if (r === n.r1) c1 = n.c1; + if (c1 <= c0) continue; + rects.push({ + top: (r - viewportStart) * cellHeight, + left: c0 * cellWidth, + width: (c1 - c0) * cellWidth, + height: cellHeight, + }); + } + return rects; +} + +interface Props { + terminalId: string; +} + +/** + * Compositor-layer selection highlight rendered above xterm's cell grid. + * Re-measures on every render tick (scroll, resize, output). + */ +export function SelectionOverlay({ terminalId }: Props) { + const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); + // Subscribe to render tick so we re-render whenever xterm scrolls or resizes. + useSyncExternalStore(subscribeToRenderTick, getRenderTick); + + const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; + const selection = state.selection; + if (!selection) return null; + + const dims = getTerminalOverlayDims(terminalId); + if (!dims || dims.cols === 0 || dims.rows === 0) return null; + + const cellWidth = dims.elementWidth / dims.cols; + const cellHeight = dims.elementHeight / dims.rows; + const rects = computeRects(selection, dims.cols, dims.viewportY, dims.rows, cellWidth, cellHeight); + + const style: CSSProperties = { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + zIndex: 10, + }; + + // Use the xterm-terminal's selection color from CSS vars set on . + const bg = getComputedStyle(document.body).getPropertyValue('--vscode-terminal-selectionBackground').trim() + || 'rgba(100, 149, 237, 0.4)'; + + return ( + diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index fd1f884..41480fe 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -190,6 +190,28 @@ export function isDragging(id: string): boolean { return !!s?.selection?.dragging; } +/** + * Extend the in-progress selection to fully cover a detected token (spec §5.3). + * No-op when no drag is active. Preserves the drag anchor; adjusts the end + * toward whichever token boundary is farther from the anchor so the drag + * direction is respected. + */ +export function extendSelectionToToken(id: string, token: TokenHint): void { + const s = states.get(id); + if (!s?.selection?.dragging) return; + const sel = s.selection; + const anchorOnTokenRow = sel.startRow === token.row; + const forward = anchorOnTokenRow + ? sel.startCol <= token.startCol + : sel.startRow < token.row; + s.selection = { + ...sel, + endRow: token.row, + endCol: forward ? token.endCol : token.startCol, + }; + notify(); +} + /** * Flip the in-progress drag's shape based on the current Alt-key state. * No-op when no drag is active. Used to react to Alt press/release while diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 70ae6b0..472b296 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -14,8 +14,10 @@ import { isDragging, removeMouseSelectionState, setDragAlt, + setHintToken, updateDrag, } from './mouse-selection'; +import { detectTokenAt } from './smart-token'; export type { SessionStatus } from './activity-monitor'; export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; @@ -421,10 +423,31 @@ function setupTerminalEntry(id: string): TerminalEntry { if (!isDragging(id)) return; const cell = computeCell(ev); updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); + // Smart-extension hint (spec §5): scan the line under the current drag + // cursor for a URL/path token. + const line = terminal.buffer.active.getLine(cell.row); + if (!line) { + setHintToken(id, null); + return; + } + const text = line.translateToString(false, 0, terminal.cols); + const token = detectTokenAt(text, cell.col); + if (token) { + setHintToken(id, { + kind: token.kind, + row: cell.row, + startCol: token.start, + endCol: token.end, + text: token.text, + }); + } else { + setHintToken(id, null); + } }; const onWindowMouseUp = (_ev: MouseEvent) => { if (!isDragging(id)) return; endDrag(id); + setHintToken(id, null); }; element.addEventListener('mousedown', onMouseDown, true); window.addEventListener('mousemove', onWindowMouseMove, true); From 5bebb2cf8338405860d44b050efa296cda83f0be Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:49:09 -0700 Subject: [PATCH 17/38] Implement story C.4: cancel selection on content change or resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a drag ends, setupTerminalEntry snapshots the selected text as a baseline. Each subsequent terminal.onRender re-extracts the current text over the same coordinates and cancels the selection if anything differs. terminal.onResize also cancels unconditionally, per spec §3.4. While a drag is in progress the baseline is held at null so the active selection isn't fighting the user's own updates. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/terminal-registry.ts | 34 ++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 472b296..7464806 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -15,9 +15,11 @@ import { removeMouseSelectionState, setDragAlt, setHintToken, + setSelection as setMouseSelection, updateDrag, } from './mouse-selection'; import { detectTokenAt } from './smart-token'; +import { extractSelectionText } from './selection-text'; export type { SessionStatus } from './activity-monitor'; export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; @@ -367,16 +369,37 @@ function setupTerminalEntry(id: string): TerminalEntry { getPlatform().writePty(id, data); }); - // Resize → PTY + // Resize → PTY. Also cancel any finalized selection (spec §3.4: resize + // counts as a content change). const resizeDisposable = terminal.onResize(({ cols, rows }) => { getPlatform().alarmResize(id); getPlatform().resizePty(id, cols, rows); bumpRenderTick(); + if (getMouseSelectionState(id).selection) { + setMouseSelection(id, null); + } + selectionBaseline = null; }); - // Selection overlay needs to re-measure on scroll/render. One shared tick - // (not per-terminal) is fine because each overlay subscribes individually. - const renderDisposable = terminal.onRender(() => bumpRenderTick()); + // Cancel-on-change: snapshot the selected text when the drag ends, then + // on every subsequent render check whether any covered cell has changed. + // If so, cancel the selection (spec §3.4). + let selectionBaseline: string | null = null; + + const renderDisposable = terminal.onRender(() => { + bumpRenderTick(); + if (selectionBaseline === null) return; + const sel = getMouseSelectionState(id).selection; + if (!sel || sel.dragging) { + selectionBaseline = null; + return; + } + const current = extractSelectionText(terminal, sel); + if (current !== selectionBaseline) { + setMouseSelection(id, null); + selectionBaseline = null; + } + }); // Observe DECSET/DECRST for mouse-reporting and bracketed-paste modes. const mouseModeObserver = attachMouseModeObserver(id, terminal); @@ -448,6 +471,9 @@ function setupTerminalEntry(id: string): TerminalEntry { if (!isDragging(id)) return; endDrag(id); setHintToken(id, null); + // Take a text snapshot of the finalized selection for cancel-on-change. + const sel = getMouseSelectionState(id).selection; + selectionBaseline = sel ? extractSelectionText(terminal, sel) : null; }; element.addEventListener('mousedown', onMouseDown, true); window.addEventListener('mousemove', onWindowMouseMove, true); From 8307c4aa29192928232f1f72e5fc648e25840e0c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:50:11 -0700 Subject: [PATCH 18/38] Take over mouse events for terminal-owned drags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the mouse router decides the terminal should handle a drag, it now calls preventDefault + stopPropagation on mousedown, mousemove, and mouseup. That prevents xterm's built-in text selection from starting in parallel with ours — which would have left two highlighted regions on top of each other. Propagation is only stopped when the terminal owns the drag; program-bound events (mouse reporting on, no override, live region) still flow through to xterm untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/terminal-registry.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 7464806..d8c92c8 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -441,11 +441,17 @@ function setupTerminalEntry(id: string): TerminalEntry { altKey: ev.altKey, startedInScrollback: cell.startedInScrollback, }); + // Prevent xterm's own selection from starting, and its text-selection + // default from engaging. + ev.preventDefault(); + ev.stopPropagation(); }; const onWindowMouseMove = (ev: MouseEvent) => { if (!isDragging(id)) return; const cell = computeCell(ev); updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); + ev.preventDefault(); + ev.stopPropagation(); // Smart-extension hint (spec §5): scan the line under the current drag // cursor for a URL/path token. const line = terminal.buffer.active.getLine(cell.row); @@ -467,13 +473,15 @@ function setupTerminalEntry(id: string): TerminalEntry { setHintToken(id, null); } }; - const onWindowMouseUp = (_ev: MouseEvent) => { + const onWindowMouseUp = (ev: MouseEvent) => { if (!isDragging(id)) return; endDrag(id); setHintToken(id, null); // Take a text snapshot of the finalized selection for cancel-on-change. const sel = getMouseSelectionState(id).selection; selectionBaseline = sel ? extractSelectionText(terminal, sel) : null; + ev.preventDefault(); + ev.stopPropagation(); }; element.addEventListener('mousedown', onMouseDown, true); window.addEventListener('mousemove', onWindowMouseMove, true); From e587cf935697071f2f9ee11904554c9ec55a2ca9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 17 Apr 2026 16:50:35 -0700 Subject: [PATCH 19/38] Implement story G.1: register mouse-and-clipboard spec in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the new spec to the AGENTS.md index with the usual one-line summary and the list of files it covers, following the existing entries' format. Stories C.5 (auto-scroll during drag), F.3 (right-click / Edit menu paste), and the Copy Rewrapped heuristic refinement stay deferred to follow-ups per spec §9. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index f1cd309..cbfda4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/vscode.md`** — VS Code extension architecture: hosting modes (WebviewView + WebviewPanel), PTY lifecycle and buffering, message protocol between webview and extension host, session persistence flow, reconnection protocol, theme integration, CSP, build pipeline, and invariants (save-before-kill ordering, PTY ownership, alarm state merging). Read this when touching: `extension.ts`, `webview-view-provider.ts`, `message-router.ts`, `message-types.ts`, `pty-manager.ts`, `pty-host.js`, `session-state.ts`, `webview-html.ts`, `vscode-adapter.ts`, or `pty-core.js`. - **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane initial layout, `tut` command and TutorialShell, 6-step progressive tutorial with detection logic, theme picker, FakePtyAdapter extensions, and Pond event hooks. Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tutorial-shell.ts`, `website/src/lib/tutorial-detection.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), or the `onApiReady`/`onEvent`/`initialPaneIds` props on Pond. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode). +- **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned text selection, copy (Raw / Rewrapped), bracketed paste, smart URL/path extension, mouse-reporting override UI (icon + banner), and the state matrix for which layer owns mouse events. Read this when touching: `lib/src/lib/mouse-selection.ts`, `lib/src/lib/mouse-mode-observer.ts`, `lib/src/lib/clipboard.ts`, `lib/src/lib/rewrap.ts`, `lib/src/lib/selection-text.ts`, `lib/src/lib/smart-token.ts`, `lib/src/components/SelectionOverlay.tsx`, `lib/src/components/SelectionPopup.tsx`, the mouse icon / override banner / Cmd+C-V handling in `lib/src/components/Pond.tsx`, or the parser hooks + mouse listeners in `lib/src/lib/terminal-registry.ts`. When updating code covered by a spec, update the spec to match. When the two specs overlap (e.g. pane header elements appear in both), layout.md documents placement and sizing while alarm.md documents behavior and visual states. From 2834311ac04c0f843a4ee433aaf4a970639b692d Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Sat, 18 Apr 2026 00:01:23 +0000 Subject: [PATCH 20/38] Claude Code simplify: deduplicate normalizeSelection, merge keyboard handler blocks in Pond, remove unused BOX_RUN_GLOBAL, flatten token hint logic --- lib/src/components/Pond.tsx | 48 ++++++++------------- lib/src/components/SelectionOverlay.test.ts | 17 ++++---- lib/src/components/SelectionOverlay.tsx | 33 ++------------ lib/src/lib/rewrap.ts | 3 +- lib/src/lib/selection-text.ts | 15 ++++++- lib/src/lib/terminal-registry.ts | 26 ++++------- 6 files changed, 55 insertions(+), 87 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 0bffc43..56e2491 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1699,14 +1699,16 @@ export function Pond({ return; } - // Mid-drag keystrokes (spec §5.3 / §3.6). When a terminal-owned drag - // is in flight, `e` extends to the detected token and Esc cancels the - // drag. These are consumed so they don't reach the inside program. + // Mid-drag keystrokes and copy/paste shortcuts. Spec §5.3, §3.6, §4.2, §8.2. { const sid = selectedIdRef.current; if (sid) { const mouseState = getMouseSelectionState(sid); const sel = mouseState.selection; + + // During a terminal-owned drag, `e` extends to the detected token + // and Esc cancels. These are consumed so they don't reach the + // inside program. if (sel?.dragging) { if (e.key === 'e' && mouseState.hintToken) { e.preventDefault(); @@ -1721,35 +1723,23 @@ export function Pond({ return; } } - } - } - // Copy and paste shortcuts fire in both modes. Spec §4.2 / §8.2. - // - // Copy is narrow: only when the terminal has an active selection. - // Paste is broad: always intercepted on the platform's paste chord. - // macOS: Cmd+V, Cmd+Shift+V. Ctrl+V passes through to the program. - // Other: Ctrl+V, Ctrl+Shift+V. Both always intercepted. - { - const sid = selectedIdRef.current; - if (sid) { - const mouseState = getMouseSelectionState(sid); - const sel = mouseState.selection; + // Copy is narrow: only when the terminal has a finalized selection. + // Paste is broad: always intercepted on the platform's paste chord. + // macOS: Cmd+V, Cmd+Shift+V. Ctrl+V passes through to the program. + // Other: Ctrl+V, Ctrl+Shift+V. Both always intercepted. const keyLower = e.key.toLowerCase(); - if (sel && !sel.dragging) { - const mod = IS_MAC ? e.metaKey : e.ctrlKey; - if (mod && keyLower === 'c') { - e.preventDefault(); - e.stopImmediatePropagation(); - const rewrapped = e.shiftKey; - void (rewrapped ? copyRewrapped(sid) : copyRaw(sid)).then(() => { - setMouseSelection(sid, null); - }); - return; - } + const mod = IS_MAC ? e.metaKey : e.ctrlKey; + if (sel && !sel.dragging && mod && keyLower === 'c') { + e.preventDefault(); + e.stopImmediatePropagation(); + const rewrapped = e.shiftKey; + void (rewrapped ? copyRewrapped(sid) : copyRaw(sid)).then(() => { + setMouseSelection(sid, null); + }); + return; } - const pasteMod = IS_MAC ? e.metaKey : e.ctrlKey; - if (pasteMod && keyLower === 'v') { + if (mod && keyLower === 'v') { e.preventDefault(); e.stopImmediatePropagation(); void doPaste(sid); diff --git a/lib/src/components/SelectionOverlay.test.ts b/lib/src/components/SelectionOverlay.test.ts index 4bb8796..bd3dffb 100644 --- a/lib/src/components/SelectionOverlay.test.ts +++ b/lib/src/components/SelectionOverlay.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; import { __testing } from './SelectionOverlay'; +import { normalizeSelection } from '../lib/selection-text'; import type { Selection } from '../lib/mouse-selection'; -const { normalize, computeRects } = __testing; +const { computeRects } = __testing; function sel(overrides: Partial): Selection { return { @@ -17,22 +18,22 @@ function sel(overrides: Partial): Selection { }; } -describe('normalize', () => { +describe('normalizeSelection', () => { it('forward linewise selection passes through', () => { - const n = normalize(sel({ startRow: 2, startCol: 3, endRow: 5, endCol: 8 })); - expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8, shape: 'linewise' }); + const n = normalizeSelection(sel({ startRow: 2, startCol: 3, endRow: 5, endCol: 8 })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8 }); }); it('reversed linewise selection swaps start/end', () => { - const n = normalize(sel({ startRow: 5, startCol: 8, endRow: 2, endCol: 3 })); - expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8, shape: 'linewise' }); + const n = normalizeSelection(sel({ startRow: 5, startCol: 8, endRow: 2, endCol: 3 })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8 }); }); it('block selection normalizes min/max independently', () => { - const n = normalize(sel({ + const n = normalizeSelection(sel({ startRow: 5, startCol: 8, endRow: 2, endCol: 3, shape: 'block', })); - expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8, shape: 'block' }); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8 }); }); }); diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx index 7903ed1..f49a2ba 100644 --- a/lib/src/components/SelectionOverlay.tsx +++ b/lib/src/components/SelectionOverlay.tsx @@ -7,6 +7,7 @@ import { subscribeToRenderTick, type Selection, } from '../lib/mouse-selection'; +import { normalizeSelection } from '../lib/selection-text'; import { getTerminalOverlayDims } from '../lib/terminal-registry'; interface Rect { @@ -16,32 +17,6 @@ interface Rect { height: number; } -/** - * Normalize a selection so start comes before end in reading order. - * For block shape we normalize min/max on both axes. - */ -function normalize(sel: Selection): { r0: number; c0: number; r1: number; c1: number; shape: Selection['shape'] } { - if (sel.shape === 'block') { - return { - r0: Math.min(sel.startRow, sel.endRow), - c0: Math.min(sel.startCol, sel.endCol), - r1: Math.max(sel.startRow, sel.endRow), - c1: Math.max(sel.startCol, sel.endCol), - shape: 'block', - }; - } - // Linewise: compare in reading order. - const before = - sel.startRow < sel.endRow || (sel.startRow === sel.endRow && sel.startCol <= sel.endCol); - return { - r0: before ? sel.startRow : sel.endRow, - c0: before ? sel.startCol : sel.endCol, - r1: before ? sel.endRow : sel.startRow, - c1: before ? sel.endCol : sel.startCol, - shape: 'linewise', - }; -} - function computeRects( sel: Selection, cols: number, @@ -50,12 +25,12 @@ function computeRects( cellWidth: number, cellHeight: number, ): Rect[] { - const n = normalize(sel); + const n = normalizeSelection(sel); const viewportStart = viewportY; const viewportEnd = viewportY + rows; - if (n.shape === 'block') { + if (sel.shape === 'block') { const top = Math.max(viewportStart, n.r0); const bottom = Math.min(viewportEnd - 1, n.r1); if (top > bottom) return []; @@ -171,4 +146,4 @@ export function SelectionOverlay({ terminalId }: Props) { } // Exported for unit tests. -export const __testing = { normalize, computeRects }; +export const __testing = { computeRects }; diff --git a/lib/src/lib/rewrap.ts b/lib/src/lib/rewrap.ts index a0ed8e2..7eb94e1 100644 --- a/lib/src/lib/rewrap.ts +++ b/lib/src/lib/rewrap.ts @@ -16,7 +16,6 @@ // (U+2550..U+256C) fall in 2500..257F and heavy blocks (2588 etc.) are often // used for the same visual effect. const BOX_CHAR = /[\u2500-\u259F]/; -const BOX_RUN_GLOBAL = /[\u2500-\u259F]+/g; const FRAME_ONLY = /^[\u2500-\u259F\s]+$/; function isFrameOnlyLine(line: string): boolean { @@ -69,4 +68,4 @@ export function rewrap(text: string): string { } // Exported for targeted unit tests. -export const __testing = { isFrameOnlyLine, stripLeadingAndTrailingFrame, BOX_RUN_GLOBAL }; +export const __testing = { isFrameOnlyLine, stripLeadingAndTrailingFrame }; diff --git a/lib/src/lib/selection-text.ts b/lib/src/lib/selection-text.ts index 160deae..8b4db05 100644 --- a/lib/src/lib/selection-text.ts +++ b/lib/src/lib/selection-text.ts @@ -1,7 +1,18 @@ import type { Terminal } from '@xterm/xterm'; import type { Selection } from './mouse-selection'; -function normalize(sel: Selection) { +export interface NormalizedSelection { + r0: number; + c0: number; + r1: number; + c1: number; +} + +/** + * Normalize a selection so start comes before end in reading order. + * For block shape we normalize min/max on both axes independently. + */ +export function normalizeSelection(sel: Selection): NormalizedSelection { if (sel.shape === 'block') { return { r0: Math.min(sel.startRow, sel.endRow), @@ -25,7 +36,7 @@ function normalize(sel: Selection) { * are rectangular slabs; linewise shapes follow reading order. */ export function extractSelectionText(terminal: Terminal, sel: Selection): string { - const n = normalize(sel); + const n = normalizeSelection(sel); const buf = terminal.buffer.active; const lines: string[] = []; diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index d8c92c8..4361a88 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -455,23 +455,15 @@ function setupTerminalEntry(id: string): TerminalEntry { // Smart-extension hint (spec §5): scan the line under the current drag // cursor for a URL/path token. const line = terminal.buffer.active.getLine(cell.row); - if (!line) { - setHintToken(id, null); - return; - } - const text = line.translateToString(false, 0, terminal.cols); - const token = detectTokenAt(text, cell.col); - if (token) { - setHintToken(id, { - kind: token.kind, - row: cell.row, - startCol: token.start, - endCol: token.end, - text: token.text, - }); - } else { - setHintToken(id, null); - } + const text = line?.translateToString(false, 0, terminal.cols); + const token = text ? detectTokenAt(text, cell.col) : null; + setHintToken(id, token ? { + kind: token.kind, + row: cell.row, + startCol: token.start, + endCol: token.end, + text: token.text, + } : null); }; const onWindowMouseUp = (ev: MouseEvent) => { if (!isDragging(id)) return; From 55add29305a149925c937ef2e890622bda6ef58c Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Sat, 18 Apr 2026 00:07:15 +0000 Subject: [PATCH 21/38] Codex review R1: fix linewise endCol off-by-one (treat as inclusive like block) and add try/catch for clipboard API errors --- lib/src/components/SelectionOverlay.test.ts | 46 ++++++++++----------- lib/src/components/SelectionOverlay.tsx | 6 +-- lib/src/lib/clipboard.ts | 21 ++++++++-- lib/src/lib/selection-text.test.ts | 18 ++++---- lib/src/lib/selection-text.ts | 6 +-- 5 files changed, 55 insertions(+), 42 deletions(-) diff --git a/lib/src/components/SelectionOverlay.test.ts b/lib/src/components/SelectionOverlay.test.ts index bd3dffb..dd61336 100644 --- a/lib/src/components/SelectionOverlay.test.ts +++ b/lib/src/components/SelectionOverlay.test.ts @@ -41,19 +41,19 @@ describe('computeRects: linewise', () => { const cellWidth = 10; const cellHeight = 20; - it('single-row selection → one rect', () => { - const rects = computeRects( - sel({ startRow: 0, startCol: 5, endRow: 0, endCol: 15 }), - 80, 0, 24, cellWidth, cellHeight, - ); + it('single-row selection → one rect', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 5, endRow: 0, endCol: 14 }), + 80, 0, 24, cellWidth, cellHeight, + ); expect(rects).toEqual([{ top: 0, left: 50, width: 100, height: 20 }]); }); - it('multi-row linewise: first row trimmed, middle rows full, last row trimmed', () => { - const rects = computeRects( - sel({ startRow: 0, startCol: 5, endRow: 2, endCol: 10 }), - 80, 0, 24, cellWidth, cellHeight, - ); + it('multi-row linewise: first row trimmed, middle rows full, last row trimmed', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 5, endRow: 2, endCol: 9 }), + 80, 0, 24, cellWidth, cellHeight, + ); expect(rects).toHaveLength(3); expect(rects[0]).toEqual({ top: 0, left: 50, width: (80 - 5) * 10, height: 20 }); expect(rects[1]).toEqual({ top: 20, left: 0, width: 800, height: 20 }); @@ -63,18 +63,18 @@ describe('computeRects: linewise', () => { it('scrollback-only selection above viewport → no rects', () => { // viewportY=50 means visible rows are 50..73. Selection in rows 10..20 // is entirely above the viewport. - const rects = computeRects( - sel({ startRow: 10, startCol: 0, endRow: 20, endCol: 10 }), - 80, 50, 24, cellWidth, cellHeight, + const rects = computeRects( + sel({ startRow: 10, startCol: 0, endRow: 20, endCol: 9 }), + 80, 50, 24, cellWidth, cellHeight, ); expect(rects).toEqual([]); }); it('selection clipped to viewport start', () => { // viewportY=10 (rows 10..33). Selection 5..15 → only rows 10..15 visible. - const rects = computeRects( - sel({ startRow: 5, startCol: 0, endRow: 15, endCol: 40 }), - 80, 10, 24, cellWidth, cellHeight, + const rects = computeRects( + sel({ startRow: 5, startCol: 0, endRow: 15, endCol: 39 }), + 80, 10, 24, cellWidth, cellHeight, ); // First visible row is row 10 — that's a "middle" row (not the original // startRow), so full-width. @@ -83,13 +83,13 @@ describe('computeRects: linewise', () => { expect(rects[rects.length - 1]).toEqual({ top: (15 - 10) * 20, left: 0, width: 400, height: 20 }); }); - it('selection with equal start/end columns renders nothing on that row', () => { - const rects = computeRects( - sel({ startRow: 0, startCol: 10, endRow: 0, endCol: 10 }), - 80, 0, 24, cellWidth, cellHeight, - ); - expect(rects).toEqual([]); - }); + it('selection with equal start/end columns renders one cell', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 10, endRow: 0, endCol: 10 }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([{ top: 0, left: 100, width: 10, height: 20 }]); + }); }); describe('computeRects: block', () => { diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx index f49a2ba..738a83a 100644 --- a/lib/src/components/SelectionOverlay.tsx +++ b/lib/src/components/SelectionOverlay.tsx @@ -51,9 +51,9 @@ function computeRects( for (let r = firstRow; r <= lastRow; r++) { let c0 = 0; let c1 = cols; - if (r === n.r0) c0 = n.c0; - if (r === n.r1) c1 = n.c1; - if (c1 <= c0) continue; + if (r === n.r0) c0 = n.c0; + if (r === n.r1) c1 = n.c1 + 1; + if (c1 <= c0) continue; rects.push({ top: (r - viewportStart) * cellHeight, left: c0 * cellWidth, diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts index b72bb3d..02f6e49 100644 --- a/lib/src/lib/clipboard.ts +++ b/lib/src/lib/clipboard.ts @@ -6,8 +6,14 @@ import { getTerminalInstance } from './terminal-registry'; async function writeText(text: string): Promise { if (!text) return; - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } + } catch { + // Clipboard write can fail when the document lacks focus or the + // Permissions API denied access. Silently ignore — the user will + // notice the paste didn't work and can retry. } } @@ -41,8 +47,15 @@ export async function copyRewrapped(terminalId: string): Promise { * inside program's bracketed-paste mode when enabled (spec §8.5). */ export async function doPaste(terminalId: string): Promise { - if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return; - const text = await navigator.clipboard.readText(); + let text: string; + try { + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return; + text = await navigator.clipboard.readText(); + } catch { + // Clipboard read can fail when the document lacks focus or the + // Permissions API denied access. Silently ignore. + return; + } if (!text) return; const bracketed = getMouseSelectionState(terminalId).bracketedPaste; const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; diff --git a/lib/src/lib/selection-text.test.ts b/lib/src/lib/selection-text.test.ts index 8e37c49..9275085 100644 --- a/lib/src/lib/selection-text.test.ts +++ b/lib/src/lib/selection-text.test.ts @@ -39,19 +39,19 @@ describe('extractSelectionText', () => { 'dog and runs away.', ]); - it('single-row linewise', () => { - const s = sel({ startRow: 0, startCol: 4, endRow: 0, endCol: 9 }); - expect(extractSelectionText(t, s)).toBe('quick'); + it('single-row linewise', () => { + const s = sel({ startRow: 0, startCol: 4, endRow: 0, endCol: 8 }); + expect(extractSelectionText(t, s)).toBe('quick'); }); - it('multi-row linewise trims each end', () => { - const s = sel({ startRow: 0, startCol: 10, endRow: 2, endCol: 3 }); - expect(extractSelectionText(t, s)).toBe('brown fox\njumps over the lazy\ndog'); + it('multi-row linewise trims each end', () => { + const s = sel({ startRow: 0, startCol: 10, endRow: 2, endCol: 2 }); + expect(extractSelectionText(t, s)).toBe('brown fox\njumps over the lazy\ndog'); }); - it('reversed linewise (user dragged right-to-left)', () => { - const s = sel({ startRow: 0, startCol: 9, endRow: 0, endCol: 4 }); - expect(extractSelectionText(t, s)).toBe('quick'); + it('reversed linewise (user dragged right-to-left)', () => { + const s = sel({ startRow: 0, startCol: 8, endRow: 0, endCol: 4 }); + expect(extractSelectionText(t, s)).toBe('quick'); }); it('block shape extracts the rectangular slab', () => { diff --git a/lib/src/lib/selection-text.ts b/lib/src/lib/selection-text.ts index 8b4db05..8b949ad 100644 --- a/lib/src/lib/selection-text.ts +++ b/lib/src/lib/selection-text.ts @@ -52,9 +52,9 @@ export function extractSelectionText(terminal: Terminal, sel: Selection): string for (let r = n.r0; r <= n.r1; r++) { const line = buf.getLine(r); if (!line) continue; - const c0 = r === n.r0 ? n.c0 : 0; - const c1 = r === n.r1 ? n.c1 : terminal.cols; - lines.push(line.translateToString(false, c0, c1).replace(/\s+$/, '')); + const c0 = r === n.r0 ? n.c0 : 0; + const c1 = r === n.r1 ? n.c1 + 1 : terminal.cols; + lines.push(line.translateToString(false, c0, c1).replace(/\s+$/, '')); } return lines.join('\n'); } From a675b9929451c1bd1207fe4e0f4cd9422c4a3c2f Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Sat, 18 Apr 2026 00:12:42 +0000 Subject: [PATCH 22/38] Claude Code review R2: fix extendSelectionToToken exclusive-to-inclusive endCol conversion --- lib/src/lib/mouse-selection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index 41480fe..d7d5938 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -207,7 +207,7 @@ export function extendSelectionToToken(id: string, token: TokenHint): void { s.selection = { ...sel, endRow: token.row, - endCol: forward ? token.endCol : token.startCol, + endCol: forward ? token.endCol - 1 : token.startCol, }; notify(); } From 7f827ac9d4e216199ac3bc44b4abf3060160f6e8 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Sat, 18 Apr 2026 00:22:44 +0000 Subject: [PATCH 23/38] =?UTF-8?q?Claude=20Code=20review=20R4:=20auto-end?= =?UTF-8?q?=20temporary=20override=20on=20mouseup=20per=20spec=20=C2=A72.1?= =?UTF-8?q?,=20fix=20stale=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/lib/terminal-registry.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 4361a88..6c1301a 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -15,6 +15,7 @@ import { removeMouseSelectionState, setDragAlt, setHintToken, + setOverride, setSelection as setMouseSelection, updateDrag, } from './mouse-selection'; @@ -405,9 +406,7 @@ function setupTerminalEntry(id: string): TerminalEntry { const mouseModeObserver = attachMouseModeObserver(id, terminal); // Mouse event router. Capture phase so we see events before xterm's own - // handlers. For now we only OBSERVE — we update our selection state but - // don't stopPropagation, so xterm's default selection still shows. Once - // the overlay lands (story C.2) we'll fully take over by stopping events. + // handlers. We preventDefault + stopPropagation to fully own the selection. const computeCell = (ev: MouseEvent): { row: number; col: number; startedInScrollback: boolean } => { const rect = element.getBoundingClientRect(); const cellWidth = rect.width / terminal.cols; @@ -472,6 +471,11 @@ function setupTerminalEntry(id: string): TerminalEntry { // Take a text snapshot of the finalized selection for cancel-on-change. const sel = getMouseSelectionState(id).selection; selectionBaseline = sel ? extractSelectionText(terminal, sel) : null; + // Per spec §2.1: a temporary override ends on the mouse-up that + // finalizes the drag. The drag we just ended is that mouse-up. + if (getMouseSelectionState(id).override === 'temporary') { + setOverride(id, 'off'); + } ev.preventDefault(); ev.stopPropagation(); }; From cc70b1f4ecb4d45cfdb48d6ed2fe9f476d6a5272 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Sat, 18 Apr 2026 00:27:31 +0000 Subject: [PATCH 24/38] =?UTF-8?q?Codex=20review=20R5:=20consume=20all=20no?= =?UTF-8?q?n-Alt=20keystrokes=20during=20terminal-owned=20drag=20per=20spe?= =?UTF-8?q?c=20=C2=A73.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/components/Pond.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 56e2491..7839a22 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1707,8 +1707,10 @@ export function Pond({ const sel = mouseState.selection; // During a terminal-owned drag, `e` extends to the detected token - // and Esc cancels. These are consumed so they don't reach the - // inside program. + // and Esc cancels. Per spec §3.6, ALL keystrokes are consumed + // during a drag so they don't reach the inside program. Alt is + // allowed to propagate because terminal-registry's onAltChange + // listener uses it for block-selection shape toggling (§3.2). if (sel?.dragging) { if (e.key === 'e' && mouseState.hintToken) { e.preventDefault(); @@ -1722,6 +1724,13 @@ export function Pond({ setMouseSelection(sid, null); return; } + // Let Alt propagate for block-selection toggling; consume + // everything else. + if (e.key !== 'Alt') { + e.preventDefault(); + e.stopImmediatePropagation(); + } + return; } // Copy is narrow: only when the terminal has a finalized selection. From b92460a1ffccd054cc6a26f906ea8d2c809beebf Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Sat, 18 Apr 2026 00:41:59 +0000 Subject: [PATCH 25/38] Claude Code review R6: guard mouseup handler to only respond to left-button release --- lib/src/lib/terminal-registry.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 6c1301a..9e166de 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -465,6 +465,7 @@ function setupTerminalEntry(id: string): TerminalEntry { } : null); }; const onWindowMouseUp = (ev: MouseEvent) => { + if (ev.button !== 0) return; // only left-button release ends a drag if (!isDragging(id)) return; endDrag(id); setHintToken(id, null); From 5632eb9d5049bf2d1578321bf74f7b730d174dfb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 20 Apr 2026 13:10:26 -0700 Subject: [PATCH 26/38] Update tutorial with missing requirements from the mouse-and-clipboard stuff. --- docs/specs/tutorial.md | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 628333b..931ef2e 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -144,3 +144,60 @@ The picker restores the persisted active theme on mount. The playground header i - `FakePtyAdapter` extensions: `setInputHandler(id, fn)` routes `writePty` calls to a custom handler; `sendOutput(id, data)` writes to a terminal's output stream. - `Pond` extensions: `initialPaneIds` prop seeds the first pane(s); `onApiReady` callback prop exposes `DockviewApi`; `onEvent` callback prop fires `PondEvent` for mode/zoom/detach/selection/split changes (types: `modeChange`, `zoomChange`, `detachChange`, `split`, `selectionChange`). - `SCENARIO_TUTORIAL_MOTD` scenario added to `lib/src/lib/platform/fake-scenarios.ts`. + +## Mouse and Clipboard Feature Coverage + +The Playground is the primary dogfood surface for the features in `docs/specs/mouse-and-clipboard.md`. As of the current three-pane layout (tutorial MOTD, `npm install`, `ls -la`) most of those features are not reachable from the Playground — the scenarios don't emit the relevant escape sequences or the right kinds of text. + +### Current state + +Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable. + +| Spec § | Feature | Status | Why | +|---|---|---|---| +| §1 | Mouse icon visible when program requests reporting | ❌ | No scenario emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h`. | +| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | ❌ | Blocked on §1. | +| §3.1–§3.3 | Drag, Alt-block shape, "Hold Alt" hint | ✅ | Works on any visible text. | +| §3.3 | "Press e to select the full URL/path" hint | ❌ | No qualifying tokens; bare filenames like `package.json` don't match the patterns in `lib/src/lib/smart-token.ts`. | +| §3.4 | Pure-scroll follows, cancel-on-change, cancel-on-resize | ⚠️ | Scenarios are too short to scroll; nothing emits additional output after the initial burst; resize cancel works. | +| §3.5 | Scrollback-origin / cross-boundary drags | ⚠️ | Scrollback is too short to exercise. | +| §3.6 | Keyboard routing during drag | ⚠️ | Works, but hard to observe — no program in Playground reacts to dropped keystrokes. | +| §3.7 | Popup on mouse-up, new-drag-replaces | ✅ | Any selection. | +| §4.1.1 | Copy Raw | ✅ | Any selection. | +| §4.1.2 | Copy Rewrapped (box-strip + paragraph unwrap) | ❌ | No box-drawing characters anywhere; no multi-line prose. Rewrapped output is identical to Raw. | +| §4.2 | Cmd+C / Cmd+Shift+C | ✅ | Any selection. | +| §4.3 | Esc / click-outside dismiss | ✅ | Any selection popup. | +| §5 | Smart-extension (URL / abs path / rel path / Windows path / error location) | ❌ | No matching tokens in the scenarios. | +| §5.3 | Press `e` to extend | ❌ | Blocked on §5 coverage. | +| §8.2 | Cmd+V / Cmd+Shift+V / Ctrl+V / Ctrl+Shift+V paste | ⚠️ | The shortcut fires and writes to the fake PTY, but `TutorialShell.handleInput` (`website/src/lib/tutorial-shell.ts:77-96`) echoes characters one by one and does not interpret bracketed-paste markers. | +| §8.5 | Bracketed paste wraps `\e[200~ … \e[201~` | ❌ | No scenario emits `\x1b[?2004h`, so `getMouseSelectionState(id).bracketedPaste` stays `false` and `doPaste` sends the raw text. | + +`§3.6` auto-scroll and `§8.7` right-click paste are deferred in the implementation itself — not Playground gaps. + +### Remediation plan + +Add three new scenarios in `lib/src/lib/platform/fake-scenarios.ts` and expand the Playground layout in `website/src/pages/Playground.tsx` to surface them alongside the existing tutorial pane. Each scenario closes a specific set of gaps; all three together plus the tutorial MOTD make every currently-implemented feature reachable. + +1. **`SCENARIO_MOUSE_TUI`** — closes §1, §2, §8.5. + Emits `\x1b[?1000h\x1b[?1006h\x1b[?2004h` and then draws an idle `htop`-style ANSI-framed view. A minimal input handler for this pane discards any mouse-report bytes xterm forwards. With this pane present the Mouse icon appears in its header, clicking it activates the temporary-override banner, and pastes into it are wrapped in `\x1b[200~ … \x1b[201~`. + +2. **`SCENARIO_SMART_TOKENS`** — closes §3.3 extension hint, §5.1–§5.3. + Prints one of each detectable shape so every branch in `lib/src/lib/smart-token.ts`'s `PATTERNS` list has a live example: + + ``` + ✗ src/components/Pond.tsx:1576:7 — unused import + ✗ ../sibling/util.rs:42 — panic here + see https://en.wikipedia.org/wiki/Foo_(bar) + docs: /usr/local/share/doc/mouseterm/README + cwd: ~/projects/mouseterm + windows: C:\Users\me\work.log + ``` + + Dragging across any of them shows "Press e to select the full URL/path" and `e` extends. + +3. **`SCENARIO_BOXED_OUTPUT`** — closes §4.1.2 and §3.4. + A short release-notes-shaped message framed in `┌─│└` so Copy Rewrapped (via `lib/src/lib/rewrap.ts`) strips the frame and joins the wrapped lines — clipboard contents visibly differ from Copy Raw. A slowly-updating ticker line at the bottom gives cancel-on-change something concrete to react to. + +**Playground layout:** keep `PANE_MAIN` as the tutorial entry; replace `PANE_NPM` / `PANE_LS` with `PANE_TUI` / `PANE_TOKENS` / `PANE_BOXED` (three `api.addPanel` calls in `handleApiReady`, same pattern as the existing ones at `website/src/pages/Playground.tsx:62-75`). A 2×2 grid fits on load. + +**Optional:** teach `TutorialShell.handleInput` to recognize `\x1b[200~ … \x1b[201~` and print `[pasted: …]` so bracketed-paste wrapping is visually distinct for users who paste into `PANE_MAIN`. From df7e57fa038378bfb2ffa8e8f69ebe700b09404f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 20 Apr 2026 13:16:10 -0700 Subject: [PATCH 27/38] Text-region handling only triggers when a click-and-drag actually happens. --- lib/src/lib/terminal-registry.ts | 55 ++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 9e166de..e6fca7b 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -406,7 +406,9 @@ function setupTerminalEntry(id: string): TerminalEntry { const mouseModeObserver = attachMouseModeObserver(id, terminal); // Mouse event router. Capture phase so we see events before xterm's own - // handlers. We preventDefault + stopPropagation to fully own the selection. + // handlers. We defer beginDrag (and stopPropagation) until the cursor has + // actually moved past a small threshold — a plain click stays a plain + // click so the pane-click handler upstream can still shift focus. const computeCell = (ev: MouseEvent): { row: number; col: number; startedInScrollback: boolean } => { const rect = element.getBoundingClientRect(); const cellWidth = rect.width / terminal.cols; @@ -420,8 +422,18 @@ function setupTerminalEntry(id: string): TerminalEntry { return { row: absRow, col, startedInScrollback }; }; + const DRAG_THRESHOLD_PX_SQ = 16; // 4px squared — typical click-vs-drag threshold + let pendingDrag: { + row: number; + col: number; + altKey: boolean; + startedInScrollback: boolean; + clientX: number; + clientY: number; + } | null = null; + const onMouseDown = (ev: MouseEvent) => { - if (ev.button !== 0) return; // only left-click starts a selection + if (ev.button !== 0) return; // only left-click can start a selection const state = getMouseSelectionState(id); const cell = computeCell(ev); // Per spec §3.5 and §6.1: @@ -434,18 +446,34 @@ function setupTerminalEntry(id: string): TerminalEntry { || state.override !== 'off' || cell.startedInScrollback; if (!terminalOwns) return; - beginDrag(id, { + // Record the anchor but don't commit — this might be a plain click. We + // deliberately do NOT preventDefault / stopPropagation here so pane + // focus-on-click (and xterm's own focus handling) still work. + pendingDrag = { row: cell.row, col: cell.col, altKey: ev.altKey, startedInScrollback: cell.startedInScrollback, - }); - // Prevent xterm's own selection from starting, and its text-selection - // default from engaging. - ev.preventDefault(); - ev.stopPropagation(); + clientX: ev.clientX, + clientY: ev.clientY, + }; }; const onWindowMouseMove = (ev: MouseEvent) => { + if (pendingDrag) { + const dx = ev.clientX - pendingDrag.clientX; + const dy = ev.clientY - pendingDrag.clientY; + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX_SQ) return; + // Threshold crossed — promote pending anchor into a real drag. + beginDrag(id, { + row: pendingDrag.row, + col: pendingDrag.col, + altKey: pendingDrag.altKey, + startedInScrollback: pendingDrag.startedInScrollback, + }); + // Wipe any nascent xterm selection that started on the bare mousedown. + terminal.clearSelection(); + pendingDrag = null; + } if (!isDragging(id)) return; const cell = computeCell(ev); updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); @@ -466,6 +494,15 @@ function setupTerminalEntry(id: string): TerminalEntry { }; const onWindowMouseUp = (ev: MouseEvent) => { if (ev.button !== 0) return; // only left-button release ends a drag + if (pendingDrag) { + // Pure click — never crossed the drag threshold. Still counts as the + // mouse-up that terminates a temporary override (spec §2.1). + if (getMouseSelectionState(id).override === 'temporary') { + setOverride(id, 'off'); + } + pendingDrag = null; + return; + } if (!isDragging(id)) return; endDrag(id); setHintToken(id, null); @@ -473,7 +510,7 @@ function setupTerminalEntry(id: string): TerminalEntry { const sel = getMouseSelectionState(id).selection; selectionBaseline = sel ? extractSelectionText(terminal, sel) : null; // Per spec §2.1: a temporary override ends on the mouse-up that - // finalizes the drag. The drag we just ended is that mouse-up. + // finalizes the drag. if (getMouseSelectionState(id).override === 'temporary') { setOverride(id, 'off'); } From 950c31344065bd7e00b426ec7de1db234ef4201c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 20 Apr 2026 13:37:25 -0700 Subject: [PATCH 28/38] Render selection highlight as a border-only SVG outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-row translucent-fill divs with a single SVG path that traces the perimeter of the whole selection. For multi-row linewise selections the path walks the right edges down, the bottom of the last row, then the left edges up in reverse — so width transitions between rows produce proper outside corners instead of stacked-rectangle double-lines. Border color prefers --vscode-focusBorder (typically fully opaque), falls back to --vscode-terminal-foreground, then selectionBackground, then a cornflower default. This renders cleanly across themes where the translucent fill previously looked bad. New rectsToPath helper has coverage for empty, single-rect, two-row Z, three-row with full-width middle, and block shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/SelectionOverlay.test.ts | 67 ++++++++++++++++++- lib/src/components/SelectionOverlay.tsx | 71 ++++++++++++++++----- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/lib/src/components/SelectionOverlay.test.ts b/lib/src/components/SelectionOverlay.test.ts index dd61336..c1352de 100644 --- a/lib/src/components/SelectionOverlay.test.ts +++ b/lib/src/components/SelectionOverlay.test.ts @@ -3,7 +3,7 @@ import { __testing } from './SelectionOverlay'; import { normalizeSelection } from '../lib/selection-text'; import type { Selection } from '../lib/mouse-selection'; -const { computeRects } = __testing; +const { computeRects, rectsToPath } = __testing; function sel(overrides: Partial): Selection { return { @@ -122,3 +122,68 @@ describe('computeRects: block', () => { expect(rects).toEqual([]); }); }); + +describe('rectsToPath', () => { + it('empty rect list → empty path', () => { + expect(rectsToPath([])).toBe(''); + }); + + it('single rect traces four corners', () => { + // 20x10 rect at origin + const path = rectsToPath([{ top: 0, left: 0, width: 20, height: 10 }]); + // Right side going down, then left side going up (reversed). + expect(path).toBe('M 20 0 L 20 10 L 0 10 L 0 0 Z'); + }); + + it('two-row linewise "Z" shape — first row narrower, second wider', () => { + // Row 0 is a tail: left=50, right=80 (narrower) + // Row 1 starts at col 0: left=0, right=30 (head) + const path = rectsToPath([ + { top: 0, left: 50, width: 30, height: 10 }, + { top: 10, left: 0, width: 30, height: 10 }, + ]); + // Expected vertex walk: + // top-right of row 0 → bottom-right of row 0 (= top-right of connector) + // top-right of row 1 → bottom-right of row 1 + // bottom-left of row 1 → top-left of row 1 (= bottom-left of connector) + // bottom-left of row 0 → top-left of row 0 → close + expect(path).toBe( + 'M 80 0 L 80 10 L 30 10 L 30 20 L 0 20 L 0 10 L 50 10 L 50 0 Z', + ); + }); + + it('three-row linewise with full-width middle row', () => { + const path = rectsToPath([ + { top: 0, left: 40, width: 40, height: 10 }, // row 0: tail, 40..80 + { top: 10, left: 0, width: 80, height: 10 }, // row 1: full, 0..80 + { top: 20, left: 0, width: 20, height: 10 }, // row 2: head, 0..20 + ]); + expect(path).toBe( + [ + 'M 80 0', // top-right of row 0 + 'L 80 10', // bottom-right of row 0 + 'L 80 10', // top-right of row 1 (same point, 0-length connector) + 'L 80 20', // bottom-right of row 1 + 'L 20 20', // top-right of row 2 + 'L 20 30', // bottom-right of row 2 + 'L 0 30', // bottom-left of row 2 + 'L 0 20', // top-left of row 2 + 'L 0 20', // bottom-left of row 1 (same point) + 'L 0 10', // top-left of row 1 + 'L 40 10', // bottom-left of row 0 + 'L 40 0', // top-left of row 0 + 'Z', + ].join(' '), + ); + }); + + it('block selection (single rect) — same as single rectangle', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 3, endRow: 2, endCol: 8, shape: 'block' }), + 80, 0, 24, 10, 20, + ); + const path = rectsToPath(rects); + // Rect: top=0, left=30, width=60, height=60 → right=90, bottom=60 + expect(path).toBe('M 90 0 L 90 60 L 30 60 L 30 0 Z'); + }); +}); diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx index 738a83a..3c99a05 100644 --- a/lib/src/components/SelectionOverlay.tsx +++ b/lib/src/components/SelectionOverlay.tsx @@ -17,6 +17,34 @@ interface Rect { height: number; } +/** + * Trace the perimeter of a linewise/block selection's visible rects as a + * single closed SVG path. + * + * Rects are row-adjacent and in top-to-bottom order (guaranteed by + * `computeRects`). We walk the right edges going down, the bottom edge of + * the last rect, the left edges going up, then the top edge of the first + * rect. Horizontal connector segments between rows of different widths + * naturally fall out of the vertex sequence. + */ +function rectsToPath(rects: Rect[]): string { + if (rects.length === 0) return ''; + const pts: Array<[number, number]> = []; + // Right side going down — each rect contributes (top-right, bottom-right). + for (const r of rects) { + pts.push([r.left + r.width, r.top]); + pts.push([r.left + r.width, r.top + r.height]); + } + // Left side going up — walk the rects in reverse, contributing + // (bottom-left, top-left) each. + for (let i = rects.length - 1; i >= 0; i--) { + const r = rects[i]; + pts.push([r.left, r.top + r.height]); + pts.push([r.left, r.top]); + } + return 'M ' + pts.map(([x, y]) => `${x} ${y}`).join(' L ') + ' Z'; +} + function computeRects( sel: Selection, cols: number, @@ -95,9 +123,16 @@ export function SelectionOverlay({ terminalId }: Props) { zIndex: 10, }; - // Use the xterm-terminal's selection color from CSS vars set on . - const bg = getComputedStyle(document.body).getPropertyValue('--vscode-terminal-selectionBackground').trim() - || 'rgba(100, 149, 237, 0.4)'; + // Border-only highlight. Pick a color with reliable contrast across themes: + // prefer focusBorder (typically fully opaque accent), fall back to the + // terminal foreground, then selectionBackground, then a hard-coded cornflower. + const styles = getComputedStyle(document.body); + const borderColor = + styles.getPropertyValue('--vscode-focusBorder').trim() + || styles.getPropertyValue('--vscode-terminal-foreground').trim() + || styles.getPropertyValue('--vscode-terminal-selectionBackground').trim() + || 'rgb(100, 149, 237)'; + const pathD = rectsToPath(rects); // Mid-drag hint. Positioned above the drag-end cell, clamped to the // overlay bounds. Shown only while the user is dragging (spec §3.3). @@ -114,19 +149,21 @@ export function SelectionOverlay({ terminalId }: Props) { return (