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}