From ae80dff35ff21995f2a48c89d09bf57f68cc2854 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:38:32 -0300 Subject: [PATCH 1/5] fix(open): Hold the injected rename until the input box is empty Queue the sidecar name while the input line is dirty, then type it after Enter and 300 ms of quiet. Cover the input gate, TUI wiring, and safety cases. Co-Authored-By: Codex --- .specs/features/pty-session-rename/spec.md | 18 +++ docs/harness-behaviour.md | 4 + src/open/pty.ts | 124 ++++++++++++++++++- tests/open-pty.test.ts | 135 +++++++++++++++++++++ 4 files changed, 276 insertions(+), 5 deletions(-) diff --git a/.specs/features/pty-session-rename/spec.md b/.specs/features/pty-session-rename/spec.md index bc74f91..107129e 100644 --- a/.specs/features/pty-session-rename/spec.md +++ b/.specs/features/pty-session-rename/spec.md @@ -54,6 +54,24 @@ 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) SHALL NOT count as a submit. +- 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..be54b24 100644 --- a/docs/harness-behaviour.md +++ b/docs/harness-behaviour.md @@ -228,6 +228,10 @@ 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. + `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..2b5d884 100644 --- a/src/open/pty.ts +++ b/src/open/pty.ts @@ -32,6 +32,117 @@ 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~"; + +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 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 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) { + for (const candidateByte of Buffer.from(escapeCandidate)) { + if (candidateByte === 0x0d && !inPaste && previousByte !== 0x5c) dirty = false; + else markDirty(); + previousByte = candidateByte; + } + escapeCandidate = ""; + } + previousByte = byte; + return; + } + if (byte === 0x1b) { + escapeCandidate = char; + markDirty(); + previousByte = byte; + return; + } + if (byte === 0x0d && !inPaste && previousByte !== 0x5c) dirty = false; + else markDirty(); + previousByte = byte; + }; + + return { + observe(chunk: Buffer): void { + if (disposed || chunk.equals(Buffer.from("\u001b[I")) || chunk.equals(Buffer.from("\u001b[O"))) 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 +386,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 +426,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..d59e9c1 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, @@ -45,6 +46,7 @@ function tempSessionFile(): string { } afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); while (tempDirs.length > 0) { fs.rmSync(tempDirs.pop()!, { recursive: true, force: true }); @@ -270,6 +272,118 @@ describe("watchNameSidecar", () => { }); }); +describe("pty input gate", () => { + const quietMs = 300; + const setup = () => { + vi.useFakeTimers(); + const inject = vi.fn(); + const gate = createInputGate({ inject }); + return { gate, inject }; + }; + + 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"); + gate.dispose(); + vi.useRealTimers(); + }); + + 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"); + vi.advanceTimersByTime(quietMs); + expect(inject).not.toHaveBeenCalled(); + gate.dispose(); + vi.useRealTimers(); + }); + + it("types a held name after Enter and 300 ms of quiet", () => { + 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(); + gate.dispose(); + vi.useRealTimers(); + }); + + 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")); + vi.advanceTimersByTime(quietMs); + expect(inject).toHaveBeenCalledOnce(); + gate.dispose(); + vi.useRealTimers(); + }); + + it("does not treat pasted Enter or line continuation as submit", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("oi")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[200~texto")); + gate.observe(Buffer.from("\r")); + vi.advanceTimersByTime(quietMs); + expect(inject).not.toHaveBeenCalled(); + gate.observe(Buffer.from("\u001b[201~")); + gate.observe(Buffer.from("\\")); + gate.observe(Buffer.from("\r")); + vi.advanceTimersByTime(quietMs); + expect(inject).not.toHaveBeenCalled(); + gate.observe(Buffer.from("\r")); + vi.advanceTimersByTime(quietMs); + expect(inject).toHaveBeenCalledOnce(); + gate.dispose(); + vi.useRealTimers(); + }); + + 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(); + gate.dispose(); + vi.useRealTimers(); + }); + + 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.observe(Buffer.from("dirty")); + second.gate.offer("/rename held\r"); + second.gate.dispose(); + vi.advanceTimersByTime(quietMs * 2); + expect(second.inject).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); + 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 +441,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, 20)); + 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 }); From db6004591fa014d487b59ff46ec76c80c7473c2d Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:51:05 -0300 Subject: [PATCH 2/5] fix(open): Treat Alt+Enter as a newline in the rename gate Alt+Enter arrives as ESC CR and inserts a newline in Claude Code, so it must not release a held rename. A lone ESC before a bracketed paste no longer hides the paste start. Tighten the dispose and wiring tests so each proves its rule. --- .specs/features/pty-session-rename/spec.md | 2 +- src/open/pty.ts | 14 +++++++---- tests/open-pty.test.ts | 28 ++++++++++++++++++++-- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.specs/features/pty-session-rename/spec.md b/.specs/features/pty-session-rename/spec.md index 107129e..1a6bdac 100644 --- a/.specs/features/pty-session-rename/spec.md +++ b/.specs/features/pty-session-rename/spec.md @@ -65,7 +65,7 @@ to own the terminal. 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) SHALL NOT count as a submit. + line continuation) or `ESC` (Alt/Option+Enter) SHALL NOT count as a submit. - 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 diff --git a/src/open/pty.ts b/src/open/pty.ts index 2b5d884..37d87fd 100644 --- a/src/open/pty.ts +++ b/src/open/pty.ts @@ -88,6 +88,9 @@ export function createInputGate(options: InputGateOptions) { dirty = true; }; + const isSubmit = (byte: number, previousByte: number | undefined, inPaste: boolean): boolean => + byte === 0x0d && !inPaste && previousByte !== 0x5c && previousByte !== 0x1b; + const observeByte = (byte: number): void => { const char = String.fromCharCode(byte); if (escapeCandidate !== "") { @@ -103,12 +106,15 @@ export function createInputGate(options: InputGateOptions) { escapeCandidate = ""; markDirty(); } else if (!isStart && !isEnd) { - for (const candidateByte of Buffer.from(escapeCandidate)) { - if (candidateByte === 0x0d && !inPaste && previousByte !== 0x5c) dirty = false; + 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)) dirty = false; else markDirty(); previousByte = candidateByte; } - escapeCandidate = ""; + escapeCandidate = retryEscape ? char : ""; } previousByte = byte; return; @@ -119,7 +125,7 @@ export function createInputGate(options: InputGateOptions) { previousByte = byte; return; } - if (byte === 0x0d && !inPaste && previousByte !== 0x5c) dirty = false; + if (isSubmit(byte, previousByte, inPaste)) dirty = false; else markDirty(); previousByte = byte; }; diff --git a/tests/open-pty.test.ts b/tests/open-pty.test.ts index d59e9c1..29980a3 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -353,6 +353,28 @@ describe("pty input gate", () => { vi.useRealTimers(); }); + it("does not treat Alt+Enter as submit", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("Bora tamb")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b\r")); + vi.advanceTimersByTime(quietMs); + expect(inject).not.toHaveBeenCalled(); + gate.dispose(); + vi.useRealTimers(); + }); + + 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(); + gate.dispose(); + vi.useRealTimers(); + }); + it("ignores whole-chunk terminal focus reports", () => { const { gate, inject } = setup(); gate.offer("/rename nome\r"); @@ -375,11 +397,13 @@ describe("pty input gate", () => { first.gate.dispose(); const second = setup(); - second.gate.observe(Buffer.from("dirty")); 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); vi.useRealTimers(); }); }); @@ -452,7 +476,7 @@ describe("startPtySession", () => { }); terminal.stdin.emit("data", Buffer.from("oi")); fs.writeFileSync(`${sessionFile}.11111111-2222-3333-4444-555555555555.name`, "nome", "utf8"); - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, 350)); expect(written.join("")).toBe("oi"); terminal.stdin.emit("data", Buffer.from("\r")); From 51904937d34f5f4a8fcddb9bc9deb29553329bf4 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:35:29 -0300 Subject: [PATCH 3/5] fix(open): Guard rename through @ mention completion Track the current token so mention acceptance keeps the input dirty, then let a later submit release the held rename. Handle backspaces and uncertain edits conservatively while preserving the existing submit rules. Co-Authored-By: Codex --- .specs/features/pty-session-rename/spec.md | 15 ++ src/open/pty.ts | 70 ++++++- tests/open-pty.test.ts | 214 +++++++++++++++++---- 3 files changed, 253 insertions(+), 46 deletions(-) diff --git a/.specs/features/pty-session-rename/spec.md b/.specs/features/pty-session-rename/spec.md index 1a6bdac..100db78 100644 --- a/.specs/features/pty-session-rename/spec.md +++ b/.specs/features/pty-session-rename/spec.md @@ -66,6 +66,21 @@ to own the terminal. - 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. +- WHEN Enter (`\r`) arrives while the current token (characters typed since + the last space, tab, newline, or submit) starts with `@`, THEN the gate SHALL + keep the box dirty and SHALL NOT release a held rename. The gate SHALL clear + the tracked token so a following Enter can submit the remaining prompt. +- IF a tab arrives while the current token starts with `@`, THEN the gate SHALL + retain the mention guard because Tab may accept an autocomplete suggestion. +- IF a backspace arrives after a token separator, or an unrecognised editing + control or escape sequence arrives, THEN the gate SHALL hold the next Enter + conservatively because the edit may have changed text before the cursor. +- WHEN a backspace (`0x7f` or `0x08`) arrives, THEN the gate SHALL remove the + last character from the current token, so `@x` followed by two backspaces + and then `ok` + Enter counts as a submit. +- WHEN Enter arrives and the current token does not start with `@`, THEN + existing submit rules SHALL apply unchanged (paste, `\` continuation, + ESC/Alt+Enter). - 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 diff --git a/src/open/pty.ts b/src/open/pty.ts index 37d87fd..b1168d9 100644 --- a/src/open/pty.ts +++ b/src/open/pty.ts @@ -35,6 +35,7 @@ const FALLBACK_COLUMNS = 80; const QUIET_MS = 300; const BRACKETED_PASTE_START = "\u001b[200~"; const BRACKETED_PASTE_END = "\u001b[201~"; +const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); export interface InputGateOptions { inject: (keystrokes: string) => void; @@ -59,6 +60,9 @@ export function createInputGate(options: InputGateOptions) { let disposed = false; let inPaste = false; let escapeCandidate = ""; + let currentToken = ""; + let pendingTokenBytes: number[] = []; + let guardNextEnter = false; let previousByte: number | undefined; let lastActivity = now(); let timer: ReturnType | undefined; @@ -89,7 +93,52 @@ export function createInputGate(options: InputGateOptions) { }; const isSubmit = (byte: number, previousByte: number | undefined, inPaste: boolean): boolean => - byte === 0x0d && !inPaste && previousByte !== 0x5c && previousByte !== 0x1b; + byte === 0x0d && + !inPaste && + previousByte !== 0x5c && + previousByte !== 0x1b; + + const clearToken = (): void => { + currentToken = ""; + pendingTokenBytes = []; + }; + + const observeEnter = (): void => { + dirty = guardNextEnter || currentToken.startsWith("@"); + guardNextEnter = false; + clearToken(); + }; + + const observeTokenByte = (byte: number): void => { + if (byte === 0x08 || byte === 0x7f) { + if (pendingTokenBytes.length > 0) pendingTokenBytes = []; + else if (currentToken === "") guardNextEnter = true; + else currentToken = Array.from(GRAPHEME_SEGMENTER.segment(currentToken)) + .slice(0, -1) + .map(({ segment }) => segment) + .join(""); + } else if (byte === 0x20 || byte === 0x09 || byte === 0x0a) { + pendingTokenBytes = []; + if (byte !== 0x09 || !currentToken.startsWith("@")) currentToken = ""; + } else if (byte < 0x20) { + // Unknown editing controls may rewrite the line, so guard the next Enter. + guardNextEnter = true; + pendingTokenBytes = []; + } else if (byte >= 0x80) { + pendingTokenBytes.push(byte); + const first = pendingTokenBytes[0]!; + const length = first >= 0xf0 ? 4 : first >= 0xe0 ? 3 : first >= 0xc2 ? 2 : 1; + if (pendingTokenBytes.length >= length) { + currentToken += Buffer.from(pendingTokenBytes.splice(0, length)).toString("utf8"); + } + } else { + if (pendingTokenBytes.length > 0) { + currentToken += Buffer.from(pendingTokenBytes).toString("utf8"); + pendingTokenBytes = []; + } + currentToken += String.fromCharCode(byte); + } + }; const observeByte = (byte: number): void => { const char = String.fromCharCode(byte); @@ -106,12 +155,18 @@ export function createInputGate(options: InputGateOptions) { escapeCandidate = ""; markDirty(); } else if (!isStart && !isEnd) { + // Unrecognised escape sequences can edit earlier text, so guard the next Enter. + if (escapeCandidate !== "\u001b\r") guardNextEnter = true; + pendingTokenBytes = []; 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)) dirty = false; - else markDirty(); + if (isSubmit(candidateByte, previousByte, inPaste)) observeEnter(); + else { + markDirty(); + if (candidateByte === 0x0d) clearToken(); + } previousByte = candidateByte; } escapeCandidate = retryEscape ? char : ""; @@ -125,8 +180,13 @@ export function createInputGate(options: InputGateOptions) { previousByte = byte; return; } - if (isSubmit(byte, previousByte, inPaste)) dirty = false; - else markDirty(); + if (isSubmit(byte, previousByte, inPaste)) { + observeEnter(); + } else { + markDirty(); + if (byte === 0x0d) clearToken(); + else observeTokenByte(byte); + } previousByte = byte; }; diff --git a/tests/open-pty.test.ts b/tests/open-pty.test.ts index 29980a3..8dd1c59 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -39,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); @@ -46,6 +47,7 @@ function tempSessionFile(): string { } afterEach(() => { + for (const gate of inputGates.splice(0)) gate.dispose(); vi.useRealTimers(); vi.restoreAllMocks(); while (tempDirs.length > 0) { @@ -278,8 +280,17 @@ describe("pty input gate", () => { 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(); @@ -290,21 +301,16 @@ describe("pty input gate", () => { vi.advanceTimersByTime(1); expect(inject).toHaveBeenCalledOnce(); expect(inject).toHaveBeenCalledWith("/rename nome\r"); - gate.dispose(); - vi.useRealTimers(); }); 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"); - vi.advanceTimersByTime(quietMs); - expect(inject).not.toHaveBeenCalled(); - gate.dispose(); - vi.useRealTimers(); + expectHeldAfterQuiet(inject); }); - it("types a held name after Enter and 300 ms of quiet", () => { + 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"); @@ -313,8 +319,6 @@ describe("pty input gate", () => { expect(inject).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(inject).toHaveBeenCalledOnce(); - gate.dispose(); - vi.useRealTimers(); }); it("keeps a held name when the user types again before quiet expires", () => { @@ -327,41 +331,43 @@ describe("pty input gate", () => { vi.advanceTimersByTime(quietMs); expect(inject).not.toHaveBeenCalled(); gate.observe(Buffer.from("\r")); - vi.advanceTimersByTime(quietMs); - expect(inject).toHaveBeenCalledOnce(); - gate.dispose(); - vi.useRealTimers(); + expectInjectedAfterQuiet(inject); }); - it("does not treat pasted Enter or line continuation as submit", () => { + 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("oi")); + gate.observe(Buffer.from(input)); gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\u001b[200~texto")); - gate.observe(Buffer.from("\r")); - vi.advanceTimersByTime(quietMs); - expect(inject).not.toHaveBeenCalled(); - gate.observe(Buffer.from("\u001b[201~")); - gate.observe(Buffer.from("\\")); - gate.observe(Buffer.from("\r")); - vi.advanceTimersByTime(quietMs); - expect(inject).not.toHaveBeenCalled(); - gate.observe(Buffer.from("\r")); - vi.advanceTimersByTime(quietMs); - expect(inject).toHaveBeenCalledOnce(); - gate.dispose(); - vi.useRealTimers(); + 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("does not treat Alt+Enter as submit", () => { + it("submits after a normal bracketed paste", () => { const { gate, inject } = setup(); - gate.observe(Buffer.from("Bora tamb")); + gate.observe(Buffer.from("see ")); gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\u001b\r")); - vi.advanceTimersByTime(quietMs); - expect(inject).not.toHaveBeenCalled(); - gate.dispose(); - vi.useRealTimers(); + 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", () => { @@ -371,8 +377,137 @@ describe("pty input gate", () => { gate.observe(Buffer.from("\u001b[200~line1\r")); vi.advanceTimersByTime(quietMs); expect(inject).not.toHaveBeenCalled(); - gate.dispose(); - vi.useRealTimers(); + }); + + it("keeps a held name after Enter accepts an @ mention", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("explain @src/file.ts")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("explain this\r")); + expectInjectedAfterQuiet(inject); + }); + + it.each([ + { name: "DEL", byte: 0x7f }, + { name: "BS", byte: 0x08 }, + ])("lets Enter submit after two $name backspaces", ({ byte }) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("@x")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from([byte, byte])); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it.each(["e\u0301", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"])("removes one Unicode grapheme per backspace: %s", (grapheme) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from(`@${grapheme}`)); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from([0x7f, 0x7f])); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it.each([ + { name: "space", separator: " " }, + { name: "tab", separator: "\t" }, + ])("starts a new token after a $name", ({ separator }) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from(`a${separator}b`)); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it("keeps the mention guard after Tab may accept a completion", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("@src")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\t\r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it("keeps the mention guard after backspace removes its separator", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("@src ")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\x7f\r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it.each([ + { name: "Enter", edit: "" }, + { name: "backspace", edit: "\x7f" }, + { name: "space", edit: " " }, + ])("keeps the guard after a cursor-moving escape and $name", ({ edit }) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("@src/fo hi")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from(`\u001b[D\u001b[D\u001b[D${edit}\r`)); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it.each([ + { name: "line continuation", separator: "\\\r" }, + { name: "Alt+Enter", separator: "\u001b\r" }, + ])("tracks an @ token after $name", ({ separator }) => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("foo")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from(`${separator}@src\r`)); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it("keeps the mention guard when an escape sequence flushes Enter", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("@src")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[\r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(inject); + }); + + it("starts a new token after Ctrl+J", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("first\nsecond")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + expectInjectedAfterQuiet(inject); + }); + + it("recognizes a mention after Ctrl+J", () => { + const { gate, inject } = setup(); + gate.observe(Buffer.from("first\n@src")); + gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\r")); + expectHeldAfterQuiet(inject); + gate.observe(Buffer.from("ok\r")); + expectInjectedAfterQuiet(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("ignores whole-chunk terminal focus reports", () => { @@ -383,8 +518,6 @@ describe("pty input gate", () => { gate.observe(Buffer.from("\u001b[O")); vi.advanceTimersByTime(1); expect(inject).toHaveBeenCalledOnce(); - gate.dispose(); - vi.useRealTimers(); }); it("types at most once and drops a held name on dispose", () => { @@ -404,7 +537,6 @@ describe("pty input gate", () => { vi.advanceTimersByTime(quietMs * 2); expect(second.inject).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(0); - vi.useRealTimers(); }); }); From 59b7b46b8179e7c807a77d8d1a67500aca008b3a Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:58:43 -0300 Subject: [PATCH 4/5] fix(open): Skip terminal replies and drop the @ token rule Claude Code queries the terminal at startup and the DCS, DA1 and DECRPM replies came through the gate as unknown escapes, so the rename waited for the second prompt. Whole-chunk terminal replies are now ignored like focus reports. Measured on Claude Code 2.1.280: Enter with the @ menu open submits, and only arrow navigation plus Enter accepts a suggestion without submitting. The escape guard covers that path, so the token tracking is gone. --- .specs/features/pty-session-rename/spec.md | 24 ++-- docs/harness-behaviour.md | 5 + src/open/pty.ts | 67 ++-------- tests/open-pty.test.ts | 148 +++++++-------------- 4 files changed, 77 insertions(+), 167 deletions(-) diff --git a/.specs/features/pty-session-rename/spec.md b/.specs/features/pty-session-rename/spec.md index 100db78..0e44a50 100644 --- a/.specs/features/pty-session-rename/spec.md +++ b/.specs/features/pty-session-rename/spec.md @@ -66,21 +66,15 @@ to own the terminal. - 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. -- WHEN Enter (`\r`) arrives while the current token (characters typed since - the last space, tab, newline, or submit) starts with `@`, THEN the gate SHALL - keep the box dirty and SHALL NOT release a held rename. The gate SHALL clear - the tracked token so a following Enter can submit the remaining prompt. -- IF a tab arrives while the current token starts with `@`, THEN the gate SHALL - retain the mention guard because Tab may accept an autocomplete suggestion. -- IF a backspace arrives after a token separator, or an unrecognised editing - control or escape sequence arrives, THEN the gate SHALL hold the next Enter - conservatively because the edit may have changed text before the cursor. -- WHEN a backspace (`0x7f` or `0x08`) arrives, THEN the gate SHALL remove the - last character from the current token, so `@x` followed by two backspaces - and then `ok` + Enter counts as a submit. -- WHEN Enter arrives and the current token does not start with `@`, THEN - existing submit rules SHALL apply unchanged (paste, `\` continuation, - ESC/Alt+Enter). +- 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 editing control such as Ctrl+U or Ctrl+W arrives, THEN the + next Enter SHALL NOT count as a submit because the control may have changed + text before the cursor. - 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 diff --git a/docs/harness-behaviour.md b/docs/harness-behaviour.md index be54b24..fc4703a 100644 --- a/docs/harness-behaviour.md +++ b/docs/harness-behaviour.md @@ -232,6 +232,11 @@ 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 b1168d9..c0c929b 100644 --- a/src/open/pty.ts +++ b/src/open/pty.ts @@ -35,7 +35,7 @@ const FALLBACK_COLUMNS = 80; const QUIET_MS = 300; const BRACKETED_PASTE_START = "\u001b[200~"; const BRACKETED_PASTE_END = "\u001b[201~"; -const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const TERMINAL_REPLY = /^(?:(?:\u001bP[\s\S]*?\u001b\\)|(?:\u001b\][\s\S]*?(?:\u0007|\u001b\\))|(?:\u001b\[[?>][0-?]*[ -/]*(?:c|\$y|u|R|n)))+$/; export interface InputGateOptions { inject: (keystrokes: string) => void; @@ -60,8 +60,6 @@ export function createInputGate(options: InputGateOptions) { let disposed = false; let inPaste = false; let escapeCandidate = ""; - let currentToken = ""; - let pendingTokenBytes: number[] = []; let guardNextEnter = false; let previousByte: number | undefined; let lastActivity = now(); @@ -93,51 +91,11 @@ export function createInputGate(options: InputGateOptions) { }; const isSubmit = (byte: number, previousByte: number | undefined, inPaste: boolean): boolean => - byte === 0x0d && - !inPaste && - previousByte !== 0x5c && - previousByte !== 0x1b; - - const clearToken = (): void => { - currentToken = ""; - pendingTokenBytes = []; - }; + byte === 0x0d && !inPaste && previousByte !== 0x5c && previousByte !== 0x1b; const observeEnter = (): void => { - dirty = guardNextEnter || currentToken.startsWith("@"); + dirty = guardNextEnter; guardNextEnter = false; - clearToken(); - }; - - const observeTokenByte = (byte: number): void => { - if (byte === 0x08 || byte === 0x7f) { - if (pendingTokenBytes.length > 0) pendingTokenBytes = []; - else if (currentToken === "") guardNextEnter = true; - else currentToken = Array.from(GRAPHEME_SEGMENTER.segment(currentToken)) - .slice(0, -1) - .map(({ segment }) => segment) - .join(""); - } else if (byte === 0x20 || byte === 0x09 || byte === 0x0a) { - pendingTokenBytes = []; - if (byte !== 0x09 || !currentToken.startsWith("@")) currentToken = ""; - } else if (byte < 0x20) { - // Unknown editing controls may rewrite the line, so guard the next Enter. - guardNextEnter = true; - pendingTokenBytes = []; - } else if (byte >= 0x80) { - pendingTokenBytes.push(byte); - const first = pendingTokenBytes[0]!; - const length = first >= 0xf0 ? 4 : first >= 0xe0 ? 3 : first >= 0xc2 ? 2 : 1; - if (pendingTokenBytes.length >= length) { - currentToken += Buffer.from(pendingTokenBytes.splice(0, length)).toString("utf8"); - } - } else { - if (pendingTokenBytes.length > 0) { - currentToken += Buffer.from(pendingTokenBytes).toString("utf8"); - pendingTokenBytes = []; - } - currentToken += String.fromCharCode(byte); - } }; const observeByte = (byte: number): void => { @@ -157,16 +115,12 @@ export function createInputGate(options: InputGateOptions) { } else if (!isStart && !isEnd) { // Unrecognised escape sequences can edit earlier text, so guard the next Enter. if (escapeCandidate !== "\u001b\r") guardNextEnter = true; - pendingTokenBytes = []; 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(); - if (candidateByte === 0x0d) clearToken(); - } + else markDirty(); previousByte = candidateByte; } escapeCandidate = retryEscape ? char : ""; @@ -184,15 +138,22 @@ export function createInputGate(options: InputGateOptions) { observeEnter(); } else { markDirty(); - if (byte === 0x0d) clearToken(); - else observeTokenByte(byte); + if (byte < 0x20 && byte !== 0x08 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) { + // Editing controls such as Ctrl+U and Ctrl+W can change text before the cursor. + guardNextEnter = true; + } } previousByte = byte; }; return { observe(chunk: Buffer): void { - if (disposed || chunk.equals(Buffer.from("\u001b[I")) || chunk.equals(Buffer.from("\u001b[O"))) return; + 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(); diff --git a/tests/open-pty.test.ts b/tests/open-pty.test.ts index 8dd1c59..14b23ba 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -334,6 +334,21 @@ describe("pty input gate", () => { 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: "pasted Enter and line continuation", @@ -379,121 +394,29 @@ describe("pty input gate", () => { expect(inject).not.toHaveBeenCalled(); }); - it("keeps a held name after Enter accepts an @ mention", () => { + it("releases a held name after Enter submits a prompt containing an @ path", () => { const { gate, inject } = setup(); - gate.observe(Buffer.from("explain @src/file.ts")); + gate.observe(Buffer.from("explain @src/foo.ts")); gate.offer("/rename nome\r"); gate.observe(Buffer.from("\r")); - expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("explain this\r")); - expectInjectedAfterQuiet(inject); - }); - - it.each([ - { name: "DEL", byte: 0x7f }, - { name: "BS", byte: 0x08 }, - ])("lets Enter submit after two $name backspaces", ({ byte }) => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("@x")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from([byte, byte])); - gate.observe(Buffer.from("ok\r")); - expectInjectedAfterQuiet(inject); - }); - - it.each(["e\u0301", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"])("removes one Unicode grapheme per backspace: %s", (grapheme) => { - const { gate, inject } = setup(); - gate.observe(Buffer.from(`@${grapheme}`)); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from([0x7f, 0x7f])); - gate.observe(Buffer.from("ok\r")); expectInjectedAfterQuiet(inject); }); - it.each([ - { name: "space", separator: " " }, - { name: "tab", separator: "\t" }, - ])("starts a new token after a $name", ({ separator }) => { + it("holds the name after Down then Enter accepts an autocomplete suggestion", () => { const { gate, inject } = setup(); - gate.observe(Buffer.from(`a${separator}b`)); + gate.observe(Buffer.from("fix the login bug")); gate.offer("/rename nome\r"); + gate.observe(Buffer.from("\u001b[B")); gate.observe(Buffer.from("\r")); - expectInjectedAfterQuiet(inject); - }); - - it("keeps the mention guard after Tab may accept a completion", () => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("@src")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\t\r")); - expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("ok\r")); - expectInjectedAfterQuiet(inject); - }); - - it("keeps the mention guard after backspace removes its separator", () => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("@src ")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\x7f\r")); - expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("ok\r")); - expectInjectedAfterQuiet(inject); - }); - - it.each([ - { name: "Enter", edit: "" }, - { name: "backspace", edit: "\x7f" }, - { name: "space", edit: " " }, - ])("keeps the guard after a cursor-moving escape and $name", ({ edit }) => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("@src/fo hi")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from(`\u001b[D\u001b[D\u001b[D${edit}\r`)); - expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("ok\r")); - expectInjectedAfterQuiet(inject); - }); - - it.each([ - { name: "line continuation", separator: "\\\r" }, - { name: "Alt+Enter", separator: "\u001b\r" }, - ])("tracks an @ token after $name", ({ separator }) => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("foo")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from(`${separator}@src\r`)); expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("ok\r")); - expectInjectedAfterQuiet(inject); }); - it("keeps the mention guard when an escape sequence flushes Enter", () => { + it("keeps a held name when an unrecognised escape flushes Enter", () => { const { gate, inject } = setup(); - gate.observe(Buffer.from("@src")); + gate.observe(Buffer.from("fix the login bug")); gate.offer("/rename nome\r"); gate.observe(Buffer.from("\u001b[\r")); expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("ok\r")); - expectInjectedAfterQuiet(inject); - }); - - it("starts a new token after Ctrl+J", () => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("first\nsecond")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\r")); - expectInjectedAfterQuiet(inject); - }); - - it("recognizes a mention after Ctrl+J", () => { - const { gate, inject } = setup(); - gate.observe(Buffer.from("first\n@src")); - gate.offer("/rename nome\r"); - gate.observe(Buffer.from("\r")); - expectHeldAfterQuiet(inject); - gate.observe(Buffer.from("ok\r")); - expectInjectedAfterQuiet(inject); }); it.each([ @@ -510,6 +433,15 @@ describe("pty input gate", () => { 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"); @@ -520,6 +452,24 @@ describe("pty input gate", () => { 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"); From 070423ae8761fde1c07397cf26e4f29ec666e9e0 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:02:58 -0300 Subject: [PATCH 5/5] fix(open): Stop the terminal reply match at the first ESC A lazy DCS or OSC body could run past the reply terminator and swallow a chunk that also held typed text. Bodies now stop at ESC or BEL, and a test pins that mixed chunks still dirty the box. The pattern needs control characters, so the Sonar control character rule is silenced on that line. --- .specs/features/pty-session-rename/spec.md | 7 ++++--- src/open/pty.ts | 5 +++-- tests/open-pty.test.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.specs/features/pty-session-rename/spec.md b/.specs/features/pty-session-rename/spec.md index 0e44a50..1333085 100644 --- a/.specs/features/pty-session-rename/spec.md +++ b/.specs/features/pty-session-rename/spec.md @@ -72,9 +72,10 @@ to own the terminal. - 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 editing control such as Ctrl+U or Ctrl+W arrives, THEN the - next Enter SHALL NOT count as a submit because the control may have changed - text before the cursor. +- 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 diff --git a/src/open/pty.ts b/src/open/pty.ts index c0c929b..2c691de 100644 --- a/src/open/pty.ts +++ b/src/open/pty.ts @@ -35,7 +35,8 @@ const FALLBACK_COLUMNS = 80; const QUIET_MS = 300; const BRACKETED_PASTE_START = "\u001b[200~"; const BRACKETED_PASTE_END = "\u001b[201~"; -const TERMINAL_REPLY = /^(?:(?:\u001bP[\s\S]*?\u001b\\)|(?:\u001b\][\s\S]*?(?:\u0007|\u001b\\))|(?:\u001b\[[?>][0-?]*[ -/]*(?:c|\$y|u|R|n)))+$/; +// 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; @@ -139,7 +140,7 @@ export function createInputGate(options: InputGateOptions) { } else { markDirty(); if (byte < 0x20 && byte !== 0x08 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) { - // Editing controls such as Ctrl+U and Ctrl+W can change text before the cursor. + // An unknown control may drive a suggestion menu, so treat it like an unknown escape. guardNextEnter = true; } } diff --git a/tests/open-pty.test.ts b/tests/open-pty.test.ts index 14b23ba..7d8643f 100644 --- a/tests/open-pty.test.ts +++ b/tests/open-pty.test.ts @@ -349,6 +349,17 @@ describe("pty input gate", () => { 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",