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
28 changes: 28 additions & 0 deletions .specs/features/pty-session-rename/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ to own the terminal.
- A name is sanitised before it is typed: control characters become spaces and
the length is capped, so nothing in a prompt can submit a second line or
move the cursor.
- WHEN a name arrives and no key has been typed since the last submit (or
since the session started) and no key has arrived for QUIET_MS, THEN the
wrapper SHALL type the rename at once.
- IF a key other than a submit was typed since the last submit, THEN the
wrapper SHALL hold the name and SHALL NOT type it.
- WHEN a held name exists and the user submits (Enter) and then no key arrives
for QUIET_MS, THEN the wrapper SHALL type the rename once.
- IF the user types again within QUIET_MS after a submit, THEN the held name
SHALL stay held until the next submit plus QUIET_MS of quiet.
- A `\r` inside a bracketed paste (between `ESC[200~` and `ESC[201~`) SHALL
NOT count as a submit. A `\r` immediately preceded by `\` (Claude Code's
line continuation) or `ESC` (Alt/Option+Enter) SHALL NOT count as a submit.
- A chunk consisting only of terminal replies (DCS, OSC, CSI replies with a
`?` or `>` prefix) SHALL NOT mark the box dirty and SHALL NOT guard the next
Enter.
- IF an unrecognised escape sequence (for example an arrow key) arrives, THEN
the next Enter SHALL NOT count as a submit, because arrow navigation plus
Enter accepts an autocomplete suggestion without submitting.
- IF an unknown control byte (below `0x20`, other than backspace, tab, LF, and
CR) arrives, THEN the next Enter SHALL NOT count as a submit. This is
conservative: a control may drive a suggestion menu, and the cost of a wrong
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.
- 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).

`QUIET_MS` is 300.

### Block C: the evidence

Expand Down
9 changes: 9 additions & 0 deletions docs/harness-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ not turned into a prompt: each queued item carries its mode
that `.trim().startsWith("/")`. The rename therefore executes when the turn
ends.

The queue path assumes the input box is empty when it receives `/rename`.
The pty wrapper now waits for a clean input box and 300 ms without keys before
typing the name, so a half-typed prompt stays in the user's line.

On 2026-09-22, Claude Code 2.1.280 measured in tmux submitted the typed prompt
when Enter was pressed with its `@` menu open; Tab accepted a suggestion, and
Down then Enter accepted one without submitting. At startup, DCS, DA1, and
DECRPM terminal replies arrived on stdin before the first prompt.

`scripts/rename-gate.sh` is the end-to-end check for exactly this half: it
submits a first prompt to a real session and then greps the transcript for the
matching `custom-title`.
Expand Down
152 changes: 147 additions & 5 deletions src/open/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,145 @@ const NAME_SUFFIX = ".name";
const INTERRUPT_KEY = 0x03;
const FALLBACK_ROWS = 24;
const FALLBACK_COLUMNS = 80;
const QUIET_MS = 300;
const BRACKETED_PASTE_START = "\u001b[200~";
const BRACKETED_PASTE_END = "\u001b[201~";
// 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

export interface InputGateOptions {
inject: (keystrokes: string) => void;
setTimeout?: typeof globalThis.setTimeout;
clearTimeout?: typeof globalThis.clearTimeout;
now?: () => number;
}

/**
* Claude Code shares its input line between user keys and `/rename`. The name
* can arrive while someone is typing, turning "Bora tamb" into
* "Bora tamb/rename XPto xyz" and submitting both as one prompt. Hold the
* rename until the input is clean and the user has been quiet for 300 ms.
*/
export function createInputGate(options: InputGateOptions) {
const schedule = options.setTimeout ?? globalThis.setTimeout;
const cancel = options.clearTimeout ?? globalThis.clearTimeout;
const now = options.now ?? Date.now;
let dirty = false;
let pending: string | undefined;
let used = false;
let disposed = false;
let inPaste = false;
let escapeCandidate = "";
let guardNextEnter = false;
let previousByte: number | undefined;
let lastActivity = now();
let timer: ReturnType<typeof setTimeout> | undefined;

const clearTimer = (): void => {
if (timer !== undefined) cancel(timer);
timer = undefined;
};

const tryInject = (): void => {
timer = undefined;
if (disposed || used || pending === undefined || dirty) return;
used = true;
const keystrokes = pending;
pending = undefined;
options.inject(keystrokes);
};

const scheduleQuiet = (): void => {
clearTimer();
const remaining = Math.max(0, QUIET_MS - (now() - lastActivity));
if (remaining === 0) tryInject();
else timer = schedule(tryInject, remaining);
};

const markDirty = (): void => {
dirty = true;
};

const isSubmit = (byte: number, previousByte: number | undefined, inPaste: boolean): boolean =>
byte === 0x0d && !inPaste && previousByte !== 0x5c && previousByte !== 0x1b;

const observeEnter = (): void => {
dirty = guardNextEnter;
guardNextEnter = false;
};

const observeByte = (byte: number): void => {
const char = String.fromCharCode(byte);
if (escapeCandidate !== "") {
escapeCandidate += char;
const isStart = BRACKETED_PASTE_START.startsWith(escapeCandidate);
const isEnd = BRACKETED_PASTE_END.startsWith(escapeCandidate);
if (escapeCandidate === BRACKETED_PASTE_START) {
inPaste = true;
escapeCandidate = "";
markDirty();
} 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;
}
escapeCandidate = retryEscape ? char : "";
}
previousByte = byte;
return;
}
if (byte === 0x1b) {
escapeCandidate = char;
markDirty();
previousByte = byte;
return;
}
if (isSubmit(byte, previousByte, inPaste)) {
observeEnter();
} else {
markDirty();
if (byte < 0x20 && byte !== 0x08 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
// An unknown control may drive a suggestion menu, so treat it like an unknown escape.
guardNextEnter = true;
}
}
previousByte = byte;
};

return {
observe(chunk: Buffer): void {
if (
disposed ||
chunk.equals(Buffer.from("\u001b[I")) ||
chunk.equals(Buffer.from("\u001b[O")) ||
TERMINAL_REPLY.test(chunk.toString("latin1"))
) return;
lastActivity = now();
for (const byte of chunk) observeByte(byte);
scheduleQuiet();
},
offer(keystrokes: string): void {
if (disposed || used || pending !== undefined) return;
pending = keystrokes;
scheduleQuiet();
},
dispose(): void {
disposed = true;
pending = undefined;
clearTimer();
},
};
}

export interface PtyTarget {
bin: string;
Expand Down Expand Up @@ -275,7 +414,13 @@ export function startPtySession(options: PtyStartOptions): PtySession {
child.stdin.write(chunk);
};

const inject = (keystrokes: string): void => {
write(keystrokes);
};
const gate = createInputGate({ inject });

const forward = (chunk: Buffer): void => {
gate.observe(chunk);
if (options.onInterrupt && chunk.includes(INTERRUPT_KEY)) options.onInterrupt();
write(chunk);
};
Expand Down Expand Up @@ -309,20 +454,17 @@ export function startPtySession(options: PtyStartOptions): PtySession {
};
connect();

const inject = (keystrokes: string): void => {
write(keystrokes);
};

const keystrokesForName = options.launch.keystrokesForName;
const stopWatching = keystrokesForName
? watchNameSidecar(options.launch.sessionFile, (name) => {
const keystrokes = keystrokesForName(name);
if (keystrokes) inject(keystrokes);
if (keystrokes) gate.offer(keystrokes);
})
: () => {};

const dispose = (): void => {
connecting = false;
gate.dispose();
stopWatching();
stdout.off("resize", sendResize);
stdin.off("data", forward);
Expand Down
Loading
Loading