diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index d20408653cf..525fd62e897 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -74,6 +74,7 @@ export default { 'Type your message or @path/to/file': 'Escriviu el vostre missatge o @camí/al/fitxer', '? for shortcuts': '? per a dreceres', + 'Pasting…': 'Enganxant…', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "Premeu 'i' per al mode INSERCIÓ i 'Esc' per al mode NORMAL.", 'Cancel operation / Clear input (double press)': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 33117f1b018..ffe21d55a8d 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1948,6 +1948,7 @@ export default { 'Reverse search history': 'Verlauf rückwärts durchsuchen', 'Press ? again to close': 'Erneut ? drücken, um zu schließen', '? for shortcuts': '? für Tastenkürzel', + 'Pasting…': 'Einfügen…', 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': 'Ungültiger Freigabemodus "{{arg}}". Gültige Modi: {{modes}}', 'Approval mode set to "{{mode}}"': 'Freigabemodus auf "{{mode}}" gesetzt', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index b8de4211b25..c6276c06c86 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -287,6 +287,7 @@ export default { 'Connecting to MCP servers... ({{connected}}/{{total}})', 'Type your message or @path/to/file': 'Type your message or @path/to/file', '? for shortcuts': '? for shortcuts', + 'Pasting…': 'Pasting…', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.", 'Cancel operation / Clear input (double press)': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 525d35d2b54..70021ba1994 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -73,6 +73,7 @@ export default { 'Type your message or @path/to/file': 'Tapez votre message ou @chemin/vers/fichier', '? for shortcuts': '? pour les raccourcis', + 'Pasting…': 'Collage…', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "Appuyez sur 'i' pour le mode INSERTION et 'Esc' pour le mode NORMAL.", 'Cancel operation / Clear input (double press)': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 8d33534df11..a73d01e76fb 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1488,6 +1488,7 @@ export default { 'to paste images': '画像を貼り付け', 'for external editor': '外部エディタ用', '? for shortcuts': '? でショートカット表示', + 'Pasting…': '貼り付け中…', 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}': '無効な承認モード "{{arg}}" です。有効なモード: {{modes}}', 'Approval mode set to "{{mode}}"': '承認モードを "{{mode}}" に設定しました', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 6ffb096d4a5..73d1f0765c1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -70,6 +70,7 @@ export default { 'Type your message or @path/to/file': 'Digite sua mensagem ou @caminho/do/arquivo', '? for shortcuts': '? para atalhos', + 'Pasting…': 'Colando…', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "Pressione 'i' para modo INSERÇÃO e 'Esc' para modo NORMAL.", 'Cancel operation / Clear input (double press)': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ad8953a5c70..0244fa6f760 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -62,6 +62,7 @@ export default { 'Подключение к MCP servers... ({{connected}}/{{total}})', 'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу', '? for shortcuts': '? — горячие клавиши', + 'Pasting…': 'Вставка…', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.", 'Cancel operation / Clear input (double press)': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 833ba86d9d0..7a252f815b1 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -272,6 +272,7 @@ export default { '正在連接到 MCP servers... ({{connected}}/{{total}})', 'Type your message or @path/to/file': '輸入您的消息或 @ 檔案路徑', '? for shortcuts': '按 ? 查看快捷鍵', + 'Pasting…': '正在貼上…', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "按 'i' 進入插入模式,按 'Esc' 進入普通模式", 'Cancel operation / Clear input (double press)': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index eda2de7c9cd..0734b56ae31 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -277,6 +277,7 @@ export default { '正在连接到 MCP servers... ({{connected}}/{{total}})', 'Type your message or @path/to/file': '输入您的消息或 @ 文件路径', '? for shortcuts': '按 ? 查看快捷键', + 'Pasting…': '正在粘贴…', "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": "按 'i' 进入插入模式,按 'Esc' 进入普通模式", 'Cancel operation / Clear input (double press)': diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 0de655c8ec5..1ece51c8595 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -25,13 +25,31 @@ import { GeminiSpinner } from './GeminiRespondingSpinner.js'; import { GoalPill, useFooterGoalState } from './GoalPill.js'; import { CronPill, useFooterCronTaskCount } from './CronPill.js'; import { t } from '../../i18n/index.js'; +import { useKeypressContext } from '../contexts/KeypressContext.js'; import { StreamingState } from '../types.js'; +import type { PasteProgress } from '../contexts/KeypressContext.js'; + +const PasteProgressBar: React.FC<{ progress: PasteProgress }> = ({ + progress, +}) => { + const { receivedBytes } = progress; + const kb = receivedBytes / 1024; + const label = kb >= 1 ? `${kb.toFixed(0)} KB` : `${receivedBytes} B`; + + return ( + + {t('Pasting…')} {label} + + ); +}; + export const Footer: React.FC = () => { const uiState = useUIState(); const config = useConfig(); const settings = useSettings(); const { vimEnabled, vimMode } = useVimModeState(); + const { pasteProgress } = useKeypressContext(); const { lines: statusLineLines, useThemeColors, @@ -83,6 +101,8 @@ export const Footer: React.FC = () => { {t('Press Ctrl+D again to exit.')} ) : uiState.showEscapePrompt ? ( {t('Press Esc again to clear.')} + ) : pasteProgress.active ? ( + ) : uiState.rewindEscPending ? ( {t('Press Esc again to rewind conversation.')} diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 72fa505d196..d78c4b50b50 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -432,13 +432,11 @@ describe('KeypressContext - Kitty Protocol', () => { ); }); - it('Ctrl+C escapes a paste mode that never received its paste-end marker', async () => { - // Regression test for the "must restart terminal" lockup reported by - // a user on Ghostty + Sogou pinyin: bracketed-paste-start arrived, - // isPaste was set true, and paste-end never followed. Every - // subsequent keystroke — including Ctrl+C — was silently buffered. - // This test checks that Ctrl+C is always dispatched regardless of - // paste mode state. + it('treats 0x03 inside bracketed paste as verbatim content, not Ctrl+C', async () => { + // Bracketed paste carries verbatim content, so a 0x03 byte in the + // pasted text must NOT be interpreted as Ctrl+C. A paste that never + // receives its paste-end marker is recovered by the idle timeout + // (see the next test), not by an in-paste Ctrl+C escape hatch. const keyHandler = vi.fn(); const { result } = renderHook(() => useKeypressContext(), { @@ -449,23 +447,21 @@ describe('KeypressContext - Kitty Protocol', () => { result.current.subscribe(keyHandler); }); - // Send ONLY the paste-start marker (no paste-end) — this puts the - // dispatcher into the broken state. + // paste-start, then content containing 0x03, then paste-end. act(() => { - stdin.emit('data', Buffer.from('\x1b[200~')); - }); - await new Promise((r) => setTimeout(r, 50)); - - // Ctrl+C should fire now, not get buffered into the stuck paste. - act(() => { - stdin.emit('data', Buffer.from('\x03')); + stdin.emit('data', Buffer.from('\x1b[200~ab\x03cd\x1b[201~')); }); await new Promise((r) => setTimeout(r, 50)); + // The 0x03 must NOT surface as a Ctrl+C keypress... const ctrlCSeen = keyHandler.mock.calls.some( (c) => c[0]?.ctrl === true && c[0]?.name === 'c', ); - expect(ctrlCSeen).toBe(true); + expect(ctrlCSeen).toBe(false); + + // ...it stays embedded in the verbatim paste payload. + const pasteEvent = keyHandler.mock.calls.find((c) => c[0]?.paste); + expect(pasteEvent?.[0]?.sequence).toBe('ab\x03cd'); }); it('auto-recovers from a stuck paste mode via idle timeout', async () => { @@ -505,6 +501,187 @@ describe('KeypressContext - Kitty Protocol', () => { expect(zSeen).toBe(true); }); + it('does not drop paste content that ends with a partial paste-end marker on idle flush', async () => { + // Regression: when a paste ends with bytes that partially match the + // paste-end marker (\x1b[201~) and paste-end never actually arrives, + // those held-back tail bytes are legitimate content and must be + // included when the idle timeout flushes — not silently dropped. + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: true }), + }); + act(() => { + result.current.subscribe(keyHandler); + }); + + // paste-start + content that ends with a partial paste-end prefix + // (\x1b[20), and NO real paste-end. + act(() => { + stdin.emit('data', Buffer.from('\x1b[200~hi\x1b[20')); + }); + + await new Promise((r) => setTimeout(r, PASTE_IDLE_TIMEOUT_MS + 200)); + + const pasteEvent = keyHandler.mock.calls.find((c) => c[0]?.paste); + expect(pasteEvent?.[0]?.sequence).toBe('hi\x1b[20'); + }); + + it('reassembles paste content delivered across three or more stdin chunks', async () => { + // Large pastes arrive in many small stdin data events. The raw-level + // interceptor (handleStdinData) must accumulate content across all + // chunks and broadcast a single paste event with the complete text — + // the core optimization this PR adds. + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: true }), + }); + act(() => { + result.current.subscribe(keyHandler); + }); + + // Deliver paste-start + content across three separate data events, + // with the paste-end marker intact in the final chunk. + act(() => { + stdin.emit('data', Buffer.from('\x1b[200~chunk1')); + stdin.emit('data', Buffer.from('chunk2')); + stdin.emit('data', Buffer.from('chunk3\x1b[201~')); + }); + + await waitFor(() => { + expect(keyHandler).toHaveBeenCalledTimes(1); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + paste: true, + sequence: 'chunk1chunk2chunk3', + }), + ); + }); + + it('does not intercept a paste-start split immediately after its ESC byte (documented tradeoff)', async () => { + // partialMarkerTailLength uses minLen=2 on the prefix path, so a lone + // trailing ESC (0x1b) is never held back: holding it would delay every + // real Esc keypress that lands at a read boundary (common) to catch the + // rare case of the OS splitting the paste-start as "\x1b" | "[200~...". + // This test pins that tradeoff — when the split happens, the paste-start + // is missed and the content leaks to readline instead of being intercepted + // as one clean paste event. Changing minLen to 1 would detect this split + // (and start delaying boundary Esc keypresses), making this test fail. + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: true }), + }); + act(() => { + result.current.subscribe(keyHandler); + }); + + // Deliver the paste-start split right after its ESC byte across two read + // boundaries (the realistic OS delivery this tradeoff concerns). The gap + // lets readline's 0ms escape timeout emit the lone ESC before the rest + // arrives, so neither the raw interceptor nor readline sees a leading ESC + // on "[200~...". + act(() => { + stdin.emit('data', Buffer.from('\x1b')); + }); + await new Promise((r) => setTimeout(r, 20)); + act(() => { + stdin.emit('data', Buffer.from('[200~body\x1b[201~')); + }); + await new Promise((r) => setTimeout(r, 50)); + + // The paste-start was missed: the content was NOT intercepted as one clean + // paste event... + const cleanPaste = keyHandler.mock.calls.find( + (c) => c[0]?.paste === true && c[0]?.sequence === 'body', + ); + expect(cleanPaste).toBeUndefined(); + + // ...it leaked to readline as literal characters (the "[" of "[200~" + // arrives as a plain keypress). Changing minLen to 1 would hold the lone + // ESC, intercept this paste at the raw level, and flip both assertions — + // surfacing the boundary-Esc-keypress delay cost of that choice. + const leakedMarker = keyHandler.mock.calls.find( + (c) => c[0]?.paste !== true && c[0]?.sequence === '[', + ); + expect(leakedMarker).toBeDefined(); + }); + + it('does not prematurely flush a slow keypress-level paste (idle timer reschedules)', () => { + // Regression for the passthrough/keypress-level path: the idle timeout + // must stay armed ~1s past the LATEST character. A slow paste (< 1000 + // chars with characters spaced > 1s apart, e.g. high-latency SSH or tmux + // rate-limiting) must NOT be flushed mid-paste — otherwise a partial + // paste is broadcast and a later '\r' could become a real Enter. This + // drives the keypress-level paste state machine directly via keypress + // events (paste-start + content), which is how passthrough mode feeds it. + vi.useFakeTimers(); + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: true }), + }); + act(() => { + result.current.subscribe(keyHandler); + }); + + try { + act(() => { + stdin.pressKey({ + name: 'paste-start', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\x1b[200~', + }); + }); + + // Deliver characters spaced just under the idle timeout apart. Each + // must push the flush deadline forward so nothing flushes mid-paste. + for (let i = 0; i < 3; i++) { + act(() => { + vi.advanceTimersByTime(PASTE_IDLE_TIMEOUT_MS - 100); + }); + act(() => { + stdin.pressKey({ + name: 'a', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'a', + }); + }); + } + + // No paste flushed yet — every character arrived within the idle window. + const flushedEarly = keyHandler.mock.calls.some( + (c) => c[0]?.paste === true, + ); + expect(flushedEarly).toBe(false); + + // Now go idle for the full timeout: the whole paste flushes as one event. + act(() => { + vi.advanceTimersByTime(PASTE_IDLE_TIMEOUT_MS + 200); + }); + + const pasteEvent = keyHandler.mock.calls.find( + (c) => c[0]?.paste === true, + ); + expect(pasteEvent?.[0]?.sequence).toBe('aaa'); + } finally { + vi.useRealTimers(); + } + }); + it('should not process kitty sequences when kitty protocol is disabled', async () => { const keyHandler = vi.fn(); @@ -1218,6 +1395,89 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); + + it('reassembles paste content whose end marker straddles a chunk boundary', async () => { + const keyHandler = vi.fn(); + + // kittyProtocolEnabled (non-passthrough) routes stdin through + // handleStdinData — the raw-level paste interceptor this optimization + // adds. The straddled paste-end marker must be reassembled there, so + // this test exercises that path rather than the legacy passthrough one. + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, kittyProtocolEnabled: true }), + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + // Large pastes arrive in many stdin chunks, and the paste-end marker + // (\x1b[201~) can straddle a chunk boundary. The partial-marker tail + // must be held back and prepended to the next chunk so content is never + // truncated at the boundary — the exact case this optimization targets. + act(() => { + stdin.emit('data', Buffer.from('\x1b[200~hello world\x1b[20')); + stdin.emit('data', Buffer.from('1~')); + }); + + await waitFor(() => { + expect(keyHandler).toHaveBeenCalledTimes(1); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + paste: true, + sequence: 'hello world', + }), + ); + }); + + it('Ctrl+C escapes a stuck paste in passthrough mode', () => { + // The keypress-level Ctrl+C escape hatch (passthrough mode: + // pasteWorkaround / Windows / Node < 20) must clear paste state and + // dispatch the Ctrl+C keypress so the user can recover from a stuck + // paste (paste-start without paste-end) without restarting the + // terminal — the legacy counterpart of the raw-level idle timeout. + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper: ({ children }) => + wrapper({ children, pasteWorkaround: true }), + }); + act(() => { + result.current.subscribe(keyHandler); + }); + + // Enter paste mode via raw data (how passthrough mode feeds the + // keypress-level state machine: stdin data → handleRawKeypress → + // keypressStream → readline → handleKeypress). Send paste-start + // with one character of content, but NO paste-end. + act(() => { + stdin.emit('data', Buffer.from('\x1b[200~a')); + }); + + // Ctrl+C must escape the stuck paste... + act(() => { + stdin.emit('data', Buffer.from('\x03')); + }); + + // ...dispatching the Ctrl+C keypress to the handler... + const ctrlC = keyHandler.mock.calls.find( + (c) => c[0]?.ctrl === true && c[0]?.name === 'c', + ); + expect(ctrlC).toBeDefined(); + + // ...and clearing paste state so normal typing resumes. + act(() => { + stdin.emit('data', Buffer.from('z')); + }); + + const zKey = keyHandler.mock.calls.find( + (c) => c[0]?.sequence === 'z' && c[0]?.paste !== true, + ); + expect(zKey).toBeDefined(); + }); }); it('buffers fragmented paste chunks before emitting newlines', () => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 556db0c62b0..e747ae0a650 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -18,6 +18,7 @@ import { useContext, useEffect, useRef, + useState, } from 'react'; import readline from 'node:readline'; import { PassThrough } from 'node:stream'; @@ -127,12 +128,18 @@ export interface Key { export type KeypressHandler = (key: Key) => void; export type MouseHandler = (event: SgrMouseEvent) => void; +export interface PasteProgress { + active: boolean; + receivedBytes: number; +} + interface KeypressContextValue { subscribe: (handler: KeypressHandler) => void; unsubscribe: (handler: KeypressHandler) => void; subscribeMouse: (handler: MouseHandler) => void; unsubscribeMouse: (handler: MouseHandler) => void; pasteWorkaround: boolean; + pasteProgress: PasteProgress; } const KeypressContext = createContext( @@ -168,6 +175,10 @@ export function KeypressProvider({ const { stdin, setRawMode } = useStdin(); const subscribers = useRef>(new Set()).current; const mouseSubscribers = useRef>(new Set()).current; + const [pasteProgress, setPasteProgress] = useState({ + active: false, + receivedBytes: 0, + }); const subscribe = useCallback( (handler: KeypressHandler) => { @@ -212,14 +223,21 @@ export function KeypressProvider({ usePassthrough = true; } + // Keypress-level bracketed-paste state machine. In the production + // non-passthrough path, paste markers are intercepted at the raw stdin + // level (handleStdinData) and never reach readline, so this machinery + // only runs in passthrough mode (pasteWorkaround / Windows / Node < 20). + // It is kept separate from the raw path; the two should eventually be + // collapsed. let isPaste = false; - let pasteBuffer = Buffer.alloc(0); + let pasteChunks: string[] = []; // Set to true when paste mode is ended by something other than a // received paste-end event (idle timeout or Ctrl+C escape). The next // real paste-end event that arrives — if any — is then a stale echo // and must be swallowed instead of producing a spurious empty paste. let pasteAlreadyFlushed = false; let pasteIdleTimeout: NodeJS.Timeout | null = null; + let lastPasteChunkAt = 0; const kittySequenceBufferRef = { current: '' }; let kittySequenceTimeout: NodeJS.Timeout | null = null; let backslashTimeout: NodeJS.Timeout | null = null; @@ -312,10 +330,10 @@ export function KeypressProvider({ // We still run when either condition is true — e.g. isPaste=true with // an empty buffer (need to clear the flag) or isPaste=false with stale // buffered content (e.g. after a race between Ctrl+C and the timer). - if (!isPaste && pasteBuffer.length === 0) return; - const buffered = pasteBuffer.toString(); + if (!isPaste && pasteChunks.length === 0) return; + const buffered = pasteChunks.join(''); isPaste = false; - pasteBuffer = Buffer.alloc(0); + pasteChunks = []; pasteAlreadyFlushed = true; if (buffered.length > 0) { broadcast({ @@ -329,12 +347,30 @@ export function KeypressProvider({ } }; + // Idle-timeout callback: flush only if the paste has actually been idle + // for PASTE_IDLE_TIMEOUT_MS, otherwise reschedule for the remaining time. + // With startPasteIdleTimeout (records the last chunk time, arms a single + // timer) this detects a stuck paste ~1s after the last received character + // WITHOUT a per-character clearTimeout/setTimeout pair — so a slow paste + // (< 1000 chars delivered > 1s apart, e.g. high-latency SSH or tmux + // rate-limiting) keeps pushing lastPasteChunkAt forward and the timer + // reschedules instead of flushing partial content prematurely. + const onPasteIdleTimeout = () => { + const idleFor = Date.now() - lastPasteChunkAt; + if (idleFor >= PASTE_IDLE_TIMEOUT_MS) { + forceFlushStuckPaste(); + } else { + pasteIdleTimeout = setTimeout( + onPasteIdleTimeout, + PASTE_IDLE_TIMEOUT_MS - idleFor, + ); + } + }; + const startPasteIdleTimeout = () => { - clearPasteIdleTimeout(); - pasteIdleTimeout = setTimeout( - forceFlushStuckPaste, - PASTE_IDLE_TIMEOUT_MS, - ); + lastPasteChunkAt = Date.now(); + if (pasteIdleTimeout) return; + pasteIdleTimeout = setTimeout(onPasteIdleTimeout, PASTE_IDLE_TIMEOUT_MS); }; const createPrintableKey = (char: string): Key => { @@ -810,9 +846,9 @@ export function KeypressProvider({ (key.ctrl && key.name === 'c') || key.sequence === `${ESC}${KITTY_CTRL_C}`; if (isCtrlCKey) { - if (isPaste || pasteBuffer.length > 0) { + if (isPaste || pasteChunks.length > 0) { isPaste = false; - pasteBuffer = Buffer.alloc(0); + pasteChunks = []; pasteAlreadyFlushed = true; clearPasteIdleTimeout(); } @@ -854,18 +890,20 @@ export function KeypressProvider({ // Reset for the next paste cycle. pasteAlreadyFlushed = false; isPaste = false; - pasteBuffer = Buffer.alloc(0); + pasteChunks = []; return; } isPaste = false; - if (pasteBuffer.toString().length > 0) { + const buffered = pasteChunks.join(''); + pasteChunks = []; + if (buffered.length > 0) { broadcast({ name: '', ctrl: false, meta: false, shift: false, paste: true, - sequence: pasteBuffer.toString(), + sequence: buffered, }); } else { let clipboardImageUnavailable = false; @@ -880,16 +918,19 @@ export function KeypressProvider({ paste: true, pasteImage: hasImage, clipboardImageUnavailable, - sequence: pasteBuffer.toString(), + sequence: buffered, }); } - - pasteBuffer = Buffer.alloc(0); return; } if (isPaste) { - pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); + pasteChunks.push(key.sequence); + // Record the chunk time on every character so the idle timer stays + // armed ~1s past the latest character. startPasteIdleTimeout arms a + // single timer (rescheduled by onPasteIdleTimeout based on actual idle + // time), so this is a cheap timestamp write — no per-character + // clearTimeout/setTimeout — yet a slow paste is never flushed early. startPasteIdleTimeout(); return; } @@ -1256,6 +1297,211 @@ export function KeypressProvider({ } }; + // Intercept raw stdin data to extract bracketed paste content BEFORE it + // reaches readline. Without this, readline fires a keypress event for + // every single character inside a paste (260K chars → 260K events), + // blocking the main thread for seconds. By scanning for paste markers + // at the raw data level, we broadcast the entire paste as one event and + // only forward non-paste bytes to readline. + const pasteModePrefixBuf = Buffer.from(PASTE_MODE_PREFIX); + const pasteModeSuffixBuf = Buffer.from(PASTE_MODE_SUFFIX); + + function partialMarkerTailLength( + chunk: Buffer, + marker: Buffer, + minLen: number = 2, + ): number { + // Default minLen=2 skips lone ESC (0x1b) which starts ALL ANSI + // sequences. In the paste-accumulating path, use minLen=1 since + // ESC there is most likely the start of paste-end, not a keypress. + const maxCheck = Math.min(chunk.length, marker.length - 1); + for (let len = maxCheck; len >= minLen; len--) { + const tail = chunk.subarray(chunk.length - len); + if (marker.subarray(0, len).equals(tail)) { + return len; + } + } + return 0; + } + let rawStdinTail = Buffer.alloc(0); + let rawPasteAccumulating = false; + let rawPasteChunks: Buffer[] = []; + let rawPasteReceivedBytes = 0; + let rawPasteIdleTimeout: NodeJS.Timeout | null = null; + + const clearRawPasteIdleTimeout = () => { + if (rawPasteIdleTimeout) { + clearTimeout(rawPasteIdleTimeout); + rawPasteIdleTimeout = null; + } + }; + + const forceFlushRawPaste = () => { + clearRawPasteIdleTimeout(); + if ( + !rawPasteAccumulating && + rawPasteChunks.length === 0 && + rawStdinTail.length === 0 + ) + return; + // Append any held-back partial paste-end marker: since paste-end never + // arrived, those bytes are legitimate paste content, not a marker. + if (rawStdinTail.length > 0) { + rawPasteChunks.push(rawStdinTail); + rawStdinTail = Buffer.alloc(0); + } + const content = Buffer.concat(rawPasteChunks); + rawPasteChunks = []; + rawPasteAccumulating = false; + // pasteAlreadyFlushed is set inside broadcastPasteFromRaw. + broadcastPasteFromRaw(content); + }; + + const startRawPasteIdleTimeout = () => { + clearRawPasteIdleTimeout(); + rawPasteIdleTimeout = setTimeout( + forceFlushRawPaste, + PASTE_IDLE_TIMEOUT_MS, + ); + }; + + const broadcastPasteFromRaw = (content: Buffer) => { + // Mark flushed so a stale paste-end marker leaking through to the + // keypress-level handler is swallowed instead of broadcasting a + // second (duplicate) paste event. Covers all call sites. + pasteAlreadyFlushed = true; + setPasteProgress({ active: false, receivedBytes: 0 }); + const text = content.toString('utf-8'); + if (text.length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: text, + }); + } else { + // Empty paste — check for clipboard image (async, but fine here). + // Mirror the keypress-level paste-end path: surface whether the + // native clipboard module was unavailable. + let clipboardImageUnavailable = false; + void clipboardHasImage(() => { + clipboardImageUnavailable = true; + }).then((hasImage) => { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + pasteImage: hasImage, + clipboardImageUnavailable, + sequence: '', + }); + }); + } + }; + + const handleStdinData = (data: Buffer) => { + const raw = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'); + const buf = + rawStdinTail.length > 0 ? Buffer.concat([rawStdinTail, raw]) : raw; + rawStdinTail = Buffer.alloc(0); + let cursor = 0; + + while (cursor < buf.length) { + if (rawPasteAccumulating) { + // Bracketed paste carries verbatim content, so we must NOT scan + // for control chars like Ctrl+C (0x03) here — they can legitimately + // appear in pasted text. A paste that never receives its paste-end + // marker is recovered by the idle timeout below. + const suffixIdx = buf.indexOf(pasteModeSuffixBuf, cursor); + + if (suffixIdx === -1) { + // No paste-end in this chunk. Check for partial suffix at + // the tail that might complete in the next chunk. + const remaining = buf.subarray(cursor); + const tailLen = partialMarkerTailLength( + remaining, + pasteModeSuffixBuf, + 1, + ); + const contentEnd = remaining.length - tailLen; + if (contentEnd > 0) { + const chunk = remaining.subarray(0, contentEnd); + rawPasteChunks.push(chunk); + rawPasteReceivedBytes += chunk.length; + setPasteProgress((prev) => ({ + ...prev, + receivedBytes: rawPasteReceivedBytes, + })); + } + if (tailLen > 0) { + rawStdinTail = Buffer.from(remaining.subarray(contentEnd)); + } + startRawPasteIdleTimeout(); + cursor = buf.length; + } else { + // Found paste-end — accumulate up to it, broadcast, continue + clearRawPasteIdleTimeout(); + if (suffixIdx > cursor) { + rawPasteChunks.push(buf.subarray(cursor, suffixIdx)); + } + const pasteContent = Buffer.concat(rawPasteChunks); + rawPasteChunks = []; + rawPasteAccumulating = false; + broadcastPasteFromRaw(pasteContent); + cursor = suffixIdx + pasteModeSuffixBuf.length; + } + } else { + const prefixIdx = buf.indexOf(pasteModePrefixBuf, cursor); + if (prefixIdx === -1) { + // No paste-start found. Check if the chunk ends with a + // partial prefix marker that might complete in the next chunk. + const remaining = buf.subarray(cursor); + if (remaining.length > 0) { + // minLen defaults to 2, so a lone trailing ESC (0x1b) is NOT + // held back: holding it would delay every real Esc keypress that + // lands at a read boundary (common) just to catch the rare case of + // the OS splitting the paste-start as "\x1b" | "[200~...". In + // that rare case the paste-start is missed and leaks to readline + // (see the split-after-ESC regression test). The suffix path passes + // minLen=1 because a trailing ESC there is most likely paste-end. + const tailLen = partialMarkerTailLength( + remaining, + pasteModePrefixBuf, + ); + if (tailLen > 0) { + const safe = remaining.subarray(0, remaining.length - tailLen); + if (safe.length > 0) keypressStream.write(safe); + rawStdinTail = Buffer.from( + remaining.subarray(remaining.length - tailLen), + ); + } else { + keypressStream.write(remaining); + } + } + cursor = buf.length; + } else { + // Found paste-start — forward data before it, then start accumulating + if (prefixIdx > cursor) { + keypressStream.write(buf.subarray(cursor, prefixIdx)); + } + rawPasteAccumulating = true; + rawPasteChunks = []; + rawPasteReceivedBytes = 0; + startRawPasteIdleTimeout(); + setPasteProgress({ + active: true, + receivedBytes: 0, + }); + cursor = prefixIdx + pasteModePrefixBuf.length; + } + } + } + }; + let rl: readline.Interface; if (usePassthrough) { @@ -1267,9 +1513,22 @@ export function KeypressProvider({ keypressStream.on('keypress', handleKeypress); stdin.on('data', handleRawKeypress); } else { - rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); - readline.emitKeypressEvents(stdin, rl); + // Route stdin through keypressStream so we can intercept bracketed + // paste markers at the raw data level. Without this, readline fires + // one keypress event per character for the entire paste content + // (260K chars → 260K events), blocking the main thread for seconds. + // handleStdinData strips out paste regions and broadcasts them + // directly; only non-paste bytes reach readline via keypressStream. + rl = readline.createInterface({ + input: keypressStream, + escapeCodeTimeout: 0, + }); + readline.emitKeypressEvents(keypressStream, rl); + keypressStream.on('keypress', handleKeypress); + // Test-only: mocks emit keypress directly on stdin; in production, + // all keypresses arrive via keypressStream. stdin.on('keypress', handleKeypress); + stdin.on('data', handleStdinData); } // Startup optimization: replay captured input if available @@ -1279,10 +1538,10 @@ export function KeypressProvider({ `Replaying ${capturedInput.length} bytes of captured input`, ); // Process in next event loop tick to ensure subscribers are ready. - // Always emit on stdin so that handleRawKeypress processes paste markers - // correctly in passthrough mode. - // In non-passthrough mode, readline.emitKeypressEvents installs an internal - // 'data' listener on stdin that converts data events to keypress events. + // Emit on stdin so the registered 'data' listener handles the replay: + // handleRawKeypress in passthrough mode, handleStdinData otherwise + // (which strips bracketed-paste regions before forwarding the remaining + // bytes to readline via keypressStream). replayPending = true; setImmediate(() => { if (!replayPending) return; @@ -1300,7 +1559,9 @@ export function KeypressProvider({ keypressStream.removeListener('keypress', handleKeypress); stdin.removeListener('data', handleRawKeypress); } else { + keypressStream.removeListener('keypress', handleKeypress); stdin.removeListener('keypress', handleKeypress); + stdin.removeListener('data', handleStdinData); } rl.close(); @@ -1314,6 +1575,7 @@ export function KeypressProvider({ clearKittyBufferAndTimeout(); clearPasteIdleTimeout(); + clearRawPasteIdleTimeout(); resetSgrMouse(); @@ -1323,16 +1585,23 @@ export function KeypressProvider({ } // Flush any pending paste data to avoid data loss on exit. - if (isPaste) { - broadcast({ - name: '', - ctrl: false, - meta: false, - shift: false, - paste: true, - sequence: pasteBuffer.toString(), - }); - pasteBuffer = Buffer.alloc(0); + if (rawPasteAccumulating || rawPasteChunks.length > 0) { + // Reuses the tail-append + broadcast logic so held-back partial + // paste-end bytes aren't dropped on teardown. + forceFlushRawPaste(); + } else if (isPaste) { + const buffered = pasteChunks.join(''); + if (buffered.length > 0) { + broadcast({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: buffered, + }); + } + pasteChunks = []; } }; }, [ @@ -1355,6 +1624,7 @@ export function KeypressProvider({ subscribeMouse, unsubscribeMouse, pasteWorkaround, + pasteProgress, }} > {children}