From 8139a27eb25e9cada59cc4408daeba552fe0b5e1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 17:30:09 -0700 Subject: [PATCH 01/36] Prototype a copy editor in Storybook Two React-drawn mock terminals (Claude Code inline and fullscreen, zsh) over a pure model of the copy transforms: line-break judgement, chrome stripping, scope growth, and word nudging. Prototypes/Copy editor compares three concepts (numbered chooser, copy-then-switch receipt, editor). Prototypes/Copy editor 2 carries the chosen one: the editor at full pane width, Auto / Exact / Spaces / No breaks cycled with f, e and Shift+e for scope, arrows to nudge, and a clickable mark on every line break. Co-Authored-By: Claude Opus 5.5 --- .../copy-editor/CopyEditor.stories.tsx | 523 ++++++++++++++++ .../copy-editor/CopyEditor2.stories.tsx | 513 +++++++++++++++ lib/src/stories/copy-editor/CopyMockups.tsx | 584 ++++++++++++++++++ lib/src/stories/copy-editor/model.ts | 547 ++++++++++++++++ 4 files changed, 2167 insertions(+) create mode 100644 lib/src/stories/copy-editor/CopyEditor.stories.tsx create mode 100644 lib/src/stories/copy-editor/CopyEditor2.stories.tsx create mode 100644 lib/src/stories/copy-editor/CopyMockups.tsx create mode 100644 lib/src/stories/copy-editor/model.ts diff --git a/lib/src/stories/copy-editor/CopyEditor.stories.tsx b/lib/src/stories/copy-editor/CopyEditor.stories.tsx new file mode 100644 index 000000000..1630067d3 --- /dev/null +++ b/lib/src/stories/copy-editor/CopyEditor.stories.tsx @@ -0,0 +1,523 @@ +import { clsx } from 'clsx'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + ArmedHint, + CELL_H, + COPY_CHORD, + ChooserPanel, + ClipboardPanel, + EditorPanel, + MockTerminal, + ReceiptPanel, + buildOptions, + type CopyOption, +} from './CopyMockups'; +import { + FORMATS, + RECOMMENDED, + SCREENS, + computeScopes, + normalizeSpan, + nudge, + render, + type BreakKind, + type FormatId, + type GridPos, + type ScreenId, + type Span, +} from './model'; + +// Mockups for a copy editor that replaces the Copy Raw / Copy Rewrapped popup +// (docs/specs/mouse-and-clipboard.md §4). Nothing here is wired to the real +// selection store: the terminal is drawn by React so the TUI's own highlight, +// Dormouse's outline, and a candidate scope can be shown side by side. + +type Concept = 'chooser' | 'receipt' | 'editor'; +type Host = 'shell' | 'claudeInline' | 'claudeFullscreen'; + +const CONCEPTS: { id: Concept; label: string; pitch: string }[] = [ + { id: 'chooser', label: 'A · Numbered chooser', pitch: 'The brief: every option numbered, previewed, one key to copy. Nothing reaches the clipboard until you pick.' }, + { id: 'receipt', label: 'B · Copy, then switch', pitch: `${COPY_CHORD} copies ★ at once and shows exactly what you got; a digit swaps it while the receipt is up.` }, + { id: 'editor', label: 'C · Editor', pitch: 'Scope ladder, format presets, and a chip on every line break you can flip by hand; arrows nudge the edges.' }, +]; + +const HOSTS: { id: Host; label: string; screen: ScreenId; mouseMode: boolean }[] = [ + { id: 'claudeInline', label: 'Claude Code, inline (Dormouse selects)', screen: 'claude', mouseMode: false }, + { id: 'claudeFullscreen', label: 'Claude Code, fullscreen (program owns the mouse)', screen: 'claude', mouseMode: true }, + { id: 'shell', label: 'zsh (true soft wraps)', screen: 'shell', mouseMode: false }, +]; + +interface Preset { + a: GridPos; + b: GridPos; + /** Chooser: which option starts focused. Receipt: which one was copied. */ + key?: string; + /** Editor: starting scope index and break overrides. */ + scopeIndex?: number; + overrides?: Record; +} + +interface Props { + concept: Concept; + host: Host; + preset?: Preset; + /** What the program itself offered over OSC 52 (fullscreen TUI only). */ + programCopy?: string; +} + +type Phase = 'idle' | 'dragging' | 'armed' | 'open'; + +async function writeClipboard(text: string) { + try { + await navigator.clipboard.writeText(text); + } catch { + // The mock clipboard panel is the record either way. + } +} + +function CopyEditorPlayground(props: Props) { + const [concept, setConcept] = useState(props.concept); + const [hostId, setHostId] = useState(props.host); + const host = HOSTS.find((h) => h.id === hostId)!; + const screen = SCREENS[host.screen]; + const { mouseMode } = host; + const program = screen.program; + + const presetSpan = props.preset ? normalizeSpan(props.preset.a, props.preset.b) : null; + const [anchor, setAnchor] = useState(null); + const [sel, setSel] = useState(presetSpan); + const [phase, setPhase] = useState(presetSpan ? 'open' : 'idle'); + const [clipboard, setClipboard] = useState(null); + + // Concept state. + const [focused, setFocused] = useState(0); + const [flashedKey, setFlashedKey] = useState(null); + const [copiedKey, setCopiedKey] = useState(props.preset?.key ?? FORMATS.find((f) => f.id === RECOMMENDED)!.key); + const [hoverScope, setHoverScope] = useState(null); + const [scopeIndex, setScopeIndex] = useState(props.preset?.scopeIndex ?? 0); + const [format, setFormat] = useState(RECOMMENDED); + const [overrides, setOverrides] = useState>(props.preset?.overrides ?? {}); + const [editorFlash, setEditorFlash] = useState(false); + const [autoCopied, setAutoCopied] = useState(false); + + const options: CopyOption[] = useMemo(() => { + if (!sel) return []; + return buildOptions(screen, sel, mouseMode && props.programCopy ? { program, text: props.programCopy } : undefined); + }, [screen, sel, mouseMode, props.programCopy, program]); + + const recommendedIndex = Math.max(0, options.findIndex((o) => o.recommended)); + + const resetConcept = useCallback((keepCopied = false) => { + setFocused(recommendedIndexFor(options)); + setFlashedKey(null); + if (!keepCopied) setCopiedKey(FORMATS.find((f) => f.id === RECOMMENDED)!.key); + setHoverScope(null); + setScopeIndex(0); + setFormat(RECOMMENDED); + setOverrides({}); + setEditorFlash(false); + setAutoCopied(false); + }, [options]); + + // Preset stories open with focus on the requested option. + const presetKey = props.preset?.key; + useEffect(() => { + if (!presetKey) { + setFocused(recommendedIndex); + return; + } + const i = options.findIndex((o) => o.key === presetKey); + setFocused(i >= 0 ? i : recommendedIndex); + // Only when the option list itself changes. + }, [options, presetKey, recommendedIndex]); + + const close = useCallback(() => { + // The program keeps painting its own highlight; Dormouse just steps back. + setPhase(mouseMode && sel ? 'armed' : 'idle'); + if (!mouseMode) setSel(null); + resetConcept(); + }, [mouseMode, sel, resetConcept]); + + const copy = useCallback((text: string) => { + setClipboard(text); + void writeClipboard(text); + }, []); + + const pickChooser = useCallback((i: number) => { + const o = options[i]; + if (!o) return; + copy(o.rendering.text); + setFocused(i); + setFlashedKey(o.key); + window.setTimeout(close, 700); + }, [options, copy, close]); + + const pickReceipt = useCallback((i: number) => { + const o = options[i]; + if (!o) return; + copy(o.rendering.text); + setCopiedKey(o.key); + setPhase('open'); + }, [options, copy]); + + const editorScopes = useMemo(() => (sel ? computeScopes(screen, sel) : []), [screen, sel]); + const editorCopy = useCallback(() => { + if (!sel) return; + const scope = editorScopes[Math.min(scopeIndex, editorScopes.length - 1)]; + copy(render(screen, scope.span, sel, format, overrides).text); + setEditorFlash(true); + window.setTimeout(close, 700); + }, [sel, editorScopes, scopeIndex, screen, format, overrides, copy, close]); + + // Open, the way the copy chord (or mouse-up, when Dormouse owns the drag) would. + const open = useCallback(() => { + if (concept === 'receipt') { + pickReceipt(recommendedIndex); + return; + } + // When every format and scope agrees there is nothing to choose. + if (options.length > 0 && options.every((o) => o.sameAs || o === options[0])) { + copy(options[0].rendering.text); + setAutoCopied(true); + setPhase('armed'); + return; + } + setPhase('open'); + }, [concept, pickReceipt, recommendedIndex, options, copy]); + + const onDrag = (dragPhase: 'down' | 'move' | 'up', pos: GridPos) => { + if (dragPhase === 'down') { + resetConcept(); + setAnchor(pos); + setSel(normalizeSpan(pos, pos)); + setPhase('dragging'); + return; + } + if (!anchor) return; + const span = normalizeSpan(anchor, pos); + setSel(span); + if (dragPhase === 'up') { + setAnchor(null); + const moved = span.start.row !== span.end.row || span.start.col !== span.end.col; + if (!moved) { + setSel(null); + setPhase('idle'); + return; + } + // Dormouse owns the drag: the chooser and editor open on mouse-up; the + // receipt concept waits for the chord. A program-owned drag always waits. + if (mouseMode || concept === 'receipt') setPhase('armed'); + else setPhase('open'); + } + }; + + // Keyboard, window-level like the real popup's listeners. + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + if (!sel || phase === 'idle' || phase === 'dragging') return; + const chord = (ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'c'; + const handled = () => { + ev.preventDefault(); + ev.stopPropagation(); + }; + if (ev.key === 'Escape') { + handled(); + if (phase === 'armed' && mouseMode) { + setSel(null); + setPhase('idle'); + } else close(); + return; + } + if (phase === 'armed') { + if (chord) { + handled(); + open(); + } else if (!mouseMode && concept === 'receipt' && /^[1-9]$/.test(ev.key)) { + handled(); + pickReceipt(Number(ev.key) - 1); + } + return; + } + // phase === 'open' + const digit = /^[1-9]$/.test(ev.key) ? Number(ev.key) - 1 : null; + if (concept === 'chooser') { + if (digit !== null) { handled(); pickChooser(digit); return; } + if (chord || ev.key === 'Enter') { handled(); pickChooser(focused); return; } + if (ev.key === 'ArrowDown') { handled(); setFocused((f) => Math.min(options.length - 1, f + 1)); return; } + if (ev.key === 'ArrowUp') { handled(); setFocused((f) => Math.max(0, f - 1)); return; } + if (ev.key === 'e') { + handled(); + const next = options.findIndex((o, i) => i > focused && o.scope.id !== 'selection'); + const first = options.findIndex((o) => o.scope.id !== 'selection'); + if (next >= 0 || first >= 0) setFocused(next >= 0 ? next : first); + return; + } + } + if (concept === 'receipt') { + if (digit !== null) { handled(); pickReceipt(digit); return; } + if (chord) { + // The chord again cycles to the next distinct text. + handled(); + const at = options.findIndex((o) => o.key === copiedKey); + for (let k = 1; k <= options.length; k++) { + const o = options[(at + k) % options.length]; + if (!o.sameAs) { pickReceipt(options.indexOf(o)); break; } + } + return; + } + if (!['Shift', 'Meta', 'Control', 'Alt'].includes(ev.key)) close(); + return; + } + if (concept === 'editor') { + if (digit !== null && digit < FORMATS.length) { handled(); setFormat(FORMATS[digit].id); setOverrides({}); return; } + if (chord || ev.key === 'Enter') { handled(); editorCopy(); return; } + if (ev.key === 'e' || ev.key === 'E') { + handled(); + setOverrides({}); + setScopeIndex((i) => (ev.shiftKey ? Math.max(0, i - 1) : Math.min(editorScopes.length - 1, i + 1))); + return; + } + } + if (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') { + handled(); + const dir = ev.key === 'ArrowRight' ? 1 : -1; + setOverrides({}); + setSel((s) => { + if (!s) return s; + const next = ev.shiftKey + ? { start: nudge(screen, s.start, dir, 'start'), end: s.end } + : { start: s.start, end: nudge(screen, s.end, dir, 'end') }; + return next.start.row < next.end.row || (next.start.row === next.end.row && next.start.col <= next.end.col) ? next : s; + }); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [sel, phase, mouseMode, concept, options, focused, copiedKey, close, open, pickChooser, pickReceipt, editorCopy, editorScopes, screen]); + + // --- what the terminal shows ------------------------------------------- + const showOutline = !mouseMode ? (phase !== 'idle') : phase === 'open'; + const outline = showOutline ? sel : null; + const programHighlight = mouseMode && phase !== 'idle' ? sel : null; + let scopePreview: Span | null = null; + if (phase === 'open' && sel) { + if (concept === 'chooser') { + const o = options[focused]; + if (o && o.scope.id !== 'selection') scopePreview = o.scope.span; + } else if (concept === 'receipt') { + const o = hoverScope !== null ? options[hoverScope] : options.find((x) => x.key === copiedKey); + if (o && o.scope.id !== 'selection') scopePreview = o.scope.span; + } else { + const s = editorScopes[Math.min(scopeIndex, editorScopes.length - 1)]; + if (s && s.id !== 'selection') scopePreview = s.span; + } + } + + const panelRef = useRef(null); + const [panelH, setPanelH] = useState(0); + useLayoutEffect(() => { + const el = panelRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setPanelH(el.getBoundingClientRect().height)); + ro.observe(el); + setPanelH(el.getBoundingClientRect().height); + return () => ro.disconnect(); + }); + + const instructions = (() => { + if (phase === 'idle') return mouseMode + ? `Drag across the text (${program} paints its own highlight), then press ${COPY_CHORD}.` + : 'Drag across the text.'; + if (phase === 'dragging') return 'Release to finish the selection.'; + if (phase === 'armed') { + if (autoCopied) return 'Every format gives the same text, so it was copied directly.'; + return mouseMode ? `Press ${COPY_CHORD}: Dormouse saw the drag and captured the same cells.` : `Press ${COPY_CHORD} to copy ★, or a digit for another format.`; + } + if (concept === 'receipt') return `Digits swap what was copied; ${COPY_CHORD} again cycles; any other key dismisses (and would reach the program).`; + if (concept === 'editor') return 'Digits pick a format, e / Shift+E change scope, ←→ nudge the edges, click a break chip to flip it.'; + return 'Digits copy directly; ↑↓ preview; e jumps to the grow options; ←→ / Shift+←→ nudge the edges.'; + })(); + + return ( +
+
+ { + setConcept(c); + resetConcept(); + if (sel && phase !== 'idle') setPhase(mouseMode || c === 'receipt' ? 'armed' : 'open'); + }} + items={CONCEPTS.map((c) => ({ id: c.id, label: c.label }))} + /> + { setHostId(h); setSel(null); setPhase('idle'); resetConcept(); }} items={HOSTS.map((h) => ({ id: h.id, label: h.label }))} /> +
+
+ {CONCEPTS.find((c) => c.id === concept)!.pitch} {instructions} +
+
+ + {({ cellW, width, height }) => { + if (!sel) return null; + const style = placePanel(sel, cellW, width, height, panelH, concept === 'editor' ? 570 : concept === 'chooser' ? 560 : 500); + if (phase === 'armed') { + const text = autoCopied + ? '✓ Copied — every format gives the same text' + : mouseMode + ? `Dormouse saw this drag · [${COPY_CHORD}] copy options` + : `[${COPY_CHORD}] copy ★ Rewrapped · [1–5] other formats`; + return ; + } + if (phase !== 'open') return null; + return ( +
+ {concept === 'chooser' && ( + + )} + {concept === 'receipt' && ( + + )} + {concept === 'editor' && ( + { setScopeIndex(i); setOverrides({}); }} + onFormat={(f) => { setFormat(f); setOverrides({}); }} + onToggleBreak={(i) => setOverrides((o) => { + const scope = editorScopes[Math.min(scopeIndex, editorScopes.length - 1)]; + const current = render(screen, scope.span, sel, format, o).breaks[i]; + const next: BreakKind = current === 'keep' ? 'space' : current === 'space' ? 'none' : 'keep'; + return { ...o, [i]: next }; + })} + onCopy={editorCopy} + /> + )} +
+ ); + }} +
+ +
+
+ ); +} + +function recommendedIndexFor(options: CopyOption[]): number { + return Math.max(0, options.findIndex((o) => o.recommended)); +} + +/** Below the selection when it fits, else above, else whichever side is roomier. */ +function placePanel(sel: Span, cellW: number, width: number, height: number, panelH: number, panelW: number): CSSProperties { + const gap = 6; + const below = (sel.end.row + 1) * CELL_H + gap; + const above = sel.start.row * CELL_H - gap; + const roomBelow = height - below; + const left = Math.max(-8, Math.min(width - panelW + 8, sel.end.col * cellW - 48)); + // Never cover the selection: when neither side fits, overflow the pane + // downward (a real build would need a pane-external surface here). + if (panelH <= roomBelow || panelH > above) return { left, top: below }; + return { left, top: above - panelH }; +} + +function placeHint(sel: Span, cellW: number, width: number, height: number): CSSProperties { + const top = (sel.end.row + 1) * CELL_H + 4; + const left = Math.max(0, Math.min(width - 340, sel.end.col * cellW)); + return top + 24 > height ? { left, top: sel.start.row * CELL_H - 24 } : { left, top }; +} + +function Segmented({ value, onChange, items }: { value: T; onChange: (v: T) => void; items: { id: T; label: string }[] }) { + return ( +
+ {items.map((it) => ( + + ))} +
+ ); +} + +const meta: Meta = { + title: 'Prototypes/Copy editor', + component: CopyEditorPlayground, + parameters: { layout: 'fullscreen' }, + argTypes: { + concept: { control: 'inline-radio', options: CONCEPTS.map((c) => c.id) }, + host: { control: 'inline-radio', options: HOSTS.map((h) => h.id) }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Drag in the terminal; switch concepts and hosts from the toolbar. */ +export const Playground: Story = { + args: { concept: 'chooser', host: 'claudeInline' }, +}; + +/** A: a hard-wrapped paragraph, the drag ending mid-word. ★ Rewrapped joins + * the rows; "Whole words" grows the clipped end. */ +export const ChooserProse: Story = { + args: { concept: 'chooser', host: 'claudeInline', preset: { a: { row: 2, col: 25 }, b: { row: 5, col: 20 } } }, +}; + +/** A: a URL the TUI hard-wrapped at the margin, with its start missed. Focus + * is on "Full URL": the dashed scope shows what it adds, and the preview marks + * the added characters and the rejoined split. */ +export const ChooserClippedUrl: Story = { + args: { concept: 'chooser', host: 'claudeInline', preset: { a: { row: 13, col: 44 }, b: { row: 14, col: 30 }, key: '6' } }, +}; + +/** A, fullscreen TUI: the program painted the highlight and owns the mouse; + * Dormouse shadowed the drag and opens on the copy chord. The last row is the + * program's own OSC 52 text, offered instead of silently dropped. */ +export const ChooserFullscreenTui: Story = { + args: { + concept: 'chooser', + host: 'claudeFullscreen', + preset: { a: { row: 16, col: 2 }, b: { row: 17, col: 43 } }, + programCopy: 'To verify locally, run `pnpm --filter dormouse-lib exec vitest run src/lib/selection-text.test.ts --repeat 50`', + }, +}; + +/** A, plain shell: xterm knows which rows are true soft wraps, so Rewrapped + * rejoins the token exactly while Spaces breaks it. */ +export const ChooserShellSoftWrap: Story = { + args: { concept: 'chooser', host: 'shell', preset: { a: { row: 6, col: 0 }, b: { row: 8, col: 37 } } }, +}; + +/** B: the copy chord already put ★ on the clipboard; the receipt shows what + * landed and offers the others. A code block, so Rewrapped kept the breaks. */ +export const ReceiptCodeBlock: Story = { + args: { concept: 'receipt', host: 'claudeFullscreen', preset: { a: { row: 9, col: 0 }, b: { row: 11, col: 44 } } }, +}; + +/** C: a drag inside one paragraph, grown to the whole reply, with one break + * flipped by hand (the dashed chip). */ +export const EditorBreakChips: Story = { + args: { concept: 'editor', host: 'claudeInline', preset: { a: { row: 3, col: 10 }, b: { row: 4, col: 40 }, scopeIndex: 3, overrides: { 1: 'keep' } } }, +}; diff --git a/lib/src/stories/copy-editor/CopyEditor2.stories.tsx b/lib/src/stories/copy-editor/CopyEditor2.stories.tsx new file mode 100644 index 000000000..b8054bd32 --- /dev/null +++ b/lib/src/stories/copy-editor/CopyEditor2.stories.tsx @@ -0,0 +1,513 @@ +import { clsx } from 'clsx'; +import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { CheckIcon } from '@phosphor-icons/react'; +import { modalSurface, Shortcut } from '../../components/design'; +import { ArmedHint, CELL_H, COPY_CHORD, ClipboardPanel, MockTerminal } from './CopyMockups'; +import { + SCREENS, + computeScopes, + duplicates, + normalizeSpan, + nudge, + render, + type BreakKind, + type FormatId, + type GridPos, + type MockRow, + type MockScreen, + type Piece, + type Rendering, + type Span, + type Tone, +} from './model'; + +// The copy editor after the first round (Prototypes/Copy editor): only the +// editor concept, at full pane width, driven by letters instead of digits. +// e / ⬆︎e grow / shrink the scope +// f next format (Auto → Exact → Spaces → No breaks) +// ◀ ▶ move the end a word; ⬆︎◀ ⬆︎▶ move the start +// ↵ or the copy chord copies; Esc closes. + +/** Shift, as the mobile compass rose writes it (`lib/src/lib/mobile-gesture-menu.ts`). */ +const SHIFT = '⬆︎'; +const LEFT = '◀'; +const RIGHT = '▶'; + +const FORMATS2: { id: FormatId; name: string; blurb: string }[] = [ + { id: 'smart', name: 'Auto', blurb: 'each line break judged on its own' }, + { id: 'exact', name: 'Exact', blurb: 'as displayed' }, + { id: 'spaces', name: 'Spaces', blurb: 'every line break becomes one space' }, + { id: 'joined', name: 'No breaks', blurb: 'every line break deleted' }, +]; +const AUTO: FormatId = 'smart'; +const formatName = (f: FormatId) => FORMATS2.find((x) => x.id === f)!.name; + +// --------------------------------------------------------------------------- +// Taller screens: some history above, so the editor has room beside a +// selection the way it would in a real pane. + +const t = (text: string, tone?: Tone): MockRow => (tone ? { text, tones: [{ from: 0, to: text.length, tone }] } : { text }); + +function withHistory(base: MockScreen, history: MockRow[], historyBlock?: { from: number; to: number; label: string }): MockScreen { + const n = history.length; + return { + ...base, + rows: [...history, ...base.rows], + blocks: [...(historyBlock ? [historyBlock] : []), ...base.blocks.map((b) => ({ ...b, from: b.from + n, to: b.to + n }))], + }; +} + +const CLAUDE_HISTORY: MockRow[] = [ + t('> run the selection tests', 'user'), + t(''), + { text: '⏺ Bash(pnpm --filter dormouse-lib test selection-text)', tones: [{ from: 0, to: 1, tone: 'accent' }] }, + { text: ' ⎿ ✓ src/lib/selection-text.test.ts (12 tests) 41ms', tones: [{ from: 2, to: 3, tone: 'muted' }, { from: 5, to: 6, tone: 'pass' }, { from: 47, to: 51, tone: 'muted' }] }, + { text: ' Test Files 1 passed (1)', tones: [{ from: 8, to: 18, tone: 'muted' }, { from: 20, to: 28, tone: 'pass' }] }, + t(''), + { text: '⏺ All 12 pass locally, so the failure only reproduces on CI.', tones: [{ from: 0, to: 1, tone: 'accent' }] }, + t(''), +]; + +const PROMPT = '~/projects/dormouse ❯ '; +const promptRow = (cmd: string): MockRow => ({ text: PROMPT + cmd, prompt: true, tones: [{ from: 0, to: PROMPT.length - 2, tone: 'link' }, { from: PROMPT.length - 2, to: PROMPT.length - 1, tone: 'prompt' }] }); + +const SHELL_HISTORY: MockRow[] = [ + promptRow('git switch -c fix-selection-flake'), + t("Switched to a new branch 'fix-selection-flake'"), + promptRow('git status --short'), + { text: ' M lib/src/lib/selection-text.ts', tones: [{ from: 1, to: 2, tone: 'code' }] }, + { text: '?? lib/src/lib/selection-text.flaky.md', tones: [{ from: 0, to: 2, tone: 'muted' }] }, +]; + +const SCREENS2 = { + claude: withHistory(SCREENS.claude, CLAUDE_HISTORY, { from: 2, to: 6, label: 'Whole reply' }), + shell: withHistory(SCREENS.shell, SHELL_HISTORY), +} as const; +const CLAUDE_OFFSET = CLAUDE_HISTORY.length; +const SHELL_OFFSET = SHELL_HISTORY.length; + +// --------------------------------------------------------------------------- +// The preview: one gutter-numbered row per line that lands on the clipboard, +// every line break the selection crossed shown as a mark you can flip. + +const MARK_GLYPH: Record = { keep: '⏎', space: '␣', none: '⌁' }; +const MARK_TITLE: Record = { + keep: 'Line break kept', + space: 'Line break became one space', + none: 'Line break deleted, so the two sides join', +}; + +function Mark({ kind, auto, onFlip }: { kind: BreakKind; auto: BreakKind; onFlip: () => void }) { + const edited = kind !== auto; + return ( + + ); +} + +function TextRun({ p }: { p: Extract }) { + const text = p.lead ? p.text.replace(/ /g, '·').replace(/\t/g, '→') : p.text; + return ( + + {text} + + ); +} + +function LinedPreview({ rendering, onFlip }: { rendering: Rendering; onFlip: (index: number) => void }) { + const lines: ReactNode[][] = [[]]; + rendering.pieces.forEach((p, i) => { + const line = lines[lines.length - 1]; + if (p.t === 'text') { + line.push(); + return; + } + line.push( onFlip(p.index)} />); + if (p.kind === 'keep') lines.push([]); + }); + if (rendering.text === '') return
(empty)
; + const gutter = String(lines.length).length; + return ( +
+ {lines.map((parts, n) => ( +
+ + {n + 1} + +
{parts}
+
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// The editor panel + +function Segment({ value, items, onPick }: { value: T; items: { id: T; label: string; dim?: boolean; title?: string }[]; onPick: (v: T) => void }) { + return ( +
+ {items.map((it) => ( + + ))} +
+ ); +} + +interface EditorProps { + screen: MockScreen; + sel: Span; + scopeIndex: number; + format: FormatId; + overrides: Record; + flashed: boolean; + onScope: (i: number) => void; + onFormat: (f: FormatId) => void; + onFlip: (index: number) => void; + onCopy: () => void; +} + +function CopyEditor({ screen, sel, scopeIndex, format, overrides, flashed, onScope, onFormat, onFlip, onCopy }: EditorProps) { + const scopes = useMemo(() => computeScopes(screen, sel), [screen, sel]); + const scope = scopes[Math.min(scopeIndex, scopes.length - 1)]; + const rendering = render(screen, scope.span, sel, format, overrides); + const dups = duplicates(FORMATS2.map((f) => render(screen, scope.span, sel, f.id))); + const edited = Object.keys(overrides).length > 0; + return ( +
e.stopPropagation()} + > +
+
+ onScope(scopes.findIndex((s) => s.id === id))} items={scopes.map((s) => ({ id: s.id, label: s.label }))} /> + e grow {SHIFT}e shrink +
+
+ { + const same = dups.get(f.id); + return { + id: f.id, + label: `${f.name}${f.id === format && edited ? '*' : ''}`, + dim: !!same, + title: same ? `${f.blurb}: same text as ${formatName(same)}` : f.blurb, + }; + })} + /> + f format +
+
+
+ +
+
+ + ⏎ kept + ␣ space + ⌁ joined + abc grown + + {LEFT}{RIGHT} end {SHIFT}{LEFT}{RIGHT} start + {rendering.lines} {rendering.lines === 1 ? 'line' : 'lines'} · {rendering.chars} ch + +
+
+ ); +} + +/** Full width, on whichever side of the selection has more room. */ +function placeEditor(sel: Span, width: number, height: number): CSSProperties { + const gap = 6; + const below = (sel.end.row + 1) * CELL_H + gap; + const above = sel.start.row * CELL_H - gap; + const roomBelow = height - below; + const base: CSSProperties = { left: -4, width: width + 8 }; + return roomBelow >= above + ? { ...base, top: below, maxHeight: Math.max(150, Math.min(320, roomBelow)) } + : { ...base, bottom: height - above, maxHeight: Math.max(150, Math.min(320, above)) }; +} + +// --------------------------------------------------------------------------- +// Playground + +type Host = 'claudeInline' | 'claudeFullscreen' | 'shell'; +const HOSTS: { id: Host; label: string; screen: keyof typeof SCREENS2; mouseMode: boolean }[] = [ + { id: 'claudeInline', label: 'Claude Code, inline (Dormouse selects)', screen: 'claude', mouseMode: false }, + { id: 'claudeFullscreen', label: 'Claude Code, fullscreen (program owns the mouse)', screen: 'claude', mouseMode: true }, + { id: 'shell', label: 'zsh (true soft wraps)', screen: 'shell', mouseMode: false }, +]; + +interface Preset { + a: GridPos; + b: GridPos; + scopeIndex?: number; + format?: FormatId; + overrides?: Record; +} + +interface Props { + host: Host; + preset?: Preset; +} + +type Phase = 'idle' | 'dragging' | 'armed' | 'open'; + +function CopyEditorPrototype({ host: initialHost, preset }: Props) { + const [hostId, setHostId] = useState(initialHost); + const host = HOSTS.find((h) => h.id === hostId)!; + const screen = SCREENS2[host.screen]; + const { mouseMode } = host; + + const [anchor, setAnchor] = useState(null); + const [sel, setSel] = useState(preset ? normalizeSpan(preset.a, preset.b) : null); + const [phase, setPhase] = useState(preset ? 'open' : 'idle'); + const [scopeIndex, setScopeIndex] = useState(preset?.scopeIndex ?? 0); + const [format, setFormat] = useState(preset?.format ?? AUTO); + const [overrides, setOverrides] = useState>(preset?.overrides ?? {}); + const [flashed, setFlashed] = useState(false); + const [clipboard, setClipboard] = useState(null); + + const scopes = useMemo(() => (sel ? computeScopes(screen, sel) : []), [screen, sel]); + const scope = scopes[Math.min(scopeIndex, scopes.length - 1)]; + + const reset = useCallback(() => { + setScopeIndex(0); + setFormat(AUTO); + setOverrides({}); + setFlashed(false); + }, []); + + const close = useCallback(() => { + // A program keeps painting its own highlight; Dormouse just steps back. + setPhase(mouseMode && sel ? 'armed' : 'idle'); + if (!mouseMode) setSel(null); + reset(); + }, [mouseMode, sel, reset]); + + const copy = useCallback(() => { + if (!sel || !scope) return; + const text = render(screen, scope.span, sel, format, overrides).text; + setClipboard(text); + void navigator.clipboard?.writeText(text).catch(() => {}); + setFlashed(true); + window.setTimeout(close, 700); + }, [sel, scope, screen, format, overrides, close]); + + const cycleFormat = useCallback((dir: 1 | -1) => { + setOverrides({}); + setFormat((f) => FORMATS2[(FORMATS2.findIndex((x) => x.id === f) + dir + FORMATS2.length) % FORMATS2.length].id); + }, []); + + const onDrag = (dragPhase: 'down' | 'move' | 'up', pos: GridPos) => { + if (dragPhase === 'down') { + reset(); + setAnchor(pos); + setSel(normalizeSpan(pos, pos)); + setPhase('dragging'); + return; + } + if (!anchor) return; + const span = normalizeSpan(anchor, pos); + setSel(span); + if (dragPhase !== 'up') return; + setAnchor(null); + if (span.start.row === span.end.row && span.start.col === span.end.col) { + setSel(null); + setPhase('idle'); + return; + } + // Dormouse owns the drag: open on mouse-up. A program-owned drag waits for + // the copy chord, which Dormouse sees before the program would. + setPhase(mouseMode ? 'armed' : 'open'); + }; + + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + if (!sel || phase === 'idle' || phase === 'dragging') return; + const chord = (ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'c'; + const handled = () => { + ev.preventDefault(); + ev.stopPropagation(); + }; + if (ev.key === 'Escape') { + handled(); + if (phase === 'armed') { + setSel(null); + setPhase('idle'); + } else close(); + return; + } + if (phase === 'armed') { + if (chord) { + handled(); + setPhase('open'); + } + return; + } + if (chord || ev.key === 'Enter') { handled(); copy(); return; } + if (ev.metaKey || ev.ctrlKey || ev.altKey) return; + const key = ev.key.toLowerCase(); + if (key === 'e') { + handled(); + setOverrides({}); + setScopeIndex((i) => (ev.shiftKey ? Math.max(0, Math.min(i, scopes.length - 1) - 1) : Math.min(scopes.length - 1, i + 1))); + return; + } + if (key === 'f') { handled(); cycleFormat(ev.shiftKey ? -1 : 1); return; } + if (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') { + handled(); + const dir = ev.key === 'ArrowRight' ? 1 : -1; + setOverrides({}); + setScopeIndex(0); + setSel((s) => { + if (!s) return s; + const next = ev.shiftKey + ? { start: nudge(screen, s.start, dir, 'start'), end: s.end } + : { start: s.start, end: nudge(screen, s.end, dir, 'end') }; + const ordered = next.start.row < next.end.row || (next.start.row === next.end.row && next.start.col <= next.end.col); + return ordered ? next : s; + }); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [sel, phase, scopes.length, screen, close, copy, cycleFormat]); + + const outline = mouseMode ? (phase === 'open' ? sel : null) : (phase !== 'idle' ? sel : null); + const programHighlight = mouseMode && phase !== 'idle' ? sel : null; + const scopePreview = phase === 'open' && scope && scope.id !== 'selection' ? scope.span : null; + + const instructions = (() => { + if (phase === 'idle') return mouseMode ? `Drag across the text (Claude Code paints its own highlight), then press ${COPY_CHORD}.` : 'Drag across the text.'; + if (phase === 'dragging') return 'Release to finish the selection.'; + if (phase === 'armed') return `Press ${COPY_CHORD}: Dormouse saw the drag and captured the same cells.`; + return `e / ${SHIFT}e scope · f format · ${LEFT}${RIGHT} end · ${SHIFT}${LEFT}${RIGHT} start · click a mark to flip one break · ↵ or ${COPY_CHORD} copies · Esc closes`; + })(); + + return ( +
+
+ { setHostId(h); setSel(null); setPhase('idle'); reset(); }} items={HOSTS.map((h) => ({ id: h.id, label: h.label }))} /> +
+
{instructions}
+
+ + {({ width, height, cellW }) => { + if (!sel) return null; + if (phase === 'armed') { + const top = (sel.end.row + 1) * CELL_H + 4; + const left = Math.max(0, Math.min(width - 300, sel.end.col * cellW)); + return height ? { left, top: sel.start.row * CELL_H - 24 } : { left, top }} />; + } + if (phase !== 'open') return null; + return ( +
+ { setScopeIndex(i); setOverrides({}); }} + onFormat={(f) => { setFormat(f); setOverrides({}); }} + onFlip={(i) => setOverrides((o) => { + const current = scope ? render(screen, scope.span, sel, format, o).breaks[i] : 'keep'; + const next: BreakKind = current === 'keep' ? 'space' : current === 'space' ? 'none' : 'keep'; + return { ...o, [i]: next }; + })} + onCopy={copy} + /> +
+ ); + }} +
+ +
+
+ ); +} + +const meta: Meta = { + title: 'Prototypes/Copy editor 2', + component: CopyEditorPrototype, + parameters: { layout: 'fullscreen' }, + argTypes: { host: { control: 'inline-radio', options: HOSTS.map((h) => h.id) } }, +}; + +export default meta; +type Story = StoryObj; + +const C = CLAUDE_OFFSET; +const S = SHELL_OFFSET; + +/** Drag in the terminal; the host switch picks who owns the mouse. */ +export const Playground: Story = { + args: { host: 'claudeInline' }, +}; + +/** A hard-wrapped paragraph, the drag ending mid-word: Auto joins each wrap + * with a space, so it lands as one line. */ +export const Prose: Story = { + args: { host: 'claudeInline', preset: { a: { row: C + 2, col: 25 }, b: { row: C + 5, col: 20 } } }, +}; + +/** The same drag with `f` pressed once: Exact, every displayed break and + * indent, for checking what the terminal actually holds. */ +export const ProseExact: Story = { + args: { host: 'claudeInline', preset: { a: { row: C + 2, col: 25 }, b: { row: C + 5, col: 20 }, format: 'exact' } }, +}; + +/** A URL the TUI split at the margin, its start missed, after one `e`: the + * scope grew to the full URL and Auto rejoined the split with no space. */ +export const ClippedUrlGrown: Story = { + args: { host: 'claudeInline', preset: { a: { row: C + 13, col: 44 }, b: { row: C + 14, col: 30 }, scopeIndex: 1 } }, +}; + +/** Fullscreen Claude Code: it painted the highlight and owns the mouse; + * Dormouse shadowed the drag and opened on the copy chord. Prose joins, the + * code keeps its breaks and relative indent. */ +export const FullscreenProseAndCode: Story = { + args: { host: 'claudeFullscreen', preset: { a: { row: C + 7, col: 2 }, b: { row: C + 11, col: 44 } } }, +}; + +/** One break flipped by hand (the dashed mark): Auto's first join kept as a + * line break, and the format reads `Auto*`. */ +export const BreakFlipped: Story = { + args: { host: 'claudeInline', preset: { a: { row: C + 2, col: 2 }, b: { row: C + 5, col: 27 }, overrides: { 1: 'keep' } } }, +}; + +/** zsh: xterm knows which rows are true soft wraps, so Auto rejoins the token + * exactly where Spaces would break it. */ +export const ShellSoftWrap: Story = { + args: { host: 'shell', preset: { a: { row: S + 6, col: 0 }, b: { row: S + 8, col: 37 } } }, +}; diff --git a/lib/src/stories/copy-editor/CopyMockups.tsx b/lib/src/stories/copy-editor/CopyMockups.tsx new file mode 100644 index 000000000..5c36d144d --- /dev/null +++ b/lib/src/stories/copy-editor/CopyMockups.tsx @@ -0,0 +1,584 @@ +import { clsx } from 'clsx'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { CheckIcon, CursorClickIcon } from '@phosphor-icons/react'; +import { modalSurface, paneHeader, PopupButtonRow, Shortcut, TERMINAL_BOTTOM_RADIUS_CLASS } from '../../components/design'; +import { rectsToPath, type Rect } from '../../lib/selection-geometry'; +import { + FORMATS, + RECOMMENDED, + computeScopes, + duplicates, + render, + renderAll, + type BreakKind, + type FormatId, + type GridPos, + type MockScreen, + type Piece, + type Rendering, + type Scope, + type Span, + type Tone, +} from './model'; + +export const CELL_H = 16; +const IS_MAC_MOCK = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform); +export const COPY_CHORD = IS_MAC_MOCK ? 'Cmd+C' : 'Ctrl+C'; + +const TONE_COLOR: Record = { + muted: 'var(--vscode-terminal-ansiBrightBlack)', + link: 'var(--vscode-terminal-ansiBlue)', + code: 'var(--vscode-terminal-ansiCyan)', + accent: 'var(--vscode-terminal-ansiGreen)', + user: 'var(--vscode-terminal-ansiBrightBlack)', + frame: 'var(--vscode-terminal-ansiBrightBlack)', + prompt: 'var(--vscode-terminal-ansiMagenta)', + pass: 'var(--vscode-terminal-ansiGreen)', +}; + +// --------------------------------------------------------------------------- +// Mock terminal + +function spanRects(span: Span, cols: number, cellW: number): Rect[] { + const rects: Rect[] = []; + for (let r = span.start.row; r <= span.end.row; r++) { + const c0 = r === span.start.row ? span.start.col : 0; + const c1 = r === span.end.row ? span.end.col + 1 : cols; + if (c1 <= c0) continue; + rects.push({ top: r * CELL_H, left: c0 * cellW, width: (c1 - c0) * cellW, height: CELL_H }); + } + return rects; +} + +function inSpan(span: Span | null, row: number, col: number): boolean { + if (!span) return false; + if (row < span.start.row || row > span.end.row) return false; + if (row === span.start.row && col < span.start.col) return false; + if (row === span.end.row && col > span.end.col) return false; + return true; +} + +function useCellWidth(): [number, (el: HTMLSpanElement | null) => void] { + const [cellW, setCellW] = useState(7.2); + const [el, setEl] = useState(null); + useLayoutEffect(() => { + if (!el) return; + const measure = () => { + const w = el.getBoundingClientRect().width / 20; + if (w > 0) setCellW(w); + }; + measure(); + void document.fonts?.ready.then(measure); + }, [el]); + return [cellW, setEl]; +} + +interface MockTerminalProps { + screen: MockScreen; + mouseMode: boolean; + /** Dormouse's own selection outline. */ + outline: Span | null; + /** The program's own highlight (inverse video), painted by the TUI. */ + programHighlight: Span | null; + /** A candidate scope previewed while its option is focused. */ + scopePreview: Span | null; + onDrag?: (phase: 'down' | 'move' | 'up', pos: GridPos) => void; + children?: (geometry: { cellW: number; width: number; height: number }) => ReactNode; +} + +export function MockTerminal({ screen, mouseMode, outline, programHighlight, scopePreview, onDrag, children }: MockTerminalProps) { + const [cellW, measureRef] = useCellWidth(); + const gridRef = useRef(null); + const dragging = useRef(false); + const width = screen.cols * cellW; + const height = screen.rows.length * CELL_H; + + const posFromEvent = (ev: { clientX: number; clientY: number }): GridPos => { + const rect = gridRef.current!.getBoundingClientRect(); + const col = Math.max(0, Math.min(screen.cols - 1, Math.floor((ev.clientX - rect.left) / cellW))); + const row = Math.max(0, Math.min(screen.rows.length - 1, Math.floor((ev.clientY - rect.top) / CELL_H))); + return { row, col }; + }; + + useEffect(() => { + if (!onDrag) return; + const move = (ev: MouseEvent) => { + if (dragging.current) onDrag('move', posFromEvent(ev)); + }; + const up = (ev: MouseEvent) => { + if (!dragging.current) return; + dragging.current = false; + onDrag('up', posFromEvent(ev)); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + return () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + }); + + return ( +
+
+
+ {screen.title} + + {mouseMode ? : null} + +
+
+
+ {'M'.repeat(20)} +
{ + if (!onDrag || ev.button !== 0) return; + ev.preventDefault(); + dragging.current = true; + onDrag('down', posFromEvent(ev)); + }} + > + {screen.rows.map((row, r) => ( +
+ {renderRowCells(row.text.padEnd(screen.cols), row.tones ?? [], r, programHighlight)} +
+ ))} + + {scopePreview && ( + + )} + {outline && ( + + )} + + {children?.({ cellW, width, height })} +
+
+
+ ); +} + +function renderRowCells(text: string, tones: { from: number; to: number; tone: Tone }[], r: number, highlight: Span | null): ReactNode { + // Runs of (tone, highlighted) so a row is a handful of spans, not 80. + const out: ReactNode[] = []; + let runStart = 0; + const toneAt = (c: number) => tones.find((t) => c >= t.from && c < t.to)?.tone; + const keyAt = (c: number) => `${toneAt(c) ?? ''}|${inSpan(highlight, r, c) && c < text.trimEnd().length + 1 ? 'h' : ''}`; + for (let c = 1; c <= text.length; c++) { + if (c < text.length && keyAt(c) === keyAt(runStart)) continue; + const tone = toneAt(runStart); + const hl = keyAt(runStart).endsWith('h'); + const style: CSSProperties = hl + ? { background: 'var(--color-terminal-fg)', color: 'var(--color-terminal-bg)' } + : tone ? { color: TONE_COLOR[tone] } : {}; + out.push({text.slice(runStart, c)}); + runStart = c; + } + return out; +} + +// --------------------------------------------------------------------------- +// Preview of one rendering, invisibles made visible + +const BREAK_GLYPH: Record = { keep: '⏎', space: '␣', none: '⌁' }; +const BREAK_TITLE: Record = { + keep: 'Line break kept', + space: 'Line break → one space', + none: 'Line break deleted (token rejoined)', +}; + +function BreakMark({ kind, auto, onToggle }: { kind: BreakKind; auto: BreakKind; onToggle?: () => void }) { + const edited = kind !== auto; + if (onToggle) { + // The editor: every break is a chip you can click to cycle. + return ( + <> + + {kind === 'keep' ? '\n' : null} + + ); + } + if (kind === 'keep') return <>⏎{'\n'}; + if (kind === 'space') return ; + return ; +} + +export function PreviewText({ + rendering, + onToggleBreak, + className, +}: { + rendering: Rendering; + onToggleBreak?: (index: number) => void; + className?: string; +}) { + return ( +
+ {rendering.pieces.map((p: Piece, i) => { + if (p.t === 'break') return onToggleBreak(p.index) : undefined} />; + const text = p.lead ? p.text.replace(/ /g, '·').replace(/\t/g, '→') : p.text; + return ( + + {text} + + ); + })} + {rendering.text === '' && (empty)} +
+ ); +} + +function stats(r: Rendering): string { + return `${r.lines} ${r.lines === 1 ? 'line' : 'lines'} · ${r.chars} ch`; +} + +function Legend({ editable }: { editable?: boolean }) { + return ( +
+ ⏎ kept +   space + joined + abc beyond your drag + ·· indent + {editable && click a break to change it} +
+ ); +} + +// --------------------------------------------------------------------------- +// Shared option model for the three concepts + +export interface CopyOption { + key: string; + format: FormatId; + scope: Scope; + label: string; + rendering: Rendering; + sameAs?: string; + recommended?: boolean; + /** The program's own OSC 52 text, offered rather than silently dropped. */ + program?: boolean; +} + +/** The numbered options: one per format at the dragged scope, then one per + * wider scope in the recommended format. Numbers never move between copies. */ +export function buildOptions(screen: MockScreen, sel: Span, programCopy?: { program: string; text: string }): CopyOption[] { + const scopes = computeScopes(screen, sel); + const base = renderAll(screen, sel, sel); + const dups = duplicates(base); + const keyOf = (f: FormatId) => FORMATS.find((x) => x.id === f)!.key; + const options: CopyOption[] = base.map((r) => { + const f = FORMATS.find((x) => x.id === r.format)!; + const prior = dups.get(r.format); + return { key: f.key, format: r.format, scope: scopes[0], label: f.name, rendering: r, sameAs: prior ? keyOf(prior) : undefined, recommended: r.format === RECOMMENDED }; + }); + if (programCopy) { + const prior = options.find((o) => o.rendering.text === programCopy.text && !o.sameAs); + options.push({ key: String(options.length + 1), format: 'exact', scope: scopes[0], label: `${programCopy.program}'s copy`, rendering: plainRendering(programCopy.text), sameAs: prior?.key, program: true }); + } + scopes.slice(1).forEach((scope) => { + options.push({ key: String(options.length + 1), format: RECOMMENDED, scope, label: scope.label, rendering: render(screen, scope.span, sel, RECOMMENDED) }); + }); + return options; +} + +function scopeGrowth(o: CopyOption): string { + const added = o.rendering.addedChars; + return added ? `+${added} ch` : ''; +} + +// --------------------------------------------------------------------------- +// Concept A — numbered chooser (the brief) + +export function ChooserPanel({ + options, + focused, + flashedKey, + onFocus, + onPick, + source, +}: { + options: CopyOption[]; + focused: number; + flashedKey: string | null; + onFocus: (i: number) => void; + onPick: (i: number) => void; + source: string; +}) { + const current = options[focused]; + const formatRows = options.filter((o) => o.scope.id === 'selection'); + const scopeRows = options.filter((o) => o.scope.id !== 'selection'); + const row = (o: CopyOption) => { + const i = options.indexOf(o); + const active = i === focused; + return ( + + ); + }; + return ( +
e.stopPropagation()} + > +
+
+
Copy {source}
+ {formatRows.map(row)} + {scopeRows.length > 0 && ( + <> +
+ Grow the selection + +
+ {scopeRows.map(row)} + + )} +
+
+
+ {current.label} + {current.program ? 'the text the program itself sent' : current.scope.id === 'selection' ? FORMATS.find((f) => f.id === current.format)!.blurb : `${FORMATS.find((f) => f.id === current.format)!.name}, grown to ${current.scope.label.toLowerCase()}`} + {stats(current.rendering)} +
+
+ +
+
+
+
+ + 1–{options.length} copy ↑↓ preview ↵ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Concept B — copy first, receipt after + +export function ReceiptPanel({ + options, + copiedKey, + onPick, + onFocusScope, +}: { + options: CopyOption[]; + copiedKey: string; + onPick: (i: number) => void; + onFocusScope: (i: number | null) => void; +}) { + const copied = options.find((o) => o.key === copiedKey)!; + return ( +
e.stopPropagation()} + > +
+ + Copied + [{copied.key}] + {copied.label} + {stats(copied.rendering)} +
+
+ +
+
+ Copy instead: + {options.map((o, i) => ( + + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Concept C — the editor: scope ladder, format presets, per-break chips + +export function EditorPanel({ + screen, + sel, + scopeIndex, + format, + overrides, + flashed, + onScope, + onFormat, + onToggleBreak, + onCopy, +}: { + screen: MockScreen; + sel: Span; + scopeIndex: number; + format: FormatId; + overrides: Record; + flashed: boolean; + onScope: (i: number) => void; + onFormat: (f: FormatId) => void; + onToggleBreak: (index: number) => void; + onCopy: () => void; +}) { + const scopes = useMemo(() => computeScopes(screen, sel), [screen, sel]); + const scope = scopes[Math.min(scopeIndex, scopes.length - 1)]; + const rendering = render(screen, scope.span, sel, format, overrides); + const all = renderAll(screen, scope.span, sel); + const dups = duplicates(all); + const edited = Object.keys(overrides).length > 0; + return ( +
e.stopPropagation()} + > +
+ Scope +
+ {scopes.map((s, i) => ( + + ))} +
+ e grow Shift+E shrink +
+
+ Format +
+ {FORMATS.map((f) => { + const active = f.id === format; + const same = dups.get(f.id); + return ( + + ); + })} +
+
+
+ +
+ +
+ ←→ end by word Shift+←→ start by word · {stats(rendering)} + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Hints shown on the terminal itself + +export function ArmedHint({ text, style }: { text: ReactNode; style: CSSProperties }) { + return ( + + {text} + + ); +} + +/** A rendering of literal text, for text no transform produced. */ +export function plainRendering(text: string): Rendering { + return { + format: 'exact', + pieces: text.split('\n').flatMap((line, i, arr): Piece[] => { + const lead = line.length - line.trimStart().length; + const out: Piece[] = []; + if (lead) out.push({ t: 'text', text: line.slice(0, lead), added: false, lead: true }); + if (line.length > lead) out.push({ t: 'text', text: line.slice(lead), added: false, lead: false }); + if (i + 1 < arr.length) out.push({ t: 'break', index: i, kind: 'keep', auto: 'keep' }); + return out; + }), + text, + lines: text.split('\n').length, + chars: text.length, + breaks: [], + addedChars: 0, + }; +} + +export function ClipboardPanel({ text }: { text: string | null }) { + const rendering = text === null ? null : plainRendering(text); + return ( +
+
+ Clipboard + {rendering && {stats(rendering)}} +
+
+ {rendering ? : Nothing copied yet} +
+
What a paste would get (mock; the real clipboard is written too when the browser allows it).
+
+ ); +} + diff --git a/lib/src/stories/copy-editor/model.ts b/lib/src/stories/copy-editor/model.ts new file mode 100644 index 000000000..13f041033 --- /dev/null +++ b/lib/src/stories/copy-editor/model.ts @@ -0,0 +1,547 @@ +// Prototype model for the copy editor mockups (Prototypes/Copy editor). Pure +// functions over a mock screen: nothing here is wired to xterm or the real +// selection store. The real feature would read the same facts from the xterm +// buffer (row text, `isWrapped`, cell attributes) and the foreground program. + +export type Tone = 'muted' | 'link' | 'code' | 'accent' | 'user' | 'frame' | 'prompt' | 'pass'; + +export interface ToneSpan { from: number; to: number; tone: Tone } + +export interface MockRow { + text: string; + /** xterm's `isWrapped`: a true soft wrap continuing the previous row. */ + wrapped?: boolean; + /** An OSC 133 prompt row: a paragraph never grows across it. */ + prompt?: boolean; + tones?: ToneSpan[]; +} + +export interface MockBlock { from: number; to: number; label: string } + +export interface MockScreen { + id: 'claude' | 'shell'; + title: string; + program: string; + cols: number; + /** The column a hard-wrapping program wraps at. Present only for programs + * known to hard-wrap (a real build would key this off the foreground + * command); absent means only `wrapped` rows are wraps. */ + hardWrapWidth?: number; + rows: MockRow[]; + /** The widest expansion: a TUI message, or an OSC 133 command output. */ + blocks: MockBlock[]; +} + +// --------------------------------------------------------------------------- +// Mock screens + +interface WrapTone { from: number; to: number; tone: Tone } + +/** Greedy word wrap with a hard break for tokens wider than the line, the way + * Ink-based TUIs render prose. Tones are ranges over the logical text. */ +function wrapParagraph(text: string, width: number, first: string, cont: string, tones: WrapTone[] = [], prefixTone?: Tone): MockRow[] { + type Cell = { ch: string; i: number }; + const rows: Cell[][] = []; + let cur: Cell[] = [...first].map((ch) => ({ ch, i: -1 })); + let hasContent = false; + const flush = () => { + rows.push(cur); + cur = [...cont].map((ch) => ({ ch, i: -1 })); + hasContent = false; + }; + const words: { w: string; at: number }[] = []; + for (const m of text.matchAll(/\S+/g)) words.push({ w: m[0], at: m.index ?? 0 }); + for (const { w, at } of words) { + const need = (hasContent ? 1 : 0) + w.length; + if (cur.length + need <= width) { + if (hasContent) cur.push({ ch: ' ', i: at - 1 }); + for (let k = 0; k < w.length; k++) cur.push({ ch: w[k], i: at + k }); + hasContent = true; + } else if (w.length <= width - cont.length) { + flush(); + for (let k = 0; k < w.length; k++) cur.push({ ch: w[k], i: at + k }); + hasContent = true; + } else { + if (hasContent) cur.push({ ch: ' ', i: at - 1 }); + for (let k = 0; k < w.length; k++) { + if (cur.length >= width) flush(); + cur.push({ ch: w[k], i: at + k }); + } + hasContent = true; + } + } + rows.push(cur); + return rows.map((cells) => { + const spans: ToneSpan[] = []; + cells.forEach((cell, col) => { + const tone = cell.i < 0 ? (cell.ch === ' ' ? undefined : prefixTone) : tones.find((t) => cell.i >= t.from && cell.i < t.to)?.tone; + if (!tone) return; + const last = spans[spans.length - 1]; + if (last && last.tone === tone && last.to === col) last.to = col + 1; + else spans.push({ from: col, to: col + 1, tone }); + }); + return { text: cells.map((c) => c.ch).join(''), tones: spans }; + }); +} + +function toned(text: string, tone: Tone): MockRow { + return { text, tones: [{ from: 0, to: text.length, tone }] }; +} + +function range(text: string, needle: string, tone: Tone): WrapTone { + const from = text.indexOf(needle); + return { from, to: from + needle.length, tone }; +} + +function buildClaudeScreen(): MockScreen { + const cols = 80; + const W = 76; + const blank: MockRow = { text: '' }; + const prose = 'The flake comes from a race between the PTY exit event and the final flush of the output buffer. When the child exits before xterm has drained its write queue, the last chunk is dropped and the assertion on the prompt text fails intermittently.'; + const fix = 'The fix is to await the write callback before reading the buffer:'; + const url = 'https://github.com/diffplug/dormouse/pull/853/files#diff-7c1f3e9a2b8d4f6e0a5c7b9d1e3f5a7c9b1d3e5f'; + const pr = `I opened a draft with the change: ${url}`; + const cmd = 'pnpm --filter dormouse-lib exec vitest run src/lib/selection-text.test.ts --repeat 50'; + const verify = `To verify locally, run ${cmd}`; + const boxInner = cols - 2; + const rows: MockRow[] = [ + toned('> why is selection-text.test.ts flaky on CI?', 'user'), + blank, + ...wrapParagraph(prose, W, '⏺ ', ' ', [], 'accent'), + blank, + ...wrapParagraph(fix, W, ' ', ' '), + blank, + toned(' await new Promise((resolve) => terminal.write(chunk, resolve));', 'code'), + toned(' const text = extractSelectionText(terminal, selection);', 'code'), + toned(" expect(text).toBe('user@dormouse:~$ ls');", 'code'), + blank, + ...wrapParagraph(pr, W, ' ', ' ', [range(pr, url, 'link')]), + blank, + ...wrapParagraph(verify, W, ' ', ' ', [range(verify, cmd, 'code')]), + blank, + toned(`╭${'─'.repeat(boxInner)}╮`, 'frame'), + { text: `│ > ${' '.repeat(boxInner - 3)}│`, tones: [{ from: 0, to: 1, tone: 'frame' }, { from: 2, to: 3, tone: 'muted' }, { from: cols - 1, to: cols, tone: 'frame' }] }, + toned(`╰${'─'.repeat(boxInner)}╯`, 'frame'), + toned(` ? for shortcuts${' '.repeat(cols - 2 - 16 - 19)}◯ auto-accept edits`, 'muted'), + ]; + const replyEnd = rows.findIndex((r) => r.text.startsWith('╭')) - 2; + return { id: 'claude', title: 'claude', program: 'Claude Code', cols, hardWrapWidth: W, rows, blocks: [{ from: 2, to: replyEnd, label: 'Whole reply' }] }; +} + +/** Split `text` into `cols`-wide rows the way a terminal soft-wraps it. */ +function softWrap(text: string, cols: number, tones?: (row: string, index: number) => ToneSpan[]): MockRow[] { + const out: MockRow[] = []; + for (let at = 0, i = 0; at < text.length || i === 0; at += cols, i++) { + const row = text.slice(at, at + cols); + out.push({ text: row, wrapped: i > 0, tones: tones?.(row, i) }); + } + return out; +} + +const PROMPT = '~/projects/dormouse ❯ '; + +function promptRow(command: string, cols: number): MockRow[] { + const rows = softWrap(PROMPT + command, cols, (_row, i) => (i === 0 ? [{ from: 0, to: PROMPT.length - 2, tone: 'link' }, { from: PROMPT.length - 2, to: PROMPT.length - 1, tone: 'prompt' }] : [])); + rows[0].prompt = true; + return rows; +} + +function buildShellScreen(): MockScreen { + const cols = 80; + const token = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkb3Jtb3VzZS1kZXYiLCJzY29wZSI6InB0eTpyZWFkIHB0eTp3cml0ZSIsImlhdCI6MTc5MDAwMDAwMH0.4f1c2a9e8b7d6f5a4c3b2a1908f7e6d5c4b3a29187f6e5d4c3b2a1908f7e6d5c4b3a2'; + const rows: MockRow[] = []; + const blocks: MockBlock[] = []; + const command = (cmd: string, output: MockRow[]) => { + rows.push(...promptRow(cmd, cols)); + if (output.length) blocks.push({ from: rows.length, to: rows.length + output.length - 1, label: 'Command output' }); + rows.push(...output); + }; + command('gh pr view 853 --json title,url', [ + { text: '{' }, + { text: ' "title": "Await the PTY write callback before reading the selection buffer",', tones: [{ from: 2, to: 9, tone: 'link' }, { from: 11, to: 78, tone: 'pass' }] }, + { text: ' "url": "https://github.com/diffplug/dormouse/pull/853"', tones: [{ from: 2, to: 7, tone: 'link' }, { from: 9, to: 56, tone: 'pass' }] }, + { text: '}' }, + ]); + command('cat .dev-token', softWrap(token, cols)); + command('git log --oneline -3', [ + { text: 'd19f2683e Theme tool iframes and upgrade file viewers with Monaco editing (#851)', tones: [{ from: 0, to: 9, tone: 'code' }] }, + { text: 'd19f2c6d6 Add a theme-aware twinkling starfield to the Pocket tutorial (#852)', tones: [{ from: 0, to: 9, tone: 'code' }] }, + { text: 'a292155da Report a reloaded editor after resetting its saved revision', tones: [{ from: 0, to: 9, tone: 'code' }] }, + ]); + command('pnpm --filter dormouse-lib exec vitest run src/lib/selection-text.test.ts --repeat 50', [ + { text: '' }, + { text: ' ✓ src/lib/selection-text.test.ts (12 tests) 38ms', tones: [{ from: 1, to: 2, tone: 'pass' }, { from: 44, to: 48, tone: 'muted' }] }, + { text: '' }, + { text: ' Test Files 1 passed (1)', tones: [{ from: 1, to: 11, tone: 'muted' }, { from: 13, to: 21, tone: 'pass' }] }, + { text: ' Tests 12 passed (50 runs)', tones: [{ from: 6, to: 11, tone: 'muted' }, { from: 13, to: 22, tone: 'pass' }] }, + ]); + rows.push(...promptRow('', cols)); + return { id: 'shell', title: 'zsh', program: 'zsh', cols, rows, blocks }; +} + +export const SCREENS = { claude: buildClaudeScreen(), shell: buildShellScreen() } as const; +export type ScreenId = keyof typeof SCREENS; + +// --------------------------------------------------------------------------- +// Positions and spans + +export interface GridPos { row: number; col: number } +/** Linewise, inclusive end, `start` before `end` in reading order. */ +export interface Span { start: GridPos; end: GridPos } + +export function comparePos(a: GridPos, b: GridPos): number { + return a.row - b.row || a.col - b.col; +} + +export function normalizeSpan(a: GridPos, b: GridPos): Span { + return comparePos(a, b) <= 0 ? { start: a, end: b } : { start: b, end: a }; +} + +export function spanEquals(a: Span, b: Span): boolean { + return comparePos(a.start, b.start) === 0 && comparePos(a.end, b.end) === 0; +} + +function contains(span: Span, pos: GridPos): boolean { + return comparePos(span.start, pos) <= 0 && comparePos(pos, span.end) <= 0; +} + +const rowText = (screen: MockScreen, r: number) => (screen.rows[r]?.text ?? '').trimEnd(); +const isSpace = (ch: string | undefined) => ch === undefined || ch === ' '; + +// --------------------------------------------------------------------------- +// Chrome and breaks + +const BOX = /[─-▟]/; +const FRAME_ONLY = /^[─-▟\s]+$/; +/** A TUI's line-leading glyph that is decoration, not text. */ +const LEAD_MARKER = /^(\s*)(⏺ |⎿ |● |[─-▟]+ ?)/; +const TRAIL_BOX = / ?[─-▟]+$/; +const LIST_MARKER = /^(?:[-*•]|\d+[.)]) /; + +function isFrameOnly(text: string): boolean { + return text.length > 0 && FRAME_ONLY.test(text) && BOX.test(text); +} + +/** The row with its decoration blanked to spaces, so indentation math still + * lines up with the columns under it. */ +function unchromed(text: string): string { + const lead = LEAD_MARKER.exec(text); + let out = lead ? lead[1] + ' '.repeat(lead[2].length) + text.slice(lead[0].length) : text; + out = out.replace(TRAIL_BOX, ''); + return out.trimEnd(); +} + +const indentOf = (text: string) => text.length - text.trimStart().length; + +/** Where a wrapped continuation of this row would start. */ +function hangIndent(text: string): number { + const indent = indentOf(text); + const list = LIST_MARKER.exec(text.slice(indent)); + return indent + (list ? list[0].length : 0); +} + +export type BreakKind = 'keep' | 'space' | 'none'; + +/** The smart decision for the break between full rows `r` and `r + 1`. */ +export function smartBreak(screen: MockScreen, r: number): BreakKind { + const next = screen.rows[r + 1]; + if (!next) return 'keep'; + if (next.wrapped) return 'none'; + const cur = unchromed(rowText(screen, r)); + const nxt = unchromed(rowText(screen, r + 1)); + if (!cur.trim() || !nxt.trim()) return 'keep'; + const W = screen.hardWrapWidth; + if (!W) return 'keep'; + const nextTrim = nxt.trimStart(); + if (LIST_MARKER.test(nextTrim)) return 'keep'; + if (/[;{}]$/.test(cur) || /^[)}\]]/.test(nextTrim)) return 'keep'; + if (indentOf(nxt) !== hangIndent(cur)) return 'keep'; + const firstWord = nextTrim.split(' ')[0]; + // A break the next word would have fit before was typed, not wrapped. + if (cur.length + 1 + firstWord.length <= W) return 'keep'; + const lastWord = cur.slice(cur.lastIndexOf(' ') + 1); + const tokenish = /[/\\#?=&]/.test(lastWord) || /^[A-Za-z0-9+_\-.]{16,}$/.test(lastWord); + return cur.length >= W && tokenish ? 'none' : 'space'; +} + +// --------------------------------------------------------------------------- +// Renderings + +export type FormatId = 'exact' | 'spaces' | 'joined' | 'smart' | 'dedent'; + +export const FORMATS: { id: FormatId; key: string; name: string; blurb: string }[] = [ + { id: 'exact', key: '1', name: 'Exact', blurb: 'as displayed' }, + { id: 'spaces', key: '2', name: 'Spaces', blurb: 'every break → one space' }, + { id: 'joined', key: '3', name: 'No breaks', blurb: 'every break deleted' }, + { id: 'smart', key: '4', name: 'Rewrapped', blurb: 'each break judged on its own' }, + { id: 'dedent', key: '5', name: 'Dedented', blurb: 'breaks kept, indent and gutter removed' }, +]; + +export type Piece = + | { t: 'text'; text: string; added: boolean; lead: boolean } + | { t: 'break'; index: number; kind: BreakKind; auto: BreakKind }; + +export interface Rendering { + format: FormatId; + pieces: Piece[]; + text: string; + lines: number; + chars: number; + breaks: BreakKind[]; + /** Characters outside the dragged selection (an expansion). */ + addedChars: number; +} + +interface Cell { ch: string; added: boolean } +interface Line { cells: Cell[]; row: number; startCol: number } + +function extract(screen: MockScreen, scope: Span, original: Span): Line[] { + const lines: Line[] = []; + for (let r = scope.start.row; r <= scope.end.row; r++) { + const text = rowText(screen, r); + const a = r === scope.start.row ? scope.start.col : 0; + const b = r === scope.end.row ? scope.end.col + 1 : text.length; + const cells: Cell[] = []; + for (let c = a; c < Math.min(b, text.length); c++) { + cells.push({ ch: text[c], added: !contains(original, { row: r, col: c }) }); + } + while (cells.length && cells[cells.length - 1].ch === ' ') cells.pop(); + lines.push({ cells, row: r, startCol: a }); + } + return lines; +} + +const lineStr = (l: Line) => l.cells.map((c) => c.ch).join(''); +const leading = (cells: Cell[]) => { + let n = 0; + while (n < cells.length && cells[n].ch === ' ') n++; + return n; +}; + +/** Blank the line's decoration the same way `unchromed` does its row. */ +function stripChrome(line: Line): Line | null { + const text = lineStr(line); + if (isFrameOnly(text)) return null; + const cells = line.cells.map((c) => ({ ...c })); + const lead = LEAD_MARKER.exec(text); + if (lead) for (let k = lead[1].length; k < lead[0].length; k++) cells[k].ch = ' '; + const trail = TRAIL_BOX.exec(text); + if (trail) cells.length = trail.index; + while (cells.length && cells[cells.length - 1].ch === ' ') cells.pop(); + return { ...line, cells }; +} + +export function render(screen: MockScreen, scope: Span, original: Span, format: FormatId, overrides: Record = {}): Rendering { + let lines = extract(screen, scope, original); + if (format !== 'exact') lines = lines.map(stripChrome).filter((l): l is Line => l !== null); + + const blank = (l: Line) => l.cells.length === 0; + // Trim leading/trailing blank lines; collapse runs of blanks to one. + while (lines.length && blank(lines[0])) lines.shift(); + while (lines.length && blank(lines[lines.length - 1])) lines.pop(); + if (format === 'spaces' || format === 'joined') lines = lines.filter((l) => !blank(l)); + else if (format !== 'exact') lines = lines.filter((l, i) => !(blank(l) && i > 0 && blank(lines[i - 1]))); + + // Shared indent, in absolute columns, for the formats that dedent. A first + // line starting mid-row has no indent of its own to keep. + const dedents = format === 'smart' || format === 'dedent'; + const absIndents = lines.filter((l) => !blank(l)).map((l) => l.startCol + leading(l.cells)); + const base = absIndents.length ? Math.min(...absIndents) : 0; + + const breaks: BreakKind[] = []; + const autos: BreakKind[] = []; + for (let i = 0; i + 1 < lines.length; i++) { + const a = lines[i]; + const b = lines[i + 1]; + let auto: BreakKind; + if (format === 'exact' || format === 'dedent') auto = 'keep'; + else if (format === 'spaces') auto = 'space'; + else if (format === 'joined') auto = 'none'; + else auto = blank(a) || blank(b) || b.row !== a.row + 1 ? 'keep' : smartBreak(screen, a.row); + autos.push(auto); + breaks.push(overrides[i] ?? auto); + } + + const pieces: Piece[] = []; + let text = ''; + let addedChars = 0; + lines.forEach((line, i) => { + const joinedToPrev = i > 0 && breaks[i - 1] !== 'keep'; + let cells = line.cells; + const lead = leading(cells); + let strip: number; + if (format === 'exact') strip = joinedToPrev ? lead : 0; + else if (joinedToPrev || format === 'spaces' || format === 'joined') strip = lead; + else if (dedents) strip = line.startCol > base ? lead : Math.min(lead, base - line.startCol); + else strip = lead; + cells = cells.slice(strip); + // Group into runs of (lead-whitespace, added) so the preview can mark them. + const leadRun = leading(cells); + let run: Piece & { t: 'text' } | null = null; + cells.forEach((cell, k) => { + const isLead = k < leadRun; + if (run && run.added === cell.added && run.lead === isLead) run.text += cell.ch; + else { + run = { t: 'text', text: cell.ch, added: cell.added, lead: isLead }; + pieces.push(run); + } + if (cell.added) addedChars++; + }); + text += cells.map((c) => c.ch).join(''); + if (i + 1 < lines.length) { + const kind = breaks[i]; + pieces.push({ t: 'break', index: i, kind, auto: autos[i] }); + text += kind === 'keep' ? '\n' : kind === 'space' ? ' ' : ''; + } + }); + + return { format, pieces, text, lines: text ? text.split('\n').length : 0, chars: text.length, breaks, addedChars }; +} + +export function renderAll(screen: MockScreen, scope: Span, original: Span): Rendering[] { + return FORMATS.map((f) => render(screen, scope, original, f.id)); +} + +/** The format whose text each rendering duplicates, if any. The recommended + * format is always the one kept, so ★ never reads "same as". */ +export function duplicates(renderings: Rendering[]): Map { + const seen = new Map(); + const out = new Map(); + const ordered = [...renderings].sort((a, b) => Number(b.format === RECOMMENDED) - Number(a.format === RECOMMENDED)); + for (const r of ordered) { + const prior = seen.get(r.text); + if (prior) out.set(r.format, prior); + else seen.set(r.text, r.format); + } + return out; +} + +export const RECOMMENDED: FormatId = 'smart'; + +// --------------------------------------------------------------------------- +// Expansions + +export type ScopeId = 'selection' | 'words' | 'paragraph' | 'block'; + +export interface Scope { id: ScopeId; label: string; span: Span } + +function contentStart(screen: MockScreen, r: number): number { + return indentOf(unchromed(rowText(screen, r))); +} + +/** Grow each edge to the whole token under it, following a token the program + * split across rows. */ +function snapToWords(screen: MockScreen, sel: Span): Span { + let { row: sr, col: sc } = sel.start; + for (;;) { + const t = rowText(screen, sr); + while (sc > 0 && !isSpace(t[sc - 1])) sc--; + if (sc <= contentStart(screen, sr) && sr > 0 && smartBreak(screen, sr - 1) === 'none' && !isSpace(t[sc])) { + sr--; + sc = rowText(screen, sr).length; + continue; + } + break; + } + let { row: er, col: ec } = sel.end; + for (;;) { + const t = rowText(screen, er); + while (ec + 1 < t.length && !isSpace(t[ec + 1])) ec++; + if (ec >= t.length - 1 && smartBreak(screen, er) === 'none' && !isSpace(t[ec])) { + er++; + ec = contentStart(screen, er) - 1; + continue; + } + break; + } + return { start: { row: sr, col: Math.max(0, sc) }, end: { row: er, col: ec } }; +} + +const boundary = (screen: MockScreen, r: number) => { + const t = rowText(screen, r); + return !t.trim() || isFrameOnly(t) || /^[│┃]/.test(t) || !!screen.rows[r]?.prompt; +}; + +function toParagraph(screen: MockScreen, sel: Span): Span { + let sr = sel.start.row; + while (sr > 0 && !boundary(screen, sr - 1)) sr--; + let er = sel.end.row; + while (er + 1 < screen.rows.length && !boundary(screen, er + 1)) er++; + return { start: { row: sr, col: contentStart(screen, sr) }, end: { row: er, col: rowText(screen, er).length - 1 } }; +} + +function toBlock(screen: MockScreen, sel: Span): { span: Span; label: string } | null { + const block = screen.blocks.find((b) => b.from <= sel.start.row && sel.end.row <= b.to); + if (!block) return null; + return { span: { start: { row: block.from, col: contentStart(screen, block.from) }, end: { row: block.to, col: rowText(screen, block.to).length - 1 } }, label: block.label }; +} + +/** Names the growth by the edge tokens that grew, not the whole text. */ +function tokenLabel(text: string, grewStart: boolean, grewEnd: boolean): string { + const tokens = text.split(/\s+/).filter(Boolean); + const edges = [grewStart ? tokens[0] : '', grewEnd ? tokens[tokens.length - 1] : '']; + if (edges.some((t) => /^https?:\/\//.test(t ?? ''))) return 'Full URL'; + if (edges.some((t) => /^(~|\.{0,2})\/\S|\S+\/\S+\.\w+/.test(t ?? ''))) return 'Full path'; + return 'Whole words'; +} + +/** Every distinct scope the selection can grow to, narrowest first. Never + * shrinks: a wider scope always contains the dragged selection. */ +export function computeScopes(screen: MockScreen, sel: Span): Scope[] { + const out: Scope[] = [{ id: 'selection', label: 'As selected', span: sel }]; + const push = (id: ScopeId, label: string, span: Span) => { + const union: Span = { + start: comparePos(span.start, sel.start) < 0 ? span.start : sel.start, + end: comparePos(span.end, sel.end) > 0 ? span.end : sel.end, + }; + if (out.some((s) => spanEquals(s.span, union))) return; + out.push({ id, label, span: union }); + }; + const words = snapToWords(screen, sel); + const grewStart = comparePos(words.start, sel.start) < 0; + const grewEnd = comparePos(words.end, sel.end) > 0; + push('words', tokenLabel(render(screen, words, words, 'smart').text, grewStart, grewEnd), words); + push('paragraph', 'Paragraph', toParagraph(screen, sel)); + const block = toBlock(screen, sel); + if (block) push('block', block.label, block.span); + return out; +} + +// --------------------------------------------------------------------------- +// Keyboard nudging (the editor concept) + +function cellAt(screen: MockScreen, p: GridPos): string | undefined { + return rowText(screen, p.row)[p.col]; +} + +function step(screen: MockScreen, p: GridPos, dir: 1 | -1): GridPos | null { + let { row, col } = p; + col += dir; + while (row >= 0 && row < screen.rows.length) { + const len = rowText(screen, row).length; + if (col >= 0 && col < len) return { row, col }; + row += dir; + if (row < 0 || row >= screen.rows.length) return null; + col = dir > 0 ? 0 : rowText(screen, row).length - 1; + } + return null; +} + +/** Move a selection edge one word. `edge` says which end of a word it sits on. */ +export function nudge(screen: MockScreen, p: GridPos, dir: 1 | -1, edge: 'start' | 'end'): GridPos { + let q: GridPos | null = p; + const land = (pos: GridPos) => { + // Walk to the boundary of the word under `pos` on the requested side. + let cur = pos; + for (;;) { + const n = step(screen, cur, edge === 'end' ? 1 : -1); + if (!n || n.row !== cur.row || isSpace(cellAt(screen, n))) return cur; + cur = n; + } + }; + // Leave the current word, cross the gap, then land on the next word's edge. + q = step(screen, p, dir); + while (q && !isSpace(cellAt(screen, q)) && q.row === p.row && ((dir > 0) === (edge === 'start'))) q = step(screen, q, dir); + while (q && isSpace(cellAt(screen, q))) q = step(screen, q, dir); + return q ? land(q) : p; +} From 6e66dd1257dbc0e0cc8f9fdde8111530a3a7112c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 17:30:09 -0700 Subject: [PATCH 02/36] Screencast Storybook and strip dormouse.yml comments The storybook Tool renders as agent-browser-screencast, innerdogfood drops its explicit desktop viewport, and the descriptive comments are gone. Co-Authored-By: Claude Opus 5.5 --- dormouse.yml | 35 +---------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/dormouse.yml b/dormouse.yml index b8fa53427..c09f1e445 100644 --- a/dormouse.yml +++ b/dormouse.yml @@ -1,67 +1,34 @@ -# Dor Tools for this repo (docs/specs/dor-tool.md). -# -# `dor tool ` runs one of these in a pane that grows a browser once the -# command starts serving. Repo-controlled, so Dormouse asks you to approve this -# directory once before it will run anything here. -# -# The comment directly above each entry is its description in -# `dor tool --list`: say what the Tool is for and anything its fields do not. browser: default_viewport: desktop tools: - # The lib component catalog (Storybook), for a human to look at. storybook: run: pnpm storybook - # Storybook never announces, so autobind: frame the one port it opens. If it - # ever opened a second, Dormouse would show that instead of guessing. + render: agent-browser-screencast port: auto - # Scoped to the checkout: parallel worktrees each get their own Storybook, - # and each frames the port it actually bound (6006, then 6007, ...). Without - # $PROJECT_ROOT both worktrees would share one key and the second would - # reveal the first instead of starting. prespawn_dedupe: [storybook, $PROJECT_ROOT] - # Dormouse Standalone running in a browser against a real sidecar, for - # debugging the app itself. Drive the page with the command it prints. innerdogfood: run: pnpm innerdogfood - # A real browser rather than an iframe, so an agent can drive the harness - # with `dor agent-browser --surface surface:N `. render: agent-browser-screencast - viewport: desktop - # The bridge and Vite each bind an OS-assigned port. Announce Vite's actual - # port via OSC 367 so Dormouse selects the UI from that pair of listeners. port: announced prespawn_dedupe: [innerdogfood, $PROJECT_ROOT] - # The marketing site and public docs (dormouse.sh), with hot reload. website: run: pnpm dev:website - # Agents check docs pages here, so a browser they can drive. render: agent-browser-screencast - # Vite moves to the next free port when 5173 is taken. port: auto prespawn_dedupe: [website, $PROJECT_ROOT] - # A selfhost Relay serving Pocket, for testing remote control locally. - # Enroll a Burrow with the URL it prints. relay: run: pnpm dev:relay - # Pocket signs in with passkeys and a session cookie, which an iframe drops. render: agent-browser-screencast - # The dev Relay binds an OS-assigned port and derives its origin from it. port: auto prespawn_dedupe: [relay, $PROJECT_ROOT] - # The Hosted accounts frontend with email sign-in; read the codes at - # /api/dev/emails on the printed origin. Needs Docker running. hosted: run: pnpm dev:hosted - # Email sign-in sets a session cookie, which an iframe drops. render: agent-browser-screencast - # One listener serves Vite and auth on an OS-assigned port; Postgres runs in - # Docker, outside this process tree. port: auto prespawn_dedupe: [hosted, $PROJECT_ROOT] From 890fdc34ffe1ddec77e50823ab270296e8da86c7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 17:37:37 -0700 Subject: [PATCH 03/36] =?UTF-8?q?Label=20the=20copy=20editor=20prototype's?= =?UTF-8?q?=20scope=20keys=20expand/shrink=20and=20show=20f/=E2=AC=86?= =?UTF-8?q?=EF=B8=8Ef=20bare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- lib/src/stories/copy-editor/CopyEditor2.stories.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/stories/copy-editor/CopyEditor2.stories.tsx b/lib/src/stories/copy-editor/CopyEditor2.stories.tsx index b8054bd32..1e7d343db 100644 --- a/lib/src/stories/copy-editor/CopyEditor2.stories.tsx +++ b/lib/src/stories/copy-editor/CopyEditor2.stories.tsx @@ -24,8 +24,8 @@ import { // The copy editor after the first round (Prototypes/Copy editor): only the // editor concept, at full pane width, driven by letters instead of digits. -// e / ⬆︎e grow / shrink the scope -// f next format (Auto → Exact → Spaces → No breaks) +// e / ⬆︎e expand / shrink the scope +// f / ⬆︎f next / previous format (Auto → Exact → Spaces → No breaks) // ◀ ▶ move the end a word; ⬆︎◀ ⬆︎▶ move the start // ↵ or the copy chord copies; Esc closes. @@ -205,7 +205,7 @@ function CopyEditor({ screen, sel, scopeIndex, format, overrides, flashed, onSco
onScope(scopes.findIndex((s) => s.id === id))} items={scopes.map((s) => ({ id: s.id, label: s.label }))} /> - e grow {SHIFT}e shrink + e expand {SHIFT}e shrink
- f format + f {SHIFT}f
@@ -410,7 +410,7 @@ function CopyEditorPrototype({ host: initialHost, preset }: Props) { if (phase === 'idle') return mouseMode ? `Drag across the text (Claude Code paints its own highlight), then press ${COPY_CHORD}.` : 'Drag across the text.'; if (phase === 'dragging') return 'Release to finish the selection.'; if (phase === 'armed') return `Press ${COPY_CHORD}: Dormouse saw the drag and captured the same cells.`; - return `e / ${SHIFT}e scope · f format · ${LEFT}${RIGHT} end · ${SHIFT}${LEFT}${RIGHT} start · click a mark to flip one break · ↵ or ${COPY_CHORD} copies · Esc closes`; + return `e / ${SHIFT}e expand / shrink · f / ${SHIFT}f format · ${LEFT}${RIGHT} end · ${SHIFT}${LEFT}${RIGHT} start · click a mark to flip one break · ↵ or ${COPY_CHORD} copies · Esc closes`; })(); return ( From c27d4501015dde9a49443e43f68f5ac9c5095967 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 17:45:39 -0700 Subject: [PATCH 04/36] Add the copy editor's text model A pure reading of the terminal buffer for the copy editor: Auto / Exact / Spaces / No breaks renderings with per-break judgement and overrides, decoration stripping, scope expansion to whole words and paragraphs, and word-wise edge nudging. Cells are read through xterm's wide-character continuation cells. Not wired to the UI yet. Co-Authored-By: Claude Opus 5.5 --- lib/src/lib/copy-text.test.ts | 194 +++++++++++++++ lib/src/lib/copy-text.ts | 443 ++++++++++++++++++++++++++++++++++ 2 files changed, 637 insertions(+) create mode 100644 lib/src/lib/copy-text.test.ts create mode 100644 lib/src/lib/copy-text.ts diff --git a/lib/src/lib/copy-text.test.ts b/lib/src/lib/copy-text.test.ts new file mode 100644 index 000000000..2c74592f7 --- /dev/null +++ b/lib/src/lib/copy-text.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; +import { + autoBreak, + computeScopes, + nudge, + render, + stringCopyBuffer, + terminalCopyBuffer, + type CopyBuffer, + type CopyFormat, + type Span, +} from './copy-text'; +import { extractSelectionText } from './selection-text'; +import type { Selection } from './mouse-selection'; + +// A Claude Code reply as Ink hard-wraps it at 76 of 80 columns. +const CLAUDE = [ + '> why is selection-text.test.ts flaky on CI?', + '', + '⏺ The flake comes from a race between the PTY exit event and the final flush', + ' of the output buffer. When the child exits before xterm has drained its', + ' write queue, the last chunk is dropped and the assertion on the prompt', + ' text fails intermittently.', + '', + ' The fix is to await the write callback before reading the buffer:', + '', + ' await new Promise((resolve) => terminal.write(chunk, resolve));', + ' const text = extractSelectionText(terminal, selection);', + " expect(text).toBe('user@dormouse:~$ ls');", + '', + ' I opened a draft with the change: https://github.com/diffplug/dormouse/pul', + ' l/853/files#diff-7c1f3e9a2b8d4f6e0a5c7b9d1e3f5a7c9b1d3e5f', + '', + `╭${'─'.repeat(78)}╮`, + `│ > ${' '.repeat(75)}│`, + `╰${'─'.repeat(78)}╯`, +]; +const claude = stringCopyBuffer(CLAUDE, { cols: 80 }); + +const span = (r0: number, c0: number, r1: number, c1: number): Span => ({ start: { row: r0, col: c0 }, end: { row: r1, col: c1 } }); +const text = (buf: CopyBuffer, s: Span, format: CopyFormat, extra: Partial[2]> = {}) => + render(buf, s, { original: s, format, ...extra }).text; + +describe('copy-text formats', () => { + const prose = span(2, 2, 5, 27); + + it('Auto joins a hard-wrapped paragraph and drops the reply bullet', () => { + expect(text(claude, prose, 'auto')).toBe( + 'The flake comes from a race between the PTY exit event and the final flush of the output buffer. When the child exits before xterm has drained its write queue, the last chunk is dropped and the assertion on the prompt text fails intermittently.', + ); + }); + + it('Exact is the old Copy Raw: rows trimmed, indents kept', () => { + const sel: Selection = { startRow: 2, startCol: 2, endRow: 5, endCol: 27, shape: 'linewise', dragging: false, startedInScrollback: false }; + const terminal = { + cols: 80, + buffer: { active: { getLine: (r: number) => ({ translateToString: (_t: boolean, s = 0, e = 80) => (CLAUDE[r] ?? '').slice(s, e) }) } }, + } as unknown as Terminal; + expect(text(claude, prose, 'exact')).toBe(extractSelectionText(terminal, sel)); + expect(text(claude, prose, 'exact').split('\n')[1]).toBe(' of the output buffer. When the child exits before xterm has drained its'); + }); + + it('Spaces turns every break into one space; No breaks deletes them', () => { + expect(text(claude, prose, 'spaces')).toBe(text(claude, prose, 'auto')); + expect(text(claude, prose, 'joined')).toContain('final flushof the output'); + }); + + it('Auto rejoins a token the program split at the margin with no space', () => { + const url = span(13, 36, 14, 58); + expect(text(claude, url, 'auto')).toBe('https://github.com/diffplug/dormouse/pull/853/files#diff-7c1f3e9a2b8d4f6e0a5c7b9d1e3f5a7c9b1d3e5f'); + expect(text(claude, url, 'spaces')).toContain('/pul l/853'); + }); + + it('Auto keeps code lines, their relative indent, and paragraph breaks', () => { + expect(text(claude, span(7, 2, 11, 44), 'auto')).toBe([ + 'The fix is to await the write callback before reading the buffer:', + '', + ' await new Promise((resolve) => terminal.write(chunk, resolve));', + ' const text = extractSelectionText(terminal, selection);', + " expect(text).toBe('user@dormouse:~$ ls');", + ].join('\n')); + }); + + it('drops frame-only rows and box sides', () => { + expect(text(claude, span(15, 0, 18, 79), 'auto')).toBe('>'); + }); + + it('joins a true soft wrap exactly, whatever the text', () => { + const buf = stringCopyBuffer(['abcdefghij', 'klm'], { cols: 10, wrapped: [1] }); + expect(text(buf, span(0, 0, 1, 2), 'auto')).toBe('abcdefghijklm'); + expect(text(buf, span(0, 0, 1, 2), 'spaces')).toBe('abcdefghij klm'); + }); + + it('never joins two short lines, however alike', () => { + const buf = stringCopyBuffer(['Hello', 'World'], { cols: 80 }); + expect(text(buf, span(0, 0, 1, 4), 'auto')).toBe('Hello\nWorld'); + }); + + it('keeps list items on their own lines', () => { + const buf = stringCopyBuffer([ + '- the first item wraps because it is long enough to reach past the margin', + ' of the pane', + '- the second item', + ], { cols: 80 }); + expect(text(buf, span(0, 0, 2, 16), 'auto')).toBe('- the first item wraps because it is long enough to reach past the margin of the pane\n- the second item'); + }); + + it('reads a block slab exactly under Auto', () => { + const block = span(2, 2, 4, 20); + expect(text(claude, block, 'auto', { block: true })).toBe(text(claude, block, 'exact', { block: true })); + expect(text(claude, block, 'exact', { block: true }).split('\n')).toEqual(['The flake comes fro', 'of the output buffe', 'write queue, the la']); + }); + + it('applies an override to one break and reports the format’s own decisions', () => { + const r = render(claude, prose, { original: prose, format: 'auto', overrides: { 1: 'keep' } }); + expect(r.breaks).toEqual(['space', 'space', 'space']); + expect(r.lines).toBe(2); + expect(r.text.split('\n')[1]).toBe('write queue, the last chunk is dropped and the assertion on the prompt text fails intermittently.'); + }); + + it('marks cells outside the dragged selection as added', () => { + const grown = span(13, 36, 14, 58); + const r = render(claude, grown, { original: span(13, 44, 14, 30), format: 'auto' }); + const added = r.pieces.filter((p) => p.t === 'text' && p.added).map((p) => p.t === 'text' ? p.text : ''); + expect(added).toEqual(['https://', '4f6e0a5c7b9d1e3f5a7c9b1d3e5f']); + }); +}); + +describe('copy-text autoBreak', () => { + it('judges each break of the reply', () => { + expect([2, 3, 4, 5].map((r) => autoBreak(claude, r))).toEqual(['space', 'space', 'space', 'keep']); + expect([9, 10].map((r) => autoBreak(claude, r))).toEqual(['keep', 'keep']); + expect(autoBreak(claude, 13)).toBe('none'); + }); +}); + +describe('copy-text scopes', () => { + it('expands a clipped URL across the split, then to its paragraph', () => { + const scopes = computeScopes(claude, span(13, 44, 14, 30)); + expect(scopes.map((s) => s.label)).toEqual(['As selected', 'Full URL', 'Paragraph']); + expect(scopes[1].span).toEqual(span(13, 36, 14, 58)); + expect(scopes[2].span).toEqual(span(13, 2, 14, 58)); + }); + + it('skips a scope that adds nothing', () => { + const scopes = computeScopes(claude, span(2, 2, 5, 27)); + expect(scopes.map((s) => s.id)).toEqual(['selection']); + }); + + it('names a growth by its grown edge', () => { + expect(computeScopes(claude, span(2, 27, 3, 10))[1].label).toBe('Whole words'); + }); + + it('gives a block slab no other scope', () => { + expect(computeScopes(claude, span(2, 2, 4, 20), true)).toHaveLength(1); + }); +}); + +describe('copy-text nudge', () => { + it('moves an end to the next or previous word end', () => { + expect(nudge(claude, { row: 2, col: 10 }, 1, 'end')).toEqual({ row: 2, col: 16 }); + expect(nudge(claude, { row: 2, col: 16 }, -1, 'end')).toEqual({ row: 2, col: 10 }); + expect(nudge(claude, { row: 2, col: 7 }, 1, 'end')).toEqual({ row: 2, col: 10 }); + }); + + it('moves a start to the previous or next word start, across rows', () => { + expect(nudge(claude, { row: 3, col: 2 }, -1, 'start')).toEqual({ row: 2, col: 71 }); + expect(nudge(claude, { row: 2, col: 71 }, 1, 'start')).toEqual({ row: 3, col: 2 }); + }); + + it('snaps a mid-word start to that word first', () => { + expect(nudge(claude, { row: 2, col: 9 }, -1, 'start')).toEqual({ row: 2, col: 6 }); + }); +}); + +describe('copy-text terminalCopyBuffer', () => { + it('maps wide characters through their continuation cells', () => { + const cells = [ + { chars: '中', width: 2 }, { chars: '', width: 0 }, + { chars: 'x', width: 1 }, { chars: '', width: 1 }, { chars: '', width: 1 }, + ]; + const line = { + length: cells.length, + isWrapped: false, + getCell: (c: number) => (cells[c] ? { getChars: () => cells[c].chars, getWidth: () => cells[c].width } : undefined), + }; + const terminal = { cols: 5, buffer: { active: { length: 1, getLine: () => line } } } as unknown as Terminal; + const buf = terminalCopyBuffer(terminal); + expect(buf.row(0).cells).toEqual(['中', '', 'x']); + expect(text(buf, span(0, 1, 0, 2), 'exact')).toBe('x'); + expect(text(buf, span(0, 0, 0, 2), 'exact')).toBe('中x'); + }); +}); diff --git a/lib/src/lib/copy-text.ts b/lib/src/lib/copy-text.ts new file mode 100644 index 000000000..b17f9c670 --- /dev/null +++ b/lib/src/lib/copy-text.ts @@ -0,0 +1,443 @@ +import type { IBufferCell, Terminal } from '@xterm/xterm'; +import type { Selection } from './mouse-selection'; +import { normalizeSelection } from './selection-text'; + +// The text a copy produces: which cells a scope covers, and how each format +// turns them into clipboard text. Pure over a `CopyBuffer`, so the editor, the +// overlay, and the tests share one reading of the terminal +// (docs/specs/mouse-and-clipboard.md §4). + +/** One buffer row: a string per cell column (`''` for the continuation half of + * a wide character), trailing blanks trimmed. */ +export interface CopyRow { + readonly cells: readonly string[]; + /** xterm's `isWrapped`: a true soft wrap continuing the previous row. */ + readonly wrapped: boolean; +} + +export interface CopyBuffer { + readonly cols: number; + readonly length: number; + /** Out of range reads as an empty row. */ + row(index: number): CopyRow; +} + +const EMPTY_ROW: CopyRow = Object.freeze({ cells: Object.freeze([]) as readonly string[], wrapped: false }); + +/** A lazily read, memoized view of a terminal's active buffer. Build one per + * read: it does not notice later output. */ +export function terminalCopyBuffer(terminal: Terminal): CopyBuffer { + const buffer = terminal.buffer.active; + const rows = new Map(); + return { + cols: terminal.cols, + length: buffer.length, + row(index) { + const cached = rows.get(index); + if (cached) return cached; + const line = buffer.getLine(index); + if (!line) return EMPTY_ROW; + const cells: string[] = []; + // One CellData for the whole row; `getCell` allocates per call otherwise. + let scratch: IBufferCell | undefined; + for (let c = 0; c < line.length; c++) { + const cell = line.getCell(c, scratch); + if (!cell) break; + scratch ??= cell; + cells.push(cell.getWidth() === 0 ? '' : cell.getChars() || ' '); + } + trimTrailingBlanks(cells); + const row = { cells, wrapped: line.isWrapped }; + rows.set(index, row); + return row; + }, + }; +} + +/** A buffer over plain strings, one cell per code point; for tests and stories. */ +export function stringCopyBuffer(lines: readonly string[], options: { cols?: number; wrapped?: readonly number[] } = {}): CopyBuffer { + const wrapped = new Set(options.wrapped ?? []); + const cols = options.cols ?? Math.max(0, ...lines.map((l) => l.length)); + return { + cols, + length: lines.length, + row(index) { + const line = lines[index]; + if (line === undefined) return EMPTY_ROW; + const cells = [...line]; + trimTrailingBlanks(cells); + return { cells, wrapped: wrapped.has(index) }; + }, + }; +} + +function trimTrailingBlanks(cells: string[]): void { + while (cells.length && /^\s*$/.test(cells[cells.length - 1])) cells.pop(); +} + +// --------------------------------------------------------------------------- +// Positions + +export interface GridPos { row: number; col: number } +/** Inclusive end, `start` before `end` in reading order. */ +export interface Span { start: GridPos; end: GridPos } + +export function comparePos(a: GridPos, b: GridPos): number { + return a.row - b.row || a.col - b.col; +} + +export function spanOfSelection(sel: Selection): Span { + const n = normalizeSelection(sel); + return { start: { row: n.r0, col: n.c0 }, end: { row: n.r1, col: n.c1 } }; +} + +export function spanEquals(a: Span, b: Span): boolean { + return comparePos(a.start, b.start) === 0 && comparePos(a.end, b.end) === 0; +} + +const rowText = (buf: CopyBuffer, r: number) => buf.row(r).cells.join(''); +const isBlankCell = (ch: string | undefined) => ch === undefined || ch === ' '; + +// --------------------------------------------------------------------------- +// Decoration and line breaks + +const BOX = /[─-▟]/; +const FRAME_ONLY = /^[─-▟\s]+$/; +/** A TUI's line-leading glyph that is decoration, not text: Claude Code's reply + * and tool bullets, or a run of box drawing. */ +const LEAD_MARKER = /^(\s*)(⏺ |⎿ |● |[─-▟]+ ?)/; +const TRAIL_BOX = / ?[─-▟]+$/; +const LIST_MARKER = /^(?:[-*•]|\d+[.)]) /; + +function isFrameOnly(text: string): boolean { + return text.length > 0 && FRAME_ONLY.test(text) && BOX.test(text); +} + +/** The row with its decoration blanked to spaces, so indentation math still + * lines up with the columns under it. */ +function unchromed(text: string): string { + const lead = LEAD_MARKER.exec(text); + const out = lead ? lead[1] + ' '.repeat(lead[2].length) + text.slice(lead[0].length) : text; + return out.replace(TRAIL_BOX, '').trimEnd(); +} + +const indentOf = (text: string) => text.length - text.trimStart().length; + +/** Where a wrapped continuation of this row would start. */ +function hangIndent(text: string): number { + const indent = indentOf(text); + const list = LIST_MARKER.exec(text.slice(indent)); + return indent + (list ? list[0].length : 0); +} + +/** A row that bounds a paragraph: blank, a frame, or a box's side. */ +function isBoundary(buf: CopyBuffer, r: number): boolean { + const text = rowText(buf, r); + return !text.trim() || isFrameOnly(text) || /^[│┃║]/.test(text); +} + +/** The width a program wrapped this row's paragraph at, as best the text says: + * its longest row, but never under half the terminal, so two short lines + * never read as one wrapped line. */ +function wrapWidth(buf: CopyBuffer, r: number): number { + let top = r; + while (top > 0 && !isBoundary(buf, top - 1) && top > r - 64) top--; + let bottom = r + 1; + while (bottom + 1 < buf.length && !isBoundary(buf, bottom + 1) && bottom < r + 64) bottom++; + let widest = 0; + for (let i = top; i <= bottom; i++) widest = Math.max(widest, unchromed(rowText(buf, i)).length); + return Math.max(widest, Math.floor(buf.cols / 2)); +} + +export type BreakKind = 'keep' | 'space' | 'none'; + +/** Auto's judgement of the line break between rows `r` and `r + 1`. */ +export function autoBreak(buf: CopyBuffer, r: number): BreakKind { + if (r + 1 >= buf.length) return 'keep'; + if (buf.row(r + 1).wrapped) return 'none'; + const cur = unchromed(rowText(buf, r)); + const next = unchromed(rowText(buf, r + 1)); + if (!cur.trim() || !next.trim()) return 'keep'; + const nextTrim = next.trimStart(); + if (LIST_MARKER.test(nextTrim)) return 'keep'; + if (/[;{}]$/.test(cur) || /^[)}\]]/.test(nextTrim)) return 'keep'; + if (indentOf(next) !== hangIndent(cur)) return 'keep'; + const firstWord = nextTrim.split(' ')[0]; + const width = wrapWidth(buf, r); + // A break the next word would have fit before was typed, not wrapped. + if (cur.length + 1 + firstWord.length <= width) return 'keep'; + const lastWord = cur.slice(cur.lastIndexOf(' ') + 1); + const tokenish = /[/\\#?=&]/.test(lastWord) || /^[A-Za-z0-9+_\-.]{16,}$/.test(lastWord); + // A token wider than the room left is split mid-token at the margin. + return cur.length >= width && tokenish ? 'none' : 'space'; +} + +// --------------------------------------------------------------------------- +// Renderings + +export type CopyFormat = 'auto' | 'exact' | 'spaces' | 'joined'; + +/** In `f` order; Auto is where every editor starts. */ +export const COPY_FORMATS: readonly CopyFormat[] = ['auto', 'exact', 'spaces', 'joined']; + +export type Piece = + | { t: 'text'; text: string; added: boolean; lead: boolean } + | { t: 'break'; index: number; kind: BreakKind; auto: BreakKind }; + +export interface Rendering { + pieces: Piece[]; + text: string; + lines: number; + /** The format's own decision for each break, before any override. */ + breaks: BreakKind[]; +} + +interface Cell { ch: string; added: boolean } +interface Line { cells: Cell[]; row: number; startCol: number } + +function inSpan(span: Span, row: number, col: number): boolean { + const p = { row, col }; + return comparePos(span.start, p) <= 0 && comparePos(p, span.end) <= 0; +} + +function extract(buf: CopyBuffer, scope: Span, original: Span, block: boolean): Line[] { + const lines: Line[] = []; + for (let r = scope.start.row; r <= scope.end.row; r++) { + const cells = buf.row(r).cells; + const a = block || r === scope.start.row ? scope.start.col : 0; + const b = block || r === scope.end.row ? scope.end.col + 1 : cells.length; + const out: Cell[] = []; + for (let c = a; c < Math.min(b, cells.length); c++) { + if (cells[c] === '') continue; + const added = block + ? !(r >= original.start.row && r <= original.end.row && c >= original.start.col && c <= original.end.col) + : !inSpan(original, r, c); + out.push({ ch: cells[c], added }); + } + while (out.length && /^\s*$/.test(out[out.length - 1].ch)) out.pop(); + lines.push({ cells: out, row: r, startCol: a }); + } + return lines; +} + +const lineText = (l: Line) => l.cells.map((c) => c.ch).join(''); +const leading = (cells: readonly Cell[]) => { + let n = 0; + while (n < cells.length && cells[n].ch === ' ') n++; + return n; +}; + +/** Cells spanned by the first `chars` UTF-16 units of `cells`. */ +function cellsForChars(cells: readonly Cell[], chars: number): number { + let n = 0; + for (let used = 0; n < cells.length && used < chars; n++) used += cells[n].ch.length; + return n; +} + +/** Blank the line's decoration the way `unchromed` does its row; null for a + * frame-only line. */ +function stripChrome(line: Line): Line | null { + const text = lineText(line); + if (isFrameOnly(text)) return null; + const cells = line.cells.map((c) => ({ ...c })); + const lead = LEAD_MARKER.exec(text); + if (lead) { + const from = cellsForChars(cells, lead[1].length); + const to = cellsForChars(cells, lead[0].length); + for (let k = from; k < to; k++) cells[k].ch = ' '; + } + const trail = TRAIL_BOX.exec(text); + if (trail) cells.length = cellsForChars(cells, trail.index); + while (cells.length && cells[cells.length - 1].ch === ' ') cells.pop(); + return { ...line, cells }; +} + +export interface RenderOptions { + /** Cells inside the dragged selection; the rest of `scope` is marked added. */ + original: Span; + format: CopyFormat; + /** A block-shape slab: never rewrapped, so Auto reads it exactly. */ + block?: boolean; + /** Break index → kind, replacing the format's own decision. */ + overrides?: Readonly>; +} + +export function render(buf: CopyBuffer, scope: Span, options: RenderOptions): Rendering { + const { original, block = false, overrides = {} } = options; + const format = block && options.format === 'auto' ? 'exact' : options.format; + let lines = extract(buf, scope, original, block); + const blank = (l: Line) => l.cells.length === 0; + + if (format !== 'exact') { + lines = lines.map(stripChrome).filter((l): l is Line => l !== null); + while (lines.length && blank(lines[0])) lines.shift(); + while (lines.length && blank(lines[lines.length - 1])) lines.pop(); + if (format === 'spaces' || format === 'joined') lines = lines.filter((l) => !blank(l)); + else lines = lines.filter((l, i) => !(blank(l) && i > 0 && blank(lines[i - 1]))); + } + + // Auto's shared indent, in absolute columns. A first line starting mid-row + // has no indent of its own to keep. + const indents = lines.filter((l) => !blank(l)).map((l) => l.startCol + leading(l.cells)); + const base = indents.length ? Math.min(...indents) : 0; + + const autos: BreakKind[] = []; + const breaks: BreakKind[] = []; + for (let i = 0; i + 1 < lines.length; i++) { + const a = lines[i]; + const b = lines[i + 1]; + let auto: BreakKind; + if (format === 'exact') auto = 'keep'; + else if (format === 'spaces') auto = 'space'; + else if (format === 'joined') auto = 'none'; + else auto = blank(a) || blank(b) || b.row !== a.row + 1 ? 'keep' : autoBreak(buf, a.row); + autos.push(auto); + breaks.push(overrides[i] ?? auto); + } + + const pieces: Piece[] = []; + let text = ''; + lines.forEach((line, i) => { + const joined = i > 0 && breaks[i - 1] !== 'keep'; + const lead = leading(line.cells); + let strip: number; + if (format === 'exact') strip = joined ? lead : 0; + else if (joined || format !== 'auto') strip = lead; + else strip = line.startCol > base ? lead : Math.min(lead, base - line.startCol); + const cells = line.cells.slice(strip); + const leadRun = leading(cells); + let run: Extract | null = null; + cells.forEach((cell, k) => { + const isLead = k < leadRun; + if (run && run.added === cell.added && run.lead === isLead) run.text += cell.ch; + else { + run = { t: 'text', text: cell.ch, added: cell.added, lead: isLead }; + pieces.push(run); + } + }); + text += cells.map((c) => c.ch).join(''); + if (i + 1 < lines.length) { + const kind = breaks[i]; + pieces.push({ t: 'break', index: i, kind, auto: autos[i] }); + text += kind === 'keep' ? '\n' : kind === 'space' ? ' ' : ''; + } + }); + + return { pieces, text, lines: text ? text.split('\n').length : 0, breaks: autos }; +} + +// --------------------------------------------------------------------------- +// Scopes + +export type ScopeId = 'selection' | 'words' | 'paragraph'; +export interface Scope { id: ScopeId; label: string; span: Span } + +function contentStart(buf: CopyBuffer, r: number): number { + return indentOf(unchromed(rowText(buf, r))); +} + +/** Grow each edge over the whole token under it, following a token the + * program split across rows. */ +function snapToWords(buf: CopyBuffer, sel: Span): Span { + let { row: sr, col: sc } = sel.start; + for (;;) { + const cells = buf.row(sr).cells; + while (sc > 0 && !isBlankCell(cells[sc - 1])) sc--; + if (sc <= contentStart(buf, sr) && sr > 0 && !isBlankCell(cells[sc]) && autoBreak(buf, sr - 1) === 'none') { + sr--; + sc = buf.row(sr).cells.length; + continue; + } + break; + } + let { row: er, col: ec } = sel.end; + for (;;) { + const cells = buf.row(er).cells; + while (ec + 1 < cells.length && !isBlankCell(cells[ec + 1])) ec++; + if (ec >= cells.length - 1 && !isBlankCell(cells[ec]) && autoBreak(buf, er) === 'none') { + er++; + ec = contentStart(buf, er) - 1; + continue; + } + break; + } + return { start: { row: sr, col: Math.max(0, sc) }, end: { row: er, col: Math.max(0, ec) } }; +} + +function toParagraph(buf: CopyBuffer, sel: Span): Span { + let sr = sel.start.row; + while (sr > 0 && !isBoundary(buf, sr - 1)) sr--; + let er = sel.end.row; + while (er + 1 < buf.length && !isBoundary(buf, er + 1)) er++; + return { + start: { row: sr, col: contentStart(buf, sr) }, + end: { row: er, col: Math.max(0, buf.row(er).cells.length - 1) }, + }; +} + +/** Names a growth by the edge tokens that grew, not the whole text. */ +function wordsLabel(text: string, grewStart: boolean, grewEnd: boolean): string { + const tokens = text.split(/\s+/).filter(Boolean); + const edges = [grewStart ? tokens[0] : undefined, grewEnd ? tokens[tokens.length - 1] : undefined] + .filter((t): t is string => !!t); + if (edges.some((t) => /^https?:\/\//.test(t))) return 'Full URL'; + if (edges.some((t) => /^(?:~|\.{1,2})?\/\S|\S+\/\S+\.\w+/.test(t))) return 'Full path'; + return 'Whole words'; +} + +/** Every distinct scope a selection can expand to, narrowest first. A wider + * scope always contains the dragged selection. A block slab only has itself. */ +export function computeScopes(buf: CopyBuffer, sel: Span, block = false): Scope[] { + const out: Scope[] = [{ id: 'selection', label: 'As selected', span: sel }]; + if (block) return out; + const push = (id: ScopeId, label: string, span: Span) => { + const union: Span = { + start: comparePos(span.start, sel.start) < 0 ? span.start : sel.start, + end: comparePos(span.end, sel.end) > 0 ? span.end : sel.end, + }; + if (out.some((s) => spanEquals(s.span, union))) return; + out.push({ id, label, span: union }); + }; + const words = snapToWords(buf, sel); + const grewStart = comparePos(words.start, sel.start) < 0; + const grewEnd = comparePos(words.end, sel.end) > 0; + push('words', wordsLabel(render(buf, words, { original: words, format: 'auto' }).text, grewStart, grewEnd), words); + push('paragraph', 'Paragraph', toParagraph(buf, sel)); + return out; +} + +// --------------------------------------------------------------------------- +// Nudging an edge a word at a time + +function step(buf: CopyBuffer, p: GridPos, dir: 1 | -1): GridPos | null { + let { row, col } = p; + col += dir; + for (;;) { + const width = buf.row(row).cells.length; + if (col >= 0 && col < width) return { row, col }; + row += dir; + if (row < 0 || row >= buf.length) return null; + col = dir > 0 ? 0 : buf.row(row).cells.length - 1; + } +} + +const cellAt = (buf: CopyBuffer, p: GridPos) => buf.row(p.row).cells[p.col]; + +/** + * Move a selection edge one word in `dir`. `edge` says which side of a word it + * rests on: a start lands on a word's first cell, an end on its last. A row + * boundary ends a word. + */ +export function nudge(buf: CopyBuffer, p: GridPos, dir: 1 | -1, edge: 'start' | 'end'): GridPos { + const leavingWord = (dir > 0) === (edge === 'start'); + let q = step(buf, p, dir); + while (q && leavingWord && q.row === p.row && !isBlankCell(cellAt(buf, q))) q = step(buf, q, dir); + while (q && isBlankCell(cellAt(buf, q))) q = step(buf, q, dir); + if (!q) return p; + // Walk to the requested side of the word `q` landed in. + for (;;) { + const n = step(buf, q, edge === 'end' ? 1 : -1); + if (!n || n.row !== q.row || isBlankCell(cellAt(buf, n))) return q; + q = n; + } +} From d87c8e469574d1a83f5db0910b7ed91719f28911 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 17:59:48 -0700 Subject: [PATCH 05/36] Replace the Copy Raw / Rewrapped popup with the copy editor Mouse-up over a terminal-handled drag opens the copy editor at full pane width: the clipboard text with a gutter line per clipboard line and a clickable mark on every line break. Formats are Auto (the old Rewrapped, now judging each break on its own: soft wraps and margin-split tokens rejoin, code and list lines keep their breaks), Exact (the old Copy Raw), Spaces, and No breaks, cycled with f / Shift+F. Scopes expand with e / Shift+E to whole words (Full URL / Full path across a split) and the paragraph, drawn dashed by the selection overlay. Arrows nudge the end and Shift+arrows the start by a word. Enter or the copy chord copies; any other key closes the editor and reaches the terminal. The tutorial's Raw / Rewrapped items now check Exact and Auto copies; the homepage and VS Code README describe the editor. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- docs/specs/mouse-and-clipboard.md | 94 ++++-- docs/specs/mouse-and-clipboard.rationale.md | 8 + docs/specs/shortcuts.md | 12 +- docs/specs/tutorial.md | 2 +- docs/specs/tutorial.rationale.md | 4 +- lib/src/components/CopyEditor.test.tsx | 239 ++++++++++++++++ lib/src/components/CopyEditor.tsx | 269 ++++++++++++++++++ lib/src/components/SelectionOverlay.tsx | 28 +- lib/src/components/SelectionPopup.test.tsx | 200 ------------- lib/src/components/SelectionPopup.tsx | 217 -------------- lib/src/components/TerminalPane.test.tsx | 2 +- lib/src/components/TerminalPane.tsx | 4 +- .../handle-mouse-selection-keys.test.ts | 100 +++++-- .../keyboard/handle-mouse-selection-keys.ts | 57 +++- lib/src/lib/clipboard.ts | 22 +- lib/src/lib/copy-editor.test.ts | 139 +++++++++ lib/src/lib/copy-editor.ts | 137 +++++++++ lib/src/lib/copy-selection.ts | 28 +- lib/src/lib/mouse-selection.test.ts | 5 +- lib/src/lib/mouse-selection.ts | 42 ++- lib/src/lib/rewrap.test.ts | 101 ------- lib/src/lib/rewrap.ts | 69 ----- lib/src/lib/terminal-lifecycle.ts | 9 + lib/src/lib/terminal-mouse-router.test.ts | 2 + lib/src/lib/terminal-mouse-router.ts | 2 + lib/src/lib/terminal-registry.ts | 1 + lib/src/stories/CopyEditor.stories.tsx | 190 +++++++++++++ lib/src/stories/SelectionPopup.stories.tsx | 129 --------- lib/src/stories/TextSelection.stories.tsx | 18 +- scripts/spec-word-budgets.json | 2 +- vscode-ext/README.md | 4 +- .../lib/__snapshots__/tut-runner.test.ts.snap | 4 +- website/src/lib/tut-detector.ts | 4 +- website/src/lib/tut-items.ts | 6 +- website/src/pages/Home.tsx | 10 +- 36 files changed, 1310 insertions(+), 852 deletions(-) create mode 100644 lib/src/components/CopyEditor.test.tsx create mode 100644 lib/src/components/CopyEditor.tsx delete mode 100644 lib/src/components/SelectionPopup.test.tsx delete mode 100644 lib/src/components/SelectionPopup.tsx create mode 100644 lib/src/lib/copy-editor.test.ts create mode 100644 lib/src/lib/copy-editor.ts delete mode 100644 lib/src/lib/rewrap.test.ts delete mode 100644 lib/src/lib/rewrap.ts create mode 100644 lib/src/stories/CopyEditor.stories.tsx delete mode 100644 lib/src/stories/SelectionPopup.stories.tsx diff --git a/AGENTS.md b/AGENTS.md index 2f54a42b1..1c5ebb952 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ A spec is the accurate reference for the current code: it states the invariants - **`docs/specs/terminal-escapes.md`** — Registry of every escape sequence parsed, answered, or ignored, each row pointing at its owning spec. Read before touching OSC/CSI parsing. - **`docs/compatible-agents.md`** — Public agent guide plus the hidden recovery contract: shutdown capture, detection, single-use records, and cold-restore execution. - **`docs/specs/transport.md`** — Adapter-agnostic webview ↔ host protocol: PTY lifecycle and buffering, reconnection, message contracts, persisted-session types, the invariants every adapter honors. -- **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned selection, copy (Raw / Rewrapped), paste tiers, smart URL/path extension, the mouse-ownership state matrix. +- **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned selection, the copy editor (Auto / Exact / Spaces / No breaks, expand, per-break marks), paste tiers, smart URL/path extension, the mouse-ownership state matrix. - **`docs/specs/theme.md`** — The two-layer CSS variable strategy, consumed-token resolver, terminal color contract, theme debugger. - **`docs/specs/dor-cli.md`** — The `dor` CLI on every Dormouse terminal's `PATH`: bundling and env contract, `spawnAndCapture` rules, control-socket plumbing, the Surface handle model, the command set. - **`docs/specs/dor-browser.md`** — The browser surface: `BrowserPanel` with swappable `renderMode`, browser chrome, the agent-browser stack, the iframe proxy and CSP boundaries. diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index be1dc9d4c..506b99271 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -87,7 +87,7 @@ A small hint sits adjacent to an in-progress selection — below when dragging d A selection is anchored to the characters under it, not to screen coordinates: stored in absolute buffer rows (scrollback + viewport). - **Pure scroll** — vertical translation with no character changes — carries the selection along; coordinate math only, no matching. -- **Content change:** any change to a cell the finalized selection overlaps cancels it immediately; repaints elsewhere on screen are irrelevant. A text snapshot taken at finalize is compared on each xterm render; **never add a partial-match or content-tracking heuristic** — cancel-on-change is the rule (§9.1). +- **Content change:** any change to a cell the finalized selection overlaps cancels it immediately; repaints elsewhere on screen are irrelevant. A text snapshot taken at finalize, and again after an editor nudge (§4.3), is compared on each xterm render; **never add a partial-match or content-tracking heuristic** — cancel-on-change is the rule (§9.1). - **Terminal resize** counts as a content change and cancels any active selection. ### 3.5 Selection in the Live Region vs. Scrollback @@ -103,43 +103,82 @@ Source of truth: `lib/src/components/wall/keyboard/handle-mouse-selection-keys.t ### 3.7 Ending a Selection -- Releasing the button ends the drag and fixes the selection; the popup (§4) appears. +- Releasing the button ends the drag and fixes the selection; the copy editor (§4) opens. - It persists until something ends it: a completed copy, a content change (§3.4), **Esc**, or a click outside (§4.3). -- **A new mouse-down in the terminal content area replaces any existing selection immediately** and dismisses its popup. +- **A new mouse-down in the terminal content area replaces any existing selection immediately** and dismisses its editor. --- -## 4. Selection Popup +## 4. Copy Editor -A finalized selection gets a popup of action buttons anchored where §3.3's drag hint sat. +Mouse-up over a terminal-handled drag opens the **copy editor**: the text a copy would produce, at full pane width, every line break the selection crossed marked (rationale). -### 4.1 Copy Buttons +### 4.1 Formats -Source of truth: `lib/src/components/SelectionPopup.tsx` (Copy Raw, Copy Rewrapped, platform-dependent shortcut labels). +| Format | Clipboard text | +|---|---| +| **Auto** (opens here) | Decoration stripped, each break judged on its own (§4.1.1). | +| **Exact** | As displayed: selected rows joined by `\n`, each trimmed of trailing whitespace — soft-wrapped rows included. | +| **Spaces** | Decoration stripped, blank lines dropped, every break one space, continuation indents removed. | +| **No breaks** | As Spaces, every break deleted. | + +**Decoration** is a frame-only line (dropped), a leading or trailing run of box drawing (`U+2500–U+259F`, Box Drawing and Block Elements), and a TUI's leading bullet (`⏺`, `⎿`, `●`). **Must read cells through xterm's wide-character continuation cells.** **Never rewrap a block-shape selection**: it is a rectangular slab, so Auto reads it as Exact, and it has no wider scope (§4.2) and no edge keys (§4.3). + +Source of truth: `render` in `lib/src/lib/copy-text.ts`, pinned by `lib/src/lib/copy-text.test.ts`. + +#### 4.1.1 Auto + +Each break between two consecutive rows is, in this order: + +1. **Kept** if either row is blank, the next starts a list item, the row ends in `;` `{` `}` or the next starts with `)` `}` `]`, or the next row's indent is not this row's hanging indent. +2. **Deleted** if the next row is a true soft wrap (xterm's `isWrapped`). +3. **Kept** if the next row's first word would have fit on this row within the paragraph's wrap width: its longest row, never under half the terminal (rationale). +4. **Deleted** if this row fills that width and its last word is token-shaped (a URL or path character, or 16+ token characters) — a token split at the margin. +5. Otherwise **one space**. -#### 4.1.1 Copy Raw +Leading and trailing blank lines are trimmed, a run of blank lines collapses to one, and the indent every line shares is removed, keeping relative indent. -**Must preserve displayed row breaks and decorative characters**, trimming trailing whitespace on each selected row; soft-wrapped rows also get `\n`. Source of truth: `extractSelectionText` in `lib/src/lib/selection-text.ts`. +Source of truth: `autoBreak` in `lib/src/lib/copy-text.ts`. -#### 4.1.2 Copy Rewrapped +### 4.2 Scopes -Copies with two transformations applied (`lib/src/lib/rewrap.ts`): +Narrowest first, each containing the dragged selection, one that adds nothing dropped: + +| Scope | Covers | +|---|---| +| **As selected** | The drag (opens here). | +| **Whole words** | Each edge grown over the token under it, following a token Auto would rejoin across rows; named **Full URL** or **Full path** when a grown edge token is one. | +| **Paragraph** | The rows between blank rows, frame-only rows, and box sides (`│ ┃ ║`). | + +The selection overlay draws a wider scope dashed around the outline; the preview marks every cell outside the drag. Source of truth: `computeScopes` in `lib/src/lib/copy-text.ts`. + +### 4.3 Keys + +| Key | Effect | +|---|---| +| `e` / `Shift+E` | Next wider / narrower scope, stopping at either end. | +| `f` / `Shift+F` | Next / previous format, wrapping, in table order (§4.1). | +| `←` `→` / `Shift+←` `→` | Move the end / start one word; a row boundary ends a word. Returns to As selected, keeping the format. | +| `Enter`, `Cmd+C` (Ctrl+C on non-macOS), either with Shift | Copy what the editor shows. | +| `Esc` | Close and cancel the selection. | -1. **Drop frame-only lines**, and **strip leading/trailing runs of box-drawing characters** (`U+2500–U+259F`, both Box Drawing and Block Elements) from each remaining line. -2. **Group remaining lines into paragraphs** at blank lines: lines within a paragraph join with a single space (unwrapping display wrapping), paragraphs join with `\n\n`. +**Any other key closes the editor and reaches the terminal**, so typing after a selection still types; a bare modifier and the paste chord leave it open. **Intercept Ctrl+C as a copy only while the editor is open**; with none it reaches the inside program (SIGINT for shells, app-defined for TUIs). An in-program selection a TUI maintains itself (vim visual mode, less search highlight) does not open the editor and does not change that routing. The editor's hints write Shift and the arrows as the mobile compass rose does (`⬆︎` `◀` `▶`), and touch shows none. -**Never rewrap a block-shape selection** — it is an intentionally rectangular slab, so Copy Rewrapped falls back to its raw text. +Source of truth: `handleCopyEditorKey` in `lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts`, pinned by its test; the transitions in `lib/src/lib/copy-editor.ts`, pinned by `lib/src/lib/copy-editor.test.ts`. -### 4.2 Keyboard Shortcuts +### 4.4 Preview and Marks -With an active, finalized terminal selection, popup focused or not: **Cmd+C** (Ctrl+C on non-macOS) triggers Copy Raw, **Cmd+Shift+C** (Ctrl+Shift+C) triggers Copy Rewrapped. +- One gutter-numbered row per clipboard line; leading whitespace shows as `·`. +- Every break is a mark — `⏎` kept, `␣` one space, `⌁` deleted. **A click cycles it keep → space → none**, and the format then reads `Auto*`. A scope or format change, or a nudge, discards those edits. +- A format whose text an earlier one already gives is dimmed. -**Intercept Ctrl+C as Copy Raw only while a terminal selection is active.** With none it is forwarded to the inside program as usual (SIGINT for shells, app-defined for TUIs). An in-program selection a TUI maintains itself (vim visual mode, less search highlight) is **not** a terminal selection and does not change that routing. +### 4.5 Placement and Dismissal -### 4.3 Dismissing the Popup +- Full pane width, on the side of the selection with more room; touch prefers above, clear of the thumb that ended the drag. **When neither side has 120px it docks at the bottom over the selection.** Remeasured on every render tick (§7). +- **Esc**, a click outside the editor, or a content change (§3.4) dismisses it and cancels the selection; a new mouse-down replaces both (§3.7). +- **Must flash only after a successful clipboard write, and only for the selection copied**: the Copy button shows a checkmark for ~700 ms, then the selection clears. Failed writes retain it for retry; canceling clears the flash immediately. -- **Esc**, or a click outside the selection, dismisses the popup and cancels the selection. -- **Must flash only after a successful clipboard write, and only for the selection copied**: swap the active button's shortcut text for a checkmark for ~700 ms, then clear the selection. Failed writes retain it for retry; canceling clears the flash immediately. Touch shows the checkmark without a shortcut label. Pinned by `lib/src/components/SelectionPopup.test.tsx`. +Source of truth: `CopyEditor` in `lib/src/components/CopyEditor.tsx`, pinned by `lib/src/components/CopyEditor.test.tsx`; `copySelection` in `lib/src/lib/copy-selection.ts`, pinned by `lib/src/lib/copy-editor.test.ts`. --- @@ -162,8 +201,8 @@ A second line on the block-selection hint names the detected kind — URL or pat ### 5.3 Extension Action - **e** during a drag, while the hint is visible, extends the selection over the full detected token: the anchor is preserved, the far end moves to the token boundary away from it. The drag then continues normally — movement updates the selection from the new boundary, Alt still toggles block shape. -- **e** with no qualifying token is consumed (per §3.6) but extends nothing, and has no effect once the drag has ended (once the popup has appeared, §4); on release the selection is finalized at whatever boundaries the drag, `e`-extensions included, produced. -- **Only this single extension step is offered** — no multi-level extension, no "open URL" action (§9.1). +- **e** with no qualifying token is consumed (per §3.6) but extends nothing; once the drag has ended, `e` is the editor's expand instead (§4.3). On release the selection is finalized at whatever boundaries the drag, `e`-extensions included, produced. +- **Only this single extension step is offered mid-drag**, and no "open URL" action (§9.1); the editor's scopes are the wider steps (§4.2). --- @@ -199,9 +238,9 @@ Source of truth: `terminalOwnsEvent` in `lib/src/lib/terminal-mouse-router.ts`, **Must keep selection and hint updates from rerendering pane headers or override banners** (rationale). -- **Must render outlines, hints, and popups above the cell grid**, isolated from inside-program output and redraws; header icons and banners remain persistent chrome. +- **Must render outlines, hints, and the copy editor above the cell grid**, isolated from inside-program output and redraws; header icons and banners remain persistent chrome. - **Geometry comes from the *measured* xterm cell grid** (`cellWidth`/`cellHeight`/`gridLeft`/`gridTop`), never element-width ÷ cols, so the outline stays aligned across xterm's internal padding. -- **Must remeasure both overlay and popup on every shared render tick** (scroll, resize, output), even when the selection is unchanged; the popup dismisses if the selection is canceled. Pinned for the popup by `lib/src/components/SelectionPopup.test.tsx`. +- **Must remeasure both overlay and editor on every shared render tick** (scroll, resize, output), even when the selection is unchanged; the editor dismisses if the selection is canceled. Pinned for the editor by `lib/src/components/CopyEditor.test.tsx`. Source of truth: `lib/src/lib/selection-text.ts` (extraction and normalization), `lib/src/lib/selection-geometry.ts` (perimeter construction), `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx` and `MouseOverrideBanner` in `lib/src/components/wall/MouseOverrideBanner.tsx` — tested in `lib/src/components/wall/mouse-chrome.test.tsx`. @@ -308,13 +347,12 @@ Not implemented today; they may be added in response to user feedback. - Auto-scroll during a drag that reaches the viewport edge. - Double-click to select word, triple-click to select line. -- Copy modes beyond Raw and Rewrapped (strip ANSI, strip line numbers, strip prompts, join hyphenated line-breaks). -- Contextual popup actions (Open URL, Open in `$EDITOR`, Copy hash). -- Multi-level `e` extension (token → line → paragraph). +- More formats (strip line numbers, strip prompts, join hyphenated line-breaks, Markdown rebuilt from styling) and scopes (a command's output from OSC 133 marks, a TUI message). +- Contextual editor actions (Open URL, Open in `$EDITOR`, Copy hash). - A "quiet mode" setting to suppress hints for experienced users. - Content-matching selection tracking when the underlying content changes (today: cancel-on-change). - Keyboard activation of the mouse icon and banner buttons. -- Refining the Copy Rewrapped heuristics based on dogfooding. +- Refining Auto's heuristics based on dogfooding. ### 9.2 Paste diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index 51279d5bd..a2db40edd 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -8,6 +8,14 @@ **How a mouse-up outside the iframe still reaches us.** Capture is taken on mouse-down, and Chromium delivers the captured `pointerup` across the frame boundary even when the button comes up over host chrome. Engines that do not honor cross-frame capture deliver nothing, so a window `mousemove` reporting `buttons === 0` stands in for the missed mouse-up: a pointer still holding the button reports `buttons === 1`, so the heal cannot fire mid-drag, but it does need the pointer to re-enter the frame. Against double-finalizing, the captured-pointerup path defers to a macrotask and stands down if the compatibility mouseup for an *inside* release arrives first. +## 4. Copy Editor + +The editor replaced a two-button Copy Raw / Copy Rewrapped popup. Three shapes were prototyped side by side (the `Prototypes/Copy editor` stories, 2026-09): a numbered chooser, copy-then-show-a-receipt, and this editor. The editor won because restating the selection at full width with every break visible is what lets a user see why a paste came out wrong. Its keys are letters rather than digits: a receipt that took digits after a copy collided with TUIs that answer prompts by number (Claude Code's `1` / `2` / `3` permission menu), and `e` already meant "extend" mid-drag (§5). + +## 4.1.1 Auto + +The fit test reads a greedy wrapper correctly by construction: a wrapper at width W only breaks where the next word would push past W, and the paragraph's longest row is at most W, so every wrapped break also fails the test against that row. A break the test calls intentional therefore never comes from a greedy wrap. The half-terminal floor exists for two-row paragraphs, whose longest row is the first row itself: without it `Hello` / `World` always read as one wrapped line. + ## 5.1 Detection **Why trailing punctuation is stripped before the patterns run.** Terminal output puts tokens inside sentences: `Error at src/foo.ts:42.` ends in a period no path pattern matches, so matching first leaves nothing to trim afterward. Stripped first it becomes `src/foo.ts:42`, which the error-location pattern recognizes — the `:line[:col]` digits are not trailing punctuation and survive. Matched bracket pairs are exempt for the mirror case: `https://en.wikipedia.org/wiki/Foo_(bar)` really does end in `)`, and trimming truncates the URL. diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 2944e0db0..fbe18d431 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -56,10 +56,12 @@ Both modes, ahead of the passthrough gate, and only on a terminal **selected** S |-----|--------|-------------| | `e` | Extend to token | Mid-drag only: extend the selection to the full URL/path token at the cursor; consumed but inert with no token. | | `Alt` (hold) | Block / linewise | Block (rectangular) rather than linewise, live through the drag; touch latches it with a double-tap-then-drag. | -| `Esc` | Cancel selection | Cancel the in-progress drag, or clear a finalized selection while its popup is up. | -| *(any other key)* | — | Swallowed during a terminal-handled drag, never reaching the inside program (`docs/specs/mouse-and-clipboard.md` §3.6). | -| `⌘C` (macOS) / `Ctrl+C` (others) | Copy raw | Copy the selection as-is; requires a finalized selection. | -| `⌘⇧C` (macOS) / `Ctrl+Shift+C` (others) | Copy rewrapped | Copy the selection rewrapped for single-line display. | +| `Esc` | Cancel selection | Cancel the in-progress drag, or close the copy editor and clear its selection. | +| *(any other key)* | — | Swallowed during a terminal-handled drag, never reaching the inside program (`docs/specs/mouse-and-clipboard.md` §3.6); with the copy editor open, closes it and reaches the terminal. | +| `e` / `⇧E` | Expand / shrink | Copy editor: the next wider / narrower scope. | +| `f` / `⇧F` | Next / previous format | Copy editor: Auto, Exact, Spaces, No breaks, wrapping. | +| `←` `→` / `⇧←` `⇧→` | Move end / start | Copy editor: the selection's end / start, one word. | +| `⌘C` (macOS) / `Ctrl+C` (others), `↵` | Copy | Copy editor: copy what it shows; `⇧` optional on the chord. | | `⌘V` / `⌘⇧V` / `Ctrl+V` / `Ctrl+Shift+V` | Paste | Paste into the terminal; the `Ctrl` variants are intercepted on every platform, macOS included. | On macOS `Ctrl+C` still reaches the running program; a literal `0x16` needs the shell's `quoted-insert` (`Ctrl+Q`) (`docs/specs/mouse-and-clipboard.md` §8.3). @@ -103,5 +105,5 @@ The standalone host contributes no chords; `docs/specs/standalone.md` owns its n - `lib/src/components/wall/chrome-keyboard-lease.ts`, `lib/src/lib/workspace-ui-store.ts` — the strip's keyboard suppression and rename/confirmation state - `lib/src/lib/vscode-keybindings.ts` — the workbench mirror allowlist - `lib/src/lib/terminal-mouse-router.ts` — live Alt tracking during a drag -- `lib/src/components/SelectionPopup.tsx`, `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/wall/InlineEditInput.tsx` — the popover/dialog handlers +- `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/wall/InlineEditInput.tsx` — the popover/dialog handlers - `lib/src/components/wall/agent-browser-surface-controller.ts` — browser key forwarding and the edit-chord bridge diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 25e885783..c1f086e5a 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -33,7 +33,7 @@ Browser-side xterm alt-screen behind `FakePtyAdapter`, **never Node `terminal-ki - Desktop `SiteHeader` at top, `themeAware` so `--vscode-*` variables drive its chrome, **carrying no controls**: **the page must restore its own theme** with `useRestoredTheme(WEBSITE_DEFAULT_THEME_ID)` (`website/src/lib/website-theme.ts`), which also declares the host fallback the Settings picker re-resolves through (rationale). `th-theme` walks the user to the Wall's Settings dialog (`docs/specs/theme.md` → "Where the user picks a theme"); Pocket renders the `compact` picker in the keyboard reserve or in the desktop marketing header. - `
` is a flex container so Wall's `flex-1 min-h-0` root gets a real height. -- `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`). **Must seed its three-pane L-shape as an explicit Lath snapshot** — `restoredLathLayout` from `DESKTOP_PLAYGROUND_LAYOUT` — never the synchronous `initialPaneIds` path (rationale); `website/src/lib/playground-desktop-layout.test.ts` pins it. `DESKTOP_PANES` in the same file owns each seed's id, command, and title; **`tut-boxed` is the Copy Rewrapped + `cp-override` target** (rationale). **Titles are seeded as pending shell opts** (`setPendingShellOpts(id, { title })`) before the Wall mounts; the lib pins each at first spawn, after the pane's state reset, and a user-pin outranks the engine fallback (`docs/specs/terminal-state.md` → "Header Derivation"). +- `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`). **Must seed its three-pane L-shape as an explicit Lath snapshot** — `restoredLathLayout` from `DESKTOP_PLAYGROUND_LAYOUT` — never the synchronous `initialPaneIds` path (rationale); `website/src/lib/playground-desktop-layout.test.ts` pins it. `DESKTOP_PANES` in the same file owns each seed's id, command, and title; **`tut-boxed` is the Auto-copy + `cp-override` target** (rationale). **Titles are seeded as pending shell opts** (`setPendingShellOpts(id, { title })`) before the Wall mounts; the lib pins each at first spawn, after the pane's state reset, and a user-pin outranks the engine fallback (`docs/specs/terminal-state.md` → "Header Derivation"). Every visible pane gets a `TutorialShell` via `PlaygroundShellRegistry`. **`ensureShell` must stay idempotent** — `paneAdded` covers every pane that becomes visible, and `FakePtyAdapter.onPtySpawn` covers the seed panes again, auto-launching each seed's command exactly once (rationale). The page’s `startProgram` factory dispatches: `tut` → `TutRunner`, `ascii-splash`/`splash` → `AsciiSplashRunner`, `changelog` → `ChangelogRunner`. **Spawned terminals use `SCENARIO_SHELL_PROMPT`; seed panes get an empty scenario**, so no delayed `user@dormouse:~$` write lands inside a runner's alt-screen. diff --git a/docs/specs/tutorial.rationale.md b/docs/specs/tutorial.rationale.md index e4334cc61..3a09b59fb 100644 --- a/docs/specs/tutorial.rationale.md +++ b/docs/specs/tutorial.rationale.md @@ -16,7 +16,7 @@ **Why the desktop layout is an explicit Lath seed.** The synchronous `initialPaneIds` path creates its leaves before the later ones have measured geometry, so it cannot reliably choose alternating split axes — the L-shape comes out however the measurements land. A valid Lath snapshot fixes the shape, and with it the one vertical and one horizontal divider. -**Why `tut-boxed` is the copy target.** Its wrapped detail lines exercise the Copy Rewrapped path, and its TUI captures the mouse, the state `cp-override` exists to demonstrate. Pocket's `pocket-changelog` session is there for the same two reasons. +**Why `tut-boxed` is the copy target.** Its wrapped detail lines exercise the copy editor's Auto format, and its TUI captures the mouse, the state `cp-override` exists to demonstrate. Pocket's `pocket-changelog` session is there for the same two reasons. **Why `ensureShell` runs from two directions.** `paneAdded` covers splits, restores, dor surfaces and the seed ids alike, but cannot auto-launch the seed commands: that has to happen at spawn, exactly once. `FakePtyAdapter.onPtySpawn` is the spawn-time hook that does, and it necessarily overlaps the seed ids `paneAdded` already announced — hence idempotence rather than a split of responsibilities. @@ -44,4 +44,4 @@ **What supplies the mouse-capturing text.** Both neighbor panes, `ascii-splash` and `changelog`; why `changelog` is also the copy target: [Layout](#layout). -**Coverage audit, against `mouse-and-clipboard.md`'s section numbers as of 2026-09.** Exercisable: §§1–2 (mouse reporting + override), §§3.1–3.3 (drag, block shape, block hint), §§3.6–3.7 (drag keys + popup), §§4.1–4.3 (raw/rewrapped copy, shortcuts, dismissal). Partial: §3.4 exposes change/resize cancellation but not pure scroll; §3.5 lacks enough scrollback; §8.2 writes paste chords to the fake PTY, whose shell ignores bracket markers. Missing: §§3.3 and 5 lack smart tokens and therefore `e` extension; §8.5 lacks a scenario that enables bracketed paste. Auto-scroll during a drag and right-click paste are deferred in the implementation ([§9. Future](mouse-and-clipboard.md#9-future)), not Playground gaps. +**Coverage audit, against `mouse-and-clipboard.md`'s section numbers as of 2026-09.** Exercisable: §§1–2 (mouse reporting + override), §§3.1–3.3 (drag, block shape, block hint), §§3.6–3.7 (drag keys + popup), §§4.1–4.3 (Raw / Rewrapped copy, shortcuts, dismissal; since replaced by the copy editor). Partial: §3.4 exposes change/resize cancellation but not pure scroll; §3.5 lacks enough scrollback; §8.2 writes paste chords to the fake PTY, whose shell ignores bracket markers. Missing: §§3.3 and 5 lack smart tokens and therefore `e` extension; §8.5 lacks a scenario that enables bracketed paste. Auto-scroll during a drag and right-click paste are deferred in the implementation ([§9. Future](mouse-and-clipboard.md#9-future)), not Playground gaps. diff --git a/lib/src/components/CopyEditor.test.tsx b/lib/src/components/CopyEditor.test.tsx new file mode 100644 index 000000000..194293f34 --- /dev/null +++ b/lib/src/components/CopyEditor.test.tsx @@ -0,0 +1,239 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; + +vi.mock('../lib/copy-selection', () => ({ copySelection: vi.fn() })); +vi.mock('../lib/platform', () => ({ IS_MAC: true })); +// The registry barrel boots xterm; the editor only reads the grid and buffer. +vi.mock('../lib/terminal-registry', () => ({ getTerminalOverlayDims: vi.fn(), getTerminalInstance: vi.fn() })); +import { copySelection } from '../lib/copy-selection'; +import { openCopyEditor } from '../lib/copy-editor'; +import { + __resetMouseSelectionForTests, + beginDrag, + bumpRenderTick, + endDrag, + flashCopy, + getMouseSelectionState, + setSelection, +} from '../lib/mouse-selection'; +import { getTerminalInstance, getTerminalOverlayDims } from '../lib/terminal-registry'; +import { CopyEditor } from './CopyEditor'; +import { TouchUiContext } from './touch-ui-context'; +import { WorkspaceActiveContext } from './wall/wall-context'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const LINES = [ + 'The flake comes from a race between the PTY exit event and the final flush', + 'of the output buffer. When the child exits before xterm has drained its', + 'write queue, the last chunk is dropped and the assertion on the prompt', + 'text fails intermittently.', + '', + 'https://github.com/diffplug/dormouse/pull/853', +]; + +/** An xterm buffer over plain strings, one narrow cell per character. */ +function fakeTerminal(lines: string[]): Terminal { + const getLine = (r: number) => { + const text = lines[r]; + if (text === undefined) return undefined; + return { + length: 80, + isWrapped: false, + getCell: (c: number) => ({ getChars: () => text[c] ?? '', getWidth: () => 1 }), + translateToString: (_trim?: boolean, start = 0, end = 80) => text.slice(start, end), + }; + }; + return { cols: 80, rows: 24, buffer: { active: { length: lines.length, getLine } } } as unknown as Terminal; +} + +// A forty-row viewport over ten scrollback lines, so a scroll moves the editor +// by whole cells. +const DIMS = { + cols: 80, + rows: 40, + viewportY: 0, + baseY: 10, + elementWidth: 800, + elementHeight: 400, + cellWidth: 10, + cellHeight: 10, + gridLeft: 0, + gridTop: 0, +}; + +let container: HTMLDivElement; +let root: Root; +let dims: typeof DIMS; +const terminal = fakeTerminal(LINES); + +const editor = () => container.querySelector('[data-copy-editor-for="term-1"]'); +const button = (label: string) => Array.from(container.querySelectorAll('button')).find((b) => b.textContent === label)!; + +function render(ui = ): void { + act(() => root.render(ui)); +} + +/** A finalized drag over rows `r0`..`r1`, opened as mouse-up opens it. */ +function drag(r0: number, c0: number, r1: number, c1: number): void { + act(() => { + beginDrag('term-1', { row: r0, col: c0, altKey: false, startedInScrollback: false }); + setSelection('term-1', { ...getMouseSelectionState('term-1').selection!, endRow: r1, endCol: c1 }); + endDrag('term-1'); + openCopyEditor('term-1', terminal); + }); +} + +beforeEach(() => { + __resetMouseSelectionForTests(); + dims = { ...DIMS }; + vi.mocked(getTerminalOverlayDims).mockImplementation(() => dims); + vi.mocked(getTerminalInstance).mockReturnValue(terminal); + vi.mocked(copySelection).mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +describe('CopyEditor: opening and placement', () => { + it('stays closed over a selection nobody opened it for', () => { + act(() => setSelection('term-1', { startRow: 0, startCol: 0, endRow: 1, endCol: 5, shape: 'linewise', dragging: false, startedInScrollback: false })); + render(); + expect(editor()).toBeNull(); + }); + + it('opens below a selection with room, and reanchors when it scrolls', () => { + drag(0, 23, 3, 26); + render(); + expect(editor()?.style.top).toBe('44px'); + dims.viewportY = 2; + act(() => bumpRenderTick()); + expect(editor()?.style.top).toBe('24px'); + }); + + it('opens above when there is more room there', () => { + drag(30, 0, 31, 5); + render(); + expect(editor()?.style.top).toBe(''); + expect(editor()?.style.bottom).toBe('104px'); + }); + + it('docks over a selection that leaves no room beside it', () => { + drag(2, 0, 37, 5); + render(); + expect(editor()?.style.bottom).toBe('4px'); + }); + + it('prefers above on touch, clear of the thumb', () => { + drag(20, 0, 20, 40); + render(); + expect(editor()?.style.bottom).not.toBe(''); + expect(container.textContent).not.toContain('[f]'); + }); +}); + +describe('CopyEditor: preview and controls', () => { + it('previews Auto with a mark on every break', () => { + drag(0, 0, 3, 26); + render(); + expect(container.textContent).toContain('final flush␣of the output'); + expect(container.textContent).toContain('1 line'); + }); + + it('switches format from the segment and dims a format that adds nothing', () => { + drag(0, 0, 3, 26); + render(); + expect(button('Spaces').className).toContain('opacity-50'); + act(() => button('Exact').click()); + expect(getMouseSelectionState('term-1').copyEditor?.format).toBe('exact'); + expect(container.textContent).toContain('4 lines'); + }); + + it('flips one break from its mark and marks the format edited', () => { + drag(0, 0, 3, 26); + render(); + act(() => button('␣').click()); + expect(getMouseSelectionState('term-1').copyEditor?.overrides).toEqual({ 0: 'none' }); + expect(button('Auto*')).toBeDefined(); + }); + + it('expands from the scope segment and shows what it added', () => { + drag(0, 25, 1, 5); + render(); + act(() => button('Whole words').click()); + expect(getMouseSelectionState('term-1').copyEditor?.scope).toBe(1); + expect(container.textContent).toContain('expanded'); + }); + + it('copies from the button', () => { + drag(0, 0, 3, 26); + render(); + act(() => button('[Cmd+C]Copy').click()); + expect(copySelection).toHaveBeenCalledWith('term-1'); + }); + + it('dismisses on a click outside, not inside', () => { + drag(0, 0, 3, 26); + render(); + act(() => { editor()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + expect(getMouseSelectionState('term-1').selection).not.toBeNull(); + act(() => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + expect(getMouseSelectionState('term-1').selection).toBeNull(); + }); +}); + +describe('CopyEditor: flash', () => { + beforeEach(() => { + drag(5, 0, 5, 10); + render(); + }); + + it('dismisses immediately when the selection is canceled during the flash', () => { + vi.useFakeTimers(); + act(() => flashCopy('term-1', 'auto')); + act(() => setSelection('term-1', null)); + expect(editor()).toBeNull(); + }); + + it('keeps a newer copied selection for its own confirmation duration', () => { + vi.useFakeTimers(); + act(() => flashCopy('term-1', 'auto')); + act(() => vi.advanceTimersByTime(400)); + drag(1, 0, 1, 10); + act(() => flashCopy('term-1', 'auto')); + act(() => vi.advanceTimersByTime(300)); + expect(getMouseSelectionState('term-1').selection?.startRow).toBe(1); + act(() => vi.advanceTimersByTime(400)); + expect(getMouseSelectionState('term-1').selection).toBeNull(); + }); +}); + +describe('CopyEditor: hidden Workspace', () => { + it('renders nothing and swallows no window input, keeping the selection for the way back', () => { + drag(0, 0, 1, 10); + render( + + + , + ); + expect(editor()).toBeNull(); + // The dismissal listener is a capture-phase window listener: answering it + // would take a click from the visible Workspace (docs/specs/layout.md -> + // "Workspaces"). + act(() => { window.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); + expect(getMouseSelectionState('term-1').selection).not.toBeNull(); + render(); + expect(editor()).not.toBeNull(); + }); +}); diff --git a/lib/src/components/CopyEditor.tsx b/lib/src/components/CopyEditor.tsx new file mode 100644 index 000000000..d027097ad --- /dev/null +++ b/lib/src/components/CopyEditor.tsx @@ -0,0 +1,269 @@ +import { clsx } from 'clsx'; +import { useContext, useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'; +import { CheckIcon } from '@phosphor-icons/react'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + getMouseSelectionSnapshot, + getRenderTick, + setSelection, + subscribeToMouseSelection, + subscribeToRenderTick, +} from '../lib/mouse-selection'; +import { COPY_FORMATS, spanOfSelection, type BreakKind, type CopyFormat, type Piece, type Rendering } from '../lib/copy-text'; +import { copyEditorView, flipCopyBreak, setCopyFormat, setCopyScope } from '../lib/copy-editor'; +import { copySelection } from '../lib/copy-selection'; +import { getTerminalInstance, getTerminalOverlayDims } from '../lib/terminal-registry'; +import { IS_MAC } from '../lib/platform'; +import { modalSurface, Shortcut } from './design'; +import { TouchUiContext } from './touch-ui-context'; +import { WorkspaceActiveContext } from './wall/wall-context'; + +/** Shift, and the arrow keys, as the mobile compass rose writes them + * (`lib/src/lib/mobile-gesture-menu.ts`). */ +const SHIFT = '⬆︎'; +const LEFT = '◀'; +const RIGHT = '▶'; + +const COPY_FORMAT_NAMES: Record = { + auto: 'Auto', + exact: 'Exact', + spaces: 'Spaces', + joined: 'No breaks', +}; + +const FORMAT_BLURBS: Record = { + auto: 'each line break judged on its own', + exact: 'as displayed', + spaces: 'every line break becomes one space', + joined: 'every line break deleted', +}; + +const MARK_GLYPH: Record = { keep: '⏎', space: '␣', none: '⌁' }; +const MARK_TITLE: Record = { + keep: 'Line break kept', + space: 'Line break became one space', + none: 'Line break deleted, joining the two sides', +}; + +/** Below this the editor overlays the selection rather than squeezing beside it. */ +const MIN_HEIGHT_PX = 120; +const GAP_PX = 4; + +interface Placement { + top?: number; + bottom?: number; + maxHeight: number; +} + +/** + * The copy editor over a finalized selection (docs/specs/mouse-and-clipboard.md + * §4): the text a copy would produce, at full pane width, with a mark on every + * line break it crossed. Keys are handled by the Wall + * (`handle-mouse-selection-keys.ts`); this owns the pointer. + */ +export function CopyEditor({ terminalId }: { terminalId: string }) { + const touchUi = useContext(TouchUiContext); + const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); + const renderTick = useSyncExternalStore(subscribeToRenderTick, getRenderTick); + // A hidden Workspace consumes no window input (docs/specs/layout.md → + // "Workspaces"); the selection stays in the store for the way back. + const workspaceActive = useContext(WorkspaceActiveContext); + + const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; + const { selection, copyEditor, copyFlash } = state; + const open = workspaceActive && !!selection && !selection.dragging && !!copyEditor; + const terminal = open ? getTerminalInstance(terminalId) : null; + const view = useMemo( + () => (terminal && selection && copyEditor ? copyEditorView(terminal, selection, copyEditor) : null), + [terminal, selection, copyEditor], + ); + + const [placement, setPlacement] = useState(null); + useLayoutEffect(() => { + if (!open || !selection) { + setPlacement(null); + return; + } + const dims = getTerminalOverlayDims(terminalId); + if (!dims || dims.rows === 0) return; + const span = spanOfSelection(selection); + const row = (r: number) => Math.max(0, Math.min(dims.rows, r - dims.viewportY)); + const below = dims.gridTop + row(span.end.row + 1) * dims.cellHeight + GAP_PX; + const above = dims.gridTop + row(span.start.row) * dims.cellHeight - GAP_PX; + const roomBelow = dims.elementHeight - below - GAP_PX; + const roomAbove = above - GAP_PX; + let next: Placement; + if (Math.max(roomBelow, roomAbove) < MIN_HEIGHT_PX) { + // The selection fills the pane: dock at the bottom, over it. + next = { bottom: GAP_PX, maxHeight: Math.max(MIN_HEIGHT_PX, Math.floor(dims.elementHeight * 0.6)) }; + } else if (touchUi ? roomAbove < MIN_HEIGHT_PX : roomBelow >= roomAbove) { + // Touch prefers above, clear of the thumb that ended the drag. + next = { top: below, maxHeight: roomBelow }; + } else { + next = { bottom: dims.elementHeight - above, maxHeight: roomAbove }; + } + // The render tick fires for every pane at up to 60 Hz: keep the previous + // object when nothing moved so React bails out. + setPlacement((prev) => (prev && prev.top === next.top && prev.bottom === next.bottom && prev.maxHeight === next.maxHeight ? prev : next)); + }, [terminalId, open, selection, touchUi, renderTick]); + + useEffect(() => { + if (!open) return; + const onMouseDown = (ev: MouseEvent) => { + const target = ev.target as HTMLElement | null; + if (!target?.closest(`[data-copy-editor-for="${terminalId}"]`)) setSelection(terminalId, null); + }; + window.addEventListener('mousedown', onMouseDown, true); + return () => window.removeEventListener('mousedown', onMouseDown, true); + }, [open, terminalId]); + + if (!open || !view || !placement || !copyEditor || !terminal) return null; + + const { scope, rendering, sameAs } = view; + const edited = Object.keys(copyEditor.overrides).length > 0; + const keys = (node: ReactNode) => (touchUi ? null : {node}); + const style: CSSProperties = { + position: 'absolute', + left: GAP_PX, + right: GAP_PX, + top: placement.top, + bottom: placement.bottom, + maxHeight: placement.maxHeight, + zIndex: 20, + }; + + return ( +
e.stopPropagation()} + > +
+
+ setCopyScope(terminalId, i)} + items={copyEditor.scopes.map((s, i) => ({ id: i, label: s.label }))} + /> + {copyEditor.scopes.length > 1 && keys(<>e expand {SHIFT}e shrink)} +
+
+ setCopyFormat(terminalId, f)} + items={COPY_FORMATS.map((f) => ({ + id: f, + label: `${COPY_FORMAT_NAMES[f]}${f === copyEditor.format && edited ? '*' : ''}`, + dim: !!sameAs[f], + title: sameAs[f] ? `${FORMAT_BLURBS[f]}: same text as ${COPY_FORMAT_NAMES[sameAs[f]!]}` : FORMAT_BLURBS[f], + }))} + /> + {keys(<>f {SHIFT}f)} +
+
+
+ flipCopyBreak(terminalId, terminal, i)} /> +
+
+ + {MARK_GLYPH.keep} kept + {MARK_GLYPH.space} space + {MARK_GLYPH.none} joined + {scope.id !== 'selection' && abc expanded} + + {selection.shape !== 'block' && keys(<>{LEFT}{RIGHT} end {SHIFT}{LEFT}{RIGHT} start)} + + {rendering.lines} {rendering.lines === 1 ? 'line' : 'lines'} · {rendering.text.length} ch + + +
+
+ ); +} + +const ADDED_CLASS = 'rounded-[2px] bg-success/15 underline decoration-success decoration-dotted underline-offset-2'; + +function Segment({ value, items, onPick }: { + value: T; + items: { id: T; label: string; dim?: boolean; title?: string }[]; + onPick: (v: T) => void; +}) { + return ( +
+ {items.map((it) => ( + + ))} +
+ ); +} + +function Mark({ kind, auto, onFlip }: { kind: BreakKind; auto: BreakKind; onFlip: () => void }) { + const edited = kind !== auto; + return ( + + ); +} + +/** One gutter-numbered row per line that lands on the clipboard. */ +function LinedPreview({ rendering, onFlip }: { rendering: Rendering; onFlip: (index: number) => void }) { + if (rendering.text === '') return
(empty)
; + const lines: ReactNode[][] = [[]]; + rendering.pieces.forEach((p: Piece, i) => { + const line = lines[lines.length - 1]; + if (p.t === 'text') { + const text = p.lead ? p.text.replace(/ /g, '·').replace(/\t/g, '→') : p.text; + line.push({text}); + return; + } + line.push( onFlip(p.index)} />); + if (p.kind === 'keep') lines.push([]); + }); + const gutterCh = String(lines.length).length; + return ( +
+ {lines.map((parts, n) => ( +
+ + {n + 1} + +
{parts}
+
+ ))} +
+ ); +} diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx index eabfe32f3..f46fac562 100644 --- a/lib/src/components/SelectionOverlay.tsx +++ b/lib/src/components/SelectionOverlay.tsx @@ -54,13 +54,27 @@ export function SelectionOverlay({ terminalId }: Props) { const borderColor = focusRingColor || 'rgb(100, 149, 237)'; const pathD = rectsToPath(rects); + // The copy editor's expanded scope, dashed around what it adds (spec §4.2). + const editor = selection.dragging ? null : state.copyEditor; + const scope = editor?.scopes[editor.scope]; + const scopeD = scope && scope.id !== 'selection' + ? rectsToPath(computeRects({ + startRow: scope.span.start.row, + startCol: scope.span.start.col, + endRow: scope.span.end.row, + endCol: scope.span.end.col, + shape: 'linewise', + dragging: false, + startedInScrollback: false, + }, dims.cols, dims.viewportY, dims.rows, cellWidth, cellHeight)) + : ''; + // Mid-drag hint. Placed outside the selection on the side opposite the // drag direction: below when the user drags down, above when they drag up. // Drag-down anchors by `top` (top edge aligned with where we want the // near-selection edge); drag-up anchors by `bottom` so the near-selection - // edge lines up regardless of element height — this keeps the hint and - // the copy popup visually coincident, since the popup uses the same - // anchoring rules. Shown only while the user is dragging (spec §3.3). + // edge lines up regardless of element height. Shown only while the user is + // dragging (spec §3.3). let hint: { left: number; top?: number; bottom?: number } | null = null; if (selection.dragging) { const endViewportRow = selection.endRow - dims.viewportY; @@ -99,6 +113,14 @@ export function SelectionOverlay({ terminalId }: Props) { style={{ position: 'absolute', top: 0, left: 0, overflow: 'visible' }} > + {scopeD && ( + + )} ({ copyRaw: vi.fn(), copyRewrapped: vi.fn() })); -vi.mock('../lib/platform', () => ({ IS_MAC: true })); -// The registry barrel boots xterm; the popup only reads the measured grid. -vi.mock('../lib/terminal-registry', () => ({ getTerminalOverlayDims: vi.fn() })); -import { copyRaw } from '../lib/clipboard'; -import { - __resetMouseSelectionForTests, - beginDrag, - bumpRenderTick, - endDrag, - flashCopy, - getMouseSelectionState, - setSelection, -} from '../lib/mouse-selection'; -import { getTerminalOverlayDims } from '../lib/terminal-registry'; -import { SelectionPopup } from './SelectionPopup'; -import { WorkspaceActiveContext } from './wall/wall-context'; - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -const DIMS = { - cols: 80, - rows: 24, - viewportY: 0, - baseY: 0, - elementWidth: 900, - elementHeight: 400, - cellWidth: 8, - cellHeight: 16, - gridLeft: 4, - gridTop: 4, -}; - -let container: HTMLDivElement; -let root: Root; - -function render(): void { - act(() => root.render()); -} - -/** Render with a finalized selection, which is what raises the popup. */ -function renderWithSelection(): void { - act(() => { - setSelection('term-1', { - startRow: 1, - startCol: 0, - endRow: 2, - endCol: 10, - shape: 'linewise', - dragging: false, - startedInScrollback: false, - }); - }); - render(); -} - -/** Render one selection and read back where the clamp placed the popup. */ -function renderAndReadLeft(selection: { startRow: number; startCol: number; endRow: number; endCol: number }): number { - act(() => { - setSelection('term-1', { - ...selection, - shape: 'linewise', - dragging: false, - startedInScrollback: false, - }); - }); - render(); - const popup = container.querySelector('[data-selection-popup-for="term-1"]'); - return Number.parseFloat(popup?.style.left ?? 'NaN'); -} - -const buttons = () => Array.from(container.querySelectorAll('button')).map((b) => b.textContent ?? ''); -beforeEach(() => { - __resetMouseSelectionForTests(); - vi.mocked(getTerminalOverlayDims).mockReturnValue({ ...DIMS }); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.useRealTimers(); -}); - -describe('SelectionPopup: copy and flash', () => { - // A ten-line viewport over ten scrollback lines, so a scroll moves the anchor - // by whole cells. - const COPY_DIMS = { ...DIMS, baseY: 10, elementWidth: 800, elementHeight: 240, cellWidth: 10, cellHeight: 10, gridLeft: 0, gridTop: 0 }; - let dims: typeof COPY_DIMS; - const popup = () => container.querySelector('[data-selection-popup-for]')!; - const copyRawButton = () => container.querySelector('button')!; - /** A finalized drag starting in scrollback, as the mouse handlers raise it. */ - const drag = (row: number, col: number) => { - beginDrag('term-1', { row, col, altKey: false, startedInScrollback: true }); - endDrag('term-1'); - }; - - beforeEach(() => { - dims = { ...COPY_DIMS }; - vi.mocked(getTerminalOverlayDims).mockImplementation(() => dims); - vi.mocked(copyRaw).mockReset(); - drag(5, 3); - render(); - }); - - it('reanchors after scrolling with the same finalized selection', () => { - expect(popup().style.top).toBe('74px'); - dims.viewportY = 3; - act(() => bumpRenderTick()); - expect(popup().style.top).toBe('44px'); - }); - - it('dismisses immediately when selection is canceled during the copied flash', () => { - vi.useFakeTimers(); - act(() => flashCopy('term-1', 'raw')); - act(() => setSelection('term-1', null)); - expect(container.querySelector('[data-selection-popup-for]')).toBeNull(); - }); - - it('keeps a newer copied selection for its own confirmation duration', () => { - vi.useFakeTimers(); - act(() => flashCopy('term-1', 'raw')); - act(() => vi.advanceTimersByTime(400)); - act(() => { - drag(8, 1); - flashCopy('term-1', 'raw'); - }); - act(() => vi.advanceTimersByTime(300)); - expect(getMouseSelectionState('term-1').selection?.startRow).toBe(8); - act(() => vi.advanceTimersByTime(400)); - expect(getMouseSelectionState('term-1').selection).toBeNull(); - }); - - it('retains a selection without a success flash when copying fails', async () => { - vi.mocked(copyRaw).mockResolvedValue(false); - await act(async () => copyRawButton().click()); - expect(getMouseSelectionState('term-1').copyFlash).toBeNull(); - expect(getMouseSelectionState('term-1').selection).not.toBeNull(); - }); - - it('does not clear a newer selection when a previous copy finishes', async () => { - let complete!: (copied: boolean) => void; - vi.mocked(copyRaw).mockImplementation(() => new Promise((resolve) => { complete = resolve; })); - act(() => copyRawButton().click()); - act(() => beginDrag('term-1', { row: 8, col: 1, altKey: false, startedInScrollback: true })); - await act(async () => complete(true)); - expect(getMouseSelectionState('term-1').copyFlash).toBeNull(); - expect(getMouseSelectionState('term-1').selection?.startRow).toBe(8); - }); -}); - -describe('SelectionPopup: hidden Workspace', () => { - /** The same finalized selection, rendered inside a Wall that is not the - * visible Workspace. */ - function renderHidden(): void { - act(() => { - setSelection('term-1', { - startRow: 1, - startCol: 0, - endRow: 2, - endCol: 10, - shape: 'linewise', - dragging: false, - startedInScrollback: false, - }); - }); - act(() => root.render( - - - , - )); - } - - it('renders nothing and swallows no window input, keeping the selection for the way back', () => { - renderHidden(); - expect(container.querySelector('[data-selection-popup-for="term-1"]')).toBeNull(); - - // The dismissal listeners are capture-phase window listeners: answering - // these would take an Escape or a click from the visible Workspace - // (docs/specs/layout.md -> "Workspaces"). - const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); - act(() => { window.dispatchEvent(escape); }); - expect(escape.defaultPrevented).toBe(false); - act(() => { window.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); - expect(getMouseSelectionState('term-1').selection).not.toBeNull(); - - // Visible again: the popup comes back over the same selection. - act(() => root.render()); - expect(container.querySelector('[data-selection-popup-for="term-1"]')).not.toBeNull(); - }); -}); diff --git a/lib/src/components/SelectionPopup.tsx b/lib/src/components/SelectionPopup.tsx deleted file mode 100644 index 85853ad3b..000000000 --- a/lib/src/components/SelectionPopup.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { useContext, useLayoutEffect, useState, useEffect, useSyncExternalStore, type CSSProperties } from 'react'; -import { - DEFAULT_MOUSE_SELECTION_STATE, - getMouseSelectionSnapshot, - getRenderTick, - setSelection, - subscribeToMouseSelection, - subscribeToRenderTick, - type CopyFlashKind, -} from '../lib/mouse-selection'; -import { copySelection } from '../lib/copy-selection'; -import { CheckIcon } from '@phosphor-icons/react'; -import { IS_MAC } from '../lib/platform'; -import { getTerminalOverlayDims } from '../lib/terminal-registry'; -import { PopupButtonRow, popupButton, Shortcut } from './design'; -import { TouchUiContext } from './touch-ui-context'; -import { WorkspaceActiveContext } from './wall/wall-context'; - -interface Anchor { - left: number; - top?: number; - bottom?: number; -} - -interface Props { - terminalId: string; -} - -// The left clamp has to know how wide the popup will be before it exists, and -// the row is two variable-length labels wide. Mono -// `text-sm` is 12px with a ~0.6em advance; each button adds `px-1.5` either -// side and the row itself a 1px border either side. -const CHAR_PX = 7.2; -const BUTTON_PADDING_PX = 12; -const ROW_BORDER_PX = 2; - -function estimatePopupWidth(labels: readonly string[]): number { - return labels.reduce((sum, label) => sum + label.length * CHAR_PX + BUTTON_PADDING_PX, ROW_BORDER_PX); -} - -/** - * Popup shown after a selection is finalized (mouse-up). Offers Copy Raw, - * Copy Rewrapped. Dismissed on Esc, click-outside, or a successful copy. - */ -export function SelectionPopup({ terminalId }: Props) { - const touchUi = useContext(TouchUiContext); - const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); - const renderTick = useSyncExternalStore(subscribeToRenderTick, getRenderTick); - // A hidden Workspace consumes no window input (docs/specs/layout.md → - // "Workspaces"): the dismissal listeners below are capture-phase and would - // otherwise answer an Escape or a click meant for the visible Workspace. The - // selection itself lives in the store, so it is still there on the way back. - const workspaceActive = useContext(WorkspaceActiveContext); - - const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; - const selection = state.selection; - const shouldRender = workspaceActive && ((!!selection && !selection.dragging) || !!state.copyFlash); - - const [anchor, setAnchor] = useState(null); - - // The touch UI has no keyboard. - const showShortcuts = !touchUi; - const copyShortcut = IS_MAC ? 'Cmd+C' : 'Ctrl+C'; - const rewrapShortcut = IS_MAC ? 'Cmd+Shift+C' : 'Ctrl+Shift+C'; - - const label = (text: string, shortcut: string | null) => - showShortcuts && shortcut ? `[${shortcut}] ${text}` : text; - - useLayoutEffect(() => { - if (!shouldRender || !selection) { - setAnchor(null); - return; - } - const dims = getTerminalOverlayDims(terminalId); - if (!dims || dims.cols === 0 || dims.rows === 0) return; - // Estimated here rather than per render: this clamp is its only consumer, - // and the popup re-renders on every xterm frame of every pane. - const popupWidth = estimatePopupWidth([ - label('Copy Raw', copyShortcut), - label('Copy Rewrapped', rewrapShortcut), - ]); - // Use the measured cell grid so the anchor aligns with the border - // outline (the overlay pulls from the same dims). - const { cellWidth, cellHeight, gridLeft, gridTop } = dims; - const endViewportRow = selection.endRow - dims.viewportY; - const endRow = Math.max(0, Math.min(dims.rows - 1, endViewportRow)); - // Place the popup on the side opposite the drag direction, matching - // exactly where the Alt hint sat. Drag-down anchors by `top`, drag-up - // anchors by `bottom` — that way both elements have their near- - // selection edge at the same y regardless of their heights. Without - // this, the popup (shorter than the hint) would appear closer to the - // selection than the hint did on drag-up. - const draggedDown = selection.endRow >= selection.startRow; - // Outer `max(0, …)`: in a pane too narrow to hold the whole row, pin it to - // the left edge rather than pushing its first button off screen. - const left = Math.max( - 0, - Math.min(dims.elementWidth - popupWidth, gridLeft + selection.endCol * cellWidth), - ); - let next: Anchor; - if (touchUi) { - // Mobile: always sit above the selection so the dragging thumb (which ends - // at the selection's lower edge) never covers the copy buttons. - const topRow = Math.max(0, Math.min(dims.rows - 1, Math.min(selection.startRow, selection.endRow) - dims.viewportY)); - const y = Math.max(gridTop + (topRow - 1) * cellHeight - 4, 28); - next = { left, bottom: dims.elementHeight - y }; - } else if (draggedDown) { - const top = Math.min( - gridTop + (endRow + 2) * cellHeight + 4, - dims.elementHeight - 24, - ); - next = { left, top }; - } else { - // Bottom-anchored one full cell above the selection — symmetric with - // the drag-down +2-row offset on the top-anchored side. - const y = Math.max(gridTop + (endRow - 1) * cellHeight - 4, 28); - next = { left, bottom: dims.elementHeight - y }; - } - // The render tick fires for every pane at up to 60 Hz, so keep the previous - // object when the position is unchanged and let React bail out of the render. - setAnchor((prev) => (prev && prev.left === next.left && prev.top === next.top && prev.bottom === next.bottom) - ? prev - : next); - }, [terminalId, shouldRender, selection, touchUi, renderTick, showShortcuts, copyShortcut, rewrapShortcut]); - - useEffect(() => { - if (!shouldRender) return; - const onKeyDown = (ev: KeyboardEvent) => { - if (ev.key === 'Escape') { - ev.preventDefault(); - ev.stopPropagation(); - setSelection(terminalId, null); - } - }; - const onMouseDown = (ev: MouseEvent) => { - // Click anywhere outside the popup → dismiss. The overlay itself and - // the terminal body both qualify. A new mousedown inside the terminal - // will also begin a new drag (terminal-mouse-router), which replaces - // the selection anyway. - const target = ev.target as HTMLElement | null; - if (!target?.closest(`[data-selection-popup-for="${terminalId}"]`)) { - setSelection(terminalId, null); - } - }; - window.addEventListener('keydown', onKeyDown, true); - window.addEventListener('mousedown', onMouseDown, true); - return () => { - window.removeEventListener('keydown', onKeyDown, true); - window.removeEventListener('mousedown', onMouseDown, true); - }; - }, [shouldRender, terminalId]); - - if (!shouldRender || !anchor) return null; - - const style: CSSProperties = { - position: 'absolute', - left: anchor.left, - top: anchor.top, - bottom: anchor.bottom, - zIndex: 20, - }; - - const onCopy = (rewrapped: boolean) => copySelection(terminalId, rewrapped); - - const flashed = (kind: CopyFlashKind) => state.copyFlash === kind; - const buttonClass = (kind: CopyFlashKind) => popupButton({ flashed: flashed(kind) }); - - // With a shortcut the check sits over the (hidden) label, so the button width - // stays put while it flashes; with none there is nothing to hide behind and - // the check simply appears. - const leadingIndicator = (kind: CopyFlashKind, shortcut: string | null) => { - if (!showShortcuts || !shortcut) { - return flashed(kind) ? ( - - - - ) : null; - } - return ( - <> - - {shortcut} - {flashed(kind) && ( - - - - )} - {' '} - - ); - }; - - return ( - e.stopPropagation()} - > - - - - ); -} diff --git a/lib/src/components/TerminalPane.test.tsx b/lib/src/components/TerminalPane.test.tsx index 8bb9da693..4ae89e7a6 100644 --- a/lib/src/components/TerminalPane.test.tsx +++ b/lib/src/components/TerminalPane.test.tsx @@ -46,7 +46,7 @@ vi.mock('../lib/terminal-registry', () => ({ })); vi.mock('./wall/AlertRingIndicator', () => ({ AlertRingIndicator: () => null })); vi.mock('./SelectionOverlay', () => ({ SelectionOverlay: () => null })); -vi.mock('./SelectionPopup', () => ({ SelectionPopup: () => null })); +vi.mock('./CopyEditor', () => ({ CopyEditor: () => null })); vi.mock('./wall/MouseOverrideBanner', () => ({ MouseOverrideBanner: () => null })); let root: Root; diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 094608581..603a6f2c7 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -8,7 +8,7 @@ import { focusSession, } from '../lib/terminal-registry'; import { SelectionOverlay } from './SelectionOverlay'; -import { SelectionPopup } from './SelectionPopup'; +import { CopyEditor } from './CopyEditor'; import { MouseOverrideBanner } from './wall/MouseOverrideBanner'; import { SizeHoldStrip } from './wall/SizeHoldStrip'; import { getSizeHolds, subscribeToSizeHolds } from '../lib/size-hold-store'; @@ -94,7 +94,7 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { return (
- +
diff --git a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts index f8622453e..37466dfdb 100644 --- a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts +++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.test.ts @@ -6,17 +6,22 @@ import { handleMouseSelectionKeys } from './handle-mouse-selection-keys'; import type { WallKeyboardCtx } from './types'; vi.mock('../../../lib/clipboard', () => ({ - copyRaw: vi.fn(), - copyRewrapped: vi.fn(), doPaste: vi.fn(), })); +vi.mock('../../../lib/copy-selection', () => ({ + copySelection: vi.fn(), + nudgeSelection: vi.fn(), +})); +vi.mock('../../../lib/copy-editor', () => ({ + cycleCopyFormat: vi.fn(), + stepCopyScope: vi.fn(), +})); vi.mock('../../../lib/platform', () => ({ IS_MAC: true })); // The real mouse-selection store keeps per-id module state; mock it so each // test can drive the drag/selection shape the handler reads. vi.mock('../../../lib/mouse-selection', () => ({ getMouseSelectionState: vi.fn(() => ({ selection: null })), extendSelectionToToken: vi.fn(), - flashCopy: vi.fn(), setSelection: vi.fn(), })); function makeCtx(params?: Record): WallKeyboardCtx { @@ -149,28 +154,71 @@ describe('handleMouseSelectionKeys', () => { expect(alt.defaultPrevented).toBe(false); }); - it('copies raw on Cmd+C and rewrapped on Cmd+Shift+C outside a drag', async () => { - const { copyRaw, copyRewrapped } = await import('../../../lib/clipboard'); - const { getMouseSelectionState, flashCopy } = await import('../../../lib/mouse-selection'); - vi.mocked(copyRaw).mockClear().mockResolvedValue(true); - vi.mocked(copyRewrapped).mockClear().mockResolvedValue(true); - vi.mocked(flashCopy).mockClear(); - vi.mocked(getMouseSelectionState).mockReturnValue({ selection: { dragging: false } } as never); - - const rawEvt = fakeEvent(document.createElement('div'), { key: 'c', metaKey: true }); - expect(handleMouseSelectionKeys(rawEvt, makeCtx())).toBe(true); - expect(rawEvt.defaultPrevented).toBe(true); - expect(copyRaw).toHaveBeenCalledWith('pane-a'); - expect(copyRewrapped).not.toHaveBeenCalled(); - - const rewrapEvt = fakeEvent(document.createElement('div'), { key: 'c', metaKey: true, shiftKey: true }); - expect(handleMouseSelectionKeys(rewrapEvt, makeCtx())).toBe(true); - expect(copyRewrapped).toHaveBeenCalledWith('pane-a'); - - // flashCopy fires from the copy promise's continuation. - await Promise.resolve(); - await Promise.resolve(); - expect(flashCopy).toHaveBeenCalledWith('pane-a', 'raw'); - expect(flashCopy).toHaveBeenCalledWith('pane-a', 'rewrapped'); + describe('with the copy editor open', () => { + const open = async () => { + const { getMouseSelectionState, setSelection } = await import('../../../lib/mouse-selection'); + const { copySelection, nudgeSelection } = await import('../../../lib/copy-selection'); + const { cycleCopyFormat, stepCopyScope } = await import('../../../lib/copy-editor'); + for (const fn of [setSelection, copySelection, nudgeSelection, cycleCopyFormat, stepCopyScope]) vi.mocked(fn).mockClear(); + vi.mocked(getMouseSelectionState).mockReturnValue({ selection: { dragging: false }, copyEditor: {} } as never); + return { setSelection, copySelection, nudgeSelection, cycleCopyFormat, stepCopyScope }; + }; + const press = (init: Partial & { key: string }) => { + const e = fakeEvent(document.createElement('div'), init); + return { e, handled: handleMouseSelectionKeys(e, makeCtx()) }; + }; + + it('copies on the copy chord, with or without Shift, and on Enter', async () => { + const { copySelection } = await open(); + for (const init of [{ key: 'c', metaKey: true }, { key: 'C', metaKey: true, shiftKey: true }, { key: 'Enter' }]) { + const { e, handled } = press(init); + expect(handled).toBe(true); + expect(e.defaultPrevented).toBe(true); + } + expect(copySelection).toHaveBeenCalledTimes(3); + expect(copySelection).toHaveBeenCalledWith('pane-a'); + }); + + it('expands and shrinks on e / Shift+E, cycles formats on f / Shift+F', async () => { + const { stepCopyScope, cycleCopyFormat } = await open(); + press({ key: 'e' }); + press({ key: 'E', shiftKey: true }); + press({ key: 'f' }); + press({ key: 'F', shiftKey: true }); + expect(vi.mocked(stepCopyScope).mock.calls).toEqual([['pane-a', 1], ['pane-a', -1]]); + expect(vi.mocked(cycleCopyFormat).mock.calls).toEqual([['pane-a', 1], ['pane-a', -1]]); + }); + + it('nudges the end on arrows and the start on Shift+arrows', async () => { + const { nudgeSelection } = await open(); + press({ key: 'ArrowRight' }); + press({ key: 'ArrowLeft', shiftKey: true }); + expect(vi.mocked(nudgeSelection).mock.calls).toEqual([['pane-a', 'end', 1], ['pane-a', 'start', -1]]); + }); + + it('closes on Escape, consuming it', async () => { + const { setSelection } = await open(); + const { e, handled } = press({ key: 'Escape' }); + expect(handled).toBe(true); + expect(e.defaultPrevented).toBe(true); + expect(setSelection).toHaveBeenCalledWith('pane-a', null); + }); + + it('closes on any other key and lets it reach the terminal', async () => { + const { setSelection, copySelection } = await open(); + const { e, handled } = press({ key: 'g' }); + expect(handled).toBe(false); + expect(e.defaultPrevented).toBe(false); + expect(setSelection).toHaveBeenCalledWith('pane-a', null); + expect(copySelection).not.toHaveBeenCalled(); + }); + + it('stays open for a bare modifier; a modified e is no editor key', async () => { + const { setSelection, stepCopyScope } = await open(); + expect(press({ key: 'Shift', shiftKey: true }).handled).toBe(false); + expect(press({ key: 'e', metaKey: true }).handled).toBe(false); + expect(stepCopyScope).not.toHaveBeenCalled(); + expect(vi.mocked(setSelection).mock.calls).toEqual([['pane-a', null]]); + }); }); }); diff --git a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts index a7757856a..ffce36f21 100644 --- a/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts +++ b/lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts @@ -1,5 +1,6 @@ import { doPaste } from '../../../lib/clipboard'; -import { copySelection } from '../../../lib/copy-selection'; +import { cycleCopyFormat, stepCopyScope } from '../../../lib/copy-editor'; +import { copySelection, nudgeSelection } from '../../../lib/copy-selection'; import { isEditableTarget, isTerminalInputProxy } from '../../../lib/dom'; import { extendSelectionToToken, @@ -12,9 +13,13 @@ import { hasTerminal } from 'dor/commands/types'; import { surfaceKindFromParams, toolFace } from '../browser-surface'; import type { WallKeyboardCtx } from './types'; +/** Keys that never dismiss the copy editor: a chord is still being held. */ +const MODIFIER_KEYS = new Set(['Shift', 'Meta', 'Control', 'Alt', 'AltGraph', 'CapsLock', 'Fn']); + /** - * Mouse-selection-aware shortcuts: token extension + Escape during drag, - * Cmd-C / Cmd-Shift-C / Cmd-V outside drag. Returns true if handled. + * Mouse-selection-aware shortcuts: token extension + Escape during drag, the + * copy editor's keys over a finalized selection, Cmd-V always. Returns true if + * handled. */ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean { // Don't shadow native clipboard ops when focus is inside a real text @@ -62,12 +67,7 @@ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx) } const keyLower = e.key.toLowerCase(); - if (sel && !sel.dragging && hasCopyModifier(e) && keyLower === 'c') { - e.preventDefault(); - e.stopImmediatePropagation(); - void copySelection(sid, e.shiftKey); - return true; - } + if (sel && mouseState.copyEditor && handleCopyEditorKey(e, sid, keyLower)) return true; // Paste takes either modifier on every platform (see `hasPasteModifier`). // Trade-off: shadows readline's ^V verbatim-insert; not worth surfacing as a // setting until someone asks for it. @@ -80,6 +80,45 @@ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx) return false; } +/** + * The copy editor's keys (spec §4.3). Any other key closes it and goes on to + * the terminal, so typing after a selection still types. + */ +function handleCopyEditorKey(e: KeyboardEvent, sid: string, keyLower: string): boolean { + const consume = () => { + e.preventDefault(); + e.stopImmediatePropagation(); + return true; + }; + if (hasCopyModifier(e) && keyLower === 'c') { + void copySelection(sid); + return consume(); + } + const bare = !e.metaKey && !e.ctrlKey && !e.altKey; + if (bare && e.key === 'Enter' && !e.shiftKey) { + void copySelection(sid); + return consume(); + } + if (e.key === 'Escape') { + setMouseSelection(sid, null); + return consume(); + } + if (bare && keyLower === 'e') { + stepCopyScope(sid, e.shiftKey ? -1 : 1); + return consume(); + } + if (bare && keyLower === 'f') { + cycleCopyFormat(sid, e.shiftKey ? -1 : 1); + return consume(); + } + if (bare && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + nudgeSelection(sid, e.shiftKey ? 'start' : 'end', e.key === 'ArrowRight' ? 1 : -1); + return consume(); + } + if (!MODIFIER_KEYS.has(e.key) && !(hasPasteModifier(e) && keyLower === 'v')) setMouseSelection(sid, null); + return false; +} + /** `paneParams` reads the store, which holds a Surface's params whether it is a pane * or a Door, so a minimized Surface needs no separate lookup. */ function hasActiveTerminal(ctx: WallKeyboardCtx, id: string): boolean { diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts index 63593d899..0ab6f9932 100644 --- a/lib/src/lib/clipboard.ts +++ b/lib/src/lib/clipboard.ts @@ -1,10 +1,8 @@ import { shellCommandKind } from 'dor/commands/shell-quote'; import { getMouseSelectionState } from './mouse-selection'; -import { rewrap } from './rewrap'; -import { extractSelectionText } from './selection-text'; import { getPlatform, PLATFORM_STRING } from './platform'; import { shellEscapePath } from './shell-escape'; -import { getDefaultShellOpts, getTerminalInstance, getTerminalShellKind, writeUserInput } from './terminal-registry'; +import { getDefaultShellOpts, getTerminalShellKind, writeUserInput } from './terminal-registry'; /** Report failure without throwing so callers retain the selection for retry. */ export async function writeTextToClipboard(text: string): Promise { @@ -20,24 +18,6 @@ export async function writeTextToClipboard(text: string): Promise { return false; } -/** Copy the current selection as-is; no-op without one. */ -export async function copyRaw(terminalId: string): Promise { - const terminal = getTerminalInstance(terminalId); - const sel = getMouseSelectionState(terminalId).selection; - if (!terminal || !sel) return false; - return writeTextToClipboard(extractSelectionText(terminal, sel)); -} - -/** Copy with rewrap, except for rectangular block selections. */ -export async function copyRewrapped(terminalId: string): Promise { - const terminal = getTerminalInstance(terminalId); - const sel = getMouseSelectionState(terminalId).selection; - if (!terminal || !sel) return false; - const raw = extractSelectionText(terminal, sel); - const out = sel.shape === 'block' ? raw : rewrap(raw); - return writeTextToClipboard(out); -} - /** Replace ESC with visible U+241B so clipboard text cannot close a bracketed * paste early. Never apply this to unbracketed input; see spec §8.5. */ function defangPasteEscapes(text: string): string { diff --git a/lib/src/lib/copy-editor.test.ts b/lib/src/lib/copy-editor.test.ts new file mode 100644 index 000000000..d2cf05d68 --- /dev/null +++ b/lib/src/lib/copy-editor.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; + +vi.mock('./clipboard', () => ({ writeTextToClipboard: vi.fn() })); +vi.mock('./terminal-registry', () => ({ getTerminalInstance: vi.fn(), refreshSelectionBaseline: vi.fn() })); +import { writeTextToClipboard } from './clipboard'; +import { cycleCopyFormat, flipCopyBreak, openCopyEditor, stepCopyScope } from './copy-editor'; +import { copySelection, nudgeSelection } from './copy-selection'; +import { + __resetMouseSelectionForTests, + beginDrag, + getMouseSelectionState, + setSelection, + type Selection, +} from './mouse-selection'; +import { getTerminalInstance, refreshSelectionBaseline } from './terminal-registry'; + +const LINES = [ + 'The flake comes from a race between the PTY exit event and the final flush', + 'of the output buffer. When the child exits before xterm has drained its', + 'write queue, the last chunk is dropped and the assertion on the prompt', + 'text fails intermittently.', +]; + +function fakeTerminal(lines: string[]): Terminal { + const getLine = (r: number) => { + const text = lines[r]; + if (text === undefined) return undefined; + return { + length: 80, + isWrapped: false, + getCell: (c: number) => ({ getChars: () => text[c] ?? '', getWidth: () => 1 }), + translateToString: (_trim?: boolean, start = 0, end = 80) => text.slice(start, end), + }; + }; + return { cols: 80, rows: 24, buffer: { active: { length: lines.length, getLine } } } as unknown as Terminal; +} + +const terminal = fakeTerminal(LINES); +const ID = 'term-1'; + +function select(over: Partial): void { + setSelection(ID, { startRow: 0, startCol: 0, endRow: 3, endCol: 26, shape: 'linewise', dragging: false, startedInScrollback: false, ...over }); + openCopyEditor(ID, terminal); +} +const editor = () => getMouseSelectionState(ID).copyEditor!; + +beforeEach(() => { + __resetMouseSelectionForTests(); + vi.mocked(getTerminalInstance).mockReturnValue(terminal); + vi.mocked(writeTextToClipboard).mockReset(); + vi.mocked(refreshSelectionBaseline).mockReset(); +}); + +describe('copy editor transitions', () => { + it('opens in Auto at the selection’s own scope', () => { + select({ startCol: 25, endRow: 1, endCol: 5 }); + expect(editor()).toMatchObject({ scope: 0, format: 'auto', overrides: {} }); + expect(editor().scopes.map((s) => s.label)).toEqual(['As selected', 'Whole words', 'Paragraph']); + }); + + it('steps the scope and stops at either end, clearing overrides', () => { + select({ startCol: 25, endRow: 1, endCol: 5 }); + flipCopyBreak(ID, terminal, 0); + stepCopyScope(ID, 1); + expect(editor()).toMatchObject({ scope: 1, overrides: {} }); + stepCopyScope(ID, 1); + stepCopyScope(ID, 1); + expect(editor().scope).toBe(2); + stepCopyScope(ID, -1); + stepCopyScope(ID, -1); + stepCopyScope(ID, -1); + expect(editor().scope).toBe(0); + }); + + it('cycles formats in f order, wrapping both ways', () => { + select({}); + cycleCopyFormat(ID, 1); + expect(editor().format).toBe('exact'); + cycleCopyFormat(ID, -1); + cycleCopyFormat(ID, -1); + expect(editor().format).toBe('joined'); + }); + + it('flips a break keep → space → none → keep from wherever the format put it', () => { + select({}); + flipCopyBreak(ID, terminal, 0); + expect(editor().overrides[0]).toBe('none'); + flipCopyBreak(ID, terminal, 0); + flipCopyBreak(ID, terminal, 0); + expect(editor().overrides[0]).toBe('space'); + }); + + it('nudges an edge, keeping the format and re-arming the baseline', () => { + select({ endRow: 0, endCol: 9 }); + cycleCopyFormat(ID, 1); + nudgeSelection(ID, 'end', 1); + expect(getMouseSelectionState(ID).selection).toMatchObject({ endRow: 0, endCol: 14 }); + expect(editor()).toMatchObject({ format: 'exact', scope: 0 }); + expect(refreshSelectionBaseline).toHaveBeenCalledWith(ID); + }); + + it('never nudges a block slab', () => { + select({ shape: 'block', endRow: 2, endCol: 9 }); + nudgeSelection(ID, 'end', 1); + expect(getMouseSelectionState(ID).selection?.endCol).toBe(9); + expect(refreshSelectionBaseline).not.toHaveBeenCalled(); + }); +}); + +describe('copySelection', () => { + it('writes what the editor shows and flashes its format', async () => { + vi.mocked(writeTextToClipboard).mockResolvedValue(true); + select({ endRow: 1, endCol: 12 }); + await copySelection(ID); + expect(writeTextToClipboard).toHaveBeenCalledWith('The flake comes from a race between the PTY exit event and the final flush of the output'); + expect(getMouseSelectionState(ID).copyFlash).toBe('auto'); + }); + + it('retains the selection without a flash when the write fails', async () => { + vi.mocked(writeTextToClipboard).mockResolvedValue(false); + select({}); + await copySelection(ID); + expect(getMouseSelectionState(ID).copyFlash).toBeNull(); + expect(getMouseSelectionState(ID).selection).not.toBeNull(); + }); + + it('does not flash a newer selection when an earlier copy finishes', async () => { + let complete!: (copied: boolean) => void; + vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise((resolve) => { complete = resolve; })); + select({}); + const pending = copySelection(ID); + beginDrag(ID, { row: 2, col: 1, altKey: false, startedInScrollback: false }); + complete(true); + await pending; + expect(getMouseSelectionState(ID).copyFlash).toBeNull(); + expect(getMouseSelectionState(ID).selection?.startRow).toBe(2); + }); +}); diff --git a/lib/src/lib/copy-editor.ts b/lib/src/lib/copy-editor.ts new file mode 100644 index 000000000..d29ad46c7 --- /dev/null +++ b/lib/src/lib/copy-editor.ts @@ -0,0 +1,137 @@ +import type { Terminal } from '@xterm/xterm'; +import { + COPY_FORMATS, + comparePos, + computeScopes, + nudge, + render, + spanOfSelection, + terminalCopyBuffer, + type BreakKind, + type CopyFormat, + type Rendering, + type Scope, +} from './copy-text'; +import { + getMouseSelectionState, + setCopyEditor, + setSelection, + type CopyEditorState, + type Selection, +} from './mouse-selection'; + +// The copy editor's state transitions over the selection store, given the +// terminal whose buffer it reads (docs/specs/mouse-and-clipboard.md §4). The +// registry-aware wrappers that write the clipboard live in `copy-selection.ts`. + +function initialEditor(terminal: Terminal, sel: Selection): CopyEditorState { + const scopes = computeScopes(terminalCopyBuffer(terminal), spanOfSelection(sel), sel.shape === 'block'); + return { scopes, scope: 0, format: 'auto', overrides: {} }; +} + +/** Open the editor over the finalized selection, at its own scope in Auto. */ +export function openCopyEditor(id: string, terminal: Terminal): void { + const sel = getMouseSelectionState(id).selection; + if (!sel || sel.dragging) return; + setCopyEditor(id, initialEditor(terminal, sel)); +} + +export interface CopyEditorView { + scope: Scope; + rendering: Rendering; + /** Each format whose text another, earlier one already gives. */ + sameAs: Partial>; +} + +/** What the editor shows and copies. Without an open editor, Auto at the + * selection's own scope. */ +export function copyEditorView(terminal: Terminal, sel: Selection, editor: CopyEditorState | null): CopyEditorView { + const state = editor ?? initialEditor(terminal, sel); + const buf = terminalCopyBuffer(terminal); + const original = spanOfSelection(sel); + const block = sel.shape === 'block'; + const scope = state.scopes[Math.min(state.scope, state.scopes.length - 1)]; + const rendering = render(buf, scope.span, { original, format: state.format, block, overrides: state.overrides }); + const seen = new Map(); + const sameAs: CopyEditorView['sameAs'] = {}; + for (const format of COPY_FORMATS) { + const text = render(buf, scope.span, { original, format, block }).text; + const prior = seen.get(text); + if (prior) sameAs[format] = prior; + else seen.set(text, format); + } + return { scope, rendering, sameAs }; +} + +function update(id: string, change: (editor: CopyEditorState) => CopyEditorState | null): void { + const editor = getMouseSelectionState(id).copyEditor; + if (!editor) return; + const next = change(editor); + if (next) setCopyEditor(id, next); +} + +/** `e` / `⬆︎e`: the next wider or narrower scope, stopping at either end. */ +export function stepCopyScope(id: string, dir: 1 | -1): void { + update(id, (e) => { + const scope = Math.max(0, Math.min(e.scopes.length - 1, e.scope + dir)); + return scope === e.scope ? null : { ...e, scope, overrides: {} }; + }); +} + +export function setCopyScope(id: string, scope: number): void { + update(id, (e) => (scope === e.scope || !e.scopes[scope] ? null : { ...e, scope, overrides: {} })); +} + +/** `f` / `⬆︎f`: the next or previous format, wrapping. */ +export function cycleCopyFormat(id: string, dir: 1 | -1): void { + update(id, (e) => { + const at = COPY_FORMATS.indexOf(e.format); + const format = COPY_FORMATS[(at + dir + COPY_FORMATS.length) % COPY_FORMATS.length]; + return { ...e, format, overrides: {} }; + }); +} + +export function setCopyFormat(id: string, format: CopyFormat): void { + update(id, (e) => (format === e.format && Object.keys(e.overrides).length === 0 ? null : { ...e, format, overrides: {} })); +} + +const NEXT_BREAK: Record = { keep: 'space', space: 'none', none: 'keep' }; + +/** A click on a break mark: keep → space → none → keep. */ +export function flipCopyBreak(id: string, terminal: Terminal, index: number): void { + const { selection, copyEditor } = getMouseSelectionState(id); + if (!selection || !copyEditor) return; + const { rendering } = copyEditorView(terminal, selection, copyEditor); + const current = copyEditor.overrides[index] ?? rendering.breaks[index]; + if (!current) return; + setCopyEditor(id, { ...copyEditor, overrides: { ...copyEditor.overrides, [index]: NEXT_BREAK[current] } }); +} + +/** + * `◀ ▶` move the end a word, `⬆︎◀ ⬆︎▶` the start. The selection is rewritten in + * reading order and the editor returns to its own scope, keeping its format. + * Returns whether the + * selection moved, so the caller can re-arm its cancel-on-change baseline. + */ +export function nudgeCopyEdge(id: string, terminal: Terminal, edge: 'start' | 'end', dir: 1 | -1): boolean { + const sel = getMouseSelectionState(id).selection; + if (!sel || sel.dragging || sel.shape === 'block') return false; + const buf = terminalCopyBuffer(terminal); + const span = spanOfSelection(sel); + const moved = edge === 'start' + ? { start: nudge(buf, span.start, dir, 'start'), end: span.end } + : { start: span.start, end: nudge(buf, span.end, dir, 'end') }; + if (comparePos(moved.start, moved.end) > 0) return false; + if (comparePos(moved.start, span.start) === 0 && comparePos(moved.end, span.end) === 0) return false; + const next: Selection = { + ...sel, + startRow: moved.start.row, + startCol: moved.start.col, + endRow: moved.end.row, + endCol: moved.end.col, + }; + const format = getMouseSelectionState(id).copyEditor?.format ?? 'auto'; + setSelection(id, next); + setCopyEditor(id, { ...initialEditor(terminal, next), format }); + return true; +} diff --git a/lib/src/lib/copy-selection.ts b/lib/src/lib/copy-selection.ts index 11d30a653..b9c334096 100644 --- a/lib/src/lib/copy-selection.ts +++ b/lib/src/lib/copy-selection.ts @@ -1,19 +1,31 @@ -import { copyRaw, copyRewrapped } from './clipboard'; +import { writeTextToClipboard } from './clipboard'; +import { copyEditorView, nudgeCopyEdge } from './copy-editor'; import { flashCopy, getMouseSelectionState } from './mouse-selection'; +import { getTerminalInstance, refreshSelectionBaseline } from './terminal-registry'; /** - * Copy the current selection and confirm it with the "Copied!" flash. Lives - * apart from `clipboard.ts` so it can reach `flashCopy` without that module - * depending on the selection store's render side. + * Copy what the editor shows and confirm it with the flash. Lives apart from + * `clipboard.ts` so it can reach `flashCopy` without that module depending on + * the selection store's render side. * * The flash is withheld unless the clipboard write succeeded *and* the * selection is still the one that was copied — an await gives a new drag time * to land, and flashing then would clear the newer selection. */ -export async function copySelection(terminalId: string, rewrapped: boolean): Promise { - const selection = getMouseSelectionState(terminalId).selection; - const copied = await (rewrapped ? copyRewrapped(terminalId) : copyRaw(terminalId)); +export async function copySelection(terminalId: string): Promise { + const { selection, copyEditor } = getMouseSelectionState(terminalId); + const terminal = getTerminalInstance(terminalId); + if (!selection || selection.dragging || !terminal) return; + const text = copyEditorView(terminal, selection, copyEditor).rendering.text; + const copied = await writeTextToClipboard(text); if (copied && getMouseSelectionState(terminalId).selection === selection) { - flashCopy(terminalId, rewrapped ? 'rewrapped' : 'raw'); + flashCopy(terminalId, copyEditor?.format ?? 'auto'); } } + +/** Move a selection edge a word (`nudgeCopyEdge`) and re-arm its + * cancel-on-change baseline. */ +export function nudgeSelection(terminalId: string, edge: 'start' | 'end', dir: 1 | -1): void { + const terminal = getTerminalInstance(terminalId); + if (terminal && nudgeCopyEdge(terminalId, terminal, edge, dir)) refreshSelectionBaseline(terminalId); +} diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts index 028b956ab..9eac74788 100644 --- a/lib/src/lib/mouse-selection.test.ts +++ b/lib/src/lib/mouse-selection.test.ts @@ -36,6 +36,7 @@ describe('mouse-selection: default state', () => { override: 'off', selection: null, hintToken: null, + copyEditor: null, copyFlash: null, }); }); @@ -280,8 +281,8 @@ describe('mouse-selection: flashCopy race', () => { endDrag('a'); // Simulate flashCopy — but we call beginDrag before the timer fires. - flashCopy('a', 'raw', 500); - expect(getMouseSelectionState('a').copyFlash).toBe('raw'); + flashCopy('a', 'auto', 500); + expect(getMouseSelectionState('a').copyFlash).toBe('auto'); // New drag starts before the 500ms timer. beginDrag('a', { row: 10, col: 2, altKey: false, startedInScrollback: false }); diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index 13cbeb025..bd8c90cc8 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -1,6 +1,8 @@ /** DOM-free per-terminal mouse/selection store with a * `useSyncExternalStore`-compatible subscription API. */ +import type { BreakKind, CopyFormat, Scope } from './copy-text'; + export type MouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'; export type OverrideState = 'off' | 'temporary' | 'permanent'; export type SelectionShape = 'linewise' | 'block'; @@ -41,7 +43,19 @@ export interface TokenHint { text: string; } -export type CopyFlashKind = 'raw' | 'rewrapped'; +/** The copy editor over a finalized selection (spec §4). */ +export interface CopyEditorState { + /** Narrowest first; `[0]` is the selection itself. */ + scopes: readonly Scope[]; + /** Index into {@link scopes}. */ + scope: number; + format: CopyFormat; + /** Break index → kind, for the current scope and format only. */ + overrides: Readonly>; +} + +/** The format a confirmed copy used. */ +export type CopyFlashKind = CopyFormat; export interface MouseSelectionState { mouseReporting: MouseTrackingMode; @@ -49,9 +63,11 @@ export interface MouseSelectionState { override: OverrideState; selection: Selection | null; hintToken: TokenHint | null; + /** Open while non-null; always over a finalized selection. */ + copyEditor: CopyEditorState | null; /** - * Set briefly after Cmd+C / Cmd+Shift+C / Cmd+N or a popup-button click, so - * the popup can flash a confirmation before everything clears. + * Set briefly after a copy from the editor, so it can flash a confirmation + * before everything clears. */ copyFlash: CopyFlashKind | null; } @@ -62,6 +78,7 @@ export const DEFAULT_MOUSE_SELECTION_STATE: MouseSelectionState = Object.freeze( override: 'off', selection: null, hintToken: null, + copyEditor: null, copyFlash: null, }) as MouseSelectionState; @@ -133,12 +150,23 @@ export function setSelection(id: string, selection: Selection | null): void { if (s.selection === null && selection === null) return; s.selection = selection; if (selection === null) { + s.copyEditor = null; s.copyFlash = null; s.hintToken = null; } notify(); } +/** Open, update, or (null) close the copy editor; a no-op without a + * finalized selection to edit. */ +export function setCopyEditor(id: string, editor: CopyEditorState | null): void { + const s = ensure(id); + if (editor && (!s.selection || s.selection.dragging)) return; + if (s.copyEditor === editor) return; + s.copyEditor = editor; + 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). @@ -151,6 +179,7 @@ export function beginDrag( // Clear any in-flight copy flash so its timer won't null out this new // selection when it fires (the timer checks `copyFlash !== kind`). s.copyFlash = null; + s.copyEditor = null; s.selection = { startRow: args.row, startCol: args.col, @@ -248,9 +277,9 @@ export function setDragAlt(id: string, altKey: boolean): void { } /** - * Trigger the copy confirmation flash ("Copied!"). - * The popup reads `copyFlash` and renders a confirmation state; after - * `durationMs` the flash clears along with the selection, dismissing the popup. + * Trigger the copy confirmation flash. + * The editor reads `copyFlash` and renders a confirmation state; after + * `durationMs` the flash clears along with the selection, dismissing the editor. */ export function flashCopy(id: string, kind: CopyFlashKind, durationMs = 700): void { const s = ensure(id); @@ -261,6 +290,7 @@ export function flashCopy(id: string, kind: CopyFlashKind, durationMs = 700): vo const current = states.get(id); if (current !== s || current.selection !== selection || current.copyFlash !== kind) return; current.copyFlash = null; + current.copyEditor = null; current.selection = null; notify(); }, durationMs); diff --git a/lib/src/lib/rewrap.test.ts b/lib/src/lib/rewrap.test.ts deleted file mode 100644 index eadbf7578..000000000 --- a/lib/src/lib/rewrap.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { rewrap, __testing } from './rewrap'; - -const { isFrameOnlyLine, stripLeadingAndTrailingFrame } = __testing; - -describe('isFrameOnlyLine', () => { - it.each([ - ['┌──────┐', true], - ['└──────┘', true], - ['├──────┤', true], - ['═══════', true], - [' │ │ ', true], // spaces + verticals is considered frame-like chrome - ['│ foo │', false], - ['foo', false], - ['', false], - ])('"%s" → %s', (input, expected) => { - expect(isFrameOnlyLine(input)).toBe(expected); - }); - - it('a line of spaces-only is not considered a frame line', () => { - expect(isFrameOnlyLine(' ')).toBe(false); - }); -}); - -describe('stripLeadingAndTrailingFrame', () => { - it('strips left + right border chars along with one adjacent space', () => { - expect(stripLeadingAndTrailingFrame('│ foo │')).toBe('foo'); - }); - it('strips heavy block chars along with one adjacent space', () => { - expect(stripLeadingAndTrailingFrame('▌ hello ▐')).toBe('hello'); - }); - it('leaves normal text untouched', () => { - expect(stripLeadingAndTrailingFrame('hello world')).toBe('hello world'); - }); - it('keeps inner whitespace after border strip', () => { - expect(stripLeadingAndTrailingFrame('│ abc │')).toBe(' abc '); - }); -}); - -describe('rewrap', () => { - it('joins single-paragraph hard-wrapped text with spaces', () => { - expect(rewrap('The quick brown\nfox jumps\nover the lazy dog.')) - .toBe('The quick brown fox jumps over the lazy dog.'); - }); - - it('preserves blank lines as paragraph separators', () => { - expect(rewrap('Para one\nstill one.\n\nPara two\nalso two.')) - .toBe('Para one still one.\n\nPara two also two.'); - }); - - it('strips a simple box-drawing frame', () => { - const boxed = [ - '┌─────────┐', - '│ message │', - '└─────────┘', - ].join('\n'); - expect(rewrap(boxed)).toBe('message'); - }); - - it('strips a double-line frame', () => { - const boxed = [ - '╔═════════╗', - '║ hello ║', - '║ world ║', - '╚═════════╝', - ].join('\n'); - expect(rewrap(boxed)).toBe('hello world'); - }); - - it('strips side borders on a table-like block', () => { - const tbl = [ - '│ foo │', - '│ bar │', - '│ baz │', - ].join('\n'); - // All three rows become non-blank lines and join into one paragraph. - expect(rewrap(tbl)).toBe('foo bar baz'); - }); - - it('leading/trailing whitespace on each line is trimmed', () => { - expect(rewrap(' foo\n bar \n baz ')).toBe('foo bar baz'); - }); - - it('empty string → empty', () => { - expect(rewrap('')).toBe(''); - }); - - it('only blank lines → empty', () => { - expect(rewrap('\n\n\n')).toBe(''); - }); - - it('single line unchanged', () => { - expect(rewrap('Just one line.')).toBe('Just one line.'); - }); - - it('multiple blank lines collapse to a single paragraph separator', () => { - // Ambiguous case: the simple rule collapses any run of blanks to one - // separator. That's acceptable for MVP. - expect(rewrap('one\n\n\n\ntwo')).toBe('one\n\ntwo'); - }); -}); diff --git a/lib/src/lib/rewrap.ts b/lib/src/lib/rewrap.ts deleted file mode 100644 index d9b2d4f21..000000000 --- a/lib/src/lib/rewrap.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copy Rewrapped heuristics. Two transformations per spec §4.1.2: - * - * 1. Unwrap hard newlines that look like display wrapping inside a flowing - * paragraph. Blank lines stay as paragraph separators. - * 2. Strip runs of Unicode box-drawing characters that form UI chrome - * (frames, table borders), keeping the text they surround. - * - * The rules are deliberately simple first-cut heuristics — see the - * mouse-and-clipboard spec §9.1 (Future) for the follow-up to refine them - * based on dogfooding. - */ - -// U+2500..U+257F is Box Drawing; U+2580..U+259F is Block Elements. We treat -// both as "frame-like" for stripping purposes, since double-line frames -// (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 FRAME_ONLY = /^[\u2500-\u259F\s]+$/; - -function isFrameOnlyLine(line: string): boolean { - if (line.length === 0) return false; - if (!FRAME_ONLY.test(line)) return false; - return BOX_CHAR.test(line); -} - -function stripLeadingAndTrailingFrame(line: string): string { - return line - .replace(/^[\u2500-\u259F]+\s?/, '') - .replace(/\s?[\u2500-\u259F]+$/, ''); -} - -/** - * Core rewrap. See module doc. - * - * Block-shape selections should NOT be run through this — they're - * intentionally rectangular slabs and the callers skip rewrap for them. - */ -export function rewrap(text: string): string { - const rawLines = text.split('\n'); - - // Pass 1: drop frame-only lines and strip leading/trailing box runs from - // the rest. - const cleaned = rawLines - .filter((l) => !isFrameOnlyLine(l)) - .map((l) => stripLeadingAndTrailingFrame(l).trimEnd()); - - // Pass 2: group into paragraphs. A blank line is a paragraph separator. - // Lines within a paragraph are joined with a single space. - const paragraphs: string[] = []; - let current: string[] = []; - for (const raw of cleaned) { - const line = raw.trim(); - if (line === '') { - if (current.length) { - paragraphs.push(current.join(' ')); - current = []; - } - } else { - current.push(line); - } - } - if (current.length) paragraphs.push(current.join(' ')); - - return paragraphs.join('\n\n'); -} - -// Exported for targeted unit tests. -export const __testing = { isFrameOnlyLine, stripLeadingAndTrailingFrame }; diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index cd90ed1b1..f0e7c04a5 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -675,6 +675,15 @@ export function getTerminalInstance(id: string): Terminal | null { return registry.get(id)?.terminal ?? null; } +/** Re-arm cancel-on-change for a selection moved without a drag (the copy + * editor's nudge): the baseline is its text now. */ +export function refreshSelectionBaseline(id: string): void { + const entry = registry.get(id); + const sel = getMouseSelectionState(id).selection; + if (!entry) return; + entry.setSelectionBaseline(sel && !sel.dragging ? extractSelectionText(entry.terminal, sel) : null); +} + export function getTerminalOverlayDims(id: string): TerminalOverlayDims | null { const entry = registry.get(id); if (!entry) return null; diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index a74f69943..6f98d600d 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -263,6 +263,8 @@ describe('terminal-mouse-router: override suppression', () => { endCol: 2, dragging: false, }); + // Mouse-up opens the copy editor over what was dragged (spec §4). + expect(getMouseSelectionState('t1').copyEditor).toMatchObject({ scope: 0, format: 'auto' }); cleanup(); }); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index ce294d0d6..fd67762f8 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -11,6 +11,7 @@ import { stateRequiresNativeMouseSuppression, updateDrag, } from './mouse-selection'; +import { openCopyEditor } from './copy-editor'; import { detectTokenInBufferLine } from './smart-token'; import { extractSelectionText } from './selection-text'; import type { TerminalOverlayDims } from './terminal-store'; @@ -228,6 +229,7 @@ export function attachTerminalMouseRouter({ setHintToken(id, null); const sel = getMouseSelectionState(id).selection; setSelectionBaseline(sel ? extractSelectionText(terminal, sel) : null); + openCopyEditor(id, terminal); clearTemporaryOverrideAfterMouseDispatch(id); consumePointerEvent(ev, suppressNativeMouse || isNonMousePointerEvent(ev)); }; diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 8ccd38cfd..c6b728201 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -39,6 +39,7 @@ export { getTerminalShellKind, getTerminalInstance, getTerminalOverlayDims, + refreshSelectionBaseline, getSessionInputVersion, isUntouched, mountElement, diff --git a/lib/src/stories/CopyEditor.stories.tsx b/lib/src/stories/CopyEditor.stories.tsx new file mode 100644 index 000000000..2ed91f8a2 --- /dev/null +++ b/lib/src/stories/CopyEditor.stories.tsx @@ -0,0 +1,190 @@ +import { useEffect, useRef } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import '@xterm/xterm/css/xterm.css'; +import { CopyEditor } from '../components/CopyEditor'; +import { SelectionOverlay } from '../components/SelectionOverlay'; +import { + focusSession, + getOrCreateTerminal, + getTerminalInstance, + getTerminalOverlayDims, + mountElement, + refitSession, + unmountElement, +} from '../lib/terminal-registry'; +import type { FakeScenario } from '../lib/platform'; +import { openCopyEditor, setCopyFormat, setCopyScope } from '../lib/copy-editor'; +import { getMouseSelectionState, setCopyEditor, setSelection, type Selection } from '../lib/mouse-selection'; +import type { BreakKind, CopyFormat } from '../lib/copy-text'; +import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../components/design'; +import { TouchUiContext } from '../components/touch-ui-context'; +import { settleTerminals, waitForCondition } from './settle-terminals'; + +// A Claude Code reply as Ink hard-wraps it at 76 of 80 columns. +const REPLY = [ + '> why is selection-text.test.ts flaky on CI?', + '', + '⏺ The flake comes from a race between the PTY exit event and the final flush', + ' of the output buffer. When the child exits before xterm has drained its', + ' write queue, the last chunk is dropped and the assertion on the prompt', + ' text fails intermittently.', + '', + ' The fix is to await the write callback before reading the buffer:', + '', + ' await new Promise((resolve) => terminal.write(chunk, resolve));', + ' const text = extractSelectionText(terminal, selection);', + " expect(text).toBe('user@dormouse:~$ ls');", + '', + ' I opened a draft with the change: https://github.com/diffplug/dormouse/pul', + ' l/853/files#diff-7c1f3e9a2b8d4f6e0a5c7b9d1e3f5a7c9b1d3e5f', + '', +]; +const SCENARIO_CLAUDE_REPLY: FakeScenario = { + name: 'claude-reply', + chunks: [{ delay: 0, data: REPLY.join('\r\n') }], +}; + +interface EditorPreset { + scope?: number; + format?: CopyFormat; + overrides?: Record; +} + +function CopyEditorStory({ + id, + selection, + editor = {}, + touch = false, +}: { + id: string; + selection: Omit; + editor?: EditorPreset; + touch?: boolean; +}) { + const hostRef = useRef(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + getOrCreateTerminal(id); + mountElement(id, host); + const observer = new ResizeObserver(() => refitSession(id)); + observer.observe(host); + return () => { + observer.disconnect(); + unmountElement(id); + }; + }, [id]); + + useEffect(() => { + focusSession(id, true); + }, [id]); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType; + const apply = () => { + if (cancelled) return; + const dims = getTerminalOverlayDims(id); + const terminal = getTerminalInstance(id); + if (!dims || dims.cellHeight === 0 || !terminal) { + timer = setTimeout(apply, 50); + return; + } + // What a mouse-up does: finalize, then open the editor over it. + setSelection(id, { ...selection, dragging: false, startedInScrollback: false }); + openCopyEditor(id, terminal); + if (editor.scope) setCopyScope(id, editor.scope); + if (editor.format) setCopyFormat(id, editor.format); + const opened = getMouseSelectionState(id).copyEditor; + if (opened && editor.overrides) setCopyEditor(id, { ...opened, overrides: editor.overrides }); + }; + timer = setTimeout(apply, 100); + return () => { + cancelled = true; + clearTimeout(timer); + setSelection(id, null); + }; + }, [id, selection, editor]); + + return ( + +
+
+ + +
+ + ); +} + +const meta: Meta = { + title: 'Components/CopyEditor', + component: CopyEditorStory, + parameters: { + fakePty: { scenario: SCENARIO_CLAUDE_REPLY }, + }, + // Hold the snapshot until the terminal painted and the editor opened. + play: async ({ args }) => { + await settleTerminals(); + await waitForCondition(() => getMouseSelectionState(args.id).copyEditor !== null); + }, +}; + +export default meta; +type Story = StoryObj; + +/** A hard-wrapped paragraph, the drag ending mid-word: Auto joins each wrap + * with a space; `e` would expand to whole words. */ +export const Prose: Story = { + args: { + id: 'copy-editor-prose', + selection: { startRow: 2, startCol: 25, endRow: 5, endCol: 20, shape: 'linewise' }, + }, +}; + +/** The same drag in Exact: every displayed break and indent. */ +export const Exact: Story = { + args: { + id: 'copy-editor-exact', + selection: { startRow: 2, startCol: 25, endRow: 5, endCol: 20, shape: 'linewise' }, + editor: { format: 'exact' }, + }, +}; + +/** A URL split at the margin with its start missed, expanded once: the dashed + * scope shows what it adds, and Auto rejoins the split with no space. */ +export const ExpandedUrl: Story = { + args: { + id: 'copy-editor-url', + selection: { startRow: 13, startCol: 44, endRow: 14, endCol: 30, shape: 'linewise' }, + editor: { scope: 1 }, + }, +}; + +/** Prose then code: Auto joins the wraps and keeps the code's breaks; one + * break flipped by hand reads `Auto*`. */ +export const ProseAndCode: Story = { + args: { + id: 'copy-editor-code', + selection: { startRow: 2, startCol: 2, endRow: 11, endCol: 44, shape: 'linewise' }, + editor: { overrides: { 1: 'keep' } }, + }, +}; + +/** A block slab: never rewrapped, never expanded, no edge keys. */ +export const Block: Story = { + args: { + id: 'copy-editor-block', + selection: { startRow: 2, startCol: 2, endRow: 5, endCol: 30, shape: 'block' }, + }, +}; + +/** Touch: no key hints, above the selection, clear of the thumb. */ +export const Touch: Story = { + args: { + id: 'copy-editor-touch', + touch: true, + selection: { startRow: 13, startCol: 36, endRow: 14, endCol: 58, shape: 'linewise' }, + }, +}; diff --git a/lib/src/stories/SelectionPopup.stories.tsx b/lib/src/stories/SelectionPopup.stories.tsx deleted file mode 100644 index 06472af43..000000000 --- a/lib/src/stories/SelectionPopup.stories.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useEffect, useRef } from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; -import '@xterm/xterm/css/xterm.css'; -import { SelectionPopup } from '../components/SelectionPopup'; -import { - focusSession, - getOrCreateTerminal, - getTerminalOverlayDims, - mountElement, - refitSession, - unmountElement, -} from '../lib/terminal-registry'; -import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform'; -import { getMouseSelectionState, setSelection, type Selection } from '../lib/mouse-selection'; -import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../components/design'; -import { TouchUiContext } from '../components/touch-ui-context'; -import { settleTerminals, waitForCondition } from './settle-terminals'; - -function SelectionPopupStory({ - id, - selection, - touch = false, -}: { - id: string; - selection: Omit; - touch?: boolean; -}) { - const terminalHostRef = useRef(null); - - useEffect(() => { - const terminalHost = terminalHostRef.current; - if (!terminalHost) return; - - getOrCreateTerminal(id); - mountElement(id, terminalHost); - - const observer = new ResizeObserver(() => refitSession(id)); - observer.observe(terminalHost); - - return () => { - observer.disconnect(); - unmountElement(id); - }; - }, [id]); - - useEffect(() => { - focusSession(id, true); - }, [id]); - - useEffect(() => { - let cancelled = false; - let timer: ReturnType; - - const applySelection = () => { - if (cancelled) return; - const dims = getTerminalOverlayDims(id); - if (!dims || dims.cellHeight === 0) { - timer = setTimeout(applySelection, 50); - return; - } - // dragging: false so the popup (shown after mouse-up) renders. - setSelection(id, { ...selection, dragging: false, startedInScrollback: false }); - }; - - timer = setTimeout(applySelection, 100); - return () => { - cancelled = true; - clearTimeout(timer); - setSelection(id, null); - }; - }, [id, selection]); - - return ( - -
-
- -
- - ); -} - -const meta: Meta = { - title: 'Components/SelectionPopup', - component: SelectionPopupStory, - parameters: { - fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) }, - }, - // Hold the snapshot until the terminal has painted AND the story's own timer has - // applied the selection (which is what reveals the copy popup). - play: async ({ args }) => { - await settleTerminals(); - await waitForCondition(() => getMouseSelectionState(args.id).selection !== null); - }, -}; - -export default meta; -type Story = StoryObj; - -const SELECTION: Omit = { - startRow: 2, - startCol: 5, - endRow: 6, - endCol: 24, - shape: 'linewise', - dragging: false, -}; - -// Desktop: all three buttons carry their keyboard shortcuts and, for a downward -// drag, sit below the selection. -export const Desktop: Story = { - args: { - id: 'selection-popup-desktop', - selection: SELECTION, - }, -}; - -// Mobile: no keyboard shortcuts, and the popup sits above the selection (never -// below) so the thumb that finished the drag can't cover it. -export const Mobile: Story = { - args: { - id: 'selection-popup-mobile', - touch: true, - selection: SELECTION, - }, -}; diff --git a/lib/src/stories/TextSelection.stories.tsx b/lib/src/stories/TextSelection.stories.tsx index af5125c32..b5128d568 100644 --- a/lib/src/stories/TextSelection.stories.tsx +++ b/lib/src/stories/TextSelection.stories.tsx @@ -3,12 +3,13 @@ import type { Meta, StoryObj } from '@storybook/react'; import { TerminalPane } from '../components/TerminalPane'; import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform'; import { getMouseSelectionState, setSelection, type Selection } from '../lib/mouse-selection'; -import { getTerminalOverlayDims } from '../lib/terminal-registry'; +import { getTerminalInstance, getTerminalOverlayDims } from '../lib/terminal-registry'; +import { openCopyEditor } from '../lib/copy-editor'; import { settleTerminals, waitForCondition } from './settle-terminals'; /** * Wires a programmatic selection state onto a live TerminalPane so we can - * visualize the overlay, the Alt hint, and the copy popup in their various + * visualize the overlay, the Alt hint, and the copy editor in their various * positions without scripting a real mouse drag. */ function TextSelectionStory({ @@ -33,6 +34,9 @@ function TextSelectionStory({ return; } setSelection(id, { ...selection, startedInScrollback: false }); + // A finalized drag opens the copy editor, as mouse-up does. + const terminal = getTerminalInstance(id); + if (!selection.dragging && terminal) openCopyEditor(id, terminal); }; timer = setTimeout(tryApply, 100); @@ -105,11 +109,11 @@ export const HintWhenDraggingUp: Story = { }, }; -// --- Copy popup positioning --------------------------------------------- +// --- Copy editor positioning -------------------------------------------- -export const PopupAfterDragDown: Story = { +export const EditorAfterDragDown: Story = { args: { - id: 'text-sel-popup-down', + id: 'text-sel-editor-down', selection: { startRow: 2, startCol: 5, endRow: 6, endCol: 24, @@ -119,9 +123,9 @@ export const PopupAfterDragDown: Story = { }, }; -export const PopupAfterDragUp: Story = { +export const EditorAfterDragUp: Story = { args: { - id: 'text-sel-popup-up', + id: 'text-sel-editor-up', selection: { startRow: 8, startCol: 22, endRow: 4, endCol: 6, diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 2d9842a19..16ee1ab7a 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,7 +13,7 @@ "docs/specs/hosted.md": 1300, "docs/specs/layout.md": 11500, "docs/specs/mobile-terminal-ui.md": 2200, - "docs/specs/mouse-and-clipboard.md": 3750, + "docs/specs/mouse-and-clipboard.md": 4250, "docs/specs/one-time.md": 3750, "docs/specs/pocket-app.md": 5050, "docs/specs/relay.md": 10250, diff --git a/vscode-ext/README.md b/vscode-ext/README.md index b3a1b72c0..2229773e3 100644 --- a/vscode-ext/README.md +++ b/vscode-ext/README.md @@ -51,9 +51,9 @@ Dormouse drives the agent-browser or Playwright CLI you already have rather than ## Select and copy-paste like you meant - + -When a TUI grabs the mouse, one click in the pane header takes it back. **Copy Raw** keeps the hard wraps; **Copy Rewrapped** joins them back into the line the program printed. Press `e` mid-drag to grab a whole URL or path, and paste a screenshot to hand your agent its path. +When a TUI grabs the mouse, one click in the pane header takes it back. Every selection opens a copy editor showing exactly what lands on the clipboard: **Auto** joins the hard wraps back into the line the program printed, **Exact** keeps them, and `e` expands a clipped selection to the whole URL, path, or paragraph. Paste a screenshot to hand your agent its path. ## Getting started diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 2d7bd47de..1bbe7d423 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -7,8 +7,8 @@ exports[`TutRunner snapshots > renders Copy paste with all items incomplete 1`] ● Drag-select some text The paragraph below is a good example — "Some terminal programs..." - · Copy-paste it somewhere else with "Copy Raw" - · Copy-paste it somewhere else with "Copy Rewrapped" + · Copy-paste it somewhere else in "Exact" + · Copy-paste it somewhere else in "Auto" · Click the cursor icon in changelog Press p to toggle the Place To Paste. diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index 7fdde870b..510cf3d05 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -289,8 +289,8 @@ export class TutDetector { const prev = this.prevMouse.get(id) ?? DEFAULT_MOUSE_SELECTION_STATE; if (current.copyFlash && current.copyFlash !== prev.copyFlash) { - if (current.copyFlash === "raw") this.state.markComplete("cp-raw"); - if (current.copyFlash === "rewrapped") this.state.markComplete("cp-rewrap"); + if (current.copyFlash === "exact") this.state.markComplete("cp-raw"); + if (current.copyFlash === "auto") this.state.markComplete("cp-rewrap"); } if (!prev.selection && current.selection) { diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 1b9cdedd8..2c25d5d61 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -108,12 +108,12 @@ const COPY_PASTE_SECTION: Section = { }, { id: 'cp-raw', - title: 'Copy-paste it somewhere else with "Copy Raw"', - hint: 'When you paste, notice how it keeps all the line-breaks. Gross!', + title: 'Copy-paste it somewhere else in "Exact"', + hint: 'Press `f` to switch format. When you paste, notice how it keeps all the line-breaks. Gross!', }, { id: 'cp-rewrap', - title: 'Copy-paste it somewhere else with "Copy Rewrapped"', + title: 'Copy-paste it somewhere else in "Auto"', hint: 'When you paste, notice how the line-breaks were removed, and the text rewraps neatly wherever you paste it?', }, diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index 312789d86..e7154e44c 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -962,11 +962,13 @@ export default function Home() { the thing.

- Then copy it the way you meant it. Raw keeps - the hard wraps; Rewrapped joins them back - into the line the program actually printed. Tap{" "} + Then copy it the way you meant it, seeing exactly what lands on + the clipboard with every line break marked.{" "} + Auto joins the hard wraps back into + the line the program actually printed; Exact{" "} + keeps them. Press{" "} e{" "} - mid-drag to snap the selection out to the whole URL or file path. + to expand a clipped selection out to the whole URL, path, or paragraph.

From b7885c51917631486e5a93796dd8473796429c3d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 18:13:54 -0700 Subject: [PATCH 06/36] Simplify the copy editor - Cancel-on-change follows the selection: the lifecycle re-snapshots it whenever the store's selection changes, replacing the router's injected setter, the nudge's re-arm, and a dead setTerminalSelectionBaseline. - setSelection writes the copy editor in the same notify (none unless one is given), so an editor never outlives its selection; consumers gate on copyEditor alone. - Span carries its block flag; selectionOfSpan inverts spanOfSelection. - Row text, decoration and paragraph bounds are computed once per row, and one decoration rule serves rows and extracted lines. - The editor renders each format once per scope and reuses it for the preview and the duplicate check; a mark passes its own kind to flip. - Whole-words labels classify edge tokens with the smart-token detector, which also shares the new readLineCells cell reader. - Segments and the Copy button use the design recipes; the preview is memoized against the render tick; tests and stories share one fixture. Co-Authored-By: Claude Opus 5.5 --- docs/specs/mouse-and-clipboard.md | 2 +- lib/src/components/CopyEditor.test.tsx | 54 ++-- lib/src/components/CopyEditor.tsx | 60 +++-- lib/src/components/SelectionOverlay.tsx | 18 +- lib/src/components/design.tsx | 4 + .../keyboard/handle-mouse-selection-keys.ts | 8 +- lib/src/lib/buffer-cells.ts | 20 ++ lib/src/lib/copy-editor.test.ts | 71 ++--- lib/src/lib/copy-editor.ts | 129 ++++----- lib/src/lib/copy-selection.ts | 18 +- lib/src/lib/copy-text-fixtures.ts | 43 +++ lib/src/lib/copy-text.test.ts | 54 ++-- lib/src/lib/copy-text.ts | 251 ++++++++++-------- lib/src/lib/mouse-selection.ts | 29 +- lib/src/lib/smart-token.ts | 19 +- lib/src/lib/terminal-lifecycle.ts | 32 +-- lib/src/lib/terminal-mouse-router.test.ts | 1 - lib/src/lib/terminal-mouse-router.ts | 6 - lib/src/lib/terminal-registry.ts | 1 - lib/src/lib/terminal-store.ts | 9 +- lib/src/stories/CopyEditor.stories.tsx | 64 +---- 21 files changed, 414 insertions(+), 479 deletions(-) create mode 100644 lib/src/lib/buffer-cells.ts create mode 100644 lib/src/lib/copy-text-fixtures.ts diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 506b99271..de234cb7b 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -87,7 +87,7 @@ A small hint sits adjacent to an in-progress selection — below when dragging d A selection is anchored to the characters under it, not to screen coordinates: stored in absolute buffer rows (scrollback + viewport). - **Pure scroll** — vertical translation with no character changes — carries the selection along; coordinate math only, no matching. -- **Content change:** any change to a cell the finalized selection overlaps cancels it immediately; repaints elsewhere on screen are irrelevant. A text snapshot taken at finalize, and again after an editor nudge (§4.3), is compared on each xterm render; **never add a partial-match or content-tracking heuristic** — cancel-on-change is the rule (§9.1). +- **Content change:** any change to a cell the finalized selection overlaps cancels it immediately; repaints elsewhere on screen are irrelevant. A text snapshot, retaken whenever the selection is finalized or moved (§4.3), is compared on each xterm render; **never add a partial-match or content-tracking heuristic** — cancel-on-change is the rule (§9.1). - **Terminal resize** counts as a content change and cancels any active selection. ### 3.5 Selection in the Live Region vs. Scrollback diff --git a/lib/src/components/CopyEditor.test.tsx b/lib/src/components/CopyEditor.test.tsx index 194293f34..11fb4e483 100644 --- a/lib/src/components/CopyEditor.test.tsx +++ b/lib/src/components/CopyEditor.test.tsx @@ -4,7 +4,6 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Terminal } from '@xterm/xterm'; vi.mock('../lib/copy-selection', () => ({ copySelection: vi.fn() })); vi.mock('../lib/platform', () => ({ IS_MAC: true })); @@ -12,6 +11,7 @@ vi.mock('../lib/platform', () => ({ IS_MAC: true })); vi.mock('../lib/terminal-registry', () => ({ getTerminalOverlayDims: vi.fn(), getTerminalInstance: vi.fn() })); import { copySelection } from '../lib/copy-selection'; import { openCopyEditor } from '../lib/copy-editor'; +import { CLAUDE_REPLY, fakeXterm } from '../lib/copy-text-fixtures'; import { __resetMouseSelectionForTests, beginDrag, @@ -28,30 +28,6 @@ import { WorkspaceActiveContext } from './wall/wall-context'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; -const LINES = [ - 'The flake comes from a race between the PTY exit event and the final flush', - 'of the output buffer. When the child exits before xterm has drained its', - 'write queue, the last chunk is dropped and the assertion on the prompt', - 'text fails intermittently.', - '', - 'https://github.com/diffplug/dormouse/pull/853', -]; - -/** An xterm buffer over plain strings, one narrow cell per character. */ -function fakeTerminal(lines: string[]): Terminal { - const getLine = (r: number) => { - const text = lines[r]; - if (text === undefined) return undefined; - return { - length: 80, - isWrapped: false, - getCell: (c: number) => ({ getChars: () => text[c] ?? '', getWidth: () => 1 }), - translateToString: (_trim?: boolean, start = 0, end = 80) => text.slice(start, end), - }; - }; - return { cols: 80, rows: 24, buffer: { active: { length: lines.length, getLine } } } as unknown as Terminal; -} - // A forty-row viewport over ten scrollback lines, so a scroll moves the editor // by whole cells. const DIMS = { @@ -70,7 +46,7 @@ const DIMS = { let container: HTMLDivElement; let root: Root; let dims: typeof DIMS; -const terminal = fakeTerminal(LINES); +const terminal = fakeXterm(CLAUDE_REPLY); const editor = () => container.querySelector('[data-copy-editor-for="term-1"]'); const button = (label: string) => Array.from(container.querySelectorAll('button')).find((b) => b.textContent === label)!; @@ -114,12 +90,12 @@ describe('CopyEditor: opening and placement', () => { }); it('opens below a selection with room, and reanchors when it scrolls', () => { - drag(0, 23, 3, 26); + drag(2, 25, 5, 27); render(); - expect(editor()?.style.top).toBe('44px'); + expect(editor()?.style.top).toBe('64px'); dims.viewportY = 2; act(() => bumpRenderTick()); - expect(editor()?.style.top).toBe('24px'); + expect(editor()?.style.top).toBe('44px'); }); it('opens above when there is more room there', () => { @@ -145,14 +121,14 @@ describe('CopyEditor: opening and placement', () => { describe('CopyEditor: preview and controls', () => { it('previews Auto with a mark on every break', () => { - drag(0, 0, 3, 26); + drag(2, 2, 5, 27); render(); expect(container.textContent).toContain('final flush␣of the output'); expect(container.textContent).toContain('1 line'); }); it('switches format from the segment and dims a format that adds nothing', () => { - drag(0, 0, 3, 26); + drag(2, 2, 5, 27); render(); expect(button('Spaces').className).toContain('opacity-50'); act(() => button('Exact').click()); @@ -161,7 +137,7 @@ describe('CopyEditor: preview and controls', () => { }); it('flips one break from its mark and marks the format edited', () => { - drag(0, 0, 3, 26); + drag(2, 2, 5, 27); render(); act(() => button('␣').click()); expect(getMouseSelectionState('term-1').copyEditor?.overrides).toEqual({ 0: 'none' }); @@ -169,7 +145,7 @@ describe('CopyEditor: preview and controls', () => { }); it('expands from the scope segment and shows what it added', () => { - drag(0, 25, 1, 5); + drag(2, 27, 3, 5); render(); act(() => button('Whole words').click()); expect(getMouseSelectionState('term-1').copyEditor?.scope).toBe(1); @@ -177,14 +153,14 @@ describe('CopyEditor: preview and controls', () => { }); it('copies from the button', () => { - drag(0, 0, 3, 26); + drag(2, 2, 5, 27); render(); - act(() => button('[Cmd+C]Copy').click()); + act(() => button('Copy').click()); expect(copySelection).toHaveBeenCalledWith('term-1'); }); it('dismisses on a click outside, not inside', () => { - drag(0, 0, 3, 26); + drag(2, 2, 5, 27); render(); act(() => { editor()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); expect(getMouseSelectionState('term-1').selection).not.toBeNull(); @@ -195,7 +171,7 @@ describe('CopyEditor: preview and controls', () => { describe('CopyEditor: flash', () => { beforeEach(() => { - drag(5, 0, 5, 10); + drag(5, 2, 5, 12); render(); }); @@ -210,10 +186,10 @@ describe('CopyEditor: flash', () => { vi.useFakeTimers(); act(() => flashCopy('term-1', 'auto')); act(() => vi.advanceTimersByTime(400)); - drag(1, 0, 1, 10); + drag(9, 4, 9, 20); act(() => flashCopy('term-1', 'auto')); act(() => vi.advanceTimersByTime(300)); - expect(getMouseSelectionState('term-1').selection?.startRow).toBe(1); + expect(getMouseSelectionState('term-1').selection?.startRow).toBe(9); act(() => vi.advanceTimersByTime(400)); expect(getMouseSelectionState('term-1').selection).toBeNull(); }); diff --git a/lib/src/components/CopyEditor.tsx b/lib/src/components/CopyEditor.tsx index d027097ad..96bebecc7 100644 --- a/lib/src/components/CopyEditor.tsx +++ b/lib/src/components/CopyEditor.tsx @@ -1,5 +1,5 @@ import { clsx } from 'clsx'; -import { useContext, useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'; +import { memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'; import { CheckIcon } from '@phosphor-icons/react'; import { DEFAULT_MOUSE_SELECTION_STATE, @@ -10,11 +10,11 @@ import { subscribeToRenderTick, } from '../lib/mouse-selection'; import { COPY_FORMATS, spanOfSelection, type BreakKind, type CopyFormat, type Piece, type Rendering } from '../lib/copy-text'; -import { copyEditorView, flipCopyBreak, setCopyFormat, setCopyScope } from '../lib/copy-editor'; +import { duplicateFormats, editorRendering, flipCopyBreak, formatRenderings, setCopyFormat, setCopyScope } from '../lib/copy-editor'; import { copySelection } from '../lib/copy-selection'; import { getTerminalInstance, getTerminalOverlayDims } from '../lib/terminal-registry'; import { IS_MAC } from '../lib/platform'; -import { modalSurface, Shortcut } from './design'; +import { modalActionButton, modalSurface, popupButton, Shortcut } from './design'; import { TouchUiContext } from './touch-ui-context'; import { WorkspaceActiveContext } from './wall/wall-context'; @@ -71,12 +71,21 @@ export function CopyEditor({ terminalId }: { terminalId: string }) { const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; const { selection, copyEditor, copyFlash } = state; - const open = workspaceActive && !!selection && !selection.dragging && !!copyEditor; + const open = workspaceActive && !!copyEditor; const terminal = open ? getTerminalInstance(terminalId) : null; - const view = useMemo( - () => (terminal && selection && copyEditor ? copyEditorView(terminal, selection, copyEditor) : null), - [terminal, selection, copyEditor], + // Every format at the scope is rendered once per scope, so `f` and the + // duplicate check reuse it; only a per-break edit renders again. + const scope = copyEditor?.scopes[copyEditor.scope].span; + const renderings = useMemo( + () => (terminal && selection && scope ? formatRenderings(terminal, selection, scope) : null), + [terminal, selection, scope], ); + const rendering = useMemo( + () => (terminal && selection && copyEditor && renderings ? editorRendering(terminal, selection, copyEditor, renderings) : null), + [terminal, selection, copyEditor, renderings], + ); + const sameAs = useMemo(() => (renderings ? duplicateFormats(renderings) : {}), [renderings]); + const onFlip = useCallback((index: number, kind: BreakKind) => flipCopyBreak(terminalId, index, kind), [terminalId]); const [placement, setPlacement] = useState(null); useLayoutEffect(() => { @@ -117,9 +126,8 @@ export function CopyEditor({ terminalId }: { terminalId: string }) { return () => window.removeEventListener('mousedown', onMouseDown, true); }, [open, terminalId]); - if (!open || !view || !placement || !copyEditor || !terminal) return null; + if (!rendering || !placement || !copyEditor || !selection) return null; - const { scope, rendering, sameAs } = view; const edited = Object.keys(copyEditor.overrides).length > 0; const keys = (node: ReactNode) => (touchUi ? null : {node}); const style: CSSProperties = { @@ -163,25 +171,26 @@ export function CopyEditor({ terminalId }: { terminalId: string }) {
- flipCopyBreak(terminalId, terminal, i)} /> +
{MARK_GLYPH.keep} kept {MARK_GLYPH.space} space {MARK_GLYPH.none} joined - {scope.id !== 'selection' && abc expanded} + {copyEditor.scope > 0 && abc expanded} {selection.shape !== 'block' && keys(<>{LEFT}{RIGHT} end {SHIFT}{LEFT}{RIGHT} start)} {rendering.lines} {rendering.lines === 1 ? 'line' : 'lines'} · {rendering.text.length} ch + {keys({IS_MAC ? 'Cmd+C' : 'Ctrl+C'})}
@@ -205,11 +214,10 @@ function Segment({ value, items, onPick }: { title={it.title} aria-pressed={it.id === value} onClick={() => onPick(it.id)} - className={clsx( - 'shrink-0 whitespace-nowrap border-r border-border px-1.5 py-0.5 last:border-r-0', - it.id === value ? 'bg-header-active-bg text-header-active-fg' : 'hover:bg-foreground/10', - it.dim && it.id !== value && 'opacity-50', - )} + className={popupButton({ + selected: it.id === value, + class: clsx('shrink-0 whitespace-nowrap border-r border-border last:border-r-0', it.dim && it.id !== value && 'opacity-50'), + })} > {it.label} @@ -218,13 +226,13 @@ function Segment({ value, items, onPick }: { ); } -function Mark({ kind, auto, onFlip }: { kind: BreakKind; auto: BreakKind; onFlip: () => void }) { +function Mark({ index, kind, auto, onFlip }: { index: number; kind: BreakKind; auto: BreakKind; onFlip: (index: number, kind: BreakKind) => void }) { const edited = kind !== auto; return ( - ))} - - ); -} - -const meta: Meta = { - title: 'Prototypes/Copy editor', - component: CopyEditorPlayground, - parameters: { layout: 'fullscreen' }, - argTypes: { - concept: { control: 'inline-radio', options: CONCEPTS.map((c) => c.id) }, - host: { control: 'inline-radio', options: HOSTS.map((h) => h.id) }, - }, -}; - -export default meta; -type Story = StoryObj; - -/** Drag in the terminal; switch concepts and hosts from the toolbar. */ -export const Playground: Story = { - args: { concept: 'chooser', host: 'claudeInline' }, -}; - -/** A: a hard-wrapped paragraph, the drag ending mid-word. ★ Rewrapped joins - * the rows; "Whole words" grows the clipped end. */ -export const ChooserProse: Story = { - args: { concept: 'chooser', host: 'claudeInline', preset: { a: { row: 2, col: 25 }, b: { row: 5, col: 20 } } }, -}; - -/** A: a URL the TUI hard-wrapped at the margin, with its start missed. Focus - * is on "Full URL": the dashed scope shows what it adds, and the preview marks - * the added characters and the rejoined split. */ -export const ChooserClippedUrl: Story = { - args: { concept: 'chooser', host: 'claudeInline', preset: { a: { row: 13, col: 44 }, b: { row: 14, col: 30 }, key: '6' } }, -}; - -/** A, fullscreen TUI: the program painted the highlight and owns the mouse; - * Dormouse shadowed the drag and opens on the copy chord. The last row is the - * program's own OSC 52 text, offered instead of silently dropped. */ -export const ChooserFullscreenTui: Story = { - args: { - concept: 'chooser', - host: 'claudeFullscreen', - preset: { a: { row: 16, col: 2 }, b: { row: 17, col: 43 } }, - programCopy: 'To verify locally, run `pnpm --filter dormouse-lib exec vitest run src/lib/selection-text.test.ts --repeat 50`', - }, -}; - -/** A, plain shell: xterm knows which rows are true soft wraps, so Rewrapped - * rejoins the token exactly while Spaces breaks it. */ -export const ChooserShellSoftWrap: Story = { - args: { concept: 'chooser', host: 'shell', preset: { a: { row: 6, col: 0 }, b: { row: 8, col: 37 } } }, -}; - -/** B: the copy chord already put ★ on the clipboard; the receipt shows what - * landed and offers the others. A code block, so Rewrapped kept the breaks. */ -export const ReceiptCodeBlock: Story = { - args: { concept: 'receipt', host: 'claudeFullscreen', preset: { a: { row: 9, col: 0 }, b: { row: 11, col: 44 } } }, -}; - -/** C: a drag inside one paragraph, grown to the whole reply, with one break - * flipped by hand (the dashed chip). */ -export const EditorBreakChips: Story = { - args: { concept: 'editor', host: 'claudeInline', preset: { a: { row: 3, col: 10 }, b: { row: 4, col: 40 }, scopeIndex: 3, overrides: { 1: 'keep' } } }, -}; diff --git a/lib/src/stories/copy-editor/CopyEditor2.stories.tsx b/lib/src/stories/copy-editor/CopyEditor2.stories.tsx deleted file mode 100644 index 1e7d343db..000000000 --- a/lib/src/stories/copy-editor/CopyEditor2.stories.tsx +++ /dev/null @@ -1,513 +0,0 @@ -import { clsx } from 'clsx'; -import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; -import { CheckIcon } from '@phosphor-icons/react'; -import { modalSurface, Shortcut } from '../../components/design'; -import { ArmedHint, CELL_H, COPY_CHORD, ClipboardPanel, MockTerminal } from './CopyMockups'; -import { - SCREENS, - computeScopes, - duplicates, - normalizeSpan, - nudge, - render, - type BreakKind, - type FormatId, - type GridPos, - type MockRow, - type MockScreen, - type Piece, - type Rendering, - type Span, - type Tone, -} from './model'; - -// The copy editor after the first round (Prototypes/Copy editor): only the -// editor concept, at full pane width, driven by letters instead of digits. -// e / ⬆︎e expand / shrink the scope -// f / ⬆︎f next / previous format (Auto → Exact → Spaces → No breaks) -// ◀ ▶ move the end a word; ⬆︎◀ ⬆︎▶ move the start -// ↵ or the copy chord copies; Esc closes. - -/** Shift, as the mobile compass rose writes it (`lib/src/lib/mobile-gesture-menu.ts`). */ -const SHIFT = '⬆︎'; -const LEFT = '◀'; -const RIGHT = '▶'; - -const FORMATS2: { id: FormatId; name: string; blurb: string }[] = [ - { id: 'smart', name: 'Auto', blurb: 'each line break judged on its own' }, - { id: 'exact', name: 'Exact', blurb: 'as displayed' }, - { id: 'spaces', name: 'Spaces', blurb: 'every line break becomes one space' }, - { id: 'joined', name: 'No breaks', blurb: 'every line break deleted' }, -]; -const AUTO: FormatId = 'smart'; -const formatName = (f: FormatId) => FORMATS2.find((x) => x.id === f)!.name; - -// --------------------------------------------------------------------------- -// Taller screens: some history above, so the editor has room beside a -// selection the way it would in a real pane. - -const t = (text: string, tone?: Tone): MockRow => (tone ? { text, tones: [{ from: 0, to: text.length, tone }] } : { text }); - -function withHistory(base: MockScreen, history: MockRow[], historyBlock?: { from: number; to: number; label: string }): MockScreen { - const n = history.length; - return { - ...base, - rows: [...history, ...base.rows], - blocks: [...(historyBlock ? [historyBlock] : []), ...base.blocks.map((b) => ({ ...b, from: b.from + n, to: b.to + n }))], - }; -} - -const CLAUDE_HISTORY: MockRow[] = [ - t('> run the selection tests', 'user'), - t(''), - { text: '⏺ Bash(pnpm --filter dormouse-lib test selection-text)', tones: [{ from: 0, to: 1, tone: 'accent' }] }, - { text: ' ⎿ ✓ src/lib/selection-text.test.ts (12 tests) 41ms', tones: [{ from: 2, to: 3, tone: 'muted' }, { from: 5, to: 6, tone: 'pass' }, { from: 47, to: 51, tone: 'muted' }] }, - { text: ' Test Files 1 passed (1)', tones: [{ from: 8, to: 18, tone: 'muted' }, { from: 20, to: 28, tone: 'pass' }] }, - t(''), - { text: '⏺ All 12 pass locally, so the failure only reproduces on CI.', tones: [{ from: 0, to: 1, tone: 'accent' }] }, - t(''), -]; - -const PROMPT = '~/projects/dormouse ❯ '; -const promptRow = (cmd: string): MockRow => ({ text: PROMPT + cmd, prompt: true, tones: [{ from: 0, to: PROMPT.length - 2, tone: 'link' }, { from: PROMPT.length - 2, to: PROMPT.length - 1, tone: 'prompt' }] }); - -const SHELL_HISTORY: MockRow[] = [ - promptRow('git switch -c fix-selection-flake'), - t("Switched to a new branch 'fix-selection-flake'"), - promptRow('git status --short'), - { text: ' M lib/src/lib/selection-text.ts', tones: [{ from: 1, to: 2, tone: 'code' }] }, - { text: '?? lib/src/lib/selection-text.flaky.md', tones: [{ from: 0, to: 2, tone: 'muted' }] }, -]; - -const SCREENS2 = { - claude: withHistory(SCREENS.claude, CLAUDE_HISTORY, { from: 2, to: 6, label: 'Whole reply' }), - shell: withHistory(SCREENS.shell, SHELL_HISTORY), -} as const; -const CLAUDE_OFFSET = CLAUDE_HISTORY.length; -const SHELL_OFFSET = SHELL_HISTORY.length; - -// --------------------------------------------------------------------------- -// The preview: one gutter-numbered row per line that lands on the clipboard, -// every line break the selection crossed shown as a mark you can flip. - -const MARK_GLYPH: Record = { keep: '⏎', space: '␣', none: '⌁' }; -const MARK_TITLE: Record = { - keep: 'Line break kept', - space: 'Line break became one space', - none: 'Line break deleted, so the two sides join', -}; - -function Mark({ kind, auto, onFlip }: { kind: BreakKind; auto: BreakKind; onFlip: () => void }) { - const edited = kind !== auto; - return ( - - ); -} - -function TextRun({ p }: { p: Extract }) { - const text = p.lead ? p.text.replace(/ /g, '·').replace(/\t/g, '→') : p.text; - return ( - - {text} - - ); -} - -function LinedPreview({ rendering, onFlip }: { rendering: Rendering; onFlip: (index: number) => void }) { - const lines: ReactNode[][] = [[]]; - rendering.pieces.forEach((p, i) => { - const line = lines[lines.length - 1]; - if (p.t === 'text') { - line.push(); - return; - } - line.push( onFlip(p.index)} />); - if (p.kind === 'keep') lines.push([]); - }); - if (rendering.text === '') return
(empty)
; - const gutter = String(lines.length).length; - return ( -
- {lines.map((parts, n) => ( -
- - {n + 1} - -
{parts}
-
- ))} -
- ); -} - -// --------------------------------------------------------------------------- -// The editor panel - -function Segment({ value, items, onPick }: { value: T; items: { id: T; label: string; dim?: boolean; title?: string }[]; onPick: (v: T) => void }) { - return ( -
- {items.map((it) => ( - - ))} -
- ); -} - -interface EditorProps { - screen: MockScreen; - sel: Span; - scopeIndex: number; - format: FormatId; - overrides: Record; - flashed: boolean; - onScope: (i: number) => void; - onFormat: (f: FormatId) => void; - onFlip: (index: number) => void; - onCopy: () => void; -} - -function CopyEditor({ screen, sel, scopeIndex, format, overrides, flashed, onScope, onFormat, onFlip, onCopy }: EditorProps) { - const scopes = useMemo(() => computeScopes(screen, sel), [screen, sel]); - const scope = scopes[Math.min(scopeIndex, scopes.length - 1)]; - const rendering = render(screen, scope.span, sel, format, overrides); - const dups = duplicates(FORMATS2.map((f) => render(screen, scope.span, sel, f.id))); - const edited = Object.keys(overrides).length > 0; - return ( -
e.stopPropagation()} - > -
-
- onScope(scopes.findIndex((s) => s.id === id))} items={scopes.map((s) => ({ id: s.id, label: s.label }))} /> - e expand {SHIFT}e shrink -
-
- { - const same = dups.get(f.id); - return { - id: f.id, - label: `${f.name}${f.id === format && edited ? '*' : ''}`, - dim: !!same, - title: same ? `${f.blurb}: same text as ${formatName(same)}` : f.blurb, - }; - })} - /> - f {SHIFT}f -
-
-
- -
-
- - ⏎ kept - ␣ space - ⌁ joined - abc grown - - {LEFT}{RIGHT} end {SHIFT}{LEFT}{RIGHT} start - {rendering.lines} {rendering.lines === 1 ? 'line' : 'lines'} · {rendering.chars} ch - -
-
- ); -} - -/** Full width, on whichever side of the selection has more room. */ -function placeEditor(sel: Span, width: number, height: number): CSSProperties { - const gap = 6; - const below = (sel.end.row + 1) * CELL_H + gap; - const above = sel.start.row * CELL_H - gap; - const roomBelow = height - below; - const base: CSSProperties = { left: -4, width: width + 8 }; - return roomBelow >= above - ? { ...base, top: below, maxHeight: Math.max(150, Math.min(320, roomBelow)) } - : { ...base, bottom: height - above, maxHeight: Math.max(150, Math.min(320, above)) }; -} - -// --------------------------------------------------------------------------- -// Playground - -type Host = 'claudeInline' | 'claudeFullscreen' | 'shell'; -const HOSTS: { id: Host; label: string; screen: keyof typeof SCREENS2; mouseMode: boolean }[] = [ - { id: 'claudeInline', label: 'Claude Code, inline (Dormouse selects)', screen: 'claude', mouseMode: false }, - { id: 'claudeFullscreen', label: 'Claude Code, fullscreen (program owns the mouse)', screen: 'claude', mouseMode: true }, - { id: 'shell', label: 'zsh (true soft wraps)', screen: 'shell', mouseMode: false }, -]; - -interface Preset { - a: GridPos; - b: GridPos; - scopeIndex?: number; - format?: FormatId; - overrides?: Record; -} - -interface Props { - host: Host; - preset?: Preset; -} - -type Phase = 'idle' | 'dragging' | 'armed' | 'open'; - -function CopyEditorPrototype({ host: initialHost, preset }: Props) { - const [hostId, setHostId] = useState(initialHost); - const host = HOSTS.find((h) => h.id === hostId)!; - const screen = SCREENS2[host.screen]; - const { mouseMode } = host; - - const [anchor, setAnchor] = useState(null); - const [sel, setSel] = useState(preset ? normalizeSpan(preset.a, preset.b) : null); - const [phase, setPhase] = useState(preset ? 'open' : 'idle'); - const [scopeIndex, setScopeIndex] = useState(preset?.scopeIndex ?? 0); - const [format, setFormat] = useState(preset?.format ?? AUTO); - const [overrides, setOverrides] = useState>(preset?.overrides ?? {}); - const [flashed, setFlashed] = useState(false); - const [clipboard, setClipboard] = useState(null); - - const scopes = useMemo(() => (sel ? computeScopes(screen, sel) : []), [screen, sel]); - const scope = scopes[Math.min(scopeIndex, scopes.length - 1)]; - - const reset = useCallback(() => { - setScopeIndex(0); - setFormat(AUTO); - setOverrides({}); - setFlashed(false); - }, []); - - const close = useCallback(() => { - // A program keeps painting its own highlight; Dormouse just steps back. - setPhase(mouseMode && sel ? 'armed' : 'idle'); - if (!mouseMode) setSel(null); - reset(); - }, [mouseMode, sel, reset]); - - const copy = useCallback(() => { - if (!sel || !scope) return; - const text = render(screen, scope.span, sel, format, overrides).text; - setClipboard(text); - void navigator.clipboard?.writeText(text).catch(() => {}); - setFlashed(true); - window.setTimeout(close, 700); - }, [sel, scope, screen, format, overrides, close]); - - const cycleFormat = useCallback((dir: 1 | -1) => { - setOverrides({}); - setFormat((f) => FORMATS2[(FORMATS2.findIndex((x) => x.id === f) + dir + FORMATS2.length) % FORMATS2.length].id); - }, []); - - const onDrag = (dragPhase: 'down' | 'move' | 'up', pos: GridPos) => { - if (dragPhase === 'down') { - reset(); - setAnchor(pos); - setSel(normalizeSpan(pos, pos)); - setPhase('dragging'); - return; - } - if (!anchor) return; - const span = normalizeSpan(anchor, pos); - setSel(span); - if (dragPhase !== 'up') return; - setAnchor(null); - if (span.start.row === span.end.row && span.start.col === span.end.col) { - setSel(null); - setPhase('idle'); - return; - } - // Dormouse owns the drag: open on mouse-up. A program-owned drag waits for - // the copy chord, which Dormouse sees before the program would. - setPhase(mouseMode ? 'armed' : 'open'); - }; - - useEffect(() => { - const onKey = (ev: KeyboardEvent) => { - if (!sel || phase === 'idle' || phase === 'dragging') return; - const chord = (ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'c'; - const handled = () => { - ev.preventDefault(); - ev.stopPropagation(); - }; - if (ev.key === 'Escape') { - handled(); - if (phase === 'armed') { - setSel(null); - setPhase('idle'); - } else close(); - return; - } - if (phase === 'armed') { - if (chord) { - handled(); - setPhase('open'); - } - return; - } - if (chord || ev.key === 'Enter') { handled(); copy(); return; } - if (ev.metaKey || ev.ctrlKey || ev.altKey) return; - const key = ev.key.toLowerCase(); - if (key === 'e') { - handled(); - setOverrides({}); - setScopeIndex((i) => (ev.shiftKey ? Math.max(0, Math.min(i, scopes.length - 1) - 1) : Math.min(scopes.length - 1, i + 1))); - return; - } - if (key === 'f') { handled(); cycleFormat(ev.shiftKey ? -1 : 1); return; } - if (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') { - handled(); - const dir = ev.key === 'ArrowRight' ? 1 : -1; - setOverrides({}); - setScopeIndex(0); - setSel((s) => { - if (!s) return s; - const next = ev.shiftKey - ? { start: nudge(screen, s.start, dir, 'start'), end: s.end } - : { start: s.start, end: nudge(screen, s.end, dir, 'end') }; - const ordered = next.start.row < next.end.row || (next.start.row === next.end.row && next.start.col <= next.end.col); - return ordered ? next : s; - }); - } - }; - window.addEventListener('keydown', onKey, true); - return () => window.removeEventListener('keydown', onKey, true); - }, [sel, phase, scopes.length, screen, close, copy, cycleFormat]); - - const outline = mouseMode ? (phase === 'open' ? sel : null) : (phase !== 'idle' ? sel : null); - const programHighlight = mouseMode && phase !== 'idle' ? sel : null; - const scopePreview = phase === 'open' && scope && scope.id !== 'selection' ? scope.span : null; - - const instructions = (() => { - if (phase === 'idle') return mouseMode ? `Drag across the text (Claude Code paints its own highlight), then press ${COPY_CHORD}.` : 'Drag across the text.'; - if (phase === 'dragging') return 'Release to finish the selection.'; - if (phase === 'armed') return `Press ${COPY_CHORD}: Dormouse saw the drag and captured the same cells.`; - return `e / ${SHIFT}e expand / shrink · f / ${SHIFT}f format · ${LEFT}${RIGHT} end · ${SHIFT}${LEFT}${RIGHT} start · click a mark to flip one break · ↵ or ${COPY_CHORD} copies · Esc closes`; - })(); - - return ( -
-
- { setHostId(h); setSel(null); setPhase('idle'); reset(); }} items={HOSTS.map((h) => ({ id: h.id, label: h.label }))} /> -
-
{instructions}
-
- - {({ width, height, cellW }) => { - if (!sel) return null; - if (phase === 'armed') { - const top = (sel.end.row + 1) * CELL_H + 4; - const left = Math.max(0, Math.min(width - 300, sel.end.col * cellW)); - return height ? { left, top: sel.start.row * CELL_H - 24 } : { left, top }} />; - } - if (phase !== 'open') return null; - return ( -
- { setScopeIndex(i); setOverrides({}); }} - onFormat={(f) => { setFormat(f); setOverrides({}); }} - onFlip={(i) => setOverrides((o) => { - const current = scope ? render(screen, scope.span, sel, format, o).breaks[i] : 'keep'; - const next: BreakKind = current === 'keep' ? 'space' : current === 'space' ? 'none' : 'keep'; - return { ...o, [i]: next }; - })} - onCopy={copy} - /> -
- ); - }} -
- -
-
- ); -} - -const meta: Meta = { - title: 'Prototypes/Copy editor 2', - component: CopyEditorPrototype, - parameters: { layout: 'fullscreen' }, - argTypes: { host: { control: 'inline-radio', options: HOSTS.map((h) => h.id) } }, -}; - -export default meta; -type Story = StoryObj; - -const C = CLAUDE_OFFSET; -const S = SHELL_OFFSET; - -/** Drag in the terminal; the host switch picks who owns the mouse. */ -export const Playground: Story = { - args: { host: 'claudeInline' }, -}; - -/** A hard-wrapped paragraph, the drag ending mid-word: Auto joins each wrap - * with a space, so it lands as one line. */ -export const Prose: Story = { - args: { host: 'claudeInline', preset: { a: { row: C + 2, col: 25 }, b: { row: C + 5, col: 20 } } }, -}; - -/** The same drag with `f` pressed once: Exact, every displayed break and - * indent, for checking what the terminal actually holds. */ -export const ProseExact: Story = { - args: { host: 'claudeInline', preset: { a: { row: C + 2, col: 25 }, b: { row: C + 5, col: 20 }, format: 'exact' } }, -}; - -/** A URL the TUI split at the margin, its start missed, after one `e`: the - * scope grew to the full URL and Auto rejoined the split with no space. */ -export const ClippedUrlGrown: Story = { - args: { host: 'claudeInline', preset: { a: { row: C + 13, col: 44 }, b: { row: C + 14, col: 30 }, scopeIndex: 1 } }, -}; - -/** Fullscreen Claude Code: it painted the highlight and owns the mouse; - * Dormouse shadowed the drag and opened on the copy chord. Prose joins, the - * code keeps its breaks and relative indent. */ -export const FullscreenProseAndCode: Story = { - args: { host: 'claudeFullscreen', preset: { a: { row: C + 7, col: 2 }, b: { row: C + 11, col: 44 } } }, -}; - -/** One break flipped by hand (the dashed mark): Auto's first join kept as a - * line break, and the format reads `Auto*`. */ -export const BreakFlipped: Story = { - args: { host: 'claudeInline', preset: { a: { row: C + 2, col: 2 }, b: { row: C + 5, col: 27 }, overrides: { 1: 'keep' } } }, -}; - -/** zsh: xterm knows which rows are true soft wraps, so Auto rejoins the token - * exactly where Spaces would break it. */ -export const ShellSoftWrap: Story = { - args: { host: 'shell', preset: { a: { row: S + 6, col: 0 }, b: { row: S + 8, col: 37 } } }, -}; diff --git a/lib/src/stories/copy-editor/CopyMockups.tsx b/lib/src/stories/copy-editor/CopyMockups.tsx deleted file mode 100644 index 5c36d144d..000000000 --- a/lib/src/stories/copy-editor/CopyMockups.tsx +++ /dev/null @@ -1,584 +0,0 @@ -import { clsx } from 'clsx'; -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; -import { CheckIcon, CursorClickIcon } from '@phosphor-icons/react'; -import { modalSurface, paneHeader, PopupButtonRow, Shortcut, TERMINAL_BOTTOM_RADIUS_CLASS } from '../../components/design'; -import { rectsToPath, type Rect } from '../../lib/selection-geometry'; -import { - FORMATS, - RECOMMENDED, - computeScopes, - duplicates, - render, - renderAll, - type BreakKind, - type FormatId, - type GridPos, - type MockScreen, - type Piece, - type Rendering, - type Scope, - type Span, - type Tone, -} from './model'; - -export const CELL_H = 16; -const IS_MAC_MOCK = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform); -export const COPY_CHORD = IS_MAC_MOCK ? 'Cmd+C' : 'Ctrl+C'; - -const TONE_COLOR: Record = { - muted: 'var(--vscode-terminal-ansiBrightBlack)', - link: 'var(--vscode-terminal-ansiBlue)', - code: 'var(--vscode-terminal-ansiCyan)', - accent: 'var(--vscode-terminal-ansiGreen)', - user: 'var(--vscode-terminal-ansiBrightBlack)', - frame: 'var(--vscode-terminal-ansiBrightBlack)', - prompt: 'var(--vscode-terminal-ansiMagenta)', - pass: 'var(--vscode-terminal-ansiGreen)', -}; - -// --------------------------------------------------------------------------- -// Mock terminal - -function spanRects(span: Span, cols: number, cellW: number): Rect[] { - const rects: Rect[] = []; - for (let r = span.start.row; r <= span.end.row; r++) { - const c0 = r === span.start.row ? span.start.col : 0; - const c1 = r === span.end.row ? span.end.col + 1 : cols; - if (c1 <= c0) continue; - rects.push({ top: r * CELL_H, left: c0 * cellW, width: (c1 - c0) * cellW, height: CELL_H }); - } - return rects; -} - -function inSpan(span: Span | null, row: number, col: number): boolean { - if (!span) return false; - if (row < span.start.row || row > span.end.row) return false; - if (row === span.start.row && col < span.start.col) return false; - if (row === span.end.row && col > span.end.col) return false; - return true; -} - -function useCellWidth(): [number, (el: HTMLSpanElement | null) => void] { - const [cellW, setCellW] = useState(7.2); - const [el, setEl] = useState(null); - useLayoutEffect(() => { - if (!el) return; - const measure = () => { - const w = el.getBoundingClientRect().width / 20; - if (w > 0) setCellW(w); - }; - measure(); - void document.fonts?.ready.then(measure); - }, [el]); - return [cellW, setEl]; -} - -interface MockTerminalProps { - screen: MockScreen; - mouseMode: boolean; - /** Dormouse's own selection outline. */ - outline: Span | null; - /** The program's own highlight (inverse video), painted by the TUI. */ - programHighlight: Span | null; - /** A candidate scope previewed while its option is focused. */ - scopePreview: Span | null; - onDrag?: (phase: 'down' | 'move' | 'up', pos: GridPos) => void; - children?: (geometry: { cellW: number; width: number; height: number }) => ReactNode; -} - -export function MockTerminal({ screen, mouseMode, outline, programHighlight, scopePreview, onDrag, children }: MockTerminalProps) { - const [cellW, measureRef] = useCellWidth(); - const gridRef = useRef(null); - const dragging = useRef(false); - const width = screen.cols * cellW; - const height = screen.rows.length * CELL_H; - - const posFromEvent = (ev: { clientX: number; clientY: number }): GridPos => { - const rect = gridRef.current!.getBoundingClientRect(); - const col = Math.max(0, Math.min(screen.cols - 1, Math.floor((ev.clientX - rect.left) / cellW))); - const row = Math.max(0, Math.min(screen.rows.length - 1, Math.floor((ev.clientY - rect.top) / CELL_H))); - return { row, col }; - }; - - useEffect(() => { - if (!onDrag) return; - const move = (ev: MouseEvent) => { - if (dragging.current) onDrag('move', posFromEvent(ev)); - }; - const up = (ev: MouseEvent) => { - if (!dragging.current) return; - dragging.current = false; - onDrag('up', posFromEvent(ev)); - }; - window.addEventListener('mousemove', move); - window.addEventListener('mouseup', up); - return () => { - window.removeEventListener('mousemove', move); - window.removeEventListener('mouseup', up); - }; - }); - - return ( -
-
-
- {screen.title} - - {mouseMode ? : null} - -
-
-
- {'M'.repeat(20)} -
{ - if (!onDrag || ev.button !== 0) return; - ev.preventDefault(); - dragging.current = true; - onDrag('down', posFromEvent(ev)); - }} - > - {screen.rows.map((row, r) => ( -
- {renderRowCells(row.text.padEnd(screen.cols), row.tones ?? [], r, programHighlight)} -
- ))} - - {scopePreview && ( - - )} - {outline && ( - - )} - - {children?.({ cellW, width, height })} -
-
-
- ); -} - -function renderRowCells(text: string, tones: { from: number; to: number; tone: Tone }[], r: number, highlight: Span | null): ReactNode { - // Runs of (tone, highlighted) so a row is a handful of spans, not 80. - const out: ReactNode[] = []; - let runStart = 0; - const toneAt = (c: number) => tones.find((t) => c >= t.from && c < t.to)?.tone; - const keyAt = (c: number) => `${toneAt(c) ?? ''}|${inSpan(highlight, r, c) && c < text.trimEnd().length + 1 ? 'h' : ''}`; - for (let c = 1; c <= text.length; c++) { - if (c < text.length && keyAt(c) === keyAt(runStart)) continue; - const tone = toneAt(runStart); - const hl = keyAt(runStart).endsWith('h'); - const style: CSSProperties = hl - ? { background: 'var(--color-terminal-fg)', color: 'var(--color-terminal-bg)' } - : tone ? { color: TONE_COLOR[tone] } : {}; - out.push({text.slice(runStart, c)}); - runStart = c; - } - return out; -} - -// --------------------------------------------------------------------------- -// Preview of one rendering, invisibles made visible - -const BREAK_GLYPH: Record = { keep: '⏎', space: '␣', none: '⌁' }; -const BREAK_TITLE: Record = { - keep: 'Line break kept', - space: 'Line break → one space', - none: 'Line break deleted (token rejoined)', -}; - -function BreakMark({ kind, auto, onToggle }: { kind: BreakKind; auto: BreakKind; onToggle?: () => void }) { - const edited = kind !== auto; - if (onToggle) { - // The editor: every break is a chip you can click to cycle. - return ( - <> - - {kind === 'keep' ? '\n' : null} - - ); - } - if (kind === 'keep') return <>⏎{'\n'}; - if (kind === 'space') return ; - return ; -} - -export function PreviewText({ - rendering, - onToggleBreak, - className, -}: { - rendering: Rendering; - onToggleBreak?: (index: number) => void; - className?: string; -}) { - return ( -
- {rendering.pieces.map((p: Piece, i) => { - if (p.t === 'break') return onToggleBreak(p.index) : undefined} />; - const text = p.lead ? p.text.replace(/ /g, '·').replace(/\t/g, '→') : p.text; - return ( - - {text} - - ); - })} - {rendering.text === '' && (empty)} -
- ); -} - -function stats(r: Rendering): string { - return `${r.lines} ${r.lines === 1 ? 'line' : 'lines'} · ${r.chars} ch`; -} - -function Legend({ editable }: { editable?: boolean }) { - return ( -
- ⏎ kept -   space - joined - abc beyond your drag - ·· indent - {editable && click a break to change it} -
- ); -} - -// --------------------------------------------------------------------------- -// Shared option model for the three concepts - -export interface CopyOption { - key: string; - format: FormatId; - scope: Scope; - label: string; - rendering: Rendering; - sameAs?: string; - recommended?: boolean; - /** The program's own OSC 52 text, offered rather than silently dropped. */ - program?: boolean; -} - -/** The numbered options: one per format at the dragged scope, then one per - * wider scope in the recommended format. Numbers never move between copies. */ -export function buildOptions(screen: MockScreen, sel: Span, programCopy?: { program: string; text: string }): CopyOption[] { - const scopes = computeScopes(screen, sel); - const base = renderAll(screen, sel, sel); - const dups = duplicates(base); - const keyOf = (f: FormatId) => FORMATS.find((x) => x.id === f)!.key; - const options: CopyOption[] = base.map((r) => { - const f = FORMATS.find((x) => x.id === r.format)!; - const prior = dups.get(r.format); - return { key: f.key, format: r.format, scope: scopes[0], label: f.name, rendering: r, sameAs: prior ? keyOf(prior) : undefined, recommended: r.format === RECOMMENDED }; - }); - if (programCopy) { - const prior = options.find((o) => o.rendering.text === programCopy.text && !o.sameAs); - options.push({ key: String(options.length + 1), format: 'exact', scope: scopes[0], label: `${programCopy.program}'s copy`, rendering: plainRendering(programCopy.text), sameAs: prior?.key, program: true }); - } - scopes.slice(1).forEach((scope) => { - options.push({ key: String(options.length + 1), format: RECOMMENDED, scope, label: scope.label, rendering: render(screen, scope.span, sel, RECOMMENDED) }); - }); - return options; -} - -function scopeGrowth(o: CopyOption): string { - const added = o.rendering.addedChars; - return added ? `+${added} ch` : ''; -} - -// --------------------------------------------------------------------------- -// Concept A — numbered chooser (the brief) - -export function ChooserPanel({ - options, - focused, - flashedKey, - onFocus, - onPick, - source, -}: { - options: CopyOption[]; - focused: number; - flashedKey: string | null; - onFocus: (i: number) => void; - onPick: (i: number) => void; - source: string; -}) { - const current = options[focused]; - const formatRows = options.filter((o) => o.scope.id === 'selection'); - const scopeRows = options.filter((o) => o.scope.id !== 'selection'); - const row = (o: CopyOption) => { - const i = options.indexOf(o); - const active = i === focused; - return ( - - ); - }; - return ( -
e.stopPropagation()} - > -
-
-
Copy {source}
- {formatRows.map(row)} - {scopeRows.length > 0 && ( - <> -
- Grow the selection - -
- {scopeRows.map(row)} - - )} -
-
-
- {current.label} - {current.program ? 'the text the program itself sent' : current.scope.id === 'selection' ? FORMATS.find((f) => f.id === current.format)!.blurb : `${FORMATS.find((f) => f.id === current.format)!.name}, grown to ${current.scope.label.toLowerCase()}`} - {stats(current.rendering)} -
-
- -
-
-
-
- - 1–{options.length} copy ↑↓ preview ↵ -
-
- ); -} - -// --------------------------------------------------------------------------- -// Concept B — copy first, receipt after - -export function ReceiptPanel({ - options, - copiedKey, - onPick, - onFocusScope, -}: { - options: CopyOption[]; - copiedKey: string; - onPick: (i: number) => void; - onFocusScope: (i: number | null) => void; -}) { - const copied = options.find((o) => o.key === copiedKey)!; - return ( -
e.stopPropagation()} - > -
- - Copied - [{copied.key}] - {copied.label} - {stats(copied.rendering)} -
-
- -
-
- Copy instead: - {options.map((o, i) => ( - - ))} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Concept C — the editor: scope ladder, format presets, per-break chips - -export function EditorPanel({ - screen, - sel, - scopeIndex, - format, - overrides, - flashed, - onScope, - onFormat, - onToggleBreak, - onCopy, -}: { - screen: MockScreen; - sel: Span; - scopeIndex: number; - format: FormatId; - overrides: Record; - flashed: boolean; - onScope: (i: number) => void; - onFormat: (f: FormatId) => void; - onToggleBreak: (index: number) => void; - onCopy: () => void; -}) { - const scopes = useMemo(() => computeScopes(screen, sel), [screen, sel]); - const scope = scopes[Math.min(scopeIndex, scopes.length - 1)]; - const rendering = render(screen, scope.span, sel, format, overrides); - const all = renderAll(screen, scope.span, sel); - const dups = duplicates(all); - const edited = Object.keys(overrides).length > 0; - return ( -
e.stopPropagation()} - > -
- Scope -
- {scopes.map((s, i) => ( - - ))} -
- e grow Shift+E shrink -
-
- Format -
- {FORMATS.map((f) => { - const active = f.id === format; - const same = dups.get(f.id); - return ( - - ); - })} -
-
-
- -
- -
- ←→ end by word Shift+←→ start by word · {stats(rendering)} - -
-
- ); -} - -// --------------------------------------------------------------------------- -// Hints shown on the terminal itself - -export function ArmedHint({ text, style }: { text: ReactNode; style: CSSProperties }) { - return ( - - {text} - - ); -} - -/** A rendering of literal text, for text no transform produced. */ -export function plainRendering(text: string): Rendering { - return { - format: 'exact', - pieces: text.split('\n').flatMap((line, i, arr): Piece[] => { - const lead = line.length - line.trimStart().length; - const out: Piece[] = []; - if (lead) out.push({ t: 'text', text: line.slice(0, lead), added: false, lead: true }); - if (line.length > lead) out.push({ t: 'text', text: line.slice(lead), added: false, lead: false }); - if (i + 1 < arr.length) out.push({ t: 'break', index: i, kind: 'keep', auto: 'keep' }); - return out; - }), - text, - lines: text.split('\n').length, - chars: text.length, - breaks: [], - addedChars: 0, - }; -} - -export function ClipboardPanel({ text }: { text: string | null }) { - const rendering = text === null ? null : plainRendering(text); - return ( -
-
- Clipboard - {rendering && {stats(rendering)}} -
-
- {rendering ? : Nothing copied yet} -
-
What a paste would get (mock; the real clipboard is written too when the browser allows it).
-
- ); -} - diff --git a/lib/src/stories/copy-editor/model.ts b/lib/src/stories/copy-editor/model.ts deleted file mode 100644 index 13f041033..000000000 --- a/lib/src/stories/copy-editor/model.ts +++ /dev/null @@ -1,547 +0,0 @@ -// Prototype model for the copy editor mockups (Prototypes/Copy editor). Pure -// functions over a mock screen: nothing here is wired to xterm or the real -// selection store. The real feature would read the same facts from the xterm -// buffer (row text, `isWrapped`, cell attributes) and the foreground program. - -export type Tone = 'muted' | 'link' | 'code' | 'accent' | 'user' | 'frame' | 'prompt' | 'pass'; - -export interface ToneSpan { from: number; to: number; tone: Tone } - -export interface MockRow { - text: string; - /** xterm's `isWrapped`: a true soft wrap continuing the previous row. */ - wrapped?: boolean; - /** An OSC 133 prompt row: a paragraph never grows across it. */ - prompt?: boolean; - tones?: ToneSpan[]; -} - -export interface MockBlock { from: number; to: number; label: string } - -export interface MockScreen { - id: 'claude' | 'shell'; - title: string; - program: string; - cols: number; - /** The column a hard-wrapping program wraps at. Present only for programs - * known to hard-wrap (a real build would key this off the foreground - * command); absent means only `wrapped` rows are wraps. */ - hardWrapWidth?: number; - rows: MockRow[]; - /** The widest expansion: a TUI message, or an OSC 133 command output. */ - blocks: MockBlock[]; -} - -// --------------------------------------------------------------------------- -// Mock screens - -interface WrapTone { from: number; to: number; tone: Tone } - -/** Greedy word wrap with a hard break for tokens wider than the line, the way - * Ink-based TUIs render prose. Tones are ranges over the logical text. */ -function wrapParagraph(text: string, width: number, first: string, cont: string, tones: WrapTone[] = [], prefixTone?: Tone): MockRow[] { - type Cell = { ch: string; i: number }; - const rows: Cell[][] = []; - let cur: Cell[] = [...first].map((ch) => ({ ch, i: -1 })); - let hasContent = false; - const flush = () => { - rows.push(cur); - cur = [...cont].map((ch) => ({ ch, i: -1 })); - hasContent = false; - }; - const words: { w: string; at: number }[] = []; - for (const m of text.matchAll(/\S+/g)) words.push({ w: m[0], at: m.index ?? 0 }); - for (const { w, at } of words) { - const need = (hasContent ? 1 : 0) + w.length; - if (cur.length + need <= width) { - if (hasContent) cur.push({ ch: ' ', i: at - 1 }); - for (let k = 0; k < w.length; k++) cur.push({ ch: w[k], i: at + k }); - hasContent = true; - } else if (w.length <= width - cont.length) { - flush(); - for (let k = 0; k < w.length; k++) cur.push({ ch: w[k], i: at + k }); - hasContent = true; - } else { - if (hasContent) cur.push({ ch: ' ', i: at - 1 }); - for (let k = 0; k < w.length; k++) { - if (cur.length >= width) flush(); - cur.push({ ch: w[k], i: at + k }); - } - hasContent = true; - } - } - rows.push(cur); - return rows.map((cells) => { - const spans: ToneSpan[] = []; - cells.forEach((cell, col) => { - const tone = cell.i < 0 ? (cell.ch === ' ' ? undefined : prefixTone) : tones.find((t) => cell.i >= t.from && cell.i < t.to)?.tone; - if (!tone) return; - const last = spans[spans.length - 1]; - if (last && last.tone === tone && last.to === col) last.to = col + 1; - else spans.push({ from: col, to: col + 1, tone }); - }); - return { text: cells.map((c) => c.ch).join(''), tones: spans }; - }); -} - -function toned(text: string, tone: Tone): MockRow { - return { text, tones: [{ from: 0, to: text.length, tone }] }; -} - -function range(text: string, needle: string, tone: Tone): WrapTone { - const from = text.indexOf(needle); - return { from, to: from + needle.length, tone }; -} - -function buildClaudeScreen(): MockScreen { - const cols = 80; - const W = 76; - const blank: MockRow = { text: '' }; - const prose = 'The flake comes from a race between the PTY exit event and the final flush of the output buffer. When the child exits before xterm has drained its write queue, the last chunk is dropped and the assertion on the prompt text fails intermittently.'; - const fix = 'The fix is to await the write callback before reading the buffer:'; - const url = 'https://github.com/diffplug/dormouse/pull/853/files#diff-7c1f3e9a2b8d4f6e0a5c7b9d1e3f5a7c9b1d3e5f'; - const pr = `I opened a draft with the change: ${url}`; - const cmd = 'pnpm --filter dormouse-lib exec vitest run src/lib/selection-text.test.ts --repeat 50'; - const verify = `To verify locally, run ${cmd}`; - const boxInner = cols - 2; - const rows: MockRow[] = [ - toned('> why is selection-text.test.ts flaky on CI?', 'user'), - blank, - ...wrapParagraph(prose, W, '⏺ ', ' ', [], 'accent'), - blank, - ...wrapParagraph(fix, W, ' ', ' '), - blank, - toned(' await new Promise((resolve) => terminal.write(chunk, resolve));', 'code'), - toned(' const text = extractSelectionText(terminal, selection);', 'code'), - toned(" expect(text).toBe('user@dormouse:~$ ls');", 'code'), - blank, - ...wrapParagraph(pr, W, ' ', ' ', [range(pr, url, 'link')]), - blank, - ...wrapParagraph(verify, W, ' ', ' ', [range(verify, cmd, 'code')]), - blank, - toned(`╭${'─'.repeat(boxInner)}╮`, 'frame'), - { text: `│ > ${' '.repeat(boxInner - 3)}│`, tones: [{ from: 0, to: 1, tone: 'frame' }, { from: 2, to: 3, tone: 'muted' }, { from: cols - 1, to: cols, tone: 'frame' }] }, - toned(`╰${'─'.repeat(boxInner)}╯`, 'frame'), - toned(` ? for shortcuts${' '.repeat(cols - 2 - 16 - 19)}◯ auto-accept edits`, 'muted'), - ]; - const replyEnd = rows.findIndex((r) => r.text.startsWith('╭')) - 2; - return { id: 'claude', title: 'claude', program: 'Claude Code', cols, hardWrapWidth: W, rows, blocks: [{ from: 2, to: replyEnd, label: 'Whole reply' }] }; -} - -/** Split `text` into `cols`-wide rows the way a terminal soft-wraps it. */ -function softWrap(text: string, cols: number, tones?: (row: string, index: number) => ToneSpan[]): MockRow[] { - const out: MockRow[] = []; - for (let at = 0, i = 0; at < text.length || i === 0; at += cols, i++) { - const row = text.slice(at, at + cols); - out.push({ text: row, wrapped: i > 0, tones: tones?.(row, i) }); - } - return out; -} - -const PROMPT = '~/projects/dormouse ❯ '; - -function promptRow(command: string, cols: number): MockRow[] { - const rows = softWrap(PROMPT + command, cols, (_row, i) => (i === 0 ? [{ from: 0, to: PROMPT.length - 2, tone: 'link' }, { from: PROMPT.length - 2, to: PROMPT.length - 1, tone: 'prompt' }] : [])); - rows[0].prompt = true; - return rows; -} - -function buildShellScreen(): MockScreen { - const cols = 80; - const token = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkb3Jtb3VzZS1kZXYiLCJzY29wZSI6InB0eTpyZWFkIHB0eTp3cml0ZSIsImlhdCI6MTc5MDAwMDAwMH0.4f1c2a9e8b7d6f5a4c3b2a1908f7e6d5c4b3a29187f6e5d4c3b2a1908f7e6d5c4b3a2'; - const rows: MockRow[] = []; - const blocks: MockBlock[] = []; - const command = (cmd: string, output: MockRow[]) => { - rows.push(...promptRow(cmd, cols)); - if (output.length) blocks.push({ from: rows.length, to: rows.length + output.length - 1, label: 'Command output' }); - rows.push(...output); - }; - command('gh pr view 853 --json title,url', [ - { text: '{' }, - { text: ' "title": "Await the PTY write callback before reading the selection buffer",', tones: [{ from: 2, to: 9, tone: 'link' }, { from: 11, to: 78, tone: 'pass' }] }, - { text: ' "url": "https://github.com/diffplug/dormouse/pull/853"', tones: [{ from: 2, to: 7, tone: 'link' }, { from: 9, to: 56, tone: 'pass' }] }, - { text: '}' }, - ]); - command('cat .dev-token', softWrap(token, cols)); - command('git log --oneline -3', [ - { text: 'd19f2683e Theme tool iframes and upgrade file viewers with Monaco editing (#851)', tones: [{ from: 0, to: 9, tone: 'code' }] }, - { text: 'd19f2c6d6 Add a theme-aware twinkling starfield to the Pocket tutorial (#852)', tones: [{ from: 0, to: 9, tone: 'code' }] }, - { text: 'a292155da Report a reloaded editor after resetting its saved revision', tones: [{ from: 0, to: 9, tone: 'code' }] }, - ]); - command('pnpm --filter dormouse-lib exec vitest run src/lib/selection-text.test.ts --repeat 50', [ - { text: '' }, - { text: ' ✓ src/lib/selection-text.test.ts (12 tests) 38ms', tones: [{ from: 1, to: 2, tone: 'pass' }, { from: 44, to: 48, tone: 'muted' }] }, - { text: '' }, - { text: ' Test Files 1 passed (1)', tones: [{ from: 1, to: 11, tone: 'muted' }, { from: 13, to: 21, tone: 'pass' }] }, - { text: ' Tests 12 passed (50 runs)', tones: [{ from: 6, to: 11, tone: 'muted' }, { from: 13, to: 22, tone: 'pass' }] }, - ]); - rows.push(...promptRow('', cols)); - return { id: 'shell', title: 'zsh', program: 'zsh', cols, rows, blocks }; -} - -export const SCREENS = { claude: buildClaudeScreen(), shell: buildShellScreen() } as const; -export type ScreenId = keyof typeof SCREENS; - -// --------------------------------------------------------------------------- -// Positions and spans - -export interface GridPos { row: number; col: number } -/** Linewise, inclusive end, `start` before `end` in reading order. */ -export interface Span { start: GridPos; end: GridPos } - -export function comparePos(a: GridPos, b: GridPos): number { - return a.row - b.row || a.col - b.col; -} - -export function normalizeSpan(a: GridPos, b: GridPos): Span { - return comparePos(a, b) <= 0 ? { start: a, end: b } : { start: b, end: a }; -} - -export function spanEquals(a: Span, b: Span): boolean { - return comparePos(a.start, b.start) === 0 && comparePos(a.end, b.end) === 0; -} - -function contains(span: Span, pos: GridPos): boolean { - return comparePos(span.start, pos) <= 0 && comparePos(pos, span.end) <= 0; -} - -const rowText = (screen: MockScreen, r: number) => (screen.rows[r]?.text ?? '').trimEnd(); -const isSpace = (ch: string | undefined) => ch === undefined || ch === ' '; - -// --------------------------------------------------------------------------- -// Chrome and breaks - -const BOX = /[─-▟]/; -const FRAME_ONLY = /^[─-▟\s]+$/; -/** A TUI's line-leading glyph that is decoration, not text. */ -const LEAD_MARKER = /^(\s*)(⏺ |⎿ |● |[─-▟]+ ?)/; -const TRAIL_BOX = / ?[─-▟]+$/; -const LIST_MARKER = /^(?:[-*•]|\d+[.)]) /; - -function isFrameOnly(text: string): boolean { - return text.length > 0 && FRAME_ONLY.test(text) && BOX.test(text); -} - -/** The row with its decoration blanked to spaces, so indentation math still - * lines up with the columns under it. */ -function unchromed(text: string): string { - const lead = LEAD_MARKER.exec(text); - let out = lead ? lead[1] + ' '.repeat(lead[2].length) + text.slice(lead[0].length) : text; - out = out.replace(TRAIL_BOX, ''); - return out.trimEnd(); -} - -const indentOf = (text: string) => text.length - text.trimStart().length; - -/** Where a wrapped continuation of this row would start. */ -function hangIndent(text: string): number { - const indent = indentOf(text); - const list = LIST_MARKER.exec(text.slice(indent)); - return indent + (list ? list[0].length : 0); -} - -export type BreakKind = 'keep' | 'space' | 'none'; - -/** The smart decision for the break between full rows `r` and `r + 1`. */ -export function smartBreak(screen: MockScreen, r: number): BreakKind { - const next = screen.rows[r + 1]; - if (!next) return 'keep'; - if (next.wrapped) return 'none'; - const cur = unchromed(rowText(screen, r)); - const nxt = unchromed(rowText(screen, r + 1)); - if (!cur.trim() || !nxt.trim()) return 'keep'; - const W = screen.hardWrapWidth; - if (!W) return 'keep'; - const nextTrim = nxt.trimStart(); - if (LIST_MARKER.test(nextTrim)) return 'keep'; - if (/[;{}]$/.test(cur) || /^[)}\]]/.test(nextTrim)) return 'keep'; - if (indentOf(nxt) !== hangIndent(cur)) return 'keep'; - const firstWord = nextTrim.split(' ')[0]; - // A break the next word would have fit before was typed, not wrapped. - if (cur.length + 1 + firstWord.length <= W) return 'keep'; - const lastWord = cur.slice(cur.lastIndexOf(' ') + 1); - const tokenish = /[/\\#?=&]/.test(lastWord) || /^[A-Za-z0-9+_\-.]{16,}$/.test(lastWord); - return cur.length >= W && tokenish ? 'none' : 'space'; -} - -// --------------------------------------------------------------------------- -// Renderings - -export type FormatId = 'exact' | 'spaces' | 'joined' | 'smart' | 'dedent'; - -export const FORMATS: { id: FormatId; key: string; name: string; blurb: string }[] = [ - { id: 'exact', key: '1', name: 'Exact', blurb: 'as displayed' }, - { id: 'spaces', key: '2', name: 'Spaces', blurb: 'every break → one space' }, - { id: 'joined', key: '3', name: 'No breaks', blurb: 'every break deleted' }, - { id: 'smart', key: '4', name: 'Rewrapped', blurb: 'each break judged on its own' }, - { id: 'dedent', key: '5', name: 'Dedented', blurb: 'breaks kept, indent and gutter removed' }, -]; - -export type Piece = - | { t: 'text'; text: string; added: boolean; lead: boolean } - | { t: 'break'; index: number; kind: BreakKind; auto: BreakKind }; - -export interface Rendering { - format: FormatId; - pieces: Piece[]; - text: string; - lines: number; - chars: number; - breaks: BreakKind[]; - /** Characters outside the dragged selection (an expansion). */ - addedChars: number; -} - -interface Cell { ch: string; added: boolean } -interface Line { cells: Cell[]; row: number; startCol: number } - -function extract(screen: MockScreen, scope: Span, original: Span): Line[] { - const lines: Line[] = []; - for (let r = scope.start.row; r <= scope.end.row; r++) { - const text = rowText(screen, r); - const a = r === scope.start.row ? scope.start.col : 0; - const b = r === scope.end.row ? scope.end.col + 1 : text.length; - const cells: Cell[] = []; - for (let c = a; c < Math.min(b, text.length); c++) { - cells.push({ ch: text[c], added: !contains(original, { row: r, col: c }) }); - } - while (cells.length && cells[cells.length - 1].ch === ' ') cells.pop(); - lines.push({ cells, row: r, startCol: a }); - } - return lines; -} - -const lineStr = (l: Line) => l.cells.map((c) => c.ch).join(''); -const leading = (cells: Cell[]) => { - let n = 0; - while (n < cells.length && cells[n].ch === ' ') n++; - return n; -}; - -/** Blank the line's decoration the same way `unchromed` does its row. */ -function stripChrome(line: Line): Line | null { - const text = lineStr(line); - if (isFrameOnly(text)) return null; - const cells = line.cells.map((c) => ({ ...c })); - const lead = LEAD_MARKER.exec(text); - if (lead) for (let k = lead[1].length; k < lead[0].length; k++) cells[k].ch = ' '; - const trail = TRAIL_BOX.exec(text); - if (trail) cells.length = trail.index; - while (cells.length && cells[cells.length - 1].ch === ' ') cells.pop(); - return { ...line, cells }; -} - -export function render(screen: MockScreen, scope: Span, original: Span, format: FormatId, overrides: Record = {}): Rendering { - let lines = extract(screen, scope, original); - if (format !== 'exact') lines = lines.map(stripChrome).filter((l): l is Line => l !== null); - - const blank = (l: Line) => l.cells.length === 0; - // Trim leading/trailing blank lines; collapse runs of blanks to one. - while (lines.length && blank(lines[0])) lines.shift(); - while (lines.length && blank(lines[lines.length - 1])) lines.pop(); - if (format === 'spaces' || format === 'joined') lines = lines.filter((l) => !blank(l)); - else if (format !== 'exact') lines = lines.filter((l, i) => !(blank(l) && i > 0 && blank(lines[i - 1]))); - - // Shared indent, in absolute columns, for the formats that dedent. A first - // line starting mid-row has no indent of its own to keep. - const dedents = format === 'smart' || format === 'dedent'; - const absIndents = lines.filter((l) => !blank(l)).map((l) => l.startCol + leading(l.cells)); - const base = absIndents.length ? Math.min(...absIndents) : 0; - - const breaks: BreakKind[] = []; - const autos: BreakKind[] = []; - for (let i = 0; i + 1 < lines.length; i++) { - const a = lines[i]; - const b = lines[i + 1]; - let auto: BreakKind; - if (format === 'exact' || format === 'dedent') auto = 'keep'; - else if (format === 'spaces') auto = 'space'; - else if (format === 'joined') auto = 'none'; - else auto = blank(a) || blank(b) || b.row !== a.row + 1 ? 'keep' : smartBreak(screen, a.row); - autos.push(auto); - breaks.push(overrides[i] ?? auto); - } - - const pieces: Piece[] = []; - let text = ''; - let addedChars = 0; - lines.forEach((line, i) => { - const joinedToPrev = i > 0 && breaks[i - 1] !== 'keep'; - let cells = line.cells; - const lead = leading(cells); - let strip: number; - if (format === 'exact') strip = joinedToPrev ? lead : 0; - else if (joinedToPrev || format === 'spaces' || format === 'joined') strip = lead; - else if (dedents) strip = line.startCol > base ? lead : Math.min(lead, base - line.startCol); - else strip = lead; - cells = cells.slice(strip); - // Group into runs of (lead-whitespace, added) so the preview can mark them. - const leadRun = leading(cells); - let run: Piece & { t: 'text' } | null = null; - cells.forEach((cell, k) => { - const isLead = k < leadRun; - if (run && run.added === cell.added && run.lead === isLead) run.text += cell.ch; - else { - run = { t: 'text', text: cell.ch, added: cell.added, lead: isLead }; - pieces.push(run); - } - if (cell.added) addedChars++; - }); - text += cells.map((c) => c.ch).join(''); - if (i + 1 < lines.length) { - const kind = breaks[i]; - pieces.push({ t: 'break', index: i, kind, auto: autos[i] }); - text += kind === 'keep' ? '\n' : kind === 'space' ? ' ' : ''; - } - }); - - return { format, pieces, text, lines: text ? text.split('\n').length : 0, chars: text.length, breaks, addedChars }; -} - -export function renderAll(screen: MockScreen, scope: Span, original: Span): Rendering[] { - return FORMATS.map((f) => render(screen, scope, original, f.id)); -} - -/** The format whose text each rendering duplicates, if any. The recommended - * format is always the one kept, so ★ never reads "same as". */ -export function duplicates(renderings: Rendering[]): Map { - const seen = new Map(); - const out = new Map(); - const ordered = [...renderings].sort((a, b) => Number(b.format === RECOMMENDED) - Number(a.format === RECOMMENDED)); - for (const r of ordered) { - const prior = seen.get(r.text); - if (prior) out.set(r.format, prior); - else seen.set(r.text, r.format); - } - return out; -} - -export const RECOMMENDED: FormatId = 'smart'; - -// --------------------------------------------------------------------------- -// Expansions - -export type ScopeId = 'selection' | 'words' | 'paragraph' | 'block'; - -export interface Scope { id: ScopeId; label: string; span: Span } - -function contentStart(screen: MockScreen, r: number): number { - return indentOf(unchromed(rowText(screen, r))); -} - -/** Grow each edge to the whole token under it, following a token the program - * split across rows. */ -function snapToWords(screen: MockScreen, sel: Span): Span { - let { row: sr, col: sc } = sel.start; - for (;;) { - const t = rowText(screen, sr); - while (sc > 0 && !isSpace(t[sc - 1])) sc--; - if (sc <= contentStart(screen, sr) && sr > 0 && smartBreak(screen, sr - 1) === 'none' && !isSpace(t[sc])) { - sr--; - sc = rowText(screen, sr).length; - continue; - } - break; - } - let { row: er, col: ec } = sel.end; - for (;;) { - const t = rowText(screen, er); - while (ec + 1 < t.length && !isSpace(t[ec + 1])) ec++; - if (ec >= t.length - 1 && smartBreak(screen, er) === 'none' && !isSpace(t[ec])) { - er++; - ec = contentStart(screen, er) - 1; - continue; - } - break; - } - return { start: { row: sr, col: Math.max(0, sc) }, end: { row: er, col: ec } }; -} - -const boundary = (screen: MockScreen, r: number) => { - const t = rowText(screen, r); - return !t.trim() || isFrameOnly(t) || /^[│┃]/.test(t) || !!screen.rows[r]?.prompt; -}; - -function toParagraph(screen: MockScreen, sel: Span): Span { - let sr = sel.start.row; - while (sr > 0 && !boundary(screen, sr - 1)) sr--; - let er = sel.end.row; - while (er + 1 < screen.rows.length && !boundary(screen, er + 1)) er++; - return { start: { row: sr, col: contentStart(screen, sr) }, end: { row: er, col: rowText(screen, er).length - 1 } }; -} - -function toBlock(screen: MockScreen, sel: Span): { span: Span; label: string } | null { - const block = screen.blocks.find((b) => b.from <= sel.start.row && sel.end.row <= b.to); - if (!block) return null; - return { span: { start: { row: block.from, col: contentStart(screen, block.from) }, end: { row: block.to, col: rowText(screen, block.to).length - 1 } }, label: block.label }; -} - -/** Names the growth by the edge tokens that grew, not the whole text. */ -function tokenLabel(text: string, grewStart: boolean, grewEnd: boolean): string { - const tokens = text.split(/\s+/).filter(Boolean); - const edges = [grewStart ? tokens[0] : '', grewEnd ? tokens[tokens.length - 1] : '']; - if (edges.some((t) => /^https?:\/\//.test(t ?? ''))) return 'Full URL'; - if (edges.some((t) => /^(~|\.{0,2})\/\S|\S+\/\S+\.\w+/.test(t ?? ''))) return 'Full path'; - return 'Whole words'; -} - -/** Every distinct scope the selection can grow to, narrowest first. Never - * shrinks: a wider scope always contains the dragged selection. */ -export function computeScopes(screen: MockScreen, sel: Span): Scope[] { - const out: Scope[] = [{ id: 'selection', label: 'As selected', span: sel }]; - const push = (id: ScopeId, label: string, span: Span) => { - const union: Span = { - start: comparePos(span.start, sel.start) < 0 ? span.start : sel.start, - end: comparePos(span.end, sel.end) > 0 ? span.end : sel.end, - }; - if (out.some((s) => spanEquals(s.span, union))) return; - out.push({ id, label, span: union }); - }; - const words = snapToWords(screen, sel); - const grewStart = comparePos(words.start, sel.start) < 0; - const grewEnd = comparePos(words.end, sel.end) > 0; - push('words', tokenLabel(render(screen, words, words, 'smart').text, grewStart, grewEnd), words); - push('paragraph', 'Paragraph', toParagraph(screen, sel)); - const block = toBlock(screen, sel); - if (block) push('block', block.label, block.span); - return out; -} - -// --------------------------------------------------------------------------- -// Keyboard nudging (the editor concept) - -function cellAt(screen: MockScreen, p: GridPos): string | undefined { - return rowText(screen, p.row)[p.col]; -} - -function step(screen: MockScreen, p: GridPos, dir: 1 | -1): GridPos | null { - let { row, col } = p; - col += dir; - while (row >= 0 && row < screen.rows.length) { - const len = rowText(screen, row).length; - if (col >= 0 && col < len) return { row, col }; - row += dir; - if (row < 0 || row >= screen.rows.length) return null; - col = dir > 0 ? 0 : rowText(screen, row).length - 1; - } - return null; -} - -/** Move a selection edge one word. `edge` says which end of a word it sits on. */ -export function nudge(screen: MockScreen, p: GridPos, dir: 1 | -1, edge: 'start' | 'end'): GridPos { - let q: GridPos | null = p; - const land = (pos: GridPos) => { - // Walk to the boundary of the word under `pos` on the requested side. - let cur = pos; - for (;;) { - const n = step(screen, cur, edge === 'end' ? 1 : -1); - if (!n || n.row !== cur.row || isSpace(cellAt(screen, n))) return cur; - cur = n; - } - }; - // Leave the current word, cross the gap, then land on the next word's edge. - q = step(screen, p, dir); - while (q && !isSpace(cellAt(screen, q)) && q.row === p.row && ((dir > 0) === (edge === 'start'))) q = step(screen, q, dir); - while (q && isSpace(cellAt(screen, q))) q = step(screen, q, dir); - return q ? land(q) : p; -} From 8b5010049f36754507497c1be04fa10633413efc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 30 Sep 2026 20:51:50 -0700 Subject: [PATCH 13/36] Simplify the copy editor across the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The editor holds the buffer it opened with: preview, copy, and nudge all read it, so what is shown is what is copied, and only opening reads the terminal. copy-editor reads the leaf registry, so the copy-selection wrappers that only looked up a terminal are gone. - A scope's rows are read and stripped once (readScope) for all four formats, cached per scope span, and each format renders in one pass. - The Whole words label renders only the grown edge tokens, and nothing when no edge grew. - Any input the terminal receives closes the editor or a shadowed drag at writeUserInput — Pocket's input bar and paste included — replacing the Wall's keydown dismissal list. The editor's bare keys apply in passthrough only; the copy chord works in either mode. - The store owns "the program format needs its offer": a replaced selection demotes it, and setCopyEditor refuses it without one. - stringCopyBuffer, Rendering.lines and the popup-era wording are gone; tests and stories share fixtures (fakeXterm, bufferLine, finalizedSelection) and one useStorySelection harness; the expanded-scope colour lives in design.tsx; the surface merges its border through tv. - Spec: one dismissal list (§4.5), Auto's rule order matches the code, Enter vs Shift+Enter stated, pointers instead of repeats in transport.md and shortcuts.md. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 4 +- docs/specs/layout.md | 2 +- docs/specs/mouse-and-clipboard.md | 27 ++-- docs/specs/shortcuts.md | 8 +- docs/specs/transport.md | 2 +- lib/src/components/CopyEditor.test.tsx | 7 +- lib/src/components/CopyEditor.tsx | 37 ++--- lib/src/components/SelectionOverlay.tsx | 12 +- lib/src/components/design.tsx | 5 + lib/src/components/touch-ui-context.ts | 2 +- lib/src/components/wall/keyboard/chords.ts | 5 + .../handle-mouse-selection-keys.test.ts | 86 ++++++----- .../keyboard/handle-mouse-selection-keys.ts | 84 ++++------- lib/src/index.css | 2 +- lib/src/lib/copy-editor.test.ts | 13 +- lib/src/lib/copy-editor.ts | 105 ++++++------- lib/src/lib/copy-selection.ts | 26 +--- lib/src/lib/copy-text-fixtures.ts | 25 +++- lib/src/lib/copy-text.test.ts | 33 ++--- lib/src/lib/copy-text.ts | 138 +++++++++--------- lib/src/lib/mouse-selection.test.ts | 20 +++ lib/src/lib/mouse-selection.ts | 14 +- lib/src/lib/smart-token.test.ts | 10 +- .../lib/terminal-lifecycle.selection.test.ts | 41 ++++++ lib/src/lib/terminal-lifecycle.ts | 4 + lib/src/lib/terminal-mouse-router.ts | 2 +- lib/src/stories/CopyEditor.stories.tsx | 38 ++--- lib/src/stories/SelectionOverlay.stories.tsx | 30 +--- lib/src/stories/TextSelection.stories.tsx | 35 +---- lib/src/stories/story-selection.ts | 35 +++++ 30 files changed, 427 insertions(+), 425 deletions(-) create mode 100644 lib/src/lib/terminal-lifecycle.selection.test.ts create mode 100644 lib/src/stories/story-selection.ts diff --git a/DESIGN.md b/DESIGN.md index ee5a2f1c2..999593d02 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -179,9 +179,9 @@ Flat by default. Pane headers, doors, the baseboard, and terminal panes carry ze Shadows appear only on **raised surfaces that float above content**: popovers, tooltips, dialogs. They are ambient, not structural; they say "I am temporary and on top," not "I have weight." ### Shadow Vocabulary -- **Popover** (`box-shadow: var(--tw-shadow-md)`): tooltips (`PopupButtonRow`), selection popup, illegal-rename warning, terminal-pane header tooltips. +- **Popover** (`box-shadow: var(--tw-shadow-md)`): tooltips (`PopupButtonRow`), illegal-rename warning, terminal-pane header tooltips. - **Dialog** (`box-shadow: var(--tw-shadow-lg)`): kill-confirm sheet, TODO alert dialog. -- **Modal** (`box-shadow: var(--tw-shadow-2xl)`): theme picker dropdown (when expanded), theme debugger, theme store dialog. +- **Modal** (`box-shadow: var(--tw-shadow-2xl)`): theme picker dropdown (when expanded), theme debugger, theme store dialog, copy editor. - **Inset hairline** (`box-shadow: inset 0 0 0 1px var(--color-focus-ring)` / `var(--color-border)`): mobile UI segmented controls. Used instead of `border` when the surface needs a 1px stroke that does not shift layout on state change. ### Named Rules diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 88ef84491..c44fa23e7 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -255,7 +255,7 @@ Each Wall renders one Workspace's Content and Baseboard (doors). Standalone moun - **Must mount every Workspace's Wall in one grid cell**, inactive Walls `inert`, then `visibility:hidden` after their fade and never `display:none` (rationale). - **Must preserve mounted leaves across switches**: no re-seed, no re-parent, no leaf unmount, and no `resumeTerminal` / `restoreTerminal`; the only mount work is the terminal reattach below, which replays nothing, so I8 holds by construction (`lib/src/components/WorkspaceWindow.test.tsx`). - **A hidden Wall's terminals hold no element and no GL context**: completion of the outgoing fade runs `unmountElement` on every terminal pane, exactly as minimize does ([Renderer](#renderer)); activation runs `mountElement` and fits through the [Animations](#animations) gate, so an unchanged grid sends no PTY resize (`lib/src/components/TerminalPane.test.tsx`). Browser Surfaces keep their live documents (rationale). -- **A hidden Wall consumes no window input**: every listener that dispatches, forwards, or `preventDefault`s window input is gated on `active`, keyboard and custom events alike, so a hidden Wall neither mounts chrome nor refits a detached element in answer to one. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation and a terminal's selection popup (rationale): a staged prompt survives the switch and is answered only where the user can see it. +- **A hidden Wall consumes no window input**: every listener that dispatches, forwards, or `preventDefault`s window input is gated on `active`, keyboard and custom events alike, so a hidden Wall neither mounts chrome nor refits a detached element in answer to one. **Only the active Wall renders the modal hosts and the overlays that trap keys** — the kill confirmation and a terminal's copy editor (rationale): a staged prompt survives the switch and is answered only where the user can see it. - **Exactly one Wall answers a `dor` request**, chosen by `docs/specs/dor-cli.md` → "Handle Model". Every Wall registers a handle, a bare one under `DEFAULT_WORKSPACE_ID`, so the router always finds one. - **Never unmount a Wall before its Surfaces are disposed** — `closeAll` waits for the kill fade to commit, bounded by the engine's exit duration, since unmounting mid-fade would leave `Orphaned` Registry entries (`docs/specs/glossary.md` → "Invariants" I4). **The deadline refuses rather than reporting clean**, and the walk re-reads membership until nothing is left, so a Surface born behind it is closed too. - **A closing Workspace takes no new Surfaces**: while `closeAll` walks, this Wall answers every Surface-creating `dor` verb with an error (`docs/specs/dor-cli.md` → "Handle Model"), **rechecked after any host round trip the verb makes before creating** (`CREATING_CONTROL_METHODS` in `lib/src/components/wall/use-dor-control.ts`; `lib/src/components/WorkspaceWindow.test.tsx`). diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 22a8b23ab..ba27e904d 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -104,8 +104,8 @@ Source of truth: `lib/src/components/wall/keyboard/handle-mouse-selection-keys.t ### 3.7 Ending a Selection - Releasing the button ends the drag and fixes the selection; the copy editor (§4) opens. -- It persists until something ends it: a completed copy, a content change (§3.4), **Esc**, or a click outside (§4.3). -- **A new mouse-down in the terminal content area replaces any existing selection immediately** and dismisses its editor. +- It persists until the editor is dismissed (§4.5). +- **A new mouse-down in the terminal content area replaces any existing selection immediately**, its editor with it. ### 3.8 Drags the Inside Program Owns @@ -113,7 +113,7 @@ A primary mouse drag that reaches the inside program (§6.1) is **shadowed**: it - **Must never consume, delay, or reorder a shadowed drag's events**; only a press that crosses the drag threshold counts, so a program click shadows nothing. - It draws no outline, only a `Press Cmd+C to copy` hint (Ctrl+C on non-macOS): the program paints its own highlight. -- **The copy chord opens the copy editor over it** (§4), outline included. Any other key goes to the program and drops the shadow, as do a new mouse-down, a content change (§3.4), and the program ending mouse reporting. +- **The copy chord opens the copy editor over it** (§4), outline included. Any input the program receives drops the shadow (§4.5), as does the program ending mouse reporting. - Touch never shadows; a touch drag over a reporting program takes §6.1's rows. Source of truth: `finishProgramDrag` in `lib/src/lib/terminal-mouse-router.ts`, pinned by `lib/src/lib/terminal-mouse-router.test.ts`; the chord in `handleMouseSelectionKeys` in `lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts`. @@ -122,7 +122,7 @@ Source of truth: `finishProgramDrag` in `lib/src/lib/terminal-mouse-router.ts`, ## 4. Copy Editor -Mouse-up over a terminal-handled drag opens the **copy editor**, as does the copy chord over a shadowed one (§3.8): the text a copy would produce, at full pane width, every line break the selection crossed marked (rationale). +Mouse-up over a terminal-handled drag opens the **copy editor**, as does the copy chord over a shadowed one (§3.8): the text a copy would produce, every line break the selection crossed marked (rationale). ### 4.1 Formats @@ -141,8 +141,8 @@ Source of truth: `render` in `lib/src/lib/copy-text.ts`, pinned by `lib/src/lib/ Each break between two consecutive rows is, in this order: -1. **Kept** if either row is blank, the next starts a list item, the row ends in `;` `{` `}` or the next starts with `)` `}` `]`, or the next row's indent is not this row's hanging indent. -2. **Deleted** if the next row is a true soft wrap (xterm's `isWrapped`). +1. **Deleted** if the next row is a true soft wrap (xterm's `isWrapped`). +2. **Kept** if either row is blank, the next starts a list item, the row ends in `;` `{` `}` or the next starts with `)` `}` `]`, or the next row's indent is not this row's hanging indent. 3. **Kept** if the paragraph's longest row is under 40 columns (60% of a narrower terminal), or the next row's first word would have fit on this row within that longest row (rationale). 4. **Deleted** if this row fills that width and its last word is token-shaped (a URL or path character, or 16+ token characters) — a token split at the margin. 5. Otherwise **one space**. @@ -167,15 +167,16 @@ The selection overlay draws a wider scope dashed around the outline; the preview | Key | Effect | |---|---| +| `Cmd+C` (Ctrl+C on non-macOS), with or without Shift | Copy what the editor shows, in either mode. | | `e` / `Shift+E` | Next wider / narrower scope, stopping at either end. | | `f` / `Shift+F` | Next / previous format, wrapping, in table order (§4.1), then the program's own copy (§4.6). | | `←` `→` / `Shift+←` `→` | Move the end / start one word; a row boundary ends a word. Returns to As selected, keeping the format. | -| `Enter`, `Cmd+C` (Ctrl+C on non-macOS), either with Shift | Copy what the editor shows. | +| `Enter` | Copy, as the chord does. | | `Esc` | Close and cancel the selection. | -**Any other key closes the editor and reaches the terminal**, so typing after a selection still types; a bare modifier and the paste chord leave it open. **Intercept Ctrl+C only while the editor is open or a shadowed drag waits for it** (§3.8); otherwise it reaches the inside program (SIGINT for shells, app-defined for TUIs). A selection a TUI makes from the keyboard (vim visual mode, less search highlight) is neither, and does not change that routing. The editor's hints write Shift and the arrows as the mobile compass rose does (`⬆︎` `◀` `▶`), and touch shows none. +**Every key but the copy chord is the editor's in passthrough only**; command mode keeps its own. Any other key goes to the terminal, which closes the editor (§4.5). **Intercept Ctrl+C only while the editor is open or a shadowed drag waits for it** (§3.8); otherwise it reaches the inside program (SIGINT for shells, app-defined for TUIs). A selection a TUI makes from the keyboard (vim visual mode, less search highlight) is neither, and does not change that routing. The editor's hints write Shift and the arrows as the mobile compass rose does (`⬆︎` `◀` `▶`), and touch shows none. -Source of truth: `handleCopyEditorKey` in `lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts`, pinned by its test; the transitions in `lib/src/lib/copy-editor.ts`, pinned by `lib/src/lib/copy-editor.test.ts`. +Source of truth: `handleMouseSelectionKeys` in `lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts`, pinned by its test; the transitions in `lib/src/lib/copy-editor.ts`, pinned by `lib/src/lib/copy-editor.test.ts`. ### 4.4 Preview and Marks @@ -186,7 +187,7 @@ Source of truth: `handleCopyEditorKey` in `lib/src/components/wall/keyboard/hand ### 4.5 Placement and Dismissal - Full pane width, on the side of the selection with more room; touch prefers above, clear of the thumb that ended the drag. **When neither side has 120px it docks at the bottom over the selection.** Remeasured on every render tick (§7). -- **Esc**, a click outside the editor, or a content change (§3.4) dismisses it and cancels the selection; a new mouse-down replaces both (§3.7). +- **Esc**, a click outside the editor, a content change (§3.4), a confirmed copy, or **any input the terminal receives** — typing, a paste, Pocket's input bar — dismisses it and cancels the selection. Source of truth: `writeUserInput` in `lib/src/lib/terminal-lifecycle.ts`, pinned by `lib/src/lib/terminal-lifecycle.selection.test.ts`. - **Must flash only after a successful clipboard write, and only for the selection copied**: the Copy button shows a checkmark for ~700 ms, then the selection clears. Failed writes retain it for retry; canceling clears the flash immediately. Source of truth: `CopyEditor` in `lib/src/components/CopyEditor.tsx`, pinned by `lib/src/components/CopyEditor.test.tsx`; `copySelection` in `lib/src/lib/copy-selection.ts`, pinned by `lib/src/lib/copy-editor.test.ts`. @@ -195,9 +196,9 @@ Source of truth: `CopyEditor` in `lib/src/components/CopyEditor.tsx`, pinned by An `OSC 52` clipboard write from the inside program is never the clipboard. It becomes an **offer** the editor can show (rationale): -1. The owner's parser decodes the base64 as UTF-8, turns `\r\n` and `\r` into `\n`, and removes every other control character but tab. **Must drop, never truncate, a payload over `CLIPBOARD_OFFER_LIMIT` base64 characters**, kept under the incomplete-OSC bound so read splitting never changes the answer; a `?` read is never answered, and an empty or malformed write offers nothing. The sequence is consumed either way. +1. The owner's parser decodes the base64 as UTF-8, turns `\r\n` and `\r` into `\n`, and removes every other control character but tab. **Must drop, never truncate, a payload over `CLIPBOARD_OFFER_LIMIT` base64 characters** (rationale); a `?` read is never answered, and an empty or malformed write offers nothing. The sequence is consumed either way. 2. The host sends it to the owning renderer as `terminal:clipboardOffer` (`docs/specs/transport.md`); replay re-parses output without offers. -3. **Must accept an offer only into a pane holding a shadowed drag** (§3.8), the latest replacing any earlier; it goes with that selection. +3. **Must accept an offer only into a pane whose selection the program owns** (§3.8), shadowed or open in the editor, the latest replacing any earlier; it goes with that selection. 4. The editor then offers a fifth format, **From ** (the running command as WATCHING keys it, `docs/specs/alert.md`, else `program`), last in `f` order. **It has no scope**: choosing it returns to As selected, and `e` does nothing while it shows. Its marks still flip, and a nudge returns to Auto. **Never write an offer to the clipboard except as that format, chosen and copied by the user.** Source of truth: `parseOsc52` and `CLIPBOARD_OFFER_LIMIT` in `lib/src/lib/terminal-protocol.ts`, pinned by `lib/src/lib/terminal-protocol.test.ts`; `offerProgramCopy` in `lib/src/lib/mouse-selection.ts`, pinned by `lib/src/lib/mouse-selection.test.ts`; `editorFormats` in `lib/src/lib/copy-editor.ts`. @@ -262,7 +263,7 @@ Source of truth: `terminalOwnsEvent` in `lib/src/lib/terminal-mouse-router.ts`, - **Must render outlines, hints, and the copy editor above the cell grid**, isolated from inside-program output and redraws; header icons and banners remain persistent chrome. - **Geometry comes from the *measured* xterm cell grid** (`cellWidth`/`cellHeight`/`gridLeft`/`gridTop`), never element-width ÷ cols, so the outline stays aligned across xterm's internal padding. -- **Must remeasure both overlay and editor on every shared render tick** (scroll, resize, output), even when the selection is unchanged; the editor dismisses if the selection is canceled. Pinned for the editor by `lib/src/components/CopyEditor.test.tsx`. +- **Must remeasure both overlay and editor on every shared render tick** (scroll, resize, output), even when the selection is unchanged. Pinned for the editor by `lib/src/components/CopyEditor.test.tsx`. Source of truth: `lib/src/lib/selection-text.ts` (extraction and normalization), `lib/src/lib/selection-geometry.ts` (perimeter construction), `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx` and `MouseOverrideBanner` in `lib/src/components/wall/MouseOverrideBanner.tsx` — tested in `lib/src/components/wall/mouse-chrome.test.tsx`. diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index c618fae18..67ac11085 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -56,12 +56,10 @@ Both modes, ahead of the passthrough gate, and only on a terminal **selected** S |-----|--------|-------------| | `e` | Extend to token | Mid-drag only: extend the selection to the full URL/path token at the cursor; consumed but inert with no token. | | `Alt` (hold) | Block / linewise | Block (rectangular) rather than linewise, live through the drag; touch latches it with a double-tap-then-drag. | -| `Esc` | Cancel selection | Cancel the in-progress drag, or close the copy editor and clear its selection. | +| `Esc` | Cancel selection | Cancel the in-progress drag, or in passthrough close the copy editor and clear its selection. | | *(any other key)* | — | Swallowed during a terminal-handled drag, never reaching the inside program (`docs/specs/mouse-and-clipboard.md` §3.6); with the copy editor open, closes it and reaches the terminal. | -| `e` / `⇧E` | Expand / shrink | Copy editor: the next wider / narrower scope. | -| `f` / `⇧F` | Next / previous format | Copy editor: Auto, Exact, Spaces, No breaks, wrapping. | -| `←` `→` / `⇧←` `⇧→` | Move end / start | Copy editor: the selection's end / start, one word. | -| `⌘C` (macOS) / `Ctrl+C` (others), `↵` | Copy | Copy editor: copy what it shows, `⇧` optional; over a program-owned drag, open it. | +| `⌘C` (macOS) / `Ctrl+C` (others) | Copy | Copy editor: copy what it shows (`⇧` optional); over a program-owned drag, open it. | +| `e` / `⇧E`, `f` / `⇧F`, `←` `→` / `⇧←` `⇧→`, `↵` | Copy editor | Passthrough only: scope, format, edges, copy (`docs/specs/mouse-and-clipboard.md` §4.3). | | `⌘V` / `⌘⇧V` / `Ctrl+V` / `Ctrl+Shift+V` | Paste | Paste into the terminal; the `Ctrl` variants are intercepted on every platform, macOS included. | On macOS `Ctrl+C` still reaches the running program; a literal `0x16` needs the shell's `quoted-insert` (`Ctrl+Q`) (`docs/specs/mouse-and-clipboard.md` §8.3). diff --git a/docs/specs/transport.md b/docs/specs/transport.md index f51850997..befb21c47 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -200,7 +200,7 @@ Transport constraints: | Host → webview | `pty:data` | PTY output after state-driving supported OSCs are parsed/stripped; `OSC 8` and ImageAddon's inline-image `OSC 1337` forms are preserved for xterm.js, routed only to the owning router. **Carries an optional `textData`** (string-control payloads removed, for the prompt heuristic), **omitted when it would equal `data`**. | | Host → webview | `terminal:semanticEvents` | Normalized CWD / prompt-command / title events the owner's parser derived, in stream order. | | Host → webview | `terminal:toolEvents` | Ordered Tool announcements, state, and command-start resets (`docs/specs/dor-tool.md` → OSC 367). | -| Host → webview | `terminal:clipboardOffer` | `text`: one decoded `OSC 52` write, for the copy editor to offer; the webview drops it unless that pane holds a shadowed drag (`docs/specs/mouse-and-clipboard.md` §4.6). | +| Host → webview | `terminal:clipboardOffer` | `text`: one decoded `OSC 52` write, an offer the copy editor may show (`docs/specs/mouse-and-clipboard.md` §4.6). | | Webview → host | `pty:spawn` | `options.alert`: a cold-restored pane's persisted alert state (`docs/specs/alert.md` → Public State). | | Webview → host | `dormouse:themeColors` (VS Code) / `pty_theme_colors` (standalone) | Resolved foreground / background / cursor, so the owner's parser can answer OSC 10/11/12. | | Host → webview | `pty:replay` | Buffered raw output since spawn; the webview runs a one-shot parser over it, the only re-parse there is. | diff --git a/lib/src/components/CopyEditor.test.tsx b/lib/src/components/CopyEditor.test.tsx index 470ad90cf..fb4bcd117 100644 --- a/lib/src/components/CopyEditor.test.tsx +++ b/lib/src/components/CopyEditor.test.tsx @@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../lib/copy-selection', () => ({ copySelection: vi.fn() })); vi.mock('../lib/platform', () => ({ IS_MAC: true })); -// The registry barrel boots xterm; the editor only reads the grid and buffer. -vi.mock('../lib/terminal-registry', () => ({ getTerminalOverlayDims: vi.fn(), getTerminalInstance: vi.fn() })); +// The registry barrel boots xterm; the editor only reads the measured grid. +vi.mock('../lib/terminal-registry', () => ({ getTerminalOverlayDims: vi.fn() })); import { copySelection } from '../lib/copy-selection'; import { openCopyEditor } from '../lib/copy-editor'; import { CLAUDE_REPLY, fakeXterm } from '../lib/copy-text-fixtures'; @@ -22,7 +22,7 @@ import { offerProgramCopy, setSelection, } from '../lib/mouse-selection'; -import { getTerminalInstance, getTerminalOverlayDims } from '../lib/terminal-registry'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; import { CopyEditor } from './CopyEditor'; import { TouchUiContext } from './touch-ui-context'; import { WorkspaceActiveContext } from './wall/wall-context'; @@ -70,7 +70,6 @@ beforeEach(() => { __resetMouseSelectionForTests(); dims = { ...DIMS }; vi.mocked(getTerminalOverlayDims).mockImplementation(() => dims); - vi.mocked(getTerminalInstance).mockReturnValue(terminal); vi.mocked(copySelection).mockReset(); container = document.createElement('div'); document.body.appendChild(container); diff --git a/lib/src/components/CopyEditor.tsx b/lib/src/components/CopyEditor.tsx index 805cbe88f..a11fe6f0d 100644 --- a/lib/src/components/CopyEditor.tsx +++ b/lib/src/components/CopyEditor.tsx @@ -10,12 +10,12 @@ import { subscribeToRenderTick, } from '../lib/mouse-selection'; import { spanOfSelection, type BreakKind, type EditorFormat, type Piece, type Rendering } from '../lib/copy-text'; -import { duplicateFormats, editorFormats, editorRendering, flipCopyBreak, formatRenderings, setCopyFormat, setCopyScope } from '../lib/copy-editor'; +import { duplicateFormats, editorFormats, editorRendering, flipCopyBreak, formatRenderings, isEdited, setCopyFormat, setCopyScope } from '../lib/copy-editor'; import { copySelection } from '../lib/copy-selection'; -import { getTerminalInstance, getTerminalOverlayDims } from '../lib/terminal-registry'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; import { getRunningCommandWatchKey } from '../lib/terminal-state-store'; import { COPY_CHORD_LABEL } from './wall/keyboard/chords'; -import { modalActionButton, modalSurface, popupButton, Shortcut } from './design'; +import { COPY_EXPANDED_TEXT_CLASS, modalActionButton, modalSurface, popupButton, Shortcut } from './design'; import { TouchUiContext } from './touch-ui-context'; import { WorkspaceActiveContext } from './wall/wall-context'; @@ -78,19 +78,13 @@ export function CopyEditor({ terminalId }: { terminalId: string }) { const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; const { selection, copyEditor, copyFlash, programCopy } = state; const open = workspaceActive && !!copyEditor; - const terminal = open ? getTerminalInstance(terminalId) : null; - // Every format at the scope is rendered once per scope, so `f` and the - // duplicate check reuse it; only a per-break edit renders again. - const scope = copyEditor?.scopes[copyEditor.scope].span; - const renderings = useMemo( - () => (terminal && selection && scope ? formatRenderings(terminal, selection, scope, programCopy) : null), - [terminal, selection, scope, programCopy], + // Each scope's formats are rendered once and cached on it (`copy-editor.ts`), + // so `f`, the duplicate check, and the copy all reuse them. + const rendering = useMemo(() => (open && copyEditor ? editorRendering(copyEditor, programCopy) : null), [open, copyEditor, programCopy]); + const sameAs = useMemo( + () => (open && copyEditor ? duplicateFormats(formatRenderings(copyEditor, programCopy)) : {}), + [open, copyEditor, programCopy], ); - const rendering = useMemo( - () => (terminal && selection && copyEditor && renderings ? editorRendering(terminal, selection, copyEditor, programCopy, renderings) : null), - [terminal, selection, copyEditor, programCopy, renderings], - ); - const sameAs = useMemo(() => (renderings ? duplicateFormats(renderings) : {}), [renderings]); const onFlip = useCallback((index: number, kind: BreakKind) => flipCopyBreak(terminalId, index, kind), [terminalId]); const [placement, setPlacement] = useState(null); @@ -134,7 +128,8 @@ export function CopyEditor({ terminalId }: { terminalId: string }) { if (!rendering || !placement || !copyEditor || !selection) return null; - const edited = Object.keys(copyEditor.overrides).length > 0; + const edited = isEdited(copyEditor); + const lines = rendering.text ? rendering.text.split('\n').length : 0; const fromProgram = copyEditor.format === 'program'; const formatName = (f: EditorFormat) => (f === 'program' ? `From ${programName(terminalId)}` : FORMAT_NAMES[f]); const keys = (node: ReactNode) => (touchUi ? null : {node}); @@ -152,7 +147,7 @@ export function CopyEditor({ terminalId }: { terminalId: string }) {
e.stopPropagation()} >
@@ -187,11 +182,11 @@ export function CopyEditor({ terminalId }: { terminalId: string }) { {MARK_GLYPH.keep} kept {MARK_GLYPH.space} space {MARK_GLYPH.none} joined - {copyEditor.scope > 0 && abc expanded} + {copyEditor.scope > 0 && abc expanded} {selection.shape !== 'block' && keys(<>{LEFT}{RIGHT} end {SHIFT}{LEFT}{RIGHT} start)} - {rendering.lines} {rendering.lines === 1 ? 'line' : 'lines'} · {rendering.text.length} ch + {lines} {lines === 1 ? 'line' : 'lines'} · {rendering.text.length} ch {keys({COPY_CHORD_LABEL})}
+
+ +
); -} + + // In `document.body`, free of the pane's clipping and of any Workspace's + // stacking context; the anchor keeps its presses inside the pane for DOM + // containment checks (`anchoredTarget`). The server renderer has no portals. + return ( + <> +