diff --git a/.specs/features/pty-session-rename/spec.md b/.specs/features/pty-session-rename/spec.md index bc74f91..1333085 100644 --- a/.specs/features/pty-session-rename/spec.md +++ b/.specs/features/pty-session-rename/spec.md @@ -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 diff --git a/docs/harness-behaviour.md b/docs/harness-behaviour.md index 7600f10..fc4703a 100644 --- a/docs/harness-behaviour.md +++ b/docs/harness-behaviour.md @@ -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`. diff --git a/src/open/pty.ts b/src/open/pty.ts index d49dbc5..2c691de 100644 --- a/src/open/pty.ts +++ b/src/open/pty.ts @@ -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 | 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; @@ -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); }; @@ -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); diff --git a/tests/open-pty.test.ts b/tests/open-pty.test.ts index 0d50d3a..7d8643f 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -16,6 +16,7 @@ import { import { buildInnerCommand, buildScriptInvocation, + createInputGate, hasBinaryOnPath, ptyShimPath, ptyUnavailableReason, @@ -38,6 +39,7 @@ const preconditions = (overrides: Record = {}) => ({ }); const tempDirs: string[] = []; +const inputGates: Array<{ dispose: () => void }> = []; function tempSessionFile(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-pty-test-")); tempDirs.push(dir); @@ -45,6 +47,8 @@ function tempSessionFile(): string { } afterEach(() => { + for (const gate of inputGates.splice(0)) gate.dispose(); + vi.useRealTimers(); vi.restoreAllMocks(); while (tempDirs.length > 0) { fs.rmSync(tempDirs.pop()!, { recursive: true, force: true }); @@ -270,6 +274,233 @@ describe("watchNameSidecar", () => { }); }); +describe("pty input gate", () => { + const quietMs = 300; + const setup = () => { + vi.useFakeTimers(); + const inject = vi.fn(); + const gate = createInputGate({ inject }); + inputGates.push(gate); + return { gate, inject }; + }; + const expectHeldAfterQuiet = (inject: ReturnType) => { + vi.advanceTimersByTime(quietMs); + expect(inject).not.toHaveBeenCalled(); + }; + const expectInjectedAfterQuiet = (inject: ReturnType) => { + vi.advanceTimersByTime(quietMs); + expect(inject).toHaveBeenCalledOnce(); + }; + + it("types a name after a clean input has been quiet for 300 ms", () => { + const { gate, inject } = setup(); + gate.offer("/rename nome\r"); + expect(inject).not.toHaveBeenCalled(); + vi.advanceTimersByTime(quietMs - 1); + expect(inject).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(inject).toHaveBeenCalledOnce(); + expect(inject).toHaveBeenCalledWith("/rename nome\r"); + }); + + it("holds the name while a typed line is dirty", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("Bora tamb")); + gate.offer("/rename nome\r"); + expectHeldAfterQuiet(inject); + }); + + it("types a held name after Enter for a non-mention token", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("oi")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + vi.advanceTimersByTime(quietMs - 1); + expect(inject).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(inject).toHaveBeenCalledOnce(); + }); + + it("keeps a held name when the user types again before quiet expires", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("oi")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + vi.advanceTimersByTime(quietMs - 1); + gate.observe(Buffer.from("!")); + vi.advanceTimersByTime(quietMs); + expect(inject).not.toHaveBeenCalled(); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it.each([ + { name: "OSC terminated by BEL", reply: "\u001b]0;title\u0007" }, + { name: "OSC terminated by ST", reply: "\u001b]0;title\u001b\\" }, + { name: "CSI with > prefix", reply: "\u001b[>0u" }, + { name: "CSI cursor report", reply: "\u001b[?1;1R" }, + { name: "CSI status report", reply: "\u001b[?1n" }, + ])("ignores a whole-chunk $name", ({ reply }) => { + const { gate, inject } = setup(); + gate.offer("/rename nome\r"); + vi.advanceTimersByTime(quietMs - 1); + gate.observe(Buffer.from(reply)); + vi.advanceTimersByTime(1); + expect(inject).toHaveBeenCalledOnce(); + }); + + it.each([ + { name: "a DCS reply followed by typed text", chunk: "\u001bP>|tmux\u001b\\ hello \u001b\\" }, + { name: "an OSC reply followed by typed text", chunk: "\u001b]0;title\u0007hello" }, + ])("marks the box dirty for $name", ({ chunk }) => { + const { gate, inject } = setup(); + gate.offer("/rename nome\r"); + vi.advanceTimersByTime(quietMs - 1); + gate.observe(Buffer.from(chunk)); + expectHeldAfterQuiet(inject); + }); + + it.each([ + { + name: "pasted Enter and line continuation", + input: "oi", + chunks: ["\u001b[200~texto", "\r", "\u001b[201~", "\\", "\r"], + submitAfter: true, + }, + { + name: "Alt+Enter", + input: "Bora tamb", + chunks: ["\u001b\r"], + submitAfter: true, + }, + ])("does not treat $name as submit", ({ input, chunks, submitAfter }) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from(input)); + gate.offer("/rename nome\r"); + for (const chunk of chunks) { + gate.observe(Buffer.from(chunk)); + if (chunk.includes("\r")) expectHeldAfterQuiet(inject); + } + if (submitAfter) { + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + } + }); + + it("submits after a normal bracketed paste", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("see ")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[200~hello world\u001b[201~")); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it("recognizes bracketed paste after a pending escape", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("\u001b")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[200~line1\r")); + vi.advanceTimersByTime(quietMs); + expect(inject).not.toHaveBeenCalled(); + }); + + it("releases a held name after Enter submits a prompt containing an @ path", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("explain @src/foo.ts")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it("holds the name after Down then Enter accepts an autocomplete suggestion", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("fix the login bug")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[B")); + gate.observe(Buffer.from("\r")); + expectHeldAfterQuiet(inject); + }); + + it("keeps a held name when an unrecognised escape flushes Enter", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("fix the login bug")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[\r")); + expectHeldAfterQuiet(inject); + }); + + it.each([ + { name: "Ctrl+U", byte: 0x15 }, + { name: "Ctrl+W", byte: 0x17 }, + ])("keeps Enter conservative after $name", ({ byte }) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("abc")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from([byte])); + gate.observe(Buffer.from(" \r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it.each([0x08, 0x7f])("lets Enter submit after backspace byte 0x%s", (byte) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("abc")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from([byte])); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it("ignores whole-chunk terminal focus reports", () => { + const { gate, inject } = setup(); + gate.offer("/rename nome\r"); + vi.advanceTimersByTime(quietMs - 1); + gate.observe(Buffer.from("\u001b[I")); + gate.observe(Buffer.from("\u001b[O")); + vi.advanceTimersByTime(1); + expect(inject).toHaveBeenCalledOnce(); + }); + + it.each([ + { + name: "separate chunks", + replies: ["\u001bP>|tmux 3.7c\u001b\\", "\u001b[?1;2;4c", "\u001b[?2026;2$y"], + }, + { + name: "one concatenated chunk", + replies: ["\u001bP>|tmux 3.7c\u001b\\\u001b[?1;2;4c\u001b[?2026;2$y"], + }, + ])("ignores startup terminal replies in $name", ({ replies }) => { + const { gate, inject } = setup(); + for (const reply of replies) gate.observe(Buffer.from(reply)); + gate.observe(Buffer.from("fix the login bug")); + gate.observe(Buffer.from("\r")); + gate.offer("/rename nome\r"); + expectInjectedAfterQuiet(inject); + }); + + it("types at most once and drops a held name on dispose", () => { + const first = setup(); + first.gate.offer("/rename first\r"); + vi.advanceTimersByTime(quietMs); + first.gate.offer("/rename second\r"); + vi.advanceTimersByTime(quietMs); + expect(first.inject).toHaveBeenCalledOnce(); + first.gate.dispose(); + + const second = setup(); + second.gate.offer("/rename held\r"); + expect(vi.getTimerCount()).toBe(1); + second.gate.dispose(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(quietMs * 2); + expect(second.inject).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + describe("startPtySession", () => { // Enough of a child to exercise the wire: what the parent typed lands in // `written`, and `writable` is the flag the EPIPE guard reads. @@ -327,6 +558,27 @@ describe("startPtySession", () => { expect(env.env.CODEDECK_PTY_COLS).toBe("137"); }); + it("waits for the user's submitted line before typing a sidecar name", async () => { + const sessionFile = tempSessionFile(); + const { session, terminal, written } = start({ + launch: { + shim: SHIM, + sessionFile, + keystrokesForName: (name: string) => `/rename ${name}\r`, + }, + }); + terminal.stdin.emit("data", Buffer.from("oi")); + fs.writeFileSync(`${sessionFile}.11111111-2222-3333-4444-555555555555.name`, "nome", "utf8"); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(written.join("")).toBe("oi"); + + terminal.stdin.emit("data", Buffer.from("\r")); + await new Promise((resolve) => setTimeout(resolve, 350)); + session.dispose(); + + expect(written.join("")).toBe("oi\r/rename nome\r"); + }); + it("falls back to a usable size when the terminal reports none", () => { const terminal = fakeTerminal(); Object.assign(terminal.stdout, { rows: 0, columns: 0 });