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
},