From 51350b1c97939dce8a11502de3ea6ff6c00891b5 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:43:04 -0300 Subject: [PATCH 1/2] fix(open): Ignore mouse reports in the rename gate Ignore SGR mouse reports without resetting the rename gate, so terminal pointer events cannot hold a queued rename. Preserve unknown escapes and process adjacent typed text normally. Co-Authored-By: Codex --- .specs/features/pty-session-rename/spec.md | 10 +++ docs/harness-behaviour.md | 4 ++ src/open/pty.ts | 81 +++++++++++++++++----- tests/open-pty.test.ts | 55 +++++++++++++++ 4 files changed, 134 insertions(+), 16 deletions(-) diff --git a/.specs/features/pty-session-rename/spec.md b/.specs/features/pty-session-rename/spec.md index 1333085..93f83c0 100644 --- a/.specs/features/pty-session-rename/spec.md +++ b/.specs/features/pty-session-rename/spec.md @@ -78,6 +78,16 @@ to own the terminal. guess is a rename that lands one prompt later. - Terminal focus reports (`ESC[I`, `ESC[O`, sent as whole chunks) SHALL NOT mark the box dirty. +- An SGR mouse report (`ESC[<` followed by three digit fields separated by + semicolons and terminated by `M` or `m`) SHALL NOT mark the input box dirty. +- An SGR mouse report SHALL NOT guard the next Enter. +- WHEN an SGR mouse report shares a chunk with typed text, THEN the typed text + SHALL still mark the box dirty (the report alone is neutral, and the rest of + the chunk is processed as before). +- WHEN an SGR mouse report is split across two chunks, THEN it SHALL still be + neutral. +- IF a sequence starts with `ESC[<` but breaks the SGR mouse grammar, THEN it + SHALL be handled like any other unrecognised escape (dirty and guard). - The rename SHALL be typed at most once per session, and a held name SHALL be dropped when the session is disposed (no timer fires after dispose). diff --git a/docs/harness-behaviour.md b/docs/harness-behaviour.md index fc4703a..18b5c4c 100644 --- a/docs/harness-behaviour.md +++ b/docs/harness-behaviour.md @@ -241,6 +241,10 @@ DECRPM terminal replies arrived on stdin before the first prompt. submits a first prompt to a real session and then greps the transcript for the matching `custom-title`. +Claude Code 2.1.281, launched from a trusted directory, enables the alternate +screen (`ESC[?1049h`), any-event mouse tracking (`ESC[?1003h`), and SGR mouse +encoding (`ESC[?1006h`), so mouse reports arrive on stdin during a session. + ### `script(1)` hands over a pty with no size `script` only dimensions its pty when its own stdin is a terminal. CodeDeck diff --git a/src/open/pty.ts b/src/open/pty.ts index 2c691de..5cce8d3 100644 --- a/src/open/pty.ts +++ b/src/open/pty.ts @@ -35,6 +35,8 @@ const FALLBACK_COLUMNS = 80; const QUIET_MS = 300; const BRACKETED_PASTE_START = "\u001b[200~"; const BRACKETED_PASTE_END = "\u001b[201~"; +const SGR_MOUSE_START = "\u001b[<"; +const SGR_MOUSE_REPORT = /^\u001b\[<[0-9]+;[0-9]+;[0-9]+[Mm]$/; // Matching terminal replies needs ESC and BEL in the pattern. const TERMINAL_REPLY = /^(?:(?:\u001bP[^\u001b]*\u001b\\)|(?:\u001b\][^\u001b\u0007]*(?:\u0007|\u001b\\))|(?:\u001b\[[?>][0-?]*[ -/]*(?:c|\$y|u|R|n)))+$/; // NOSONAR @@ -61,6 +63,9 @@ export function createInputGate(options: InputGateOptions) { let disposed = false; let inPaste = false; let escapeCandidate = ""; + let sgrMouseCandidate = false; + let dirtyBeforeEscape = false; + let previousByteBeforeEscape: number | undefined; let guardNextEnter = false; let previousByte: number | undefined; let lastActivity = now(); @@ -99,7 +104,35 @@ export function createInputGate(options: InputGateOptions) { guardNextEnter = false; }; - const observeByte = (byte: number): void => { + const isSgrMousePrefix = (candidate: string): boolean => { + if (!candidate.startsWith(SGR_MOUSE_START)) return false; + const body = candidate.slice(SGR_MOUSE_START.length); + if (SGR_MOUSE_REPORT.test(candidate)) return true; + if (!/^[0-9;]*$/.test(body)) return false; + const fields = body.split(";"); + return fields.length <= 3 && fields.slice(0, -1).every(Boolean); + }; + + const flushUnknownEscape = (byte: number): void => { + // Unrecognised escape sequences can edit earlier text, so guard the next Enter. + if (escapeCandidate !== "\u001b\r") guardNextEnter = true; + const candidate = Buffer.from(escapeCandidate); + const retryEscape = byte === 0x1b; + const flush = retryEscape ? candidate.subarray(0, -1) : candidate; + for (const candidateByte of flush) { + if (isSubmit(candidateByte, previousByte, inPaste)) observeEnter(); + else markDirty(); + previousByte = candidateByte; + } + escapeCandidate = retryEscape ? String.fromCharCode(byte) : ""; + sgrMouseCandidate = false; + if (retryEscape) { + dirtyBeforeEscape = dirty; + previousByteBeforeEscape = previousByte; + } + }; + + const observeByte = (byte: number): boolean => { const char = String.fromCharCode(byte); if (escapeCandidate !== "") { escapeCandidate += char; @@ -109,31 +142,44 @@ export function createInputGate(options: InputGateOptions) { inPaste = true; escapeCandidate = ""; markDirty(); + return true; } else if (escapeCandidate === BRACKETED_PASTE_END) { inPaste = false; escapeCandidate = ""; markDirty(); - } else if (!isStart && !isEnd) { - // Unrecognised escape sequences can edit earlier text, so guard the next Enter. - if (escapeCandidate !== "\u001b\r") guardNextEnter = true; - const candidate = Buffer.from(escapeCandidate); - const retryEscape = byte === 0x1b; - const flush = retryEscape ? candidate.subarray(0, -1) : candidate; - for (const candidateByte of flush) { - if (isSubmit(candidateByte, previousByte, inPaste)) observeEnter(); - else markDirty(); - previousByte = candidateByte; + return true; + } else if (escapeCandidate === SGR_MOUSE_START && !inPaste) { + sgrMouseCandidate = true; + } else if (sgrMouseCandidate) { + if (SGR_MOUSE_REPORT.test(escapeCandidate)) { + escapeCandidate = ""; + sgrMouseCandidate = false; + dirty = dirtyBeforeEscape; + previousByte = previousByteBeforeEscape; + return false; + } + if (isSgrMousePrefix(escapeCandidate)) { + previousByte = byte; + return false; } - escapeCandidate = retryEscape ? char : ""; + flushUnknownEscape(byte); + previousByte = byte; + return true; + } else if (!isStart && !isEnd) { + flushUnknownEscape(byte); + previousByte = byte; + return true; } previousByte = byte; - return; + return false; } if (byte === 0x1b) { + dirtyBeforeEscape = dirty; + previousByteBeforeEscape = previousByte; escapeCandidate = char; markDirty(); previousByte = byte; - return; + return false; } if (isSubmit(byte, previousByte, inPaste)) { observeEnter(); @@ -145,6 +191,7 @@ export function createInputGate(options: InputGateOptions) { } } previousByte = byte; + return true; }; return { @@ -155,8 +202,10 @@ export function createInputGate(options: InputGateOptions) { chunk.equals(Buffer.from("\u001b[O")) || TERMINAL_REPLY.test(chunk.toString("latin1")) ) return; - lastActivity = now(); - for (const byte of chunk) observeByte(byte); + const observedAt = now(); + let hasActivity = false; + for (const byte of chunk) hasActivity = observeByte(byte) || hasActivity; + if (hasActivity) lastActivity = observedAt; scheduleQuiet(); }, offer(keystrokes: string): void { diff --git a/tests/open-pty.test.ts b/tests/open-pty.test.ts index 7d8643f..e64a10b 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -463,6 +463,61 @@ describe("pty input gate", () => { expect(inject).toHaveBeenCalledOnce(); }); + it("ignores a complete SGR mouse report without dirtying the input box", () => { + const { gate, inject } = setup(); + gate.offer("/rename nome\r"); + vi.advanceTimersByTime(quietMs - 1); + gate.observe(Buffer.from("\u001b[<35;40;12M")); + vi.advanceTimersByTime(1); + expect(inject).toHaveBeenCalledOnce(); + }); + + it("does not guard the next Enter after an SGR mouse report", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("prompt")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + gate.observe(Buffer.from("\u001b[<65;40;12m")); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it("still marks typed text beside an SGR mouse report as dirty", () => { + const { gate, inject } = setup(); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[<35;40;12Mtyped")); + expectHeldAfterQuiet(inject); + }); + + it("ignores an SGR mouse report split across chunks", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("\u001b[<35;40;")); + gate.observe(Buffer.from("12M")); + gate.offer("/rename nome\r"); + expectInjectedAfterQuiet(inject); + }); + + it("guards Enter when an SGR mouse prefix breaks its grammar", () => { + const { gate, inject } = setup(); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[<35x")); + gate.observe(Buffer.from("\r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it("still guards Enter after the Up arrow escape", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("prompt")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[A")); + gate.observe(Buffer.from("\r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + it.each([ { name: "separate chunks", From 4fb95ad0f32f67e8c4b8303c991c97f7c596fcc0 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:41:41 -0300 Subject: [PATCH 2/2] test(open): Table the rename gate mouse cases Keep the six gate scenarios together without changing their timing or assertions. Co-Authored-By: Codex --- tests/open-pty.test.ts | 129 ++++++++++++++++++++++++----------------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/tests/open-pty.test.ts b/tests/open-pty.test.ts index e64a10b..018a86d 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -463,59 +463,84 @@ describe("pty input gate", () => { expect(inject).toHaveBeenCalledOnce(); }); - it("ignores a complete SGR mouse report without dirtying the input box", () => { - const { gate, inject } = setup(); - gate.offer("/rename nome\r"); - vi.advanceTimersByTime(quietMs - 1); - gate.observe(Buffer.from("\u001b[<35;40;12M")); - vi.advanceTimersByTime(1); - expect(inject).toHaveBeenCalledOnce(); - }); - - it("does not guard the next Enter after an SGR mouse report", () => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("prompt")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\r")); - gate.observe(Buffer.from("\u001b[<65;40;12m")); - gate.observe(Buffer.from("\r")); - expectInjectedAfterQuiet(inject); - }); - - it("still marks typed text beside an SGR mouse report as dirty", () => { - const { gate, inject } = setup(); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\u001b[<35;40;12Mtyped")); - expectHeldAfterQuiet(inject); - }); - - it("ignores an SGR mouse report split across chunks", () => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("\u001b[<35;40;")); - gate.observe(Buffer.from("12M")); - gate.offer("/rename nome\r"); - expectInjectedAfterQuiet(inject); - }); - - it("guards Enter when an SGR mouse prefix breaks its grammar", () => { - const { gate, inject } = setup(); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\u001b[<35x")); - gate.observe(Buffer.from("\r")); - expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("\r")); - expectInjectedAfterQuiet(inject); - }); - - it("still guards Enter after the Up arrow escape", () => { + type InputGateStep = + | "offer" + | { observe: string } + | { advance: number } + | { assert: "held" | "injected" | "called" }; + it.each([ + [ + "ignores a complete SGR mouse report without dirtying the input box", + [ + "offer", + { advance: quietMs - 1 }, + { observe: "\u001b[<35;40;12M" }, + { advance: 1 }, + { assert: "called" }, + ], + ], + [ + "does not guard the next Enter after an SGR mouse report", + [ + { observe: "prompt" }, + "offer", + { observe: "\r" }, + { observe: "\u001b[<65;40;12m" }, + { observe: "\r" }, + { assert: "injected" }, + ], + ], + [ + "still marks typed text beside an SGR mouse report as dirty", + [ + "offer", + { observe: "\u001b[<35;40;12Mtyped" }, + { assert: "held" }, + ], + ], + [ + "ignores an SGR mouse report split across chunks", + [ + { observe: "\u001b[<35;40;" }, + { observe: "12M" }, + "offer", + { assert: "injected" }, + ], + ], + [ + "guards Enter when an SGR mouse prefix breaks its grammar", + [ + "offer", + { observe: "\u001b[<35x" }, + { observe: "\r" }, + { assert: "held" }, + { observe: "\r" }, + { assert: "injected" }, + ], + ], + [ + "still guards Enter after the Up arrow escape", + [ + { observe: "prompt" }, + "offer", + { observe: "\u001b[A" }, + { observe: "\r" }, + { assert: "held" }, + { observe: "\r" }, + { assert: "injected" }, + ], + ], + ] satisfies Array<[string, InputGateStep[]]>)("%s", (_name, steps) => { const { gate, inject } = setup(); - gate.observe(Buffer.from("prompt")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\u001b[A")); - gate.observe(Buffer.from("\r")); - expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("\r")); - expectInjectedAfterQuiet(inject); + for (const step of steps) { + if (step === "offer") gate.offer("/rename nome\r"); + else if ("observe" in step) gate.observe(Buffer.from(step.observe)); + else if ("advance" in step) vi.advanceTimersByTime(step.advance); + else if (step.assert === "held") expectHeldAfterQuiet(inject); + else if (step.assert === "injected") expectInjectedAfterQuiet(inject); + else if (step.assert === "called") expect(inject).toHaveBeenCalledOnce(); + else throw new Error("Unhandled input gate step"); + } }); it.each([