From 28f13f588f90b3e40babe10493bbf71d696e03e0 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Sat, 2 May 2026 17:39:45 -0500 Subject: [PATCH] ui: fix mobile input and viewport, improve scroll & selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps xterm to ^5.3 (and xterm-addon-fit to ^0.8) to pick up the upstream fix for Android Chrome's IME composition double-emitting input — the symptom was characters and pasted strings repeating in strange interleaved order. Also adds the canvas renderer addon for smoother scroll on mobile. Mobile layout fixes: - viewport meta gets `interactive-widget=resizes-content` so the layout viewport shrinks when the soft keyboard opens - body/#root pinned via `position: fixed` and a `--app-height` CSS var driven off `window.visualViewport.height`, so the page can no longer pan beneath the keyboard - Buffer subscribes to `visualViewport.resize` so xterm refits when the keyboard shows/hides UX: - bumps font to 16px - enables native text selection (`user-select: text`) on the rendered rows - stops emitting X10 mouse reporting (`csi('?9h')`) on session init; it broke desktop drag-select and swipe-scroll. Apps that need it can request it themselves via blits. - selection highlight uses a translucent overlay so text remains readable through the highlight - on touch devices, auto-copies xterm's selection (e.g. on double-tap word select) to the system clipboard API churn from the xterm 5 upgrade: - `bellStyle: 'sound'` / `bellSound` removed → use `term.onBell` to play the existing bel.ts sample - `selection` theme key renamed to `selectionBackground` - `rows`/`cols` moved from `ITerminalOptions` to `ITerminalInitOnlyOptions` Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/Buffer.tsx | 45 +++++++++++++++++++++++++++++++------- ui/index.html | 45 ++++++++++++++++++++++++++++++++++++-- ui/lib/theme.ts | 6 ++++-- ui/package-lock.json | 51 +++++++++++++++++++++++++++++++------------- ui/package.json | 5 +++-- ui/state.ts | 2 +- 6 files changed, 124 insertions(+), 30 deletions(-) diff --git a/ui/Buffer.tsx b/ui/Buffer.tsx index 65c368e..e2cc770 100644 --- a/ui/Buffer.tsx +++ b/ui/Buffer.tsx @@ -1,9 +1,12 @@ -import { Terminal, ITerminalOptions } from 'xterm'; +import { Terminal, ITerminalOptions, ITerminalInitOnlyOptions } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; +import { CanvasAddon } from 'xterm-addon-canvas'; import { debounce } from 'lodash'; import bel from './lib/bel'; import api from './api'; +const belAudio = new Audio(bel); + import { pokeTask, pokeBelt } from './lib/utils' @@ -18,7 +21,7 @@ import { DEFAULT_SESSION, RESIZE_DEBOUNCE_MS, RESIZE_THRESHOLD_PX } from './cons import { retry } from './lib/retry'; import { Belt } from 'lib/types'; -const termConfig: ITerminalOptions = { +const termConfig: ITerminalOptions & ITerminalInitOnlyOptions = { logLevel: 'warn', // convertEol: true, @@ -28,12 +31,10 @@ const termConfig: ITerminalOptions = { scrollback: 10000, // fontFamily: '"Source Code Pro", "Roboto mono", "Courier New", monospace', + fontSize: 16, fontWeight: 400, // NOTE theme colors configured dynamically // - bellStyle: 'sound', - bellSound: bel, - // // allows text selection by holding modifier (option, or shift) macOptionClickForcesSelection: true, // prevent insertion of simulated arrow keys on-altclick @@ -178,12 +179,18 @@ export default function Buffer({ name, selected, dark }: BufferProps) { term.options.theme = makeTheme(dark); const fit = new FitAddon(); term.loadAddon(fit); + try { + term.loadAddon(new CanvasAddon()); + } catch (e) { + console.warn('canvas renderer unavailable, falling back to DOM', e); + } fit.fit(); term.focus(); - // start mouse reporting - // - term.write(csi('?9h')); + // NOTE X10 mouse reporting (csi('?9h')) used to be enabled here + // unconditionally, but it makes xterm intercept clicks/drags so + // native selection can't initiate. dojo can re-enable it itself + // via blit if it ever wants click events. const ses: Session = { term, @@ -211,6 +218,7 @@ export default function Buffer({ name, selected, dark }: BufferProps) { }); term.onData(e => onInput(name, ses, e)); term.onBinary(e => onInput(name, ses, e)); + term.onBell(() => { belAudio.play().catch(() => {}); }); // open subscription // @@ -291,6 +299,25 @@ export default function Buffer({ name, selected, dark }: BufferProps) { } }, [session, containerRef]); + // on touch devices, auto-copy whenever xterm's own selection changes + // (e.g. via double-tap word select) so you can paste elsewhere. + // + useEffect(() => { + if (!session) { + return; + } + if (!window.matchMedia('(pointer: coarse)').matches) { + return; + } + const sub = session.term.onSelectionChange(() => { + const sel = session.term.getSelection(); + if (sel && navigator.clipboard?.writeText) { + navigator.clipboard.writeText(sel).catch(() => {}); + } + }); + return () => sub.dispose(); + }, [session]); + // initialize resize listeners // useEffect(() => { @@ -301,9 +328,11 @@ export default function Buffer({ name, selected, dark }: BufferProps) { // TODO: use ResizeObserver for improved performance? const debouncedResize = debounce(() => onResize(name, session), RESIZE_DEBOUNCE_MS); window.addEventListener('resize', debouncedResize); + window.visualViewport?.addEventListener('resize', debouncedResize); return () => { window.removeEventListener('resize', debouncedResize); + window.visualViewport?.removeEventListener('resize', debouncedResize); }; }, [session]); diff --git a/ui/index.html b/ui/index.html index 4086ccc..2d73b95 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,7 @@ Terminal + content="width=device-width, initial-scale=1, shrink-to-fit=no, maximum-scale=1, interactive-widget=resizes-content"/> @@ -22,10 +22,20 @@ + diff --git a/ui/lib/theme.ts b/ui/lib/theme.ts index cf2beb1..2eefeb3 100644 --- a/ui/lib/theme.ts +++ b/ui/lib/theme.ts @@ -1,13 +1,15 @@ import { ITheme } from 'xterm'; export const makeTheme = (dark: boolean): ITheme => { - let fg, bg: string; + let fg, bg, sel: string; if (dark) { fg = 'white'; bg = 'rgb(26,26,26)'; + sel = 'rgba(255,255,255,0.3)'; } else { fg = 'black'; bg = 'white'; + sel = 'rgba(0,0,0,0.25)'; } // TODO indigo colors. // we can't pluck these from ThemeContext because they have transparency. @@ -18,6 +20,6 @@ export const makeTheme = (dark: boolean): ITheme => { brightBlack: '#7f7f7f', // NOTE slogs cursor: fg, cursorAccent: bg, - selection: fg + selectionBackground: sel }; }; diff --git a/ui/package-lock.json b/ui/package-lock.json index 08ede24..ce7ac27 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -25,8 +25,9 @@ "style-loader": "^1.3.0", "styled-components": "^5.1.1", "styled-system": "^5.1.5", - "xterm": "^4.15.0", - "xterm-addon-fit": "^0.5.0", + "xterm": "^5.3.0", + "xterm-addon-canvas": "^0.5.0", + "xterm-addon-fit": "^0.8.0", "zustand": "^3.5.0" }, "devDependencies": { @@ -19722,16 +19723,30 @@ } }, "node_modules/xterm": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.15.0.tgz", - "integrity": "sha512-Ik1GoSq1yqKZQ2LF37RPS01kX9t4TP8gpamUYblD09yvWX5mEYuMK4CcqH6+plgiNEZduhTz/UrcaWs97gOlOw==" + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" }, - "node_modules/xterm-addon-fit": { + "node_modules/xterm-addon-canvas": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz", - "integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==", + "resolved": "https://registry.npmjs.org/xterm-addon-canvas/-/xterm-addon-canvas-0.5.0.tgz", + "integrity": "sha512-QOo/eZCMrCleAgMimfdbaZCgmQRWOml63Ued6RwQ+UTPvQj3Av9QKx3xksmyYrDGRO/AVRXa9oNuzlYvLdmoLQ==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-canvas instead.", + "license": "MIT", "peerDependencies": { - "xterm": "^4.0.0" + "xterm": "^5.0.0" + } + }, + "node_modules/xterm-addon-fit": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", + "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" } }, "node_modules/y18n": { @@ -34998,14 +35013,20 @@ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, "xterm": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.15.0.tgz", - "integrity": "sha512-Ik1GoSq1yqKZQ2LF37RPS01kX9t4TP8gpamUYblD09yvWX5mEYuMK4CcqH6+plgiNEZduhTz/UrcaWs97gOlOw==" + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==" }, - "xterm-addon-fit": { + "xterm-addon-canvas": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz", - "integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==", + "resolved": "https://registry.npmjs.org/xterm-addon-canvas/-/xterm-addon-canvas-0.5.0.tgz", + "integrity": "sha512-QOo/eZCMrCleAgMimfdbaZCgmQRWOml63Ued6RwQ+UTPvQj3Av9QKx3xksmyYrDGRO/AVRXa9oNuzlYvLdmoLQ==", + "requires": {} + }, + "xterm-addon-fit": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", + "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", "requires": {} }, "y18n": { diff --git a/ui/package.json b/ui/package.json index 729e3e6..6db9c9c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,8 +22,9 @@ "style-loader": "^1.3.0", "styled-components": "^5.1.1", "styled-system": "^5.1.5", - "xterm": "^4.15.0", - "xterm-addon-fit": "^0.5.0", + "xterm": "^5.3.0", + "xterm-addon-canvas": "^0.5.0", + "xterm-addon-fit": "^0.8.0", "zustand": "^3.5.0" }, "devDependencies": { diff --git a/ui/state.ts b/ui/state.ts index fa22e58..9eb1232 100644 --- a/ui/state.ts +++ b/ui/state.ts @@ -31,7 +31,7 @@ const useTermState = create((set, get) => ({ theme: 'auto', // eslint-disable-next-line no-unused-vars set: (f: (draft: TermState) => void) => { - set(produce(f)); + set(produce(f) as any); } } as TermState));