diff --git a/src/renderer/src/lib/prompt-echo-wait.test.ts b/src/renderer/src/lib/prompt-echo-wait.test.ts index 86c83fedd87..6975c0dce96 100644 --- a/src/renderer/src/lib/prompt-echo-wait.test.ts +++ b/src/renderer/src/lib/prompt-echo-wait.test.ts @@ -107,6 +107,15 @@ describe('watchForPromptEcho', () => { ) const watch = watchForPromptEcho('remote-pty', PROMPT, 10_000, {}) await flushMicrotasks() + // The echo watch must opt out of the pre-paste buffer snapshot (#7466), + // or a swallow screen already on the terminal could falsely verify. + expect(mocks.subscribeToRuntimeTerminalData).toHaveBeenCalledWith( + expect.anything(), + 'remote-pty', + expect.any(String), + expect.any(Function), + { includeSnapshot: false } + ) remoteObserver?.(ECHO) await expect(watch.result).resolves.toBe(true) expect(remoteUnsubscribe).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 60ab65140bf..e7b349763d3 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -189,8 +189,10 @@ describe('remote runtime terminal data subscriptions', () => { // Live output rendered after the subscription still reaches the watcher. deliverOutput(streamId, 'live') - expect(watcher).not.toHaveBeenCalledWith('PRE-PASTE SCREEN') - expect(watcher).toHaveBeenCalledWith('live') + // onSnapshot passes a second meta arg, so match on the first arg only — + // a bare not.toHaveBeenCalledWith(...) would vacuously pass either way. + expect(watcher.mock.calls.map((call) => call[0])).not.toContain('PRE-PASTE SCREEN') + expect(watcher.mock.calls.map((call) => call[0])).toContain('live') }) it('keeps the shared terminal multiplexer until the last watcher closes', async () => { diff --git a/src/shared/prompt-echo-scanner.test.ts b/src/shared/prompt-echo-scanner.test.ts index fcff11084fc..ac284d562b6 100644 --- a/src/shared/prompt-echo-scanner.test.ts +++ b/src/shared/prompt-echo-scanner.test.ts @@ -103,4 +103,13 @@ describe('createPromptEchoScanner', () => { const scanner = createPromptEchoScanner('fix ci') expect(scanner.observe('\x1b[11;3Hfix\x1b[11;7Hci')).toBe(true) }) + + it('verifies a non-Latin-script prompt instead of failing open on any render', () => { + // #7466: Cyrillic folds to letters (not away), so it matches on its render + // and correctly withholds the submit on a swallow screen. + const swallowed = createPromptEchoScanner('Разреши конфликты слияния') + expect(swallowed.observe(CODEX_TRUST_SWALLOW_REDRAW)).toBe(false) + const rendered = createPromptEchoScanner('Разреши конфликты слияния') + expect(rendered.observe('\x1b[11;3HРазреши\x1b[11;15Hконфликты\x1b[11;25Hслияния')).toBe(true) + }) }) diff --git a/src/shared/prompt-echo-scanner.ts b/src/shared/prompt-echo-scanner.ts index 53b1cc2aefc..2acbfe04ca7 100644 --- a/src/shared/prompt-echo-scanner.ts +++ b/src/shared/prompt-echo-scanner.ts @@ -7,9 +7,12 @@ /** * Fold terminal output down to comparable text: * - strip OSC (title/hyperlink) and CSI/charset escape sequences - * - drop every non-alphanumeric character (TUIs repaint with cursor moves - * instead of literal spaces, wrap lines at pane width, and draw box - * borders through the text, so only the letters/digits survive intact) + * - drop every non-letter/non-number character (TUIs repaint with cursor + * moves instead of literal spaces, wrap lines at pane width, and draw box + * borders through the text, so only the letters/digits survive intact). + * Uses the Unicode letter/number classes rather than ASCII so non-Latin + * prompts (Cyrillic, CJK, Arabic, ...) still fold to matchable content + * instead of vanishing to nothing. * - lowercase for case-stable matching */ export function foldTerminalTextForEchoMatch(raw: string): string { @@ -23,16 +26,7 @@ export function foldTerminalTextForEchoMatch(raw: string): string { .replace(/\x1b[()#][0-9A-Za-z]/g, '') .replace(/\x1b[@-_]/g, '') /* oxlint-enable no-control-regex */ - let folded = '' - for (const ch of withoutEscapes.toLowerCase()) { - const code = ch.codePointAt(0) ?? 0 - const isDigit = code >= 48 && code <= 57 - const isLower = code >= 97 && code <= 122 - if (isDigit || isLower) { - folded += ch - } - } - return folded + return withoutEscapes.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '') } // Why: large pastes never render verbatim — codex collapses to