Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .specs/features/pty-session-rename/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 4 additions & 0 deletions docs/harness-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 65 additions & 16 deletions src/open/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -145,6 +191,7 @@ export function createInputGate(options: InputGateOptions) {
}
}
previousByte = byte;
return true;
};

return {
Expand All @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions tests/open-pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading