From 7d32e259f328317afce2a9eff418341e344f7adc Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 12 Aug 2026 21:32:01 +0530 Subject: [PATCH 1/3] feat(web): copy off a terminal with a finger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phone could read the terminal and never lift anything out of it. The line telling you to run `claude --resume ` was on screen and there was no way to get the id into the prompt below it. Nothing was broken. The screen is painted to a canvas, so there is no text on the page for the operating system to offer Copy over, and `user-select` is off across xterm besides — the browser has no idea the glyphs it drew are characters. Only flue knows that, so only flue can offer it. A long press selects the word under the finger and a drag widens the range, either direction, across as many rows as the finger travels; a menu offers Copy, Paste and Cancel at whichever end of the terminal the hand is not covering. Words break on whitespace alone, which is not what a text editor would do and is what a terminal wants: paths, URLs, hashes and session ids are made of the punctuation an editor breaks on, and a rule that stopped at the hyphen would turn a UUID into six presses. The range is xterm's own — `select` takes a length that wraps past the end of a row, which is the only way the public API can express a multi-row range, and it means the renderer draws the selection without flue owning a pixel of it. What flue owns is the gesture: the press has to be told apart from the scroll that shares the surface with it, so a finger that travels more than the slop before the hold elapses is a scroll, and once a selection begins the scroll path is abandoned for the rest of the gesture. Two smaller things fall out of it. The compatibility mousedown behind a press that never moved is swallowed, because xterm answers a mousedown by starting a selection of its own and would clear this one a frame after making it. And the transparent textarea from the release before this one is gone — it was there to give a long press some editable text to land on, and it was also what drew a focus ring around the whole terminal and risked iOS zooming the page on every tap. flue owns the press now, so the platform is told to keep its callout to itself. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/selection-menu.tsx | 88 +++++++++++ web/src/components/terminal.test.tsx | 187 ++++++++++++++++++++++- web/src/components/terminal.tsx | 205 ++++++++++++++++++++++++++ web/src/emulator/emulator.test.ts | 102 +++++++++++++ web/src/emulator/types.ts | 65 ++++++++ web/src/emulator/xterm.ts | 118 ++++++++++++++- web/src/lib/geometry.test.ts | 37 ++++- web/src/lib/geometry.ts | 37 +++++ web/src/styles.build.test.ts | 42 ++---- web/src/styles.css | 80 +--------- web/src/testing/emulator.ts | 52 ++++++- 11 files changed, 906 insertions(+), 107 deletions(-) create mode 100644 web/src/components/selection-menu.tsx diff --git a/web/src/components/selection-menu.tsx b/web/src/components/selection-menu.tsx new file mode 100644 index 0000000..185116b --- /dev/null +++ b/web/src/components/selection-menu.tsx @@ -0,0 +1,88 @@ +import { cn } from '@/lib/utils' + +/** + * Which end of the terminal the menu sits at. + * + * There are two positions and not a coordinate, because the only thing the + * placement has to get right is not covering what was just selected. A menu + * pinned beside the selection would also have to dodge the control strip, the + * key bar and the edges of a scaled screen, for a precision nobody reading it + * would notice. + */ +export type MenuEnd = 'top' | 'bottom' + +/** + * Copy and paste for a device with no keyboard to do either from. + * + * The gesture behind it lives in the terminal view: a long press selects the + * word under the finger and a drag widens the range, which is a thing flue has + * to do for itself. The OS would offer a menu of its own over real editable + * text, but a terminal rendered to a canvas has none — there is nothing on + * the page for a press to land on, and the browser has no idea the glyphs it + * drew are text at all. + * + * Presses land on pointerdown for the same reason the key bar's do: the press + * must not take focus from xterm's textarea, because losing it closes the + * keyboard. The onClick beside each is the assistive-technology path, where a + * double-tap synthesises a click and dispatches no pointer event; `detail` is + * 0 for exactly those and keeps a finger's own follow-up click from firing + * the action twice. + */ +export function SelectionMenu({ + at, + onCopy, + onPaste, + onCancel, +}: { + at: MenuEnd + onCopy: () => void + onPaste: () => void + onCancel: () => void +}) { + const chip = 'rounded-md px-3 py-1.5 text-sm/4 transition-colors select-none' + const press = (act: () => void) => ({ + onPointerDown: (e: React.PointerEvent) => { + e.preventDefault() + act() + }, + onClick: (e: React.MouseEvent) => { + if (e.detail === 0) act() + }, + }) + return ( +
+ + + +
+ ) +} diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index b6a6956..190a9fa 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -8,7 +8,7 @@ import type { SessionInfo } from '@/client/protocol' import { GUTTER_PX } from '@/lib/geometry' import { createFakeEmulator, type FakeEmulator } from '@/testing/emulator' import { attached, fakeClient, sizeChanged, type FakeSocket } from '@/testing/socket' -import { RESIZE_SETTLE_MS, Terminal, TERMINAL_SHORTCUT_HINT } from './terminal' +import { LONG_PRESS_MS, RESIZE_SETTLE_MS, Terminal, TERMINAL_SHORTCUT_HINT } from './terminal' /** * One emulator per mount, all of them kept. @@ -90,9 +90,12 @@ function touch( type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', ys: number[], at?: number, + xs: number[] = [], ) { const e = new Event(type, { bubbles: true, cancelable: true }) - Object.defineProperty(e, 'touches', { value: ys.map((clientY) => ({ clientY })) }) + Object.defineProperty(e, 'touches', { + value: ys.map((clientY, i) => ({ clientY, clientX: xs[i] ?? 0 })), + }) if (at !== undefined) Object.defineProperty(e, 'timeStamp', { value: at }) return e } @@ -851,6 +854,186 @@ describe('Terminal', () => { }) }) + describe('touch selection', () => { + /** + * Attached, measured, and told where its screen sits on the glass. + * + * The screen box is what a touch is measured against, and jsdom lays + * nothing out, so it is stated here: 80x24 over 800x408 puts a cell at + * 10 x 17. `selected` is what the emulator will claim it selected, which + * a fake cannot work out for itself. + */ + function mountSelectable(selected = 'c1ff4c66') { + const mounted = mountTerminal((e) => ) + act(() => mounted.sock.emitControl(attached({ ref: 1, id: 's1', cols: 80, rows: 24 }))) + const em = mounted.em.live() + em.measured = { width: 800, height: 408 } + em.onGlass = { x: 0, y: 0, width: 800, height: 408 } + em.word = selected + return mounted + } + + const press = (x: number, y: number) => + act(() => void inset().dispatchEvent(touch('touchstart', [y], 0, [x]))) + const hold = () => act(() => void vi.advanceTimersByTime(LONG_PRESS_MS)) + const menu = () => screen.queryByRole('toolbar', { name: 'Selection' }) + + beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })) + afterEach(() => vi.useRealTimers()) + + it('lifts the word under a finger that holds still', () => { + const { em } = mountSelectable() + + press(125, 105) + hold() + + // 10px columns and 17px rows, so that point is column 12, row 6. + expect(em.live().wordPresses).toEqual([{ col: 12, row: 6 }]) + expect(menu()).not.toBeNull() + }) + + it('reads a finger that sets off as a scroll and not as a press', () => { + // The two gestures share a surface and are the same event until they + // are not. Travel inside the hold is what tells them apart. + const { em } = mountSelectable() + + press(125, 300) + act(() => void inset().dispatchEvent(touch('touchmove', [200], 16, [125]))) + hold() + + expect(em.live().wordPresses).toEqual([]) + expect(em.live().scrolled).not.toBe(0) + expect(menu()).toBeNull() + }) + + it('shows nothing where there is no word to show it for', () => { + // A press on the blank half of a line. A menu offering to copy an empty + // selection is a menu of buttons that do nothing. + const { em } = mountSelectable('') + + press(125, 105) + hold() + + expect(em.live().wordPresses).toHaveLength(1) + expect(menu()).toBeNull() + }) + + it('widens the range as the finger travels, instead of scrolling', () => { + const { em } = mountSelectable() + press(125, 105) + hold() + + const move = touch('touchmove', [190], 16, [305]) + act(() => void inset().dispatchEvent(move)) + + expect(em.live().extensions).toEqual([{ col: 30, row: 11 }]) + // The scrollback stays where it was: one drag cannot both grade a + // selection and move what is under it. + expect(em.live().scrolled).toBe(0) + expect(move.defaultPrevented).toBe(true) + }) + + it('does not send the scrollback coasting when a selection lifts', () => { + const { em } = mountSelectable() + press(125, 300) + hold() + + act(() => { + inset().dispatchEvent(touch('touchmove', [200], 16, [125])) + inset().dispatchEvent(touch('touchmove', [100], 32, [125])) + inset().dispatchEvent(touch('touchend', [], 40)) + }) + + expect(em.live().scrolled).toBe(0) + }) + + it('swallows the mouse press the browser replays behind the gesture', () => { + // A touch nothing cancelled comes back as a mouse, and xterm answers a + // mousedown by starting a selection of its own — clearing this one a + // frame after it was made. A press that never moved has no touchmove + // to cancel, so the mouse event is the only place left to stop it. + mountSelectable() + press(125, 105) + hold() + + const replay = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + act(() => void surfaceEl().dispatchEvent(replay)) + + expect(replay.defaultPrevented).toBe(true) + expect(menu()).not.toBeNull() + }) + + it('puts the selection away when a finger lands again', () => { + const { em } = mountSelectable() + press(125, 105) + hold() + + press(125, 105) + + expect(em.live().selectionClears).toBeGreaterThan(0) + expect(menu()).toBeNull() + }) + + it('copies the selection to the clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const { em } = mountSelectable('claude --resume c1ff4c66') + press(125, 105) + hold() + + fireEvent.pointerDown(screen.getByText('Copy')) + + expect(writeText).toHaveBeenCalledWith('claude --resume c1ff4c66') + await waitFor(() => expect(menu()).toBeNull()) + expect(em.live().selectionClears).toBeGreaterThan(0) + }) + + it('pastes through the emulator, so bracketed paste survives', async () => { + const readText = vi.fn().mockResolvedValue('git status\n') + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText } }) + const { sock, em } = mountSelectable() + press(125, 105) + hold() + + fireEvent.pointerDown(screen.getByText('Paste')) + + await waitFor(() => expect(em.live().pasted).toEqual(['git status\n'])) + expect(sock.input()).toEqual([{ ref: 1, text: 'git status\n' }]) + }) + + it('says so when the browser refuses the clipboard', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + mountSelectable() + press(125, 105) + hold() + + fireEvent.pointerDown(screen.getByText('Copy')) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'This browser would not take the copy.', + ) + }) + + it('sits at the end of the terminal the finger is not at', () => { + // So the menu never covers the words it is offering to copy. + mountSelectable() + vi.spyOn(inset(), 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 800, + height: 400, + } as DOMRect) + + press(125, 380) + hold() + + expect(menu()!.className).toContain('top-16') + }) + }) + describe('the sizing policy', () => { it('asks the daemon for the cells its own pane holds, when primary', async () => { // Driven through the ResizeObserver rather than a window event, because diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 8ac8fdb..0272764 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -4,6 +4,7 @@ import { ArrowLeftRightIcon, LayoutGridIcon, PlusIcon } from 'lucide-react' import { useFlueClient } from '@/client/provider' import { ExitOverlay } from '@/components/exit-overlay' import { KeyBar } from '@/components/key-bar' +import { SelectionMenu, type MenuEnd } from '@/components/selection-menu' import { ThemeMenu } from '@/components/theme-menu' import { DARK_SCHEME_QUERY, prefersDark } from '@/emulator/palette' import { controlColors, resolveTheme, THEME_SYSTEM } from '@/emulator/themes' @@ -11,6 +12,7 @@ import type { Emulator } from '@/emulator/types' import { createXtermEmulator, type XtermOptions } from '@/emulator/xterm' import { loadThemePref, saveThemePref, THEME_PREF_KEY } from '@/lib/theme-pref' import { + cellAt, cellBox, cellsThatFit, fitFactor, @@ -91,6 +93,22 @@ const EXIT_NOTICE = (code: number) => */ export const RESIZE_SETTLE_MS = 150 +/** + * How long a finger must hold still before the press counts as a selection. + * + * Exported for the test, which cannot wait it out in real time and must not + * hard-code a number this file is free to change. + */ +export const LONG_PRESS_MS = 450 + +/** + * How far a finger may wander in that time and still be holding still. + * + * Nobody keeps a thumb inside a pixel, and a zero here would mean the + * gesture only ever worked for people whose hands did not shake. + */ +const PRESS_SLOP = 10 + /** * The terminal, full bleed, one session. * @@ -169,6 +187,10 @@ export function Terminal({ const ctrlArmedRef = useRef(ctrlArmed) ctrlArmedRef.current = ctrlArmed const [exitCode, setExitCode] = useState(null) + // Which end the selection menu is showing at, or null for not showing. + // One state and not two: there is no menu without a selection. + const [menuEnd, setMenuEnd] = useState(null) + const [clipProblem, setClipProblem] = useState(null) // This session's directory, for Restart and the new-session link. From the // session list, because `attached` does not carry it. const [cwd, setCwd] = useState(null) @@ -194,6 +216,9 @@ export function Terminal({ restart: (dir: string | null) => void applyTheme: (id: string) => void sendKey: (key: BarKey) => void + copy: () => void + paste: () => void + dismiss: () => void } | null>(null) // The latest onRestarted, readable from inside the effect without putting // a prop identity in its dependency array. @@ -244,6 +269,9 @@ export function Terminal({ const palette = resolveTheme(themeIdRef.current, prefersDark()) const emulator = createEmulator({ cols: 80, rows: 24, theme: palette }) + // Read by the clipboard callbacks, which settle a promise later and + // must not write state into a view that has gone away. + let alive = true emulator.attachTo(surface) emulator.focus() paintGround(palette.background) @@ -455,6 +483,76 @@ export function Terminal({ // every other scrolling surface on a phone does. let touchY: number | null = null let touchCarry = 0 + /* + * Long press to select, drag to widen it. + * + * Copying off a terminal is the one thing a phone could not do here at + * all, and it is not for want of a control: the screen is painted to a + * canvas, so there is no text on the page for the operating system to + * offer its own Copy over, and `user-select` is off across xterm besides. + * Nothing but flue can know that those pixels are characters. + * + * The gesture has to be told apart from the scroll that shares the + * surface with it, and the two are the same event until they are not. A + * press that holds still for `LONG_PRESS_MS` is a selection; a finger + * that travels more than `PRESS_SLOP` before then is a scroll, and the + * slop is what keeps a thumb resting on glass from being read as a drag. + * Once a selection has begun the scroll path is abandoned for the rest of + * the gesture — a drag that both grew a range and scrolled the scrollback + * under it would be unusable. + */ + let pressTimer: ReturnType | null = null + let pressFrom: { x: number; y: number } | null = null + let selectDrag = false + /* + * The compatibility mousedown behind a press that selected. + * + * A touch the page did not cancel is replayed as a mouse, and xterm's own + * mousedown handler begins a fresh selection of its own — clearing the + * one the press just made, a frame after making it. A long press that + * never moved has no touchmove to cancel, so the only place left to stop + * it is the mouse event itself. Capture phase on the inset, which is an + * ancestor of the element xterm listens on, so this runs first. + */ + let swallowMouse = false + const cellUnder = (x: number, y: number) => { + const box = emulator.screenBox() + return box ? cellAt({ x, y }, box, dims) : null + } + const endPress = () => { + if (pressTimer !== null) clearTimeout(pressTimer) + pressTimer = null + pressFrom = null + } + const dismissSelection = () => { + emulator.clearSelection() + setMenuEnd(null) + setClipProblem(null) + } + const beginSelection = () => { + pressTimer = null + const from = pressFrom + if (from === null) return + const cell = cellUnder(from.x, from.y) + if (cell === null) return + emulator.selectWordAt(cell) + // Blank space holds no word, and a press on it selects nothing. Leaving + // the menu away is the honest answer: a Copy over an empty selection is + // a button that does nothing. + if (emulator.selection() === '') return + selectDrag = true + swallowMouse = true + // Whatever this gesture was going to be, it is a selection now. + touchY = null + touchCarry = 0 + flick = [] + glide?.() + glide = null + const box = inner.getBoundingClientRect() + // Away from the half the finger is in, so the menu never covers the + // words it is offering to copy. + setMenuEnd(from.y > box.y + box.height / 2 ? 'top' : 'bottom') + } // The flick record: the last few moves' clocks and positions, enough to // read a release velocity from. Cleared whenever a gesture starts. let flick: Array<{ t: number; y: number }> = [] @@ -472,12 +570,21 @@ export function Terminal({ // is not enough to give it back: touch-action only says the browser // *may* pan, while the preventDefault() below cancels that pan whatever // it says. So while zoomed this takes no gesture at all. + // A finger anywhere on the terminal puts a showing selection away. + // The menu is outside this element, so tapping Copy does not come + // through here and does not dismiss the thing it is copying. + selectDrag = false + endPress() + dismissSelection() if (e.touches.length !== 1 || zoomedIn(window.visualViewport)) { touchY = null touchCarry = 0 flick = [] return } + const press = e.touches[0]! + pressFrom = { x: press.clientX, y: press.clientY } + pressTimer = setTimeout(beginSelection, LONG_PRESS_MS) touchY = e.touches[0]!.clientY touchCarry = 0 // The anchor counts as a sample. A flick is often three moves long at a @@ -491,8 +598,24 @@ export function Terminal({ touchY = null touchCarry = 0 flick = [] + // A second finger ends the drag but keeps what it selected: the range + // is on screen and the menu is up, and throwing both away because + // somebody steadied the phone would be its own bug. + selectDrag = false + endPress() + return + } + const moved = e.touches[0]! + if (selectDrag) { + e.preventDefault() + const cell = cellUnder(moved.clientX, moved.clientY) + if (cell !== null) emulator.extendSelectionTo(cell) return } + if (pressFrom !== null) { + const travel = Math.hypot(moved.clientX - pressFrom.x, moved.clientY - pressFrom.y) + if (travel > PRESS_SLOP) endPress() + } if (touchY === null) return // The zoom can arrive after the finger is already down, so the same // question is asked again here. Dropping the anchor rather than merely @@ -515,6 +638,16 @@ export function Terminal({ if (lines !== 0) emulator.scrollLines(lines) } const touchEnd = (e: TouchEvent) => { + endPress() + if (selectDrag) { + // The lift leaves the range and the menu standing; there is nothing + // here to coast. + selectDrag = false + touchY = null + touchCarry = 0 + flick = [] + return + } const wasDragging = touchY !== null touchY = null touchCarry = 0 @@ -552,11 +685,20 @@ export function Terminal({ touchY = null touchCarry = 0 flick = [] + selectDrag = false + endPress() + } + const swallowSyntheticMouse = (e: MouseEvent) => { + if (!swallowMouse) return + swallowMouse = false + e.preventDefault() + e.stopPropagation() } inner.addEventListener('touchstart', touchStart, { passive: true }) inner.addEventListener('touchmove', touchMove, { passive: false }) inner.addEventListener('touchend', touchEnd, { passive: true }) inner.addEventListener('touchcancel', touchCancel, { passive: true }) + inner.addEventListener('mousedown', swallowSyntheticMouse, true) // The pane hugs the visual viewport: a phone keyboard shrinks it and the // ResizeObserver below refits the terminal above the keyboard. While @@ -785,6 +927,50 @@ export function Terminal({ } client.sendInput(ref, bytes) }, + copy: () => { + const text = emulator.selection() + // Nothing selected is not a failure worth a message; the menu only + // exists because something was, and this is the race where a + // relayout ate it in between. + if (text === '') return dismissSelection() + const clipboard = navigator.clipboard + if (!clipboard?.writeText) { + return setClipProblem('This browser does not allow reading the clipboard here.') + } + void clipboard.writeText(text).then( + () => alive && dismissSelection(), + () => alive && setClipProblem('This browser would not take the copy.'), + ) + }, + paste: () => { + if (ref === null || consumed < muteUntil) { + return setClipProblem('The terminal is not ready for input yet.') + } + const clipboard = navigator.clipboard + if (!clipboard?.readText) { + return setClipProblem('This browser does not allow reading the clipboard here.') + } + // A latched hardware modifier does not alter a clipboard paste. Clear + // ours before xterm emits the prepared text through onData, or a + // one-character paste such as "c" would turn into Ctrl+C. + ctrlArmedRef.current = false + setCtrlArmed(false) + void clipboard.readText().then( + (text) => { + if (!alive) return + if (text === '') return setClipProblem('The clipboard is empty.') + dismissSelection() + // Through the emulator, so the newlines are normalised and a + // program in bracketed-paste mode is told what it asked to be + // told. Sending these bytes straight down the wire would let a + // pasted command with a newline in it run itself. + emulator.paste(text) + emulator.focus() + }, + () => alive && setClipProblem('This browser would not hand flue the clipboard.'), + ) + }, + dismiss: dismissSelection, } // Another tab choosing a theme lands here: the preference is global, and @@ -805,12 +991,15 @@ export function Terminal({ client.list() return () => { + alive = false actionsRef.current = null for (const off of offs) off() inner.removeEventListener('touchstart', touchStart) inner.removeEventListener('touchmove', touchMove) inner.removeEventListener('touchend', touchEnd) inner.removeEventListener('touchcancel', touchCancel) + inner.removeEventListener('mousedown', swallowSyntheticMouse, true) + endPress() glide?.() untrackViewport() window.removeEventListener('storage', onStorage) @@ -897,6 +1086,22 @@ export function Terminal({ onKey={(k) => actionsRef.current?.sendKey(k)} /> )} + {menuEnd !== null && ( + actionsRef.current?.copy()} + onPaste={() => actionsRef.current?.paste()} + onCancel={() => actionsRef.current?.dismiss()} + /> + )} + {clipProblem !== null && ( +

+ {clipProblem} +

+ )} {/* z-10: xterm's own layers carry z-indexes, and an unindexed sibling loses to them — the controls must win the stack or the scrollbar eats their clicks. */} diff --git a/web/src/emulator/emulator.test.ts b/web/src/emulator/emulator.test.ts index 19ef0fd..d453202 100644 --- a/web/src/emulator/emulator.test.ts +++ b/web/src/emulator/emulator.test.ts @@ -123,6 +123,108 @@ describe('Emulator interface', () => { em.dispose() }) + describe('touch selection', () => { + /** A mounted terminal with `text` on screen, ready to be selected from. */ + async function screen(text: string, cols = 40) { + const el = document.createElement('div') + document.body.appendChild(el) + const em = createXtermEmulator({ cols, rows: 6 }) + em.attachTo(el) + await settled(em, text) + return { em, done: () => (em.dispose(), el.remove()) } + } + + it('lifts the word under the finger and nothing either side of it', async () => { + // The case the gesture exists for: a session id printed in a sentence, + // wanted on its own and without the words around it. + const { em, done } = await screen('resume c1ff4c66-5b72 now') + + em.selectWordAt({ col: 10, row: 0 }) + + expect(em.selection()).toBe('c1ff4c66-5b72') + done() + }) + + it('takes whitespace as no word at all', async () => { + const { em, done } = await screen('one two') + em.selectWordAt({ col: 0, row: 0 }) + + em.selectWordAt({ col: 4, row: 0 }) + + // The earlier selection survives: a press on blank space is not a way + // to lose what was already held. + expect(em.selection()).toBe('one') + done() + }) + + it('extends forward across a row boundary', async () => { + const { em, done } = await screen('alpha bravo\r\ncharlie delta', 12) + + em.selectWordAt({ col: 6, row: 0 }) + // The last cell of "charlie", not the space after it: the cell under + // the finger is inside the selection, so a drag onto the space would + // rightly carry the space along. + em.extendSelectionTo({ col: 6, row: 1 }) + + expect(em.selection()).toBe('bravo\ncharlie') + done() + }) + + it('extends backwards from the anchored word, keeping its far end', async () => { + const { em, done } = await screen('alpha bravo charlie') + + em.selectWordAt({ col: 6, row: 0 }) + em.extendSelectionTo({ col: 0, row: 0 }) + + expect(em.selection()).toBe('alpha bravo') + done() + }) + + it('falls back to the anchored word when the drag returns inside it', async () => { + const { em, done } = await screen('alpha bravo charlie') + + em.selectWordAt({ col: 6, row: 0 }) + em.extendSelectionTo({ col: 14, row: 0 }) + em.extendSelectionTo({ col: 8, row: 0 }) + + expect(em.selection()).toBe('bravo') + done() + }) + + it('ignores a drag that no press anchored', async () => { + const { em, done } = await screen('alpha bravo') + + em.extendSelectionTo({ col: 8, row: 0 }) + + expect(em.selection()).toBe('') + done() + }) + + it('drops the anchor along with the selection', async () => { + const { em, done } = await screen('alpha bravo charlie') + em.selectWordAt({ col: 0, row: 0 }) + + em.clearSelection() + em.extendSelectionTo({ col: 14, row: 0 }) + + expect(em.selection()).toBe('') + done() + }) + + it('counts rows from the top of the viewport, not the scrollback', async () => { + // Six rows of screen and ten lines written, so the buffer has scrolled + // and viewport row 0 is no longer buffer line 0. A caller holding a + // finger over the top line means the line it can see. + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\r\n') + const { em, done } = await screen(lines) + + em.selectWordAt({ col: 0, row: 0 }) + + expect(em.selection()).toBe('line4') + done() + }) + }) + it('stops reporting the pointer a replayed program had asked for', async () => { // The bug this is the floor for: a snapshot's scrollback carries the // mouse-tracking sequence of a program that died with the daemon, so diff --git a/web/src/emulator/types.ts b/web/src/emulator/types.ts index 169fbc7..f34bdd2 100644 --- a/web/src/emulator/types.ts +++ b/web/src/emulator/types.ts @@ -16,6 +16,19 @@ export interface PixelSize { height: number } +/** + * A cell on the screen in front of somebody: column, and row counted from + * the top of the viewport rather than from the top of the scrollback. + * + * Viewport-relative because that is what a finger knows. Where the viewport + * sits in the buffer is the emulator's business, and a caller that had to + * hold both would have to keep them in step through every scroll. + */ +export interface Cell { + col: number + row: number +} + /** * A terminal colour palette. * @@ -106,6 +119,58 @@ export interface Emulator { * destination rather than registering a second callback. */ onData(cb: (bytes: Uint8Array) => void): void + /** + * Where the rendered screen sits on the glass, in client coordinates. + * + * The counterpart of contentSize, and the difference between them is the + * whole point of having both. contentSize reports the screen's own layout + * size and deliberately ignores any scaling, because that is the number the + * sizing policy divides by. This one reports the box as the browser sees + * it, scaling and all, because that is the number a touch has to be + * measured against: a finger lands in client coordinates, and a mirroring + * view draws its screen at a fraction of the size it was laid out at. + * + * Null before anything has been laid out, jsdom included. + */ + screenBox(): (PixelSize & { x: number; y: number }) | null + /** + * Select the word under a cell, and remember it as the anchor. + * + * The opening move of a touch selection, and a word rather than a cell + * because a single cell is not something anybody wants: the gesture exists + * to lift a path, a hash or a session id off the screen, and every one of + * those is a word. Whitespace under the finger selects nothing and leaves + * any existing selection alone, so a press on the empty half of a line is + * not a way to lose what was already held. + * + * The anchor is the whole word, not a point in it. Extending backwards + * past the press therefore keeps the word's far end pinned, which is what + * every phone does and what makes a first press worth having. + */ + selectWordAt(cell: Cell): void + /** + * Grow the selection out to a cell, from the anchor the press left. + * + * Either direction: a cell past the anchor extends the end, a cell before + * it extends the start, and anywhere inside falls back to the anchored + * word. Does nothing without an anchor — a drag that never began as a + * press is a scroll, and this is not the code that decides that. + */ + extendSelectionTo(cell: Cell): void + /** The selected text, empty when there is no selection. */ + selection(): string + /** Drop the selection and the anchor with it. */ + clearSelection(): void + /** + * Paste text as terminal input. + * + * Distinct from writing bytes: a terminal normalises the newlines in + * pasted text and wraps the whole of it when the program has asked for + * bracketed-paste mode. A clipboard control that put the text on the wire + * itself would lose both, and a pasted command with a newline in it would + * run rather than land in the line editor. + */ + paste(text: string): void /** * Forget the reporting modes a replayed backlog turned on. * diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts index bc0c2fe..b424cf7 100644 --- a/web/src/emulator/xterm.ts +++ b/web/src/emulator/xterm.ts @@ -1,6 +1,6 @@ import { Terminal } from '@xterm/xterm' import { WebLinksAddon } from '@xterm/addon-web-links' -import type { Emulator, Grid, PixelSize, TerminalTheme } from './types' +import type { Cell, Emulator, Grid, PixelSize, TerminalTheme } from './types' export interface XtermOptions { cols?: number @@ -122,6 +122,10 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { const encoder = new TextEncoder() let disposed = false + // The word a long press anchored, in buffer rows: where a drag extends + // from, and what it falls back to when the finger comes back inside it. + // `to` is the column after the last cell, the way a range end usually is. + let anchor: { row: number; from: number; to: number } | null = null // Device-query suppression for mirrors. The session's byte stream is // broadcast to every attached client, each of which is a full emulator @@ -184,6 +188,60 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { term.onData((data) => cb(encoder.encode(data))) }, + screenBox() { + if (disposed) return null + const screen = term.element?.querySelector(SCREEN_SELECTOR) + if (!(screen instanceof HTMLElement)) return null + // getBoundingClientRect and not offsetWidth, and for exactly the reason + // contentSize gives for the opposite choice: a mirroring view is scaled + // by CSS on an ancestor, and the rect is the only one of the two that + // has the scale in it. + const rect = screen.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return null + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }, + + selectWordAt(cell: Cell) { + if (disposed) return + const row = bufferRow(term, cell.row) + const word = wordAt(term, row, cell.col) + if (!word) return + anchor = { row, from: word.from, to: word.to } + apply(term, row, word.from, row, word.to) + }, + + extendSelectionTo(cell: Cell) { + if (disposed || !anchor) return + const row = bufferRow(term, cell.row) + // The cell under the finger belongs to the selection, so the far edge + // is the column after it. Without this a drag can never reach the last + // character of a line, which is where the interesting half of a path + // or a hash lives. + if (after(row, cell.col + 1, anchor.row, anchor.to)) { + apply(term, anchor.row, anchor.from, row, cell.col + 1) + } else if (after(anchor.row, anchor.from, row, cell.col)) { + apply(term, row, cell.col, anchor.row, anchor.to) + } else { + apply(term, anchor.row, anchor.from, anchor.row, anchor.to) + } + }, + + selection() { + if (disposed) return '' + return term.getSelection() + }, + + clearSelection() { + anchor = null + if (disposed) return + term.clearSelection() + }, + + paste(text: string) { + if (disposed) return + term.paste(text) + }, + stopReporting() { if (disposed) return // Written as output rather than set on xterm's services, because the @@ -260,6 +318,64 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { } } +/** The buffer line a viewport row is currently showing. */ +function bufferRow(term: Terminal, row: number): number { + return term.buffer.active.viewportY + row +} + +/** Whether (rowA, colA) comes after (rowB, colB) in reading order. */ +function after(rowA: number, colA: number, rowB: number, colB: number): boolean { + return rowA > rowB || (rowA === rowB && colA > colB) +} + +/** + * Hand a range to xterm's own selection. + * + * `select` takes a start and a *length*, and that length wraps: past the + * width of a row it carries into the next one, which is the only reason a + * multi-row range can be expressed through the public API at all. + */ +function apply(term: Terminal, row: number, from: number, endRow: number, to: number): void { + term.select(from, row, (endRow - row) * term.cols + (to - from)) +} + +/** + * Whether a cell is blank, asked by column rather than by string index. + * + * The distinction is not pedantic. A double-width character occupies two + * cells and one position in the row's translated text, so walking that text + * puts every column after the first CJK glyph out by one. Reading cells + * instead keeps the count honest, and the second cell of a wide character — + * width zero, no characters of its own — is part of the glyph before it + * rather than a gap in the middle of a word. + */ +function blankAt(term: Terminal, row: number, col: number): boolean { + const cell = term.buffer.active.getLine(row)?.getCell(col) + if (!cell) return true + if (cell.getWidth() === 0) return false + const chars = cell.getChars() + return chars === '' || chars === ' ' +} + +/** + * The run of non-blank cells around a column, or null over blank space. + * + * Whitespace is the only separator, which is deliberate and is not what a + * text editor would do. What gets lifted off a terminal is paths, URLs, + * hashes, flags and session ids, and every one of them is full of the + * punctuation an editor would break on — a rule that stopped at the hyphen + * would turn a UUID into six presses. + */ +function wordAt(term: Terminal, row: number, col: number): { from: number; to: number } | null { + const blank = (c: number) => blankAt(term, row, c) + if (col < 0 || col >= term.cols || blank(col)) return null + let from = col + while (from > 0 && !blank(from - 1)) from-- + let to = col + 1 + while (to < term.cols && !blank(to)) to++ + return { from, to } +} + /** * Open a link the terminal's output produced. * diff --git a/web/src/lib/geometry.test.ts b/web/src/lib/geometry.test.ts index ce7de1a..fc281f6 100644 --- a/web/src/lib/geometry.test.ts +++ b/web/src/lib/geometry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { cellBox, cellsThatFit, fitFactor, GUTTER_PX } from './geometry' +import { cellAt, cellBox, cellsThatFit, fitFactor, GUTTER_PX } from './geometry' describe('cellBox', () => { it('divides rendered pixels by the cells they were rendered at', () => { @@ -80,3 +80,38 @@ describe('fitFactor', () => { expect(fitFactor({ width: 400, height: 400 }, { width: 0, height: 0 })).toBe(1) }) }) + +describe('cellAt', () => { + const screen = { x: 20, y: 40, width: 800, height: 480 } + const dims = { cols: 80, rows: 24 } + + it('reads a point as the cell drawn under it', () => { + // 10px per column, 20px per row, and the screen's own origin subtracted + // first: the inset around it is not part of the grid. + expect(cellAt({ x: 20, y: 40 }, screen, dims)).toEqual({ col: 0, row: 0 }) + expect(cellAt({ x: 125, y: 105 }, screen, dims)).toEqual({ col: 10, row: 3 }) + }) + + it('cancels the scale a mirroring view is rendered at', () => { + // The same 80x24 screen, drawn at half size on a phone. The reported box + // is the scaled one, so dividing by it gives the scaled column width and + // the factor drops out — a touch two thirds along still reads column 53 + // and not column 26. + const half = { x: 0, y: 0, width: 400, height: 240 } + expect(cellAt({ x: 265, y: 120 }, half, dims)).toEqual({ col: 53, row: 12 }) + }) + + it('clamps a finger that has run off the edge', () => { + // What makes dragging to the end of a line possible: past the right edge + // means the last column, not "no cell". + expect(cellAt({ x: 9999, y: 9999 }, screen, dims)).toEqual({ col: 79, row: 23 }) + expect(cellAt({ x: -9999, y: -9999 }, screen, dims)).toEqual({ col: 0, row: 0 }) + }) + + it('has no answer before anything has been laid out', () => { + // jsdom, and the first frame in a browser. Dividing by this box would + // produce an Infinity that only shows up as a selection nobody can aim. + expect(cellAt({ x: 10, y: 10 }, { x: 0, y: 0, width: 0, height: 0 }, dims)).toBeNull() + expect(cellAt({ x: 10, y: 10 }, screen, { cols: 0, rows: 0 })).toBeNull() + }) +}) diff --git a/web/src/lib/geometry.ts b/web/src/lib/geometry.ts index fd89dc2..4ae13c3 100644 --- a/web/src/lib/geometry.ts +++ b/web/src/lib/geometry.ts @@ -21,6 +21,8 @@ * NaN that only shows up as a blank screen. */ +import type { Cell } from '@/emulator/types' + export interface Box { width: number height: number @@ -95,3 +97,38 @@ export function fitFactor(content: Box, pane: Box): number { if (!measurable(content) || !measurable(pane)) return 1 return Math.min(pane.width / content.width, pane.height / content.height, 1) } + +/** A point on the glass, in the same coordinates a touch reports. */ +export interface Point { + x: number + y: number +} + +/** Where a rendered screen sits on the glass, and how big it ended up. */ +export interface ScreenBox extends Point, Box {} + +/** + * Which cell a point on the glass is over. + * + * `screen` is the rendered surface's own box as the browser reports it, which + * on a scaled view is the *scaled* box. That is exactly why the arithmetic + * here divides by it rather than by a cell size measured anywhere else: + * dividing a scaled width by the column count gives the scaled width of one + * column, and the factor cancels out. A cell size taken from `cellBox` would + * not cancel, and every touch on a phone mirroring a laptop would land a + * column or two off, further out the further right the finger went. + * + * Clamped to the screen rather than refused outside it. A drag that runs off + * the edge means the row it ran off, which is what makes dragging to the end + * of a line possible at all; and the inset around the surface is blank space + * a press can legitimately begin in. + */ +export function cellAt(point: Point, screen: ScreenBox, dims: Dimensions): Cell | null { + if (!measurable(screen) || dims.cols < 1 || dims.rows < 1) return null + const col = Math.floor(((point.x - screen.x) / screen.width) * dims.cols) + const row = Math.floor(((point.y - screen.y) / screen.height) * dims.rows) + return { + col: Math.min(Math.max(col, 0), dims.cols - 1), + row: Math.min(Math.max(row, 0), dims.rows - 1), + } +} diff --git a/web/src/styles.build.test.ts b/web/src/styles.build.test.ts index 7c234c0..0c4bf5e 100644 --- a/web/src/styles.build.test.ts +++ b/web/src/styles.build.test.ts @@ -120,34 +120,20 @@ describe('compiled stylesheet', () => { expect(css).not.toContain('touch-action:none') }) - it('gives a touch device a pressable box over the terminal', () => { - // What makes a long-press Paste possible at all: xterm ships its input - // as a zero-sized element parked off-page, and a finger cannot land on - // it. This is also the rule most likely to be lost silently — it beats - // inline style, and only `!important` does that, so a tidy-up that drops - // the annotations would leave a stylesheet that still builds and a - // gesture that no longer works. - // The last one, not the first: xterm.css names the same element, and the - // whole point of flue's rule is that it comes after and overrides it. - const at = css.lastIndexOf('.xterm-helper-textarea') - expect(at).toBeGreaterThan(-1) - const rule = css.slice(at, css.indexOf('}', at)) - for (const decl of [ - 'inline-size:100%!important', - 'block-size:100%!important', - // Without this the browser rings the focused element, and the element - // now has the terminal's box — so it reads as a blue line drawn around - // the whole terminal for as long as the session is being typed into. - 'outline:none!important', - ]) { - expect(rule).toContain(decl) - } - // And only where there is no mouse to lose a text selection to. - expect(css.slice(0, at)).toContain('pointer:coarse') - // Unlayered, so it outranks xterm.css whatever the specificity — the - // import at the top of styles.css puts that sheet in `layer(base)` - // precisely so rules like this one can win. - expect(css.slice(at).indexOf('@layer')).not.toBe(0) + it('keeps the long press for flue and not for the platform', () => { + // The gesture selects a word and opens flue's own Copy and Paste menu. + // iOS answers the same press with a callout of its own, which lands on + // top of that menu offering different verbs for one gesture. + expect(css).toContain('-webkit-touch-callout:none') + }) + + it('no longer parks an input element over the terminal', () => { + // There was one, briefly, to give a long press some editable text to + // land on. flue owns the gesture now, and that element sized to the + // terminal is what put a focus ring around the whole session. One hit is + // xterm's own rule, which ships whatever flue does; a second would be + // flue putting the box back. + expect(css.split('xterm-helper-textarea').length - 1).toBe(1) }) }) diff --git a/web/src/styles.css b/web/src/styles.css index 09632ad..2467399 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -435,6 +435,12 @@ * first. The pinch stays with the browser — `none` here once made the * page unzoomable on phones, which is too much to take. */ touch-action: pinch-zoom; + /* A long press is ours too — it selects a word and opens flue's own Copy + * and Paste menu (terminal.tsx, selection-menu.tsx). iOS answers the same + * press with a callout of its own over anything it thinks it can act on, + * which would arrive on top of that menu offering a different set of verbs + * for the same gesture. */ + -webkit-touch-callout: none; } /* xterm v6's overlay scrollbar, hidden. It draws over the last column and @@ -444,77 +450,3 @@ .flue-term-surface .xterm .xterm-scrollable-element > .scrollbar { display: none; } - -/* - * A long press that reaches real editable text, so a phone offers its own - * Paste. - * - * A terminal is a div, and no operating system offers a paste menu over a - * div. xterm does keep its input in a real textarea, but ships it as a - * zero-sized box parked off to the far side of the page — enough for a - * keyboard to type into, never enough for a finger to land on. So a phone - * has nowhere to press, which is why flue grew a paste chip in the key bar - * instead, and why that chip fired on pointerdown and threw the clipboard - * into the terminal whenever a thumb brushed the bar. - * - * Giving the textarea the terminal's own box hands the press somewhere to - * go. The callout is the platform's, so what it offers is what the platform - * decided to offer; the paste that comes back travels xterm's own paste - * path, which is what keeps newline handling and bracketed-paste mode - * right. Typing is unmoved: the same element received it before. - * - * Coarse pointers only. On a desktop this element would sit over the screen - * and eat the drag that selects text. - * - * `!important` throughout, and unavoidable: xterm writes this element's - * placement and stacking as inline style every time the cursor moves, to - * park an IME popup beside the caret. The cost is that the popup is now - * placed against the whole terminal rather than the caret, which on a phone - * is a keyboard the OS positions for itself. - */ -@media (pointer: coarse) { - .flue-term-surface .xterm .xterm-helpers { - inline-size: 100%; - block-size: 100%; - /* The layer is a coordinate space, not a target. Only its textarea - * answers a touch, or this would swallow every press meant for the - * terminal underneath. */ - pointer-events: none; - } - - .flue-term-surface .xterm .xterm-helper-textarea { - inset-block-start: 0 !important; - inset-inline-start: 0 !important; - inline-size: 100% !important; - block-size: 100% !important; - line-height: normal !important; - /* Sixteen pixels exactly, and it is not a typographic choice: iOS zooms - * the whole page in when a person focuses a form control smaller than - * this, and a terminal that jumped to 150% on every tap would be a worse - * bug than the one above. Nothing renders it — the element is empty and - * its ink is transparent — so this is a threshold, not a size. */ - font-size: 16px !important; - /* Above xterm's own canvases, which is what the helpers layer is for; - * xterm's inline -5 would put it behind them. */ - z-index: 1 !important; - pointer-events: auto; - /* Present rather than see-through: a fully transparent control is not - * reliably offered a callout. Nothing shows, because the element is - * empty except mid-composition and every ink colour here is none. */ - opacity: 1 !important; - color: transparent; - caret-color: transparent; - background-color: transparent; - -webkit-text-fill-color: transparent; - /* No focus ring, and no accessibility owed by dropping it. A browser - * draws one around a focused control to say where typing will land, and - * this control is a proxy: it has the terminal's box, so the ring is a - * blue line around the whole terminal, and what it would be announcing - * is already announced by the block cursor blinking in the cells. It - * only became visible when the box did — parked off-page at zero size, - * this element had been focused and outlined all along. */ - outline: none !important; - /* Safari draws its own, separately, and ignores the line above. */ - -webkit-tap-highlight-color: transparent; - } -} diff --git a/web/src/testing/emulator.ts b/web/src/testing/emulator.ts index 53e13c3..ecf3a40 100644 --- a/web/src/testing/emulator.ts +++ b/web/src/testing/emulator.ts @@ -1,4 +1,4 @@ -import type { Emulator, Grid, PixelSize, TerminalTheme } from '@/emulator/types' +import type { Cell, Emulator, Grid, PixelSize, TerminalTheme } from '@/emulator/types' export interface FakeEmulatorOptions { cols?: number @@ -23,6 +23,8 @@ export interface FakeEmulator extends Emulator { readonly scrolled: number /** What contentSize() reports. jsdom lays nothing out, so this is set by hand. */ measured: PixelSize | null + /** What screenBox() reports; set by hand like measured. */ + onGlass: (PixelSize & { x: number; y: number }) | null /** What applicationCursorKeys() reports; set by hand like measured. */ appCursor: boolean /** Simulate the user typing. */ @@ -37,6 +39,21 @@ export interface FakeEmulator extends Emulator { readonly reportingStops: number[] /** What reportsPointer() answers; set by hand like measured. */ pointerReports: boolean + /** Every selectWordAt() cell, in order. */ + readonly wordPresses: Cell[] + /** Every extendSelectionTo() cell, in order. */ + readonly extensions: Cell[] + /** How many times the selection has been cleared. */ + readonly selectionClears: number + /** What selection() answers. Cleared and set by the calls below. */ + selected: string + /** + * The word a selectWordAt() will find, which a fake cannot work out for + * itself. Empty stands for a press on blank space. + */ + word: string + /** Text handed to paste(), in order. */ + readonly pasted: string[] } /** @@ -56,12 +73,21 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator const themes: TerminalTheme[] = [] const queryAnswers: boolean[] = [] const reportingStops: number[] = [] + const wordPresses: Cell[] = [] + const extensions: Cell[] = [] + const pasted: string[] = [] const self: FakeEmulator = { written, themes, queryAnswers, reportingStops, + wordPresses, + extensions, + pasted, + selectionClears: 0, + selected: '', + word: '', cols: opts.cols ?? 80, rows: opts.rows ?? 24, mountedOn: null, @@ -69,6 +95,7 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator focusCalls: 0, scrolled: 0, measured: null, + onGlass: null, appCursor: false, pointerReports: false, @@ -103,6 +130,29 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator listeners.push(cb) }, + screenBox: () => self.onGlass, + + selectWordAt(cell: Cell) { + wordPresses.push(cell) + mutable(self).selected = self.word + }, + + extendSelectionTo(cell: Cell) { + extensions.push(cell) + }, + + selection: () => self.selected, + + clearSelection() { + mutable(self).selectionClears++ + mutable(self).selected = '' + }, + + paste(text: string) { + pasted.push(text) + self.send(text) + }, + stopReporting() { reportingStops.push(written.length) mutable(self).pointerReports = false From ea80fc48c4ddce422a621d4fa5310e74bbb37dc3 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 12 Aug 2026 22:26:41 +0530 Subject: [PATCH 2/3] fix(web): a long press on an empty prompt still offers Paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The press only opened the menu when it found a word, so the one place a paste is actually wanted — the empty prompt somebody is trying to paste into — was the one place nothing happened. Copy is the only verb in that menu that needs a selection. Paste never did, and hiding both behind one is what left the terminal with no way to paste into it at all. So the press always opens the menu now, and Copy is left out rather than greyed when there is nothing under the finger to copy: a press on blank space is a press asking to paste, and the shortest menu that answers it is the right one. Whichever verb the press was asking for is the one that reads as loud. A press on blank space also leaves the scroll alone. There is no range for a drag to widen, so the scrollback is the only thing left for the finger to do. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/selection-menu.tsx | 33 +++++++++++++++----- web/src/components/terminal.test.tsx | 25 ++++++++++++--- web/src/components/terminal.tsx | 45 ++++++++++++++++----------- 3 files changed, 73 insertions(+), 30 deletions(-) diff --git a/web/src/components/selection-menu.tsx b/web/src/components/selection-menu.tsx index 185116b..24e59bc 100644 --- a/web/src/components/selection-menu.tsx +++ b/web/src/components/selection-menu.tsx @@ -30,11 +30,21 @@ export type MenuEnd = 'top' | 'bottom' */ export function SelectionMenu({ at, + canCopy, onCopy, onPaste, onCancel, }: { at: MenuEnd + /** + * Whether the press found a word. + * + * False leaves Copy out rather than showing it greyed, because the press + * that opens an empty prompt is a press asking to paste: the shortest menu + * that answers it is the right one, and a disabled button is a thing to + * read past on the way to the button you wanted. + */ + canCopy: boolean onCopy: () => void onPaste: () => void onCancel: () => void @@ -62,17 +72,24 @@ export function SelectionMenu({ at === 'top' ? 'top-16' : 'bottom-20', )} > - + {canCopy && ( + + )} diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 190a9fa..ea97b1f 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -906,16 +906,33 @@ describe('Terminal', () => { expect(menu()).toBeNull() }) - it('shows nothing where there is no word to show it for', () => { - // A press on the blank half of a line. A menu offering to copy an empty - // selection is a menu of buttons that do nothing. + it('offers Paste over blank space, which is where a paste is wanted', () => { + // The press that matters most lands on an empty prompt, and there is + // nothing there to select. A menu that only opened over a word would + // leave the terminal with no way to paste into it at all. const { em } = mountSelectable('') press(125, 105) hold() expect(em.live().wordPresses).toHaveLength(1) - expect(menu()).toBeNull() + expect(menu()).not.toBeNull() + expect(screen.getByText('Paste')).toBeTruthy() + // And no Copy, because there is nothing for it to act on. + expect(screen.queryByText('Copy')).toBeNull() + }) + + it('leaves a press on blank space as a scroll it can still become', () => { + // Nothing was selected, so there is no range for a drag to widen. The + // scrollback is the only thing left for the finger to do. + const { em } = mountSelectable('') + press(125, 300) + hold() + + act(() => void inset().dispatchEvent(touch('touchmove', [200], 16, [125]))) + + expect(em.live().extensions).toEqual([]) + expect(em.live().scrolled).not.toBe(0) }) it('widens the range as the finger travels, instead of scrolling', () => { diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index 0272764..1a46e32 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -187,9 +187,14 @@ export function Terminal({ const ctrlArmedRef = useRef(ctrlArmed) ctrlArmedRef.current = ctrlArmed const [exitCode, setExitCode] = useState(null) - // Which end the selection menu is showing at, or null for not showing. - // One state and not two: there is no menu without a selection. - const [menuEnd, setMenuEnd] = useState(null) + // The touch menu: which end of the terminal it is at, and whether the + // press it came from found anything to copy. Null for not showing. + // + // Both, because the menu outlives the selection. Paste needs no selection + // at all — it is the whole reason to long-press an empty prompt — so a + // press on blank space still opens this, offering the one verb that means + // something there. + const [menu, setMenu] = useState<{ at: MenuEnd; canCopy: boolean } | null>(null) const [clipProblem, setClipProblem] = useState(null) // This session's directory, for Restart and the new-session link. From the // session list, because `attached` does not carry it. @@ -526,7 +531,7 @@ export function Terminal({ } const dismissSelection = () => { emulator.clearSelection() - setMenuEnd(null) + setMenu(null) setClipProblem(null) } const beginSelection = () => { @@ -536,22 +541,25 @@ export function Terminal({ const cell = cellUnder(from.x, from.y) if (cell === null) return emulator.selectWordAt(cell) - // Blank space holds no word, and a press on it selects nothing. Leaving - // the menu away is the honest answer: a Copy over an empty selection is - // a button that does nothing. - if (emulator.selection() === '') return - selectDrag = true + // Blank space holds no word. The menu still opens, because Paste is + // the reason to press an empty prompt in the first place, and only + // Copy has nothing to act on — see SelectionMenu. + const held = emulator.selection() !== '' swallowMouse = true - // Whatever this gesture was going to be, it is a selection now. - touchY = null - touchCarry = 0 - flick = [] - glide?.() - glide = null + if (held) { + // Whatever this gesture was going to be, it is a selection now, and + // the scroll it might have become is given up for the rest of it. + selectDrag = true + touchY = null + touchCarry = 0 + flick = [] + glide?.() + glide = null + } const box = inner.getBoundingClientRect() // Away from the half the finger is in, so the menu never covers the // words it is offering to copy. - setMenuEnd(from.y > box.y + box.height / 2 ? 'top' : 'bottom') + setMenu({ at: from.y > box.y + box.height / 2 ? 'top' : 'bottom', canCopy: held }) } // The flick record: the last few moves' clocks and positions, enough to // read a release velocity from. Cleared whenever a gesture starts. @@ -1086,9 +1094,10 @@ export function Terminal({ onKey={(k) => actionsRef.current?.sendKey(k)} /> )} - {menuEnd !== null && ( + {menu !== null && ( actionsRef.current?.copy()} onPaste={() => actionsRef.current?.paste()} onCancel={() => actionsRef.current?.dismiss()} From edabf1963957da128c3668022e1ddafb33a0b976 Mon Sep 17 00:00:00 2001 From: Karn Date: Sun, 16 Aug 2026 23:45:17 +0530 Subject: [PATCH 3/3] fix(web): Paste that a phone browser cannot quietly refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy worked and Paste did nothing, which was the clue: writing the clipboard and reading it are held to very different rules. Writing needs a user gesture. Reading needs one Safari approves of, and a pointerdown whose default was cancelled is not one — it answers by doing nothing at all, raising no error there is anything to report. That cancel is load-bearing and stays: without it the press takes focus off xterm's textarea and the keyboard closes under the menu. So the menu acts on the click instead, which is a gesture Safari accepts and is also one press for one action rather than an action on the way down and another on the way up. Cancelling a pointerdown suppresses the compatibility mouse events and never the click, so there is still a click to act on — and the double-tap assistive technology synthesises, which dispatches no pointer event at all, arrives as one too. And a way through that cannot be refused, for when it is. Pasting into a text field is not a permission: it is somebody using their own clipboard through their own operating system, and no browser has ever gated it. So a refusal now opens a plain field with the platform's own Paste one press away, and what lands in it goes on to the terminal. It reads the paste event where there is one and the field's own contents where there is not, because clipboard managers and dictation insert text as ordinary typing. A fallback and not the front door: where the clipboard can be read, one tap beats three and none of this appears. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/paste-box.tsx | 76 +++++++++++++++++++++++++++ web/src/components/selection-menu.tsx | 28 +++++----- web/src/components/terminal.test.tsx | 69 ++++++++++++++++++++++-- web/src/components/terminal.tsx | 49 +++++++++++++---- 4 files changed, 195 insertions(+), 27 deletions(-) create mode 100644 web/src/components/paste-box.tsx diff --git a/web/src/components/paste-box.tsx b/web/src/components/paste-box.tsx new file mode 100644 index 0000000..a279d7a --- /dev/null +++ b/web/src/components/paste-box.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef } from 'react' + +/** + * Somewhere to paste into, for when the browser will not be asked. + * + * Reading the clipboard from script is a permission, and a phone browser is + * entitled to refuse it — Safari in particular grants it only for a gesture + * it approves of, and answers everything else by doing nothing at all. That + * leaves flue's Paste looking broken for a reason no message can explain, + * because no error is raised: the promise simply never resolves the way it + * was asked to. + * + * Pasting *into a text field* is not a permission. It is the person choosing + * their own clipboard through their own operating system, and no browser has + * ever gated it. So this is the way that cannot be refused: a real, plain, + * ordinary input on the screen, long-pressed like any other, with the + * platform's own Paste in the callout above it. What lands here goes on to + * the terminal. + * + * A fallback and not the front door. When the clipboard can be read, one tap + * is better than three, and this never appears. + */ +export function PasteBox({ onText, onCancel }: { onText: (text: string) => void; onCancel: () => void }) { + const field = useRef(null) + + useEffect(() => { + // Focused on arrival so the callout is one press away rather than two, + // and so the keyboard that was already up does not drop while this opens. + field.current?.focus({ preventScroll: true }) + }, []) + + const send = (text: string) => { + if (text !== '') onText(text) + } + + return ( +
+

+ This browser will not hand over the clipboard. Press and hold here, choose Paste, and it + goes to the terminal. +

+
+