diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index f48815d..a8a9b53 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -186,41 +186,24 @@ describe('Terminal', () => { expect(em.live().text()).toBe('\x1bcfresh') }) - it('stops reporting the pointer once the replayed backlog has drained', () => { - // The bug: a daemon restart replays a snapshot's scrollback, and that - // scrollback carries the mouse-tracking sequence of a program that was - // killed with the daemon and so never wrote its own reset. The emulator - // ends the replay armed, with a brand new shell behind it, and every - // pointer move over the terminal is an SGR report typed at the prompt. + it('leaves a live program its modes once the replayed backlog has drained', () => { + // A replay ends at the live program's present state: mouse tracking in + // the backlog of a running session is not the orphan of a dead shell, it + // is what the program on the other end believes is armed right now. + // Clearing it here desynced this emulator from that program — with + // claude's fullscreen renderer (alternate screen plus tracking) the + // cleared emulator turned every wheel tick into arrow keys at the + // program's stdin until its next full redraw. A revived session needs no + // clearing from this side either: the daemon ends every revive preload + // with settleModes, so a dead shell's reset is already in these bytes. const { sock, em } = mountTerminal((e) => ) act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0, head: 8 }))) - act(() => sock.emitOutput(1, 'backlog!')) - - // After the backlog, never before it: clearing the modes first would be - // undone by the very bytes that set them. - expect(em.live().reportingStops).toEqual([1]) - }) - - it('leaves the modes alone until the whole backlog is in', () => { - const { sock, em } = mountTerminal((e) => ) - - act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0, head: 12 }))) - act(() => sock.emitOutput(1, 'part')) - - expect(em.live().reportingStops).toEqual([]) - }) - - it('says nothing about the modes when there is no backlog to replay', () => { - // A freshly spawned session has head === seq. Nothing was replayed, so - // there is no stale state to answer for, and a program that armed - // tracking on its first line must keep it. - const { sock, em } = mountTerminal((e) => ) - - act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0 }))) act(() => sock.emitOutput(1, '\x1b[?1003h')) - expect(em.live().reportingStops).toEqual([]) + // The backlog and nothing after it: any settle sequence appended here + // would be this client overruling a program that is still running. + expect(em.live().text()).toBe('\x1b[?1003h') }) it('does not reset when the attach is an ordinary continuation', () => { diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index d58ab55..cfb3e48 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -389,19 +389,12 @@ export function Terminal({ // the shell's stdin. head === seq on a fresh spawn opens it immediately. let consumed = 0 let muteUntil = 0 - // Whether a replayed backlog is still arriving under this attachment. - // - // The gate above keeps the emulator's *answers* off the wire while the - // scrollback replays. This is the other half of the same problem: a - // replay also re-runs every mode change in that scrollback, and the - // modes outlive it. A shell killed with the daemon inside a - // mouse-tracking program wrote the sequence that turned tracking on and - // never the one that turns it off, so replaying its snapshot leaves this - // emulator reporting the pointer at a fresh prompt — see stopReporting. - // Turned off the moment the backlog has been consumed, which is where - // the reset goes; false already on a fresh spawn, whose head === seq - // means there is nothing replayed to answer for. - let replaying = false + // The modes a replay re-runs — mouse tracking, focus reporting — are + // deliberately left exactly where the backlog puts them. A live + // session's replay ends at the program's present state, so clearing + // anything here desyncs this emulator from a program that still holds + // those modes armed; and a revived session's preload already ends with + // the daemon's settleModes, so a dead shell's reset is in the bytes. // The attachment's epoch, stepped with every reseed. Each done callback // below closes over the value it was written under: one enqueued under a // previous attachment can fire after the reseed, and its bytes are @@ -878,7 +871,6 @@ export function Terminal({ epoch++ consumed = a.seq muteUntil = a.head - replaying = a.head > a.seq if (a.truncated) emulator.write(RESET) emulator.resize(a.cols, a.rows) tabOsc = a.title @@ -897,13 +889,6 @@ export function Terminal({ emulator.write(bytes, () => { if (e !== epoch) return consumed += bytes.length - // In the done callback and not at frame arrival, for the same - // reason the gate is: this has to land after the parser has read - // the backlog, or the modes it clears are set again behind it. - if (replaying && consumed >= muteUntil) { - replaying = false - emulator.stopReporting() - } }) }), ) diff --git a/web/src/emulator/emulator.test.ts b/web/src/emulator/emulator.test.ts index 48bcb9a..79ae936 100644 --- a/web/src/emulator/emulator.test.ts +++ b/web/src/emulator/emulator.test.ts @@ -241,49 +241,6 @@ describe('Emulator interface', () => { }) }) - 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))) - 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('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) - em.stopReporting() - await settled(em, '\x1b[?1002h') - - expect(em.reportsPointer()).toBe(true) - em.dispose() - el.remove() - }) - describe('alt-screen scrolling', () => { // Fullscreen TUIs — claude's fullscreen mode, vim, less — live on the // alternate buffer, which keeps no scrollback, so the viewport scroll @@ -442,12 +399,10 @@ describe('Emulator interface', () => { em.dispose() expect(() => em.focus()).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 b4dbc42..fe40322 100644 --- a/web/src/emulator/types.ts +++ b/web/src/emulator/types.ts @@ -211,34 +211,6 @@ export interface Emulator { * run rather than land in the line editor. */ paste(text: string): void - /** - * Forget the reporting modes a replayed backlog turned on. - * - * 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. - */ - reportsPointer(): boolean /** * Mount into the DOM. * diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts index c7213ec..07c6d19 100644 --- a/web/src/emulator/xterm.ts +++ b/web/src/emulator/xterm.ts @@ -52,19 +52,6 @@ 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. * @@ -364,21 +351,6 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { term.paste(text) }, - stopReporting() { - if (disposed) return - // 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) { term.open(el) // Best-effort GPU rendering; the DOM renderer is a fine fallback, and diff --git a/web/src/testing/emulator.ts b/web/src/testing/emulator.ts index 1c893de..41c3452 100644 --- a/web/src/testing/emulator.ts +++ b/web/src/testing/emulator.ts @@ -29,16 +29,6 @@ export interface FakeEmulator extends Emulator { appCursor: boolean /** Simulate the user typing. */ send(text: string): void - /** - * 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 /** Every selectWordAt() cell, in order. */ readonly wordPresses: Cell[] /** Every extendSelectionTo() cell, in order. */ @@ -74,7 +64,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator const written: string[] = [] const themes: TerminalTheme[] = [] const queryAnswers: boolean[] = [] - const reportingStops: number[] = [] const wordPresses: Cell[] = [] const extensions: Cell[] = [] const pasted: string[] = [] @@ -83,7 +72,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator written, themes, queryAnswers, - reportingStops, wordPresses, extensions, pasted, @@ -99,7 +87,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator measured: null, onGlass: null, appCursor: false, - pointerReports: false, detector: null, text: () => written.join(''), @@ -156,13 +143,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator self.send(text) }, - stopReporting() { - reportingStops.push(written.length) - mutable(self).pointerReports = false - }, - - reportsPointer: () => self.pointerReports, - attachTo(el: HTMLElement) { mutable(self).mountedOn = el },