Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b09e39e
Initial spec.
nedtwigg Apr 17, 2026
9c77f8e
Update the spec.
nedtwigg Apr 17, 2026
7e9dd43
Add a plan.
nedtwigg Apr 17, 2026
5cacb09
Implement story A.1: per-terminal mouse selection state store
nedtwigg Apr 17, 2026
9935135
Implement story A.2: observe mouse-reporting and bracketed-paste DECS…
nedtwigg Apr 17, 2026
508c768
Implement story A.3: IS_MAC platform detection helper
nedtwigg Apr 17, 2026
d0d6408
Implement story B.1: mouse/no-mouse header icon
nedtwigg Apr 17, 2026
fb78602
Implement story B.2: temporary-override banner
nedtwigg Apr 17, 2026
e102341
Implement story C.1: mouse event router (observe-only)
nedtwigg Apr 17, 2026
1ed3c36
Implement story C.2: selection overlay rendering
nedtwigg Apr 17, 2026
10c8370
Implement story C.3: drag shapes + hint text
nedtwigg Apr 17, 2026
2251566
Implement story D.2a: rewrap heuristics, selection text extraction, c…
nedtwigg Apr 17, 2026
e9055b2
Implement stories D.1 + D.2b + D.3: selection popup and copy shortcuts
nedtwigg Apr 17, 2026
942a749
Implement stories F.1 + F.2: bracketed paste shortcuts
nedtwigg Apr 17, 2026
e285448
Implement story E.1: smart-token URL/path detection
nedtwigg Apr 17, 2026
f1daf46
Implement story E.2: mid-drag hint and e-to-extend
nedtwigg Apr 17, 2026
5bebb2c
Implement story C.4: cancel selection on content change or resize
nedtwigg Apr 17, 2026
8307c4a
Take over mouse events for terminal-owned drags
nedtwigg Apr 17, 2026
e587cf9
Implement story G.1: register mouse-and-clipboard spec in AGENTS.md
nedtwigg Apr 17, 2026
2834311
Claude Code simplify: deduplicate normalizeSelection, merge keyboard …
Apr 18, 2026
55add29
Codex review R1: fix linewise endCol off-by-one (treat as inclusive l…
Apr 18, 2026
a675b99
Claude Code review R2: fix extendSelectionToToken exclusive-to-inclus…
Apr 18, 2026
7f827ac
Claude Code review R4: auto-end temporary override on mouseup per spe…
Apr 18, 2026
cc70b1f
Codex review R5: consume all non-Alt keystrokes during terminal-owned…
Apr 18, 2026
b92460a
Claude Code review R6: guard mouseup handler to only respond to left-…
Apr 18, 2026
5632eb9
Update tutorial with missing requirements from the mouse-and-clipboar…
nedtwigg Apr 20, 2026
df7e57f
Text-region handling only triggers when a click-and-drag actually hap…
nedtwigg Apr 20, 2026
950c313
Render selection highlight as a border-only SVG outline
nedtwigg Apr 20, 2026
b4ed7de
Align selection math with xterm's actual cell grid
nedtwigg Apr 20, 2026
eacb494
Stabilize hint/popup placement and match native Alt/Opt label
nedtwigg Apr 20, 2026
6119b53
Add Storybook stories for the text selection stuff.
nedtwigg Apr 20, 2026
c1acd02
Match popup button padding to Alt hint and add TextSelection stories
nedtwigg Apr 20, 2026
acefdce
Bottom-anchor the Alt hint and copy popup on drag-up
nedtwigg Apr 20, 2026
34cb8a8
Flash the pressed copy button in place instead of swapping the popup
nedtwigg Apr 20, 2026
25c3d23
Keep copy-button width stable across the flash
nedtwigg Apr 20, 2026
5ecb80c
Fix header icon stories
nedtwigg Apr 21, 2026
4f7bedb
Icon fixup
nedtwigg Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
506 changes: 506 additions & 0 deletions docs/plans/mouse-and-clipboard-plan.md

Large diffs are not rendered by default.

358 changes: 358 additions & 0 deletions docs/specs/mouse-and-clipboard.md

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions docs/specs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
176 changes: 175 additions & 1 deletion lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,20 @@ 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 { PopupButtonRow, popupButton } from './design';
import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon, CursorClickIcon, SelectionSlashIcon } from '@phosphor-icons/react';
import {
DEFAULT_MOUSE_SELECTION_STATE,
extendSelectionToToken,
flashCopy,
getMouseSelectionSnapshot,
getMouseSelectionState,
setOverride as setMouseOverride,
setSelection as setMouseSelection,
subscribeToMouseSelection,
} from '../lib/mouse-selection';
import { copyRaw, copyRewrapped, doPaste } from '../lib/clipboard';
import { IS_MAC } from '../lib/platform';
import {
type AlarmButtonActionResult,
clearSessionAttention,
Expand Down Expand Up @@ -202,6 +215,71 @@ 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);
const [flashed, setFlashed] = useState<'sticky' | 'cancel' | 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]);

useEffect(() => {
if (!flashed) return;
const id = window.setTimeout(() => {
if (flashed === 'sticky') onMakePermanent();
else onCancel();
}, 260);
return () => window.clearTimeout(id);
}, [flashed, onMakePermanent, onCancel]);

if (!pos) return null;

return createPortal(
<PopupButtonRow
className="z-[9999]"
style={clampOverlayPosition({ left: pos.x, top: pos.y, width: 340, height: 32 })}
onMouseDown={(e) => e.stopPropagation()}
role="status"
>
<span className="px-1.5 py-0.5">Temporary mouse override until mouse-up.</span>
<button
type="button"
className={popupButton({ tone: 'muted', flashed: flashed === 'sticky' })}
onClick={() => !flashed && setFlashed('sticky')}
>Make sticky</button>
<button
type="button"
className={popupButton({ tone: 'muted', flashed: flashed === 'cancel' })}
onClick={() => !flashed && setFlashed('cancel')}
>Cancel</button>
</PopupButtonRow>,
document.body,
);
}

function clampOverlayPosition({ left, top, width, height }: {
left: number;
top: number;
Expand Down Expand Up @@ -529,12 +607,21 @@ 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;
const tabRef = useRef<HTMLDivElement>(null);
const [mouseIconAnchor, setMouseIconAnchor] = useState<HTMLDivElement | null>(null);
const suppressAlarmClickRef = useRef(false);
const [tier, setTier] = useState<HeaderTier>('full');
const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null);
Expand Down Expand Up @@ -684,6 +771,35 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
</div>
{!isRenaming && (
<>
{showMouseIcon && (
<div ref={setMouseIconAnchor} className="ml-1 shrink-0">
<HeaderActionButton
className="flex h-5 min-w-5 items-center justify-center rounded transition-colors shrink-0 text-muted hover:bg-foreground/10 hover:text-foreground"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setMouseOverride(api.id, inOverride ? 'off' : 'temporary');
}}
ariaLabel={mouseIconAriaLabel}
tooltip={mouseIconTooltip}
>
<span className="relative flex items-center justify-center">
{inOverride ? (
<SelectionSlashIcon size={14} />
) : (
<CursorClickIcon size={14} />
)}
</span>
</HeaderActionButton>
</div>
)}
{mouseIconAnchor && mouseState.override === 'temporary' && (
<MouseOverrideBanner
anchor={mouseIconAnchor}
onMakePermanent={() => setMouseOverride(api.id, 'permanent')}
onCancel={() => setMouseOverride(api.id, 'off')}
/>
)}
{/* Split/Zoom controls — hidden at compact and minimal tiers */}
{tier === 'full' && (
<div className="ml-1 flex shrink-0 items-center gap-0.5">
Expand Down Expand Up @@ -1596,6 +1712,64 @@ export function Pond({
return;
}

// 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. 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();
e.stopImmediatePropagation();
extendSelectionToToken(sid, mouseState.hintToken);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
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.
// 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();
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(() => {
flashCopy(sid, rewrapped ? 'rewrapped' : 'raw');
});
return;
}
if (mod && keyLower === 'v') {
e.preventDefault();
e.stopImmediatePropagation();
void doPaste(sid);
return;
}
}
}

// In terminal mode, only the Meta gesture above matters — everything else goes to xterm
if (currentMode === 'passthrough') return;

Expand Down
Loading
Loading