+
{/*
The way to another session without leaving this one.
@@ -886,7 +939,7 @@ export function Terminal({
onClick={() => switcher.open()}
title={`Switch session · ${chordLabel}`}
className={cn(
- 'rounded-lg px-2.5 py-1.5',
+ 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
'bg-(--chip-bg) text-(--chip-dim) shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
'transition-colors hover:text-(--chip-fg)',
)}
@@ -906,7 +959,7 @@ export function Terminal({
rel="noopener"
title="Open the dashboard"
className={cn(
- 'rounded-lg px-2.5 py-1.5',
+ 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
'bg-(--chip-bg) text-(--chip-dim) shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
'transition-colors hover:text-(--chip-fg)',
)}
@@ -930,7 +983,7 @@ export function Terminal({
onClick={() => onNewSession?.(cwd)}
title="New session here"
className={cn(
- 'rounded-lg px-2.5 py-1.5',
+ 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
'bg-(--chip-bg) text-(--chip-dim) shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
'transition-colors hover:text-(--chip-fg)',
)}
@@ -951,7 +1004,7 @@ export function Terminal({
className={cn(
// /4 line-height: 16px text box + py-1.5 = the same 28px as the
// icon buttons beside it (size-4 in py-1.5), one strip height.
- 'rounded-lg px-3 py-1.5 text-base/4 font-medium sm:text-sm/4',
+ 'shrink-0 rounded-lg px-3 py-1.5 text-base/4 font-medium sm:text-sm/4',
'bg-(--chip-bg) text-(--chip-fg) shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
)}
>
diff --git a/web/src/components/theme-menu.tsx b/web/src/components/theme-menu.tsx
index d7187c9..53df0c2 100644
--- a/web/src/components/theme-menu.tsx
+++ b/web/src/components/theme-menu.tsx
@@ -48,7 +48,7 @@ export function ThemeMenu({
title="Terminal theme"
style={chipStyle}
className={cn(
- 'rounded-lg px-2.5 py-1.5',
+ 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
'bg-(--chip-bg) text-(--chip-dim) shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
'transition-colors hover:text-(--chip-fg) data-[state=open]:text-(--chip-fg)',
)}
diff --git a/web/src/emulator/emulator.test.ts b/web/src/emulator/emulator.test.ts
index 02a1c98..cf93d9c 100644
--- a/web/src/emulator/emulator.test.ts
+++ b/web/src/emulator/emulator.test.ts
@@ -123,6 +123,37 @@ describe('Emulator interface', () => {
em.dispose()
})
+ it('normalises pasted newlines through xterm', () => {
+ const el = document.createElement('div')
+ document.body.appendChild(el)
+ const em = createXtermEmulator({ cols: 10, rows: 4 })
+ em.attachTo(el)
+ const seen: string[] = []
+ em.onData((b) => seen.push(new TextDecoder().decode(b)))
+
+ em.paste('one\ntwo\r\nthree')
+
+ expect(seen.join('')).toBe('one\rtwo\rthree')
+ em.dispose()
+ el.remove()
+ })
+
+ it('honours a program that enabled bracketed-paste mode', async () => {
+ const el = document.createElement('div')
+ document.body.appendChild(el)
+ const em = createXtermEmulator({ cols: 10, rows: 4 })
+ em.attachTo(el)
+ const seen: string[] = []
+ em.onData((b) => seen.push(new TextDecoder().decode(b)))
+ await settled(em, '\x1b[?2004h')
+
+ em.paste('safe')
+
+ expect(seen.join('')).toBe('\x1b[200~safe\x1b[201~')
+ em.dispose()
+ el.remove()
+ })
+
it('reports no measurement before it is mounted', () => {
// The sizing policy divides by whatever this returns. A zero-sized answer
// dressed up as a real one becomes an Infinity one line later; jsdom lays
@@ -156,6 +187,7 @@ describe('Emulator interface', () => {
em.dispose()
expect(() => em.focus()).not.toThrow()
+ expect(() => em.paste('ignored')).not.toThrow()
expect(() => em.setTheme({ background: '#000000' })).not.toThrow()
expect(em.contentSize()).toBeNull()
expect(() => em.answerQueries(true)).not.toThrow()
diff --git a/web/src/emulator/types.ts b/web/src/emulator/types.ts
index ddf955a..4c5d010 100644
--- a/web/src/emulator/types.ts
+++ b/web/src/emulator/types.ts
@@ -106,6 +106,15 @@ export interface Emulator {
* destination rather than registering a second callback.
*/
onData(cb: (bytes: Uint8Array) => void): void
+ /**
+ * Paste text as terminal input.
+ *
+ * This is distinct from feeding raw bytes: a terminal normalises pasted
+ * newlines and wraps the text when the program has enabled bracketed-paste
+ * mode. Mobile clipboard controls must go through the emulator so they keep
+ * those semantics.
+ */
+ paste(text: string): void
/**
* Mount into the DOM.
*
diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts
index fe45c64..43c257c 100644
--- a/web/src/emulator/xterm.ts
+++ b/web/src/emulator/xterm.ts
@@ -171,6 +171,11 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator {
term.onData((data) => cb(encoder.encode(data)))
},
+ paste(text: string) {
+ if (disposed) return
+ term.paste(text)
+ },
+
attachTo(el: HTMLElement) {
term.open(el)
// Best-effort GPU rendering; the DOM renderer is a fine fallback, and
diff --git a/web/src/hooks/use-mobile.ts b/web/src/hooks/use-mobile.ts
index 2b0fe1d..5c4a812 100644
--- a/web/src/hooks/use-mobile.ts
+++ b/web/src/hooks/use-mobile.ts
@@ -3,7 +3,11 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
- const [isMobile, setIsMobile] = React.useState(undefined)
+ // The first render matters for focus policy: a dialog can run its opening
+ // autofocus before this hook's effect. Starting at a desktop-shaped false
+ // would briefly focus a mobile input and summon the keyboard before the
+ // viewport listener had a chance to correct the answer.
+ const [isMobile, setIsMobile] = React.useState(() => window.innerWidth < MOBILE_BREAKPOINT)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
@@ -15,5 +19,5 @@ export function useIsMobile() {
return () => mql.removeEventListener("change", onChange)
}, [])
- return !!isMobile
+ return isMobile
}
diff --git a/web/src/lib/viewport.test.ts b/web/src/lib/viewport.test.ts
index f20b1fb..fdd4c02 100644
--- a/web/src/lib/viewport.test.ts
+++ b/web/src/lib/viewport.test.ts
@@ -90,7 +90,8 @@ describe('trackVisualViewport', () => {
it('releases the surface to the browser while pinch-zoomed', () => {
const vv = fakeViewport({ height: 700 })
- trackVisualViewport({ pane, surface, viewport: vv })
+ const gestureArea = document.createElement('div')
+ trackVisualViewport({ pane, surface, gestureArea, viewport: vv })
vv.scale = 2
vv.height = 350
@@ -101,12 +102,14 @@ describe('trackVisualViewport', () => {
// a zoom is not a layout change: the pane keeps its unzoomed size, so the
// pty never refits on a pinch.
expect(surface.style.touchAction).toBe('auto')
+ expect(gestureArea.style.touchAction).toBe('auto')
expect(pane.style.height).toBe('700px')
vv.scale = 1
vv.height = 700
vv.fire()
expect(surface.style.touchAction).toBe('')
+ expect(gestureArea.style.touchAction).toBe('')
expect(pane.style.height).toBe('700px')
})
diff --git a/web/src/lib/viewport.ts b/web/src/lib/viewport.ts
index d982e29..e5cce40 100644
--- a/web/src/lib/viewport.ts
+++ b/web/src/lib/viewport.ts
@@ -55,17 +55,20 @@ export function zoomedIn(viewport: ViewportLike | null | undefined): boolean {
export function trackVisualViewport(opts: {
pane: HTMLElement
surface: HTMLElement
+ gestureArea?: HTMLElement
viewport: ViewportLike | null
}): () => void {
- const { pane, surface, viewport } = opts
+ const { pane, surface, gestureArea = surface, viewport } = opts
if (!viewport) return () => {}
const apply = () => {
if (zoomedIn(viewport)) {
surface.style.touchAction = 'auto'
+ gestureArea.style.touchAction = 'auto'
return
}
surface.style.touchAction = ''
+ gestureArea.style.touchAction = ''
pane.style.height = `${viewport.height}px`
pane.style.translate = `0px ${viewport.offsetTop}px`
}
@@ -83,6 +86,7 @@ export function trackVisualViewport(opts: {
if (viewport.onresize === apply) viewport.onresize = null
if (viewport.onscroll === apply) viewport.onscroll = null
surface.style.touchAction = ''
+ gestureArea.style.touchAction = ''
pane.style.height = ''
pane.style.translate = ''
}
diff --git a/web/src/styles.css b/web/src/styles.css
index b594cec..318aeab 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -424,6 +424,12 @@
@utility flue-term-surface {
block-size: 100%;
inline-size: 100%;
+}
+
+/* The inset is the gesture surface, including any blank space beside a
+ * cross-device scaled screen. Keeping this on the full usable pane means a
+ * scroll does not depend on whether the first pixel happened to hit xterm. */
+@utility flue-term-gesture {
/* Single-finger gestures are ours: touches become scrollLines() calls
* (terminal.tsx), and a browser allowed to pan the page would eat them
* first. The pinch stays with the browser — `none` here once made the
diff --git a/web/src/testing/emulator.ts b/web/src/testing/emulator.ts
index 2aada1f..d1f9aed 100644
--- a/web/src/testing/emulator.ts
+++ b/web/src/testing/emulator.ts
@@ -27,6 +27,8 @@ export interface FakeEmulator extends Emulator {
appCursor: boolean
/** Simulate the user typing. */
send(text: string): void
+ /** Text handed to paste(), in order. */
+ readonly pasted: string[]
}
/**
@@ -45,11 +47,13 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
const written: string[] = []
const themes: TerminalTheme[] = []
const queryAnswers: boolean[] = []
+ const pasted: string[] = []
const self: FakeEmulator = {
written,
themes,
queryAnswers,
+ pasted,
cols: opts.cols ?? 80,
rows: opts.rows ?? 24,
mountedOn: null,
@@ -90,6 +94,11 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
listeners.push(cb)
},
+ paste(text: string) {
+ pasted.push(text)
+ self.send(text)
+ },
+
attachTo(el: HTMLElement) {
mutable(self).mountedOn = el
},