+
{/*
The way to another session without leaving this one.
@@ -939,7 +917,7 @@ export function Terminal({
onClick={() => switcher.open()}
title={`Switch session · ${chordLabel}`}
className={cn(
- 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
+ 'rounded-lg px-2.5 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)',
)}
@@ -959,7 +937,7 @@ export function Terminal({
rel="noopener"
title="Open the dashboard"
className={cn(
- 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
+ 'rounded-lg px-2.5 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)',
)}
@@ -983,7 +961,7 @@ export function Terminal({
onClick={() => onNewSession?.(cwd)}
title="New session here"
className={cn(
- 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
+ 'rounded-lg px-2.5 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)',
)}
@@ -1004,7 +982,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.
- 'shrink-0 rounded-lg px-3 py-1.5 text-base/4 font-medium sm:text-sm/4',
+ '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 53df0c2..d7187c9 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(
- 'inline-flex size-12 shrink-0 items-center justify-center rounded-lg sm:size-auto sm:px-2.5 sm:py-1.5',
+ 'rounded-lg px-2.5 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 cf93d9c..19ef0fd 100644
--- a/web/src/emulator/emulator.test.ts
+++ b/web/src/emulator/emulator.test.ts
@@ -123,33 +123,45 @@ describe('Emulator interface', () => {
em.dispose()
})
- it('normalises pasted newlines through xterm', () => {
+ 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
+ // replaying it arms an emulator sitting in front of a fresh shell. From
+ // there every mouse move is an SGR report typed at the prompt.
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')
+ await settled(em, '\x1b[?1003h\x1b[?1006h\x1b[?1004h')
+
+ expect(em.reportsPointer()).toBe(true)
+ // Everything the arming itself put on the wire is the bug, not the fix —
+ // an unfocused terminal answers ESC[?1004h with a focus-out report right
+ // away, which is exactly the kind of typing-with-nobody-there this
+ // clears. What matters below is that the clearing adds none of its own.
+ seen.length = 0
+ em.stopReporting()
+ await settled(em, '')
+
+ expect(em.reportsPointer()).toBe(false)
+ expect(seen.join('')).toBe('')
em.dispose()
el.remove()
})
- it('honours a program that enabled bracketed-paste mode', async () => {
+ it('leaves a live program its pointer reporting', async () => {
+ // stopReporting is aimed at a replay, never at output. A program that
+ // turns tracking on after the backlog has drained keeps it.
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')
+ em.stopReporting()
+ await settled(em, '\x1b[?1002h')
- expect(seen.join('')).toBe('\x1b[200~safe\x1b[201~')
+ expect(em.reportsPointer()).toBe(true)
em.dispose()
el.remove()
})
@@ -187,11 +199,12 @@ describe('Emulator interface', () => {
em.dispose()
expect(() => em.focus()).not.toThrow()
- expect(() => em.paste('ignored')).not.toThrow()
+ expect(() => em.stopReporting()).not.toThrow()
expect(() => em.setTheme({ background: '#000000' })).not.toThrow()
expect(em.contentSize()).toBeNull()
expect(() => em.answerQueries(true)).not.toThrow()
expect(em.applicationCursorKeys()).toBe(false)
+ expect(em.reportsPointer()).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 4c5d010..169fbc7 100644
--- a/web/src/emulator/types.ts
+++ b/web/src/emulator/types.ts
@@ -107,14 +107,33 @@ export interface Emulator {
*/
onData(cb: (bytes: Uint8Array) => void): void
/**
- * Paste text as terminal input.
+ * Forget the reporting modes a replayed backlog turned on.
*
- * 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.
+ * A session's scrollback is bytes, not state, so replaying it re-runs
+ * every mode change the shell ever wrote — including the ones belonging to
+ * a program that has since exited or been killed with the daemon. Mouse
+ * tracking and focus reporting are the two that matter, because they are
+ * the only modes that put bytes on the wire with nobody typing: an armed
+ * emulator sends an SGR report for every pointer move, and the shell
+ * behind it receives that as somebody typing "35;61;22M" at the prompt.
+ *
+ * Only those two, and deliberately. Application cursor keys and bracketed
+ * paste are also replayable and also stale, but they change what a
+ * keystroke means rather than inventing keystrokes, so clearing them
+ * against a live program would break arrows and pastes in a client that
+ * had nothing wrong with it. See settleModes in internal/session for the
+ * wider reset, which runs where there is no live program to break.
+ *
+ * Local to this emulator. Nothing reaches the shell.
+ */
+ stopReporting(): void
+ /**
+ * Whether this emulator would report pointer movement to the program.
+ *
+ * Exists so the reset above can be tested for what it does rather than for
+ * the bytes it writes.
*/
- paste(text: string): void
+ reportsPointer(): boolean
/**
* Mount into the DOM.
*
diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts
index 43c257c..bc0c2fe 100644
--- a/web/src/emulator/xterm.ts
+++ b/web/src/emulator/xterm.ts
@@ -44,6 +44,19 @@ export const TERMINAL_FONT_FAMILY =
*/
export const NEWLINE_CHORD_BYTES = '\x1b\r'
+/**
+ * Every mouse protocol off, every mouse encoding off, focus reporting off.
+ *
+ * The set is exhaustive on purpose. The protocols (1000 press-only, 1002
+ * drag, 1003 any motion) and the encodings (1005 UTF-8, 1006 SGR, 1015
+ * urxvt, 1016 SGR-pixels) are separate switches in the terminal, and a
+ * program may have set any combination of them; clearing the protocol a
+ * particular program happened to use is how this fix would work on one
+ * machine and not the next.
+ */
+const STOP_REPORTING =
+ '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l\x1b[?1004l'
+
/**
* xterm.js behind the Emulator seam.
*
@@ -171,9 +184,19 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator {
term.onData((data) => cb(encoder.encode(data)))
},
- paste(text: string) {
+ stopReporting() {
if (disposed) return
- term.paste(text)
+ // Written as output rather than set on xterm's services, because the
+ // parser is the only supported way in and because it keeps the ordering
+ // honest: this lands in the stream where the caller put it, so live
+ // output arriving after it is applied after it. A program that turns
+ // tracking back on a moment later still gets tracking.
+ term.write(STOP_REPORTING)
+ },
+
+ reportsPointer() {
+ if (disposed) return false
+ return term.modes.mouseTrackingMode !== 'none'
},
attachTo(el: HTMLElement) {
diff --git a/web/src/styles.build.test.ts b/web/src/styles.build.test.ts
index cfca633..7c234c0 100644
--- a/web/src/styles.build.test.ts
+++ b/web/src/styles.build.test.ts
@@ -119,6 +119,36 @@ describe('compiled stylesheet', () => {
expect(css).toContain('touch-action:pinch-zoom')
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)
+ })
})
/**
diff --git a/web/src/styles.css b/web/src/styles.css
index 318aeab..09632ad 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -444,3 +444,77 @@
.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 d1f9aed..53e13c3 100644
--- a/web/src/testing/emulator.ts
+++ b/web/src/testing/emulator.ts
@@ -27,8 +27,16 @@ export interface FakeEmulator extends Emulator {
appCursor: boolean
/** Simulate the user typing. */
send(text: string): void
- /** Text handed to paste(), in order. */
- readonly pasted: string[]
+ /**
+ * Where each stopReporting() call landed, as a count of written chunks.
+ *
+ * A count rather than a flag because the ordering against the output
+ * stream is the property worth testing: clearing the modes before a
+ * replayed backlog has been written would be undone by the backlog.
+ */
+ readonly reportingStops: number[]
+ /** What reportsPointer() answers; set by hand like measured. */
+ pointerReports: boolean
}
/**
@@ -47,13 +55,13 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
const written: string[] = []
const themes: TerminalTheme[] = []
const queryAnswers: boolean[] = []
- const pasted: string[] = []
+ const reportingStops: number[] = []
const self: FakeEmulator = {
written,
themes,
queryAnswers,
- pasted,
+ reportingStops,
cols: opts.cols ?? 80,
rows: opts.rows ?? 24,
mountedOn: null,
@@ -62,6 +70,7 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
scrolled: 0,
measured: null,
appCursor: false,
+ pointerReports: false,
text: () => written.join(''),
@@ -94,11 +103,13 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
listeners.push(cb)
},
- paste(text: string) {
- pasted.push(text)
- self.send(text)
+ stopReporting() {
+ reportingStops.push(written.length)
+ mutable(self).pointerReports = false
},
+ reportsPointer: () => self.pointerReports,
+
attachTo(el: HTMLElement) {
mutable(self).mountedOn = el
},