diff --git a/web/src/components/key-bar.tsx b/web/src/components/key-bar.tsx
new file mode 100644
index 0000000..4af1d45
--- /dev/null
+++ b/web/src/components/key-bar.tsx
@@ -0,0 +1,85 @@
+import type { BarKey } from '@/lib/keys'
+import { cn } from '@/lib/utils'
+
+const KEYS: ReadonlyArray<{ key: BarKey; label: string; name: string }> = [
+ { key: 'esc', label: 'esc', name: 'Escape' },
+ { key: 'tab', label: 'tab', name: 'Tab' },
+ { key: 'left', label: '←', name: 'Arrow left' },
+ { key: 'down', label: '↓', name: 'Arrow down' },
+ { key: 'up', label: '↑', name: 'Arrow up' },
+ { key: 'right', label: '→', name: 'Arrow right' },
+]
+
+/**
+ * The touch device's missing keys, floated over the terminal's bottom edge.
+ *
+ * Presses land on pointerdown, and the handler prevents the default so the
+ * press never takes focus from xterm's textarea — losing it would close the
+ * very keyboard the bar exists to work beside. Ctrl is latched: one press
+ * arms it for the next key, bar or typed, and the Terminal owns that state
+ * because the fold happens on the input path, not here.
+ *
+ * The onClick beside each is not a duplicate. VoiceOver's and TalkBack's
+ * double-tap synthesise a click and dispatch no pointer event at all, as does
+ * Enter or Space on a keyboard — so a bar wired to pointerdown alone offers
+ * assistive technology a named, pressable control that answers nothing, on
+ * exactly the devices this exists for. `detail` counts the presses behind a
+ * real click and is 0 for a synthesised one, which is what keeps a finger's
+ * own follow-up click from sending the key a second time: cancelling
+ * pointerdown suppresses the compatibility mouse events, never the click.
+ */
+export function KeyBar(props: {
+ ctrl: boolean
+ onCtrl: () => void
+ onKey: (key: BarKey) => void
+}) {
+ const chip = 'rounded-md px-2.5 py-1.5 font-mono text-sm/4 transition-colors select-none'
+ return (
+
+
+ {KEYS.map((k) => (
+
+ ))}
+
+ )
+}
diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx
index 6da53b3..09c84ef 100644
--- a/web/src/components/terminal.test.tsx
+++ b/web/src/components/terminal.test.tsx
@@ -77,14 +77,22 @@ const surfaceEl = () => document.querySelector('[data-flue-surface]
* A touch event jsdom will really dispatch.
*
* jsdom ships no `Touch` constructor, so a genuine TouchEvent cannot be built
- * with any points in it. The handlers under test read exactly three things —
- * how many touches there are, the first one's clientY, and preventDefault —
- * so this carries those. `cancelable` is the load-bearing part: whether the
- * event comes back prevented is what decides if the browser may pan.
+ * with any points in it. The handlers under test read exactly four things —
+ * how many touches there are, the first one's clientY, the clock, and
+ * preventDefault — so this carries those. `cancelable` is the load-bearing
+ * part: whether the event comes back prevented is what decides if the browser
+ * may pan. `at` overrides the stamp jsdom writes at construction time, which
+ * is the only way consecutive events can be told apart within one tick; the
+ * drag tests that never lift a finger pass none and read a real clock.
*/
-function touch(type: 'touchstart' | 'touchmove' | 'touchend', ys: number[]) {
+function touch(
+ type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel',
+ ys: number[],
+ at?: number,
+) {
const e = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(e, 'touches', { value: ys.map((clientY) => ({ clientY })) })
+ if (at !== undefined) Object.defineProperty(e, 'timeStamp', { value: at })
return e
}
@@ -464,6 +472,51 @@ describe('Terminal', () => {
return mounted
}
+ /**
+ * An animation frame queue that honours cancellation.
+ *
+ * A no-op cancelAnimationFrame would let a glide nobody wants keep running
+ * with no test able to tell, which is the one thing these are here to
+ * catch. Ids start high on purpose: the component cancels its own relayout
+ * frame on unmount, and that id was handed out by the real clock before
+ * these stubs existed, so a low counter here would collide with it and a
+ * glide would look cancelled by the wrong hand.
+ */
+ function frameQueue() {
+ const queued = new Map void>()
+ let nextId = 1000
+ let now = 48
+ vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => {
+ const id = nextId++
+ queued.set(id, cb)
+ return id
+ })
+ vi.stubGlobal('cancelAnimationFrame', (id: number) => void queued.delete(id))
+ return {
+ pending: () => queued.size,
+ /** Runs up to `n` frames on a 16ms clock, each free to queue the next. */
+ run(n: number) {
+ act(() => {
+ for (let i = 0; i < n && queued.size; i++) {
+ const [id, cb] = [...queued][0]!
+ queued.delete(id)
+ cb((now += 16))
+ }
+ })
+ },
+ }
+ }
+
+ /** A fast upward drag, lifted while still moving: 34px (2 lines) per 16ms. */
+ function flick(surface: HTMLElement, liftAt = 48) {
+ act(() => {
+ surface.dispatchEvent(touch('touchstart', [300], 0))
+ surface.dispatchEvent(touch('touchmove', [266], 16))
+ surface.dispatchEvent(touch('touchmove', [232], 32))
+ surface.dispatchEvent(touch('touchend', [], liftAt))
+ })
+ }
+
it('scrolls the scrollback, and takes the gesture from the browser to do it', () => {
const { em } = mountDraggable()
@@ -494,6 +547,126 @@ describe('Terminal', () => {
expect(surfaceEl().style.touchAction).toBe('auto')
})
+ it('glides on after a flick, and a new touch stops the glide', () => {
+ // Mounted before the stubs: the relayout frame the mount schedules would
+ // otherwise land in this queue and be drained as if it were the glide.
+ const { em } = mountDraggable()
+ const frames = frameQueue()
+ const surface = surfaceEl()
+
+ flick(surface)
+ // 68px of finger over a 17px line, all of it while the finger is down.
+ const dragged = em.live().scrolled
+ expect(dragged).toBe(4)
+
+ // The glide keeps scrolling with no finger on the glass at all.
+ frames.run(4)
+ const glided = em.live().scrolled
+ expect(glided).toBeGreaterThan(dragged)
+ // Still asking for frames, which is what makes the next part a test.
+ expect(frames.pending()).toBe(1)
+
+ // A finger back on the glass pins the content: no glide survives it.
+ act(() => void surface.dispatchEvent(touch('touchstart', [200], 400)))
+ expect(frames.pending()).toBe(0)
+ frames.run(4)
+ expect(em.live().scrolled).toBe(glided)
+ })
+
+ it('comes to rest on its own', () => {
+ const { em } = mountDraggable()
+ const frames = frameQueue()
+
+ flick(surfaceEl())
+ // Far more frames than a glide this size can use; it stops when the
+ // velocity dies, not when the caller runs out of patience.
+ frames.run(2000)
+
+ expect(frames.pending()).toBe(0)
+ // 4 lines in 32ms is 125 lines/s, and 0.998^ms friction spends that over
+ // about 63 further lines — bounded rather than pinned, because the exact
+ // total is an artifact of the frame clock a test happens to crank.
+ expect(em.live().scrolled).toBeGreaterThan(40)
+ expect(em.live().scrolled).toBeLessThan(90)
+ })
+
+ it('reads the speed of the flick, not of the pause before it', () => {
+ // The commonest gesture there is: touch to stop the last glide, hesitate,
+ // then flick. The reaction gap belongs to the gesture but to no part of
+ // its speed, and a window measured from touchdown divides the answer by
+ // however long the thumb took to make up its mind.
+ const { em } = mountDraggable()
+ const frames = frameQueue()
+ const surface = surfaceEl()
+
+ act(() => {
+ surface.dispatchEvent(touch('touchstart', [300], 0))
+ surface.dispatchEvent(touch('touchmove', [283], 200))
+ surface.dispatchEvent(touch('touchmove', [249], 216))
+ surface.dispatchEvent(touch('touchmove', [215], 232))
+ surface.dispatchEvent(touch('touchend', [], 240))
+ })
+ expect(em.live().scrolled).toBe(5)
+
+ frames.run(2000)
+
+ // 68px over the last 32ms is 125 lines/s, worth about 63 lines. Measured
+ // from touchdown it would read 21 lines/s instead and stop inside 15.
+ expect(em.live().scrolled).toBeGreaterThan(40)
+ expect(em.live().scrolled).toBeLessThan(90)
+ })
+
+ it('does not glide when the finger came to rest before it lifted', () => {
+ // The samples still describe a fast drag; the release does not. A thumb
+ // that parks the content and lets go expects it to stay parked, and the
+ // moves stop arriving the moment the finger stops, so only the lift's
+ // own clock can tell this apart from a flick.
+ const { em } = mountDraggable()
+ const frames = frameQueue()
+
+ flick(surfaceEl(), 400)
+
+ expect(em.live().scrolled).toBe(4)
+ expect(frames.pending()).toBe(0)
+ })
+
+ it('takes no glide from a gesture the system cancelled', () => {
+ // The notification shade, an alert, an incoming call: the browser fires
+ // touchcancel, promptly and with the drag's fast samples still on the
+ // record, so every test a lift is judged by passes. But no finger left
+ // the glass — nothing was thrown — and a scrollback that coasted here
+ // would be moving after the interruption put it down.
+ const { em } = mountDraggable()
+ const frames = frameQueue()
+ const surface = surfaceEl()
+
+ act(() => {
+ surface.dispatchEvent(touch('touchstart', [300], 0))
+ surface.dispatchEvent(touch('touchmove', [266], 16))
+ surface.dispatchEvent(touch('touchmove', [232], 32))
+ surface.dispatchEvent(touch('touchcancel', [], 48))
+ })
+
+ // The 68px the finger did travel stand; nothing is added to them.
+ expect(em.live().scrolled).toBe(4)
+ expect(frames.pending()).toBe(0)
+ })
+
+ it('drops the glide when the terminal goes away', () => {
+ // A glide outliving its effect would go on calling scrollLines into an
+ // emulator that has already been disposed.
+ const { view } = mountDraggable()
+ const frames = frameQueue()
+
+ flick(surfaceEl())
+ frames.run(2)
+ expect(frames.pending()).toBe(1)
+
+ act(() => view.unmount())
+
+ expect(frames.pending()).toBe(0)
+ })
+
it('drops a drag already under way when the zoom arrives mid-gesture', () => {
const { em } = mountDraggable()
@@ -635,6 +808,38 @@ describe('Terminal', () => {
expect(report.rows).toBeLessThan(47)
})
+ it('stays one-to-one when only rows stop fitting, so a keyboard cannot pinch the width', async () => {
+ const observers = resizeObservers()
+ const box = paneOf(800 + GUTTER_PX, 408)
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ // 80x24 rendered at 800x408 puts a cell at 10 x 17.
+ em.live().measured = { width: 800, height: 408 }
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1', cols: 80, rows: 24, primary: true })))
+
+ // The keyboard takes half the pane's height: same columns, half the rows.
+ box.mockReturnValue({ width: 800 + GUTTER_PX, height: 204 } as DOMRect)
+ act(() => observers.fire())
+
+ // The settled report proves the whole relayout → settle path ran…
+ await waitFor(() =>
+ expect(sock.ofType('resize')).toContainEqual({
+ type: 'resize',
+ ref: 1,
+ cols: 80,
+ rows: 12,
+ primary: true,
+ }),
+ )
+ // …and through all of it the surface was never scaled or resized: the
+ // bottom rows clip behind the keyboard until the pty follows, and the
+ // width never moves.
+ expect(surfaceEl().style.scale).toBe('')
+ expect(surfaceEl().style.width).toBe('')
+ expect(surfaceEl().style.height).toBe('')
+ })
+
it('reshapes nothing when promoted — the role moves voices, not sizes', async () => {
// The daemon promotes the most recently active client when a primary
// leaves, and the promotion arrives as a sizeChanged. Under the
@@ -972,6 +1177,165 @@ describe('Terminal', () => {
expect(sock.input()).toEqual([{ ref: 2, text: 'live' }])
})
})
+
+ describe('the key bar', () => {
+ /** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */
+ function coarsePointer() {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: query.includes('coarse'),
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }))
+ }
+ const bar = () => document.querySelector('[data-flue-keybar]')
+ const key = (label: string) =>
+ Array.from(document.querySelectorAll('[data-flue-keybar] button')).find(
+ (b) => b.textContent?.startsWith(label),
+ )!
+
+ it('exists only for touch', () => {
+ const { sock } = mountTerminal((e) => )
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+ expect(bar()).toBeNull()
+ })
+
+ it('sends CSI arrows for a shell and SS3 once the program asks', () => {
+ coarsePointer()
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('↑'))
+ expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[A' }])
+
+ em.live().appCursor = true
+ fireEvent.pointerDown(key('↓'))
+ expect(sock.input()).toEqual([
+ { ref: 1, text: '\x1b[A' },
+ { ref: 1, text: '\x1bOB' },
+ ])
+ })
+
+ it('arms Ctrl for exactly one following keystroke', () => {
+ coarsePointer()
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('ctrl'))
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('true')
+ act(() => em.live().send('c'))
+ act(() => em.live().send('c'))
+ expect(sock.input()).toEqual([
+ { ref: 1, text: '\x03' },
+ { ref: 1, text: 'c' },
+ ])
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
+ })
+
+ it('spends the arming on the first of two keystrokes in one task', () => {
+ // The spend cannot wait on a render. Both deliveries here land before
+ // React flushes the state change, so the input path reads the ref it was
+ // given — and a ref left true until the flush folds the second keystroke
+ // too, on one press of ctrl.
+ coarsePointer()
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('ctrl'))
+ act(() => {
+ em.live().send('c')
+ em.live().send('c')
+ })
+
+ expect(sock.input()).toEqual([
+ { ref: 1, text: '\x03' },
+ { ref: 1, text: 'c' },
+ ])
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
+ })
+
+ it('spends the arming on a keystroke it cannot fold', () => {
+ // A digit has no control code, and neither does a paste or a multi-byte
+ // character: ctrlTransform returns null and the bytes go out untouched.
+ // The arming is spent all the same, as a latched modifier on a real
+ // keyboard would be — otherwise Ctrl stays armed for good and the next
+ // letter typed is silently folded into a control code nobody asked for.
+ coarsePointer()
+ const { sock, em } = mountTerminal((e) => (
+
+ ))
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('ctrl'))
+ act(() => em.live().send('7'))
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
+
+ act(() => em.live().send('c'))
+ expect(sock.input()).toEqual([
+ { ref: 1, text: '7' },
+ { ref: 1, text: 'c' },
+ ])
+ })
+
+ it('answers a screen reader’s activation without double-sending a real tap', () => {
+ // VoiceOver's and TalkBack's double-tap, and Enter or Space on a hybrid
+ // device's keyboard, all synthesise a click and dispatch no pointer
+ // event at all. A bar wired to pointerdown alone advertises itself to
+ // assistive technology — aria-pressed, an accessible name each — and
+ // then does nothing when that technology activates it, on exactly the
+ // devices the bar exists for. `detail` is 0 for a synthesised click and
+ // counts the presses behind a real one, which is what keeps a finger's
+ // own follow-up click from sending the key twice.
+ coarsePointer()
+ const { sock } = mountTerminal((e) => )
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.click(key('esc'), { detail: 0 })
+ expect(sock.input()).toEqual([{ ref: 1, text: '\x1b' }])
+ fireEvent.click(key('ctrl'), { detail: 0 })
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('true')
+ fireEvent.click(key('ctrl'), { detail: 0 })
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
+
+ // A finger: pointerdown, and then a click of its own that must not
+ // send a second tab.
+ fireEvent.pointerDown(key('tab'))
+ fireEvent.click(key('tab'), { detail: 1 })
+ expect(sock.input()).toEqual([
+ { ref: 1, text: '\x1b' },
+ { ref: 1, text: '\x09' },
+ ])
+ })
+
+ it('chords Ctrl with an arrow', () => {
+ coarsePointer()
+ const { sock } = mountTerminal((e) => )
+ act(() => sock.emitControl(attached({ ref: 1, id: 's1' })))
+
+ fireEvent.pointerDown(key('ctrl'))
+ fireEvent.pointerDown(key('→'))
+ expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[1;5C' }])
+ expect(key('ctrl').getAttribute('aria-pressed')).toBe('false')
+ })
+
+ it('drops bar keys pressed before the attach comes back', () => {
+ coarsePointer()
+ const { sock } = mountTerminal((e) => )
+ fireEvent.pointerDown(key('esc'))
+ expect(sock.input()).toEqual([])
+ })
+
+ it('reserves bottom room in the inset so the bar covers no rows', () => {
+ coarsePointer()
+ mountTerminal((e) => )
+ expect(inset().className).toContain('bottom-16')
+ })
+ })
})
/** A complete SessionInfo, so a caller only names what it cares about. */
diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx
index 3a93741..949717a 100644
--- a/web/src/components/terminal.tsx
+++ b/web/src/components/terminal.tsx
@@ -3,6 +3,7 @@ import { LayoutGridIcon, PlusIcon } from 'lucide-react'
import { useFlueClient } from '@/client/provider'
import { ExitOverlay } from '@/components/exit-overlay'
+import { KeyBar } from '@/components/key-bar'
import { ThemeMenu } from '@/components/theme-menu'
import { DARK_SCHEME_QUERY, prefersDark } from '@/emulator/palette'
import { controlColors, resolveTheme, THEME_SYSTEM } from '@/emulator/themes'
@@ -17,7 +18,9 @@ import {
type Box,
type Dimensions,
} from '@/lib/geometry'
+import { startGlide } from '@/lib/glide'
import { createKeyboardModes, type KeyboardMode } from '@/lib/keyboard'
+import { barKeyBytes, ctrlTransform, type BarKey } from '@/lib/keys'
import { cn } from '@/lib/utils'
import { trackVisualViewport, zoomedIn } from '@/lib/viewport'
@@ -131,6 +134,15 @@ export function Terminal({
client.status === 'reconnecting' ? 'reconnecting' : 'connecting',
)
const [mode, setMode] = useState('tab')
+ // Coarse pointer once per mount: whether this device's primary pointer is a
+ // finger decides the key bar's existence, and a pointer does not change
+ // class mid-session in any way worth re-rendering for.
+ const [coarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)')?.matches ?? false)
+ // The latched Ctrl: state for the chip's pressed look, a ref for the input
+ // path, which lives inside the effect and must read it without re-running.
+ const [ctrlArmed, setCtrlArmed] = useState(false)
+ const ctrlArmedRef = useRef(ctrlArmed)
+ ctrlArmedRef.current = ctrlArmed
const [exitCode, setExitCode] = useState(null)
// This session's directory, for Restart and the new-session link. From the
// session list, because `attached` does not carry it.
@@ -156,6 +168,7 @@ export function Terminal({
const actionsRef = useRef<{
restart: (dir: string | null) => void
applyTheme: (id: string) => void
+ sendKey: (key: BarKey) => void
} | null>(null)
// The latest onRestarted, readable from inside the effect without putting
// a prop identity in its dependency array.
@@ -260,17 +273,24 @@ export function Terminal({
settleTimer = window.setTimeout(sendFittedSize, RESIZE_SETTLE_MS)
}
- if (want.cols >= dims.cols && want.rows >= dims.rows) {
- // The pty fits this pane — whatever view set the size, this one can
- // show it whole — so the surface simply fills the pane and nothing is
- // transformed.
+ if (want.cols >= dims.cols) {
+ // Every column fits, so the surface lays out one-to-one whatever the
+ // row count says. Rows overflowing alone is almost always this view's
+ // own keyboard sliding over the pane: for the settle window the bottom
+ // rows clip behind it and then the pty takes the new height. Scaling
+ // here instead used to pinch both axes for that window — a lurch on
+ // every keyboard open. The cost is deliberate: a view whose columns
+ // fit while its rows do not shows the top of the screen until the pty
+ // follows, and in the rare enduring cross-device shape of that kind,
+ // scrollback still reaches what the pane cannot.
surface.style.removeProperty('width')
surface.style.removeProperty('height')
surface.style.removeProperty('scale')
return
}
- // A larger view is setting the size. Lay the surface out at the
+ // Columns overflow: a wider view is setting the size, and columns
+ // clipping would amputate lines mid-word. Lay the surface out at the
// screen's true size and scale the whole thing down, rather than
// reflowing text.
//
@@ -316,7 +336,18 @@ export function Terminal({
emulator.onData((bytes) => {
// No ref, no destination — and no input while the backlog replays.
if (ref === null || consumed < muteUntil) return
- client.sendInput(ref, bytes)
+ let out = bytes
+ if (ctrlArmedRef.current) {
+ // The bar's latched Ctrl folds this keystroke, and is spent on it
+ // whether or not it could fold — like a real latched modifier.
+ out = ctrlTransform(bytes) ?? bytes
+ // The ref by hand, beside the state and not through it: the render
+ // that would re-sync it is a flush away, and two keystrokes delivered
+ // inside one task would both read the arming and both fold.
+ ctrlArmedRef.current = false
+ setCtrlArmed(false)
+ }
+ client.sendInput(ref, out)
})
// Touch scrolling, by hand: xterm's viewport scrolls on wheel events and
@@ -325,14 +356,24 @@ export function Terminal({
// remainder carried between moves so slow drags still add up. The
// surface's own CSS sets touch-action: pinch-zoom (styles.css), which is
// what keeps the browser from spending the gesture on panning the page
- // while still leaving two fingers to the zoom.
+ // while still leaving two fingers to the zoom. A finger that lifts while
+ // still moving hands its speed to a glide (lib/glide.ts), which is what
+ // every other scrolling surface on a phone does.
let touchY: number | null = null
let touchCarry = 0
+ // 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 }> = []
+ let glide: (() => void) | null = null
const lineHeightPx = () => {
const content = emulator.contentSize()
return content && dims.rows > 0 ? content.height / dims.rows : 17
}
const touchStart = (e: TouchEvent) => {
+ // A finger on the glass pins the content — any glide in flight ends.
+ glide?.()
+ glide = null
+ flick = []
// A magnified page belongs to the browser, and releasing touch-action
// is not enough to give it back: touch-action only says the browser
// *may* pan, while the preventDefault() below cancels that pan whatever
@@ -343,6 +384,11 @@ export function Terminal({
}
touchY = e.touches[0]!.clientY
touchCarry = 0
+ // The anchor counts as a sample. A flick is often three moves long at a
+ // 60Hz touch rate, and reading its speed from the moves alone would
+ // throw away a third of the evidence and most of the window. The lift
+ // trims it back off again if the finger rested here before setting off.
+ flick.push({ t: e.timeStamp, y: touchY })
}
const touchMove = (e: TouchEvent) => {
if (touchY === null || e.touches.length !== 1) return
@@ -362,16 +408,53 @@ export function Terminal({
const lines = Math.trunc(delta)
touchCarry = delta - lines
touchY = y
+ flick.push({ t: e.timeStamp, y })
+ if (flick.length > 6) flick.shift()
if (lines !== 0) emulator.scrollLines(lines)
}
- const touchEnd = () => {
+ const touchEnd = (e: TouchEvent) => {
+ const wasDragging = touchY !== null
+ touchY = null
+ touchCarry = 0
+ // Velocity is the recent motion, not the whole gesture: a hesitation
+ // after touchdown, or a drag that turned around mid-way, is no part of
+ // the speed at the lift and averaging across it would divide the answer
+ // by the length of the pause. Two samples are always kept, so a flick
+ // short enough to be three samples still reads.
+ while (flick.length > 2 && flick[flick.length - 1]!.t - flick[0]!.t > 100) flick.shift()
+ // Two samples and thirty milliseconds are the floor, so a tap reads as
+ // no flick at all. The lift's own clock is the other half: moves stop
+ // arriving the instant the finger stops, so a thumb that parks the
+ // content and lets go a moment later leaves fast samples behind it, and
+ // only their age gives it away.
+ const a = flick[0]
+ const b = flick[flick.length - 1]
+ flick = []
+ if (!wasDragging || !a || !b || b.t - a.t < 30) return
+ if (e.timeStamp - b.t > 100) return
+ const dt = (b.t - a.t) / 1000
+ const lps = (a.y - b.y) / lineHeightPx() / dt
+ glide = startGlide({ velocity: lps, onLines: (n) => emulator.scrollLines(n) })
+ }
+ /**
+ * The gesture taken away rather than finished: a notification shade pulled
+ * down over it, an alert, a call. No finger ever lifted, so there is no
+ * release to read a speed from — the drag simply stops where it was.
+ *
+ * Its own handler rather than touchEnd's, because the samples a
+ * system-stolen drag leaves behind are indistinguishable from a flick's:
+ * fast, recent, and promptly followed by an event. Sharing the lift path
+ * would send the scrollback coasting with nothing on the glass.
+ */
+ const touchCancel = () => {
touchY = null
touchCarry = 0
+ flick = []
}
surface.addEventListener('touchstart', touchStart, { passive: true })
surface.addEventListener('touchmove', touchMove, { passive: false })
surface.addEventListener('touchend', touchEnd, { passive: true })
- surface.addEventListener('touchcancel', touchEnd, { passive: true })
+ surface.addEventListener('touchcancel', touchCancel, { passive: true })
// The pane hugs the visual viewport: a phone keyboard shrinks it and the
// ResizeObserver below refits the terminal above the keyboard. While
@@ -556,6 +639,19 @@ export function Terminal({
emulator.setTheme(next)
pane.style.backgroundColor = next.background ?? ''
},
+ sendKey: (key) => {
+ if (ref === null || consumed < muteUntil) return
+ const bytes = barKeyBytes(key, {
+ appCursor: emulator.applicationCursorKeys(),
+ ctrl: ctrlArmedRef.current,
+ })
+ if (ctrlArmedRef.current) {
+ // Spent here too, ref first: see the onData path above.
+ ctrlArmedRef.current = false
+ setCtrlArmed(false)
+ }
+ client.sendInput(ref, bytes)
+ },
}
// Another tab choosing a theme lands here: the preference is global, and
@@ -581,7 +677,8 @@ export function Terminal({
surface.removeEventListener('touchstart', touchStart)
surface.removeEventListener('touchmove', touchMove)
surface.removeEventListener('touchend', touchEnd)
- surface.removeEventListener('touchcancel', touchEnd)
+ surface.removeEventListener('touchcancel', touchCancel)
+ glide?.()
untrackViewport()
window.removeEventListener('storage', onStorage)
window.removeEventListener('keydown', onKey, true)
@@ -644,6 +741,7 @@ export function Terminal({
data-flue-inset=""
className={cn(
'absolute inset-3 transition-opacity',
+ coarse && 'bottom-16',
phase === 'exited' && 'opacity-60',
)}
>
@@ -653,6 +751,13 @@ export function Terminal({
className="flue-term-surface absolute top-0 left-0 origin-top-left"
/>
+ {coarse && (
+ setCtrlArmed((v) => !v)}
+ onKey={(k) => actionsRef.current?.sendKey(k)}
+ />
+ )}
{/* 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 66b2863..2909a19 100644
--- a/web/src/emulator/emulator.test.ts
+++ b/web/src/emulator/emulator.test.ts
@@ -138,16 +138,21 @@ describe('Emulator interface', () => {
em.dispose()
})
- it('survives focus, setTheme and contentSize after disposal', () => {
- // All three are called from React effects and from event handlers that can
- // outlive the view by a frame — a queued animation frame, a media-query
- // change mid-teardown. xterm throws on a disposed terminal.
+ it('survives every read and write that can outlive disposal', () => {
+ // All of these are called from React effects and from event handlers that
+ // can outlive the view by a frame — a queued animation frame, a
+ // media-query change mid-teardown, a key bar tapped as the view goes
+ // away. xterm throws on a disposed terminal — from the mode reads only by
+ // luck of where they land today, which is why the answer this pins is the
+ // seam's own and not whatever the wreckage still holds.
const em = createXtermEmulator({ cols: 20, rows: 4 })
em.dispose()
expect(() => em.focus()).not.toThrow()
expect(() => em.setTheme({ background: '#000000' })).not.toThrow()
expect(em.contentSize()).toBeNull()
+ expect(() => em.answerQueries(true)).not.toThrow()
+ expect(em.applicationCursorKeys()).toBe(false)
})
it('attaches to an element with no WebGL context available', () => {
diff --git a/web/src/emulator/types.ts b/web/src/emulator/types.ts
index 65b26d4..ddf955a 100644
--- a/web/src/emulator/types.ts
+++ b/web/src/emulator/types.ts
@@ -59,13 +59,17 @@ export interface TerminalTheme {
* xterm.js implements this today. Keeping it small and free of xterm-specific
* concepts is what keeps the protocol client and the terminal route testable
* without a DOM, and is the whole reason this file exists: every part of flue
- * that talks to a terminal talks to these ten methods.
+ * that talks to a terminal talks to this interface.
*
- * The last three arrived with the terminal view, and each is here rather than
- * in the view because the alternative was the view reaching past the seam into
- * xterm's own DOM and options — which is the one thing this file exists to
- * prevent. All three are emulator-agnostic: every terminal emulator has a
- * palette, a focus state, and a rendered size.
+ * `setTheme`, `focus` and `contentSize` arrived with the terminal view, and
+ * each is here rather than in the view because the alternative was the view
+ * reaching past the seam into xterm's own DOM and options — which is the one
+ * thing this file exists to prevent. All three are emulator-agnostic: every
+ * terminal emulator has a palette, a focus state, and a rendered size. The two
+ * that came after them, `answerQueries` and `applicationCursorKeys`, are the
+ * same bargain struck over terminal modes rather than over what is drawn:
+ * which client may answer a program's questions, and how a program wants its
+ * arrow keys encoded.
*/
export interface Emulator {
/**
@@ -144,6 +148,16 @@ export interface Emulator {
* primary role. Off by default until the daemon says who is primary.
*/
answerQueries(on: boolean): void
+ /**
+ * Whether the program has asked for application cursor keys (DECCKM).
+ *
+ * The on-screen key bar synthesises arrow presses, and an arrow's encoding
+ * is the program's choice, not the bar's: CSI moves history at a shell,
+ * SS3 is what vim and friends expect once they have set the mode. Reading
+ * it here keeps the bar as mode-honest as a hardware keyboard through
+ * xterm would be.
+ */
+ applicationCursorKeys(): boolean
/** Test-only: simulate user input. */
injectForTest(data: string): void
}
diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts
index fced945..8ccb51c 100644
--- a/web/src/emulator/xterm.ts
+++ b/web/src/emulator/xterm.ts
@@ -142,9 +142,13 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator {
term.dispose()
},
- // The three below are all reachable after disposal: a queued animation
+ // Everything from here on can be called after disposal: a queued animation
// frame, a media-query change landing mid-teardown, a focus effect racing
- // an unmount. xterm throws from a disposed terminal, so each checks.
+ // an unmount, a key bar tap that arrives as the view goes away. xterm
+ // throws from a disposed terminal, so each of the four that reach into
+ // `term` checks first. answerQueries needs no check — it writes the local
+ // flag the parser handlers read and touches nothing of xterm's — and
+ // injectForTest is reached from tests alone, which own their teardown.
setTheme(theme: TerminalTheme) {
if (disposed) return
@@ -160,6 +164,14 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator {
answers = on
},
+ applicationCursorKeys() {
+ // A key bar tap can outrun the unmount that disposed this. CSI is the
+ // honest answer for a terminal that no longer exists — and the bytes go
+ // nowhere anyway, because the view drops them with no ref to send on.
+ if (disposed) return false
+ return term.modes.applicationCursorKeysMode
+ },
+
contentSize(): PixelSize | null {
if (disposed) return null
const screen = term.element?.querySelector(SCREEN_SELECTOR)
diff --git a/web/src/lib/glide.test.ts b/web/src/lib/glide.test.ts
new file mode 100644
index 0000000..dfaf2fc
--- /dev/null
+++ b/web/src/lib/glide.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from 'vitest'
+import { startGlide } from './glide'
+
+/** A hand-cranked animation frame loop. step() advances the clock. */
+function frames() {
+ const queue: Array<{ id: number; cb: (t: number) => void }> = []
+ let nextId = 1
+ let now = 0
+ return {
+ raf: (cb: (t: number) => void) => {
+ const id = nextId++
+ queue.push({ id, cb })
+ return id
+ },
+ caf: (id: number) => {
+ const at = queue.findIndex((f) => f.id === id)
+ if (at >= 0) queue.splice(at, 1)
+ },
+ step(ms: number) {
+ now += ms
+ const due = queue.splice(0, queue.length)
+ for (const f of due) f.cb(now)
+ },
+ pending: () => queue.length,
+ }
+}
+
+describe('startGlide', () => {
+ it('keeps scrolling after the finger lifts, in decaying whole lines', () => {
+ const f = frames()
+ let lines = 0
+ startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+
+ f.step(16) // first frame establishes the clock; no time has passed yet
+ const after1 = lines
+ for (let i = 0; i < 30; i++) f.step(16)
+ const after31 = lines
+
+ expect(after31).toBeGreaterThan(after1)
+ // Half a second of 0.998^ms friction eats most of 60 lines/s: the total
+ // lands well under what the starting velocity alone would cover…
+ expect(after31).toBeLessThan(30)
+ expect(after31).toBeGreaterThan(5)
+ })
+
+ it('carries fractions so slow glides still add up to whole lines', () => {
+ const f = frames()
+ let lines = 0
+ startGlide({ velocity: 4, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ for (let i = 0; i < 30; i++) f.step(16)
+ // 4 lines/s decaying over ~0.5s comes to about 1.25 lines, and every
+ // single frame of it is 0.064 of a line: deliverable only by carry.
+ expect(lines).toBeGreaterThanOrEqual(1)
+ })
+
+ it('emits only whole lines, never fractions', () => {
+ const f = frames()
+ const emitted: number[] = []
+ startGlide({ velocity: 25, onLines: (n) => emitted.push(n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ for (let i = 0; i < 10; i++) f.step(16)
+ for (const n of emitted) expect(Number.isInteger(n)).toBe(true)
+ })
+
+ it('scrolls the other way for a negative velocity', () => {
+ const f = frames()
+ let lines = 0
+ startGlide({ velocity: -60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ for (let i = 0; i < 10; i++) f.step(16)
+ expect(lines).toBeLessThan(0)
+ })
+
+ it('comes to rest on its own and stops asking for frames', () => {
+ const f = frames()
+ startGlide({ velocity: 10, onLines: () => {}, raf: f.raf, caf: f.caf })
+ for (let i = 0; i < 400 && f.pending(); i++) f.step(16)
+ expect(f.pending()).toBe(0)
+ })
+
+ it('cancel stops it mid-glide', () => {
+ const f = frames()
+ let lines = 0
+ const cancel = startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf })
+ f.step(16)
+ f.step(16)
+ const before = lines
+ cancel()
+ f.step(16)
+ f.step(16)
+ expect(lines).toBe(before)
+ expect(f.pending()).toBe(0)
+ })
+
+ it('declines a velocity too small to glide', () => {
+ const f = frames()
+ startGlide({ velocity: 0.2, onLines: () => {}, raf: f.raf, caf: f.caf })
+ expect(f.pending()).toBe(0)
+ })
+})
diff --git a/web/src/lib/glide.ts b/web/src/lib/glide.ts
new file mode 100644
index 0000000..2bed3a9
--- /dev/null
+++ b/web/src/lib/glide.ts
@@ -0,0 +1,58 @@
+/**
+ * Friction per millisecond. UIKit's "normal" deceleration rate — velocity
+ * multiplied by 0.998 every millisecond — which is the feel a finger that
+ * has used any phone expects, and the reason the constant is not tunable.
+ */
+const FRICTION = 0.998
+
+/** Below this many lines per second a glide has visibly stopped. */
+const REST = 0.5
+
+/**
+ * Scroll on after the finger lifts.
+ *
+ * The drag handlers translate touch motion into whole-line scrolls while the
+ * finger is down; this carries the motion past the lift, decaying an initial
+ * lines-per-second velocity and emitting whole lines with the fraction
+ * carried between frames — the same carry trick the drag itself uses.
+ * Returns a cancel; the caller cancels on the next touch, on a pinch, and
+ * on unmount, because a glide must never outlive the surface it scrolls.
+ */
+export function startGlide(opts: {
+ velocity: number
+ onLines: (lines: number) => void
+ raf?: typeof requestAnimationFrame
+ caf?: typeof cancelAnimationFrame
+}): () => void {
+ const raf = opts.raf ?? requestAnimationFrame
+ const caf = opts.caf ?? cancelAnimationFrame
+ let v = opts.velocity
+ if (Math.abs(v) < REST) return () => {}
+
+ let carry = 0
+ let last: number | null = null
+ let frame = 0
+
+ const tick = (t: number) => {
+ frame = 0
+ if (last !== null) {
+ const dt = t - last
+ // Integrate at the frame's start velocity, then decay: at 60fps the
+ // difference from exact integration is under a line per flick.
+ const delta = (v * dt) / 1000 + carry
+ const lines = Math.trunc(delta)
+ carry = delta - lines
+ if (lines !== 0) opts.onLines(lines)
+ v *= FRICTION ** dt
+ if (Math.abs(v) < REST) return
+ }
+ last = t
+ frame = raf(tick)
+ }
+
+ frame = raf(tick)
+ return () => {
+ if (frame) caf(frame)
+ frame = 0
+ }
+}
diff --git a/web/src/lib/keys.test.ts b/web/src/lib/keys.test.ts
new file mode 100644
index 0000000..11cab16
--- /dev/null
+++ b/web/src/lib/keys.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { barKeyBytes, ctrlTransform } from './keys'
+
+const text = (b: Uint8Array) => new TextDecoder().decode(b)
+
+describe('barKeyBytes', () => {
+ it('encodes arrows as CSI when the program has not asked for more', () => {
+ expect(text(barKeyBytes('up', { appCursor: false, ctrl: false }))).toBe('\x1b[A')
+ expect(text(barKeyBytes('down', { appCursor: false, ctrl: false }))).toBe('\x1b[B')
+ expect(text(barKeyBytes('right', { appCursor: false, ctrl: false }))).toBe('\x1b[C')
+ expect(text(barKeyBytes('left', { appCursor: false, ctrl: false }))).toBe('\x1b[D')
+ })
+
+ it('switches arrows to SS3 under application cursor keys', () => {
+ expect(text(barKeyBytes('up', { appCursor: true, ctrl: false }))).toBe('\x1bOA')
+ expect(text(barKeyBytes('left', { appCursor: true, ctrl: false }))).toBe('\x1bOD')
+ })
+
+ it('encodes Ctrl-arrows as modified CSI, whatever the cursor mode', () => {
+ // xterm sends CSI 1;5 for ctrl-arrows even in application mode.
+ expect(text(barKeyBytes('up', { appCursor: false, ctrl: true }))).toBe('\x1b[1;5A')
+ expect(text(barKeyBytes('right', { appCursor: true, ctrl: true }))).toBe('\x1b[1;5C')
+ })
+
+ it('sends esc and tab as their single bytes, ctrl or not', () => {
+ expect(text(barKeyBytes('esc', { appCursor: false, ctrl: false }))).toBe('\x1b')
+ expect(text(barKeyBytes('tab', { appCursor: true, ctrl: true }))).toBe('\x09')
+ })
+})
+
+describe('ctrlTransform', () => {
+ const of = (...b: number[]) => Uint8Array.from(b)
+
+ it('folds letters onto control codes, either case', () => {
+ expect(ctrlTransform(of(0x63))).toEqual(of(0x03)) // c → ETX (Ctrl+C)
+ expect(ctrlTransform(of(0x43))).toEqual(of(0x03)) // C too
+ expect(ctrlTransform(of(0x64))).toEqual(of(0x04)) // d → EOT
+ })
+
+ it('covers the punctuation controls a terminal actually uses', () => {
+ expect(ctrlTransform(of(0x5b))).toEqual(of(0x1b)) // [ → ESC
+ expect(ctrlTransform(of(0x20))).toEqual(of(0x00)) // space → NUL
+ expect(ctrlTransform(of(0x3f))).toEqual(of(0x7f)) // ? → DEL
+ })
+
+ it('declines anything it cannot fold', () => {
+ expect(ctrlTransform(of(0x31))).toBeNull() // digit
+ expect(ctrlTransform(new TextEncoder().encode('é'))).toBeNull() // multi-byte
+ expect(ctrlTransform(new TextEncoder().encode('ls'))).toBeNull() // paste
+ })
+})
diff --git a/web/src/lib/keys.ts b/web/src/lib/keys.ts
new file mode 100644
index 0000000..a05327a
--- /dev/null
+++ b/web/src/lib/keys.ts
@@ -0,0 +1,42 @@
+/** The keys the on-screen bar offers. Ctrl is a modifier, not a key here. */
+export type BarKey = 'esc' | 'tab' | 'up' | 'down' | 'left' | 'right'
+
+const encoder = new TextEncoder()
+
+/** VT arrow finals: CSI/SS3 A B C D are up, down, right, left — in that order. */
+const ARROW_FINAL = { up: 'A', down: 'B', right: 'C', left: 'D' } as const
+
+/**
+ * The bytes a bar key sends.
+ *
+ * Arrows follow DECCKM — CSI for a shell, SS3 once a full-screen program has
+ * asked for application cursor keys — because a bar that always sent CSI
+ * would move the cursor in vim and type `A` in less. Ctrl-arrows are the
+ * modified CSI form whatever the mode, which is what xterm itself emits.
+ * Esc and tab are single bytes with no Ctrl form worth sending.
+ */
+export function barKeyBytes(key: BarKey, opts: { appCursor: boolean; ctrl: boolean }): Uint8Array {
+ if (key === 'esc') return encoder.encode('\x1b')
+ if (key === 'tab') return encoder.encode('\x09')
+ const fin = ARROW_FINAL[key]
+ if (opts.ctrl) return encoder.encode(`\x1b[1;5${fin}`)
+ return encoder.encode(opts.appCursor ? `\x1bO${fin}` : `\x1b[${fin}`)
+}
+
+/**
+ * Fold one typed key onto its control code, for the Ctrl the bar latches.
+ *
+ * Touch keyboards carry no Ctrl, so the bar arms one and the next keystroke
+ * lands here. Null means "not foldable" — a digit, a paste, a multi-byte
+ * character — and the caller sends the bytes untouched; the arming is spent
+ * either way, as a latched modifier on a real keyboard would be.
+ */
+export function ctrlTransform(bytes: Uint8Array): Uint8Array | null {
+ if (bytes.length !== 1) return null
+ const b = bytes[0]!
+ if (b === 0x20) return Uint8Array.of(0x00) // Ctrl+Space
+ if (b === 0x3f) return Uint8Array.of(0x7f) // Ctrl+?
+ if (b >= 0x61 && b <= 0x7a) return Uint8Array.of(b & 0x1f) // a-z
+ if (b >= 0x40 && b <= 0x5f) return Uint8Array.of(b & 0x1f) // @, A-Z, [ \ ] ^ _
+ return null
+}
diff --git a/web/src/testing/emulator.ts b/web/src/testing/emulator.ts
index 056dd4e..2aada1f 100644
--- a/web/src/testing/emulator.ts
+++ b/web/src/testing/emulator.ts
@@ -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 applicationCursorKeys() reports; set by hand like measured. */
+ appCursor: boolean
/** Simulate the user typing. */
send(text: string): void
}
@@ -55,6 +57,7 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
focusCalls: 0,
scrolled: 0,
measured: null,
+ appCursor: false,
text: () => written.join(''),
@@ -109,6 +112,8 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
contentSize: () => self.measured,
+ applicationCursorKeys: () => self.appCursor,
+
injectForTest(data: string) {
self.send(data)
},