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..018a86d 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -463,6 +463,86 @@ describe("pty input gate", () => { expect(inject).toHaveBeenCalledOnce(); }); + 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(); + 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([ { name: "separate chunks",