diff --git a/.changeset/abbreviate-large-pasted-text.md b/.changeset/abbreviate-large-pasted-text.md new file mode 100644 index 00000000000..b8715c71f2e --- /dev/null +++ b/.changeset/abbreviate-large-pasted-text.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Abbreviate large pasted text in CLI input as `[Pasted text #N +X lines]` to prevent input field overflow when pasting logs or large code blocks diff --git a/cli/src/media/__tests__/processPastedText.test.ts b/cli/src/media/__tests__/processPastedText.test.ts new file mode 100644 index 00000000000..e6295fc4d53 --- /dev/null +++ b/cli/src/media/__tests__/processPastedText.test.ts @@ -0,0 +1,159 @@ +import { + extractPastedTextReferences, + removePastedTextReferences, + expandPastedTextReferences, + PASTED_TEXT_REFERENCE_REGEX, +} from "../processPastedText" + +describe("processPastedText helpers", () => { + describe("PASTED_TEXT_REFERENCE_REGEX", () => { + it("should match valid pasted text references", () => { + const text = "[Pasted text #1 +25 lines]" + PASTED_TEXT_REFERENCE_REGEX.lastIndex = 0 + const match = PASTED_TEXT_REFERENCE_REGEX.exec(text) + expect(match).not.toBeNull() + expect(match?.[1]).toBe("1") + expect(match?.[2]).toBe("25") + }) + + it("should match references with large numbers", () => { + const text = "[Pasted text #999 +1000 lines]" + PASTED_TEXT_REFERENCE_REGEX.lastIndex = 0 + const match = PASTED_TEXT_REFERENCE_REGEX.exec(text) + expect(match).not.toBeNull() + expect(match?.[1]).toBe("999") + expect(match?.[2]).toBe("1000") + }) + + it("should not match malformed references", () => { + const malformed = [ + "[Pasted text #1]", // missing line count + "[Pasted text 1 +25 lines]", // missing # + "[Pasted #1 +25 lines]", // missing "text" + "Pasted text #1 +25 lines", // missing brackets + "[Pasted text #a +25 lines]", // non-numeric ref + ] + + for (const text of malformed) { + PASTED_TEXT_REFERENCE_REGEX.lastIndex = 0 + const match = PASTED_TEXT_REFERENCE_REGEX.exec(text) + expect(match).toBeNull() + } + }) + }) + + describe("extractPastedTextReferences", () => { + it("should extract single reference number", () => { + const input = "Hello [Pasted text #1 +25 lines] world" + const result = extractPastedTextReferences(input) + expect(result).toEqual([1]) + }) + + it("should extract multiple reference numbers in order", () => { + const input = "[Pasted text #1 +10 lines] and [Pasted text #3 +50 lines] and [Pasted text #2 +30 lines]" + const result = extractPastedTextReferences(input) + expect(result).toEqual([1, 3, 2]) + }) + + it("should return empty array when no references", () => { + const input = "Hello world" + const result = extractPastedTextReferences(input) + expect(result).toEqual([]) + }) + + it("should handle large reference numbers", () => { + const input = "[Pasted text #999 +1 lines]" + const result = extractPastedTextReferences(input) + expect(result).toEqual([999]) + }) + + it("should not extract from similar but invalid patterns", () => { + const input = "[Pasted text #1] [Image #2] [Pasted #3 +10 lines]" + const result = extractPastedTextReferences(input) + expect(result).toEqual([]) + }) + }) + + describe("removePastedTextReferences", () => { + it("should remove pasted text reference tokens without collapsing whitespace", () => { + const input = "Line1\n [Pasted text #1 +25 lines]\nLine3" + const result = removePastedTextReferences(input) + expect(result).toBe("Line1\n \nLine3") + }) + + it("should remove multiple references", () => { + const input = "Hello [Pasted text #1 +10 lines] world [Pasted text #2 +20 lines] test" + const result = removePastedTextReferences(input) + expect(result).toBe("Hello world test") + }) + + it("should handle text with no references", () => { + const input = "Hello world" + const result = removePastedTextReferences(input) + expect(result).toBe("Hello world") + }) + + it("should preserve image references", () => { + const input = "[Image #1] [Pasted text #2 +10 lines]" + const result = removePastedTextReferences(input) + expect(result).toBe("[Image #1] ") + }) + }) + + describe("expandPastedTextReferences", () => { + it("should expand single reference with full text", () => { + const input = "Check this: [Pasted text #1 +3 lines]" + const references = { 1: "line one\nline two\nline three" } + const result = expandPastedTextReferences(input, references) + expect(result).toBe("Check this: line one\nline two\nline three") + }) + + it("should expand multiple references", () => { + const input = "[Pasted text #1 +2 lines] and [Pasted text #2 +1 lines]" + const references = { + 1: "first\nsecond", + 2: "single line", + } + const result = expandPastedTextReferences(input, references) + expect(result).toBe("first\nsecond and single line") + }) + + it("should leave unknown references unchanged", () => { + const input = "Check this: [Pasted text #99 +10 lines]" + const references = { 1: "some text" } + const result = expandPastedTextReferences(input, references) + expect(result).toBe("Check this: [Pasted text #99 +10 lines]") + }) + + it("should handle empty references map", () => { + const input = "[Pasted text #1 +5 lines]" + const references = {} + const result = expandPastedTextReferences(input, references) + expect(result).toBe("[Pasted text #1 +5 lines]") + }) + + it("should handle text with no references", () => { + const input = "Hello world" + const references = { 1: "unused" } + const result = expandPastedTextReferences(input, references) + expect(result).toBe("Hello world") + }) + + it("should handle mixed references (expand known, keep unknown)", () => { + const input = "[Pasted text #1 +2 lines] [Pasted text #2 +3 lines] [Pasted text #3 +1 lines]" + const references = { + 1: "found\ntext", + 3: "also found", + } + const result = expandPastedTextReferences(input, references) + expect(result).toBe("found\ntext [Pasted text #2 +3 lines] also found") + }) + + it("should preserve surrounding text and whitespace", () => { + const input = " Before [Pasted text #1 +1 lines] After " + const references = { 1: "middle" } + const result = expandPastedTextReferences(input, references) + expect(result).toBe(" Before middle After ") + }) + }) +}) diff --git a/cli/src/media/processPastedText.ts b/cli/src/media/processPastedText.ts new file mode 100644 index 00000000000..90d30ca56e8 --- /dev/null +++ b/cli/src/media/processPastedText.ts @@ -0,0 +1,28 @@ +// Regex to match pasted text references: [Pasted text #N +X lines] +export const PASTED_TEXT_REFERENCE_REGEX = /\[Pasted text #(\d+) \+(\d+) lines\]/g + +export function extractPastedTextReferences(text: string): number[] { + const refs: number[] = [] + let match + PASTED_TEXT_REFERENCE_REGEX.lastIndex = 0 + while ((match = PASTED_TEXT_REFERENCE_REGEX.exec(text)) !== null) { + const ref = match[1] + if (ref !== undefined) { + refs.push(parseInt(ref, 10)) + } + } + return refs +} + +export function removePastedTextReferences(text: string): string { + return text.replace(PASTED_TEXT_REFERENCE_REGEX, "") +} + +export function expandPastedTextReferences(text: string, pastedTextReferences: Record): string { + return text.replace(PASTED_TEXT_REFERENCE_REGEX, (match, refNum) => { + const content = pastedTextReferences[parseInt(refNum, 10)] + if (content === undefined) return match + // Normalize tabs to spaces when expanding + return content.replace(/\t/g, " ") + }) +} diff --git a/cli/src/state/atoms/__tests__/keyboard.test.ts b/cli/src/state/atoms/__tests__/keyboard.test.ts index d859de95abf..e5e9ff43b8d 100644 --- a/cli/src/state/atoms/__tests__/keyboard.test.ts +++ b/cli/src/state/atoms/__tests__/keyboard.test.ts @@ -17,6 +17,7 @@ import { keyboardHandlerAtom, submissionCallbackAtom, submitInputAtom, + pastedTextReferencesAtom, } from "../keyboard.js" import { pendingApprovalAtom, approvalOptionsAtom } from "../approval.js" import { historyDataAtom, historyModeAtom, historyIndexAtom as _historyIndexAtom } from "../history.js" @@ -1373,6 +1374,166 @@ describe("keypress atoms", () => { }) }) + describe("paste abbreviation", () => { + it("should insert small pastes directly into buffer", () => { + // Small paste (less than threshold) + const smallPaste = "line1\nline2\nline3" + const pasteKey: Key = { + name: "", + sequence: smallPaste, + ctrl: false, + meta: false, + shift: false, + paste: true, + } + + store.set(keyboardHandlerAtom, pasteKey) + + // Should insert text directly + const text = store.get(textBufferStringAtom) + expect(text).toBe(smallPaste) + }) + + it("should abbreviate large pastes as references", () => { + // Large paste (10+ lines to trigger abbreviation) + const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`) + const largePaste = lines.join("\n") + const pasteKey: Key = { + name: "", + sequence: largePaste, + ctrl: false, + meta: false, + shift: false, + paste: true, + } + + store.set(keyboardHandlerAtom, pasteKey) + + // Should insert abbreviated reference + const text = store.get(textBufferStringAtom) + expect(text).toContain("[Pasted text #1 +15 lines]") + expect(text).not.toContain("line 1") + }) + + it("should store full text in references map for large pastes", () => { + const lines = Array.from({ length: 12 }, (_, i) => `content line ${i + 1}`) + const largePaste = lines.join("\n") + const pasteKey: Key = { + name: "", + sequence: largePaste, + ctrl: false, + meta: false, + shift: false, + paste: true, + } + + store.set(keyboardHandlerAtom, pasteKey) + + // Full text should be in references map + const refs = store.get(pastedTextReferencesAtom) + expect(refs.get(1)).toBe(largePaste) + }) + + it("should increment reference numbers for multiple large pastes", () => { + const createLargePaste = (id: number) => { + const lines = Array.from({ length: 11 }, (_, i) => `paste${id} line ${i + 1}`) + return lines.join("\n") + } + + // First large paste + store.set(keyboardHandlerAtom, { + name: "", + sequence: createLargePaste(1), + ctrl: false, + meta: false, + shift: false, + paste: true, + }) + + // Add a space + store.set(keyboardHandlerAtom, { + name: "space", + sequence: " ", + ctrl: false, + meta: false, + shift: false, + paste: false, + }) + + // Second large paste + store.set(keyboardHandlerAtom, { + name: "", + sequence: createLargePaste(2), + ctrl: false, + meta: false, + shift: false, + paste: true, + }) + + const text = store.get(textBufferStringAtom) + expect(text).toContain("[Pasted text #1 +11 lines]") + expect(text).toContain("[Pasted text #2 +11 lines]") + }) + + it("should handle paste at exactly threshold boundary", () => { + // Exactly 10 lines (threshold) + const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`) + const boundaryPaste = lines.join("\n") + const pasteKey: Key = { + name: "", + sequence: boundaryPaste, + ctrl: false, + meta: false, + shift: false, + paste: true, + } + + store.set(keyboardHandlerAtom, pasteKey) + + // Should abbreviate (>= threshold) + const text = store.get(textBufferStringAtom) + expect(text).toContain("[Pasted text #1 +10 lines]") + }) + + it("should not abbreviate paste just below threshold", () => { + // 9 lines (below threshold) + const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`) + const smallPaste = lines.join("\n") + const pasteKey: Key = { + name: "", + sequence: smallPaste, + ctrl: false, + meta: false, + shift: false, + paste: true, + } + + store.set(keyboardHandlerAtom, pasteKey) + + // Should insert directly + const text = store.get(textBufferStringAtom) + expect(text).toBe(smallPaste) + expect(text).not.toContain("[Pasted text") + }) + + it("should convert tabs to spaces in both direct and abbreviated pastes", () => { + // Small paste with tabs + const smallWithTabs = "col1\tcol2\ncol3\tcol4" + store.set(keyboardHandlerAtom, { + name: "", + sequence: smallWithTabs, + ctrl: false, + meta: false, + shift: false, + paste: true, + }) + + const text = store.get(textBufferStringAtom) + expect(text).not.toContain("\t") + expect(text).toContain("col1 col2") // tabs converted to 2 spaces + }) + }) + describe("word navigation", () => { it("should move cursor to previous word with Meta+B", () => { // Type "hello world test" diff --git a/cli/src/state/atoms/__tests__/pastedText.test.ts b/cli/src/state/atoms/__tests__/pastedText.test.ts new file mode 100644 index 00000000000..20abbeb134f --- /dev/null +++ b/cli/src/state/atoms/__tests__/pastedText.test.ts @@ -0,0 +1,134 @@ +import { createStore } from "jotai" +import { + pastedTextReferencesAtom, + pastedTextReferenceCounterAtom, + addPastedTextReferenceAtom, + clearPastedTextReferencesAtom, + getPastedTextReferencesAtom, + formatPastedTextReference, + PASTE_LINE_THRESHOLD, +} from "../keyboard.js" + +describe("pasted text reference atoms", () => { + let store: ReturnType + + beforeEach(() => { + store = createStore() + }) + + describe("pastedTextReferencesAtom", () => { + it("should initialize with empty map", () => { + const refs = store.get(pastedTextReferencesAtom) + expect(refs.size).toBe(0) + }) + }) + + describe("pastedTextReferenceCounterAtom", () => { + it("should initialize with 0", () => { + const counter = store.get(pastedTextReferenceCounterAtom) + expect(counter).toBe(0) + }) + }) + + describe("addPastedTextReferenceAtom", () => { + it("should store text and return reference number", () => { + const text = "line one\nline two\nline three" + const refNumber = store.set(addPastedTextReferenceAtom, text) + + expect(refNumber).toBe(1) + }) + + it("should increment counter for each paste", () => { + store.set(addPastedTextReferenceAtom, "first paste") + const ref2 = store.set(addPastedTextReferenceAtom, "second paste") + const ref3 = store.set(addPastedTextReferenceAtom, "third paste") + + expect(ref2).toBe(2) + expect(ref3).toBe(3) + }) + + it("should store text content in references map", () => { + const text = "stored content\nwith lines" + const refNumber = store.set(addPastedTextReferenceAtom, text) + + const refs = store.get(pastedTextReferencesAtom) + expect(refs.get(refNumber)).toBe(text) + }) + }) + + describe("clearPastedTextReferencesAtom", () => { + it("should clear all references", () => { + store.set(addPastedTextReferenceAtom, "text 1") + store.set(addPastedTextReferenceAtom, "text 2") + + store.set(clearPastedTextReferencesAtom) + + const refs = store.get(pastedTextReferencesAtom) + expect(refs.size).toBe(0) + }) + + it("should reset counter to 0", () => { + store.set(addPastedTextReferenceAtom, "text 1") + store.set(addPastedTextReferenceAtom, "text 2") + + store.set(clearPastedTextReferencesAtom) + + const counter = store.get(pastedTextReferenceCounterAtom) + expect(counter).toBe(0) + }) + + it("should allow new references after clear", () => { + store.set(addPastedTextReferenceAtom, "text 1") + store.set(clearPastedTextReferencesAtom) + + const refNumber = store.set(addPastedTextReferenceAtom, "new text") + expect(refNumber).toBe(1) + }) + }) + + describe("getPastedTextReferencesAtom", () => { + it("should return empty object when no references", () => { + const refs = store.get(getPastedTextReferencesAtom) + expect(refs).toEqual({}) + }) + + it("should return references as plain object", () => { + store.set(addPastedTextReferenceAtom, "text one") + store.set(addPastedTextReferenceAtom, "text two") + + const refs = store.get(getPastedTextReferencesAtom) + expect(refs).toEqual({ + 1: "text one", + 2: "text two", + }) + }) + }) + + describe("formatPastedTextReference", () => { + it("should format reference correctly", () => { + const result = formatPastedTextReference(1, 25) + expect(result).toBe("[Pasted text #1 +25 lines]") + }) + + it("should handle large numbers", () => { + const result = formatPastedTextReference(999, 1000) + expect(result).toBe("[Pasted text #999 +1000 lines]") + }) + + it("should handle single line", () => { + const result = formatPastedTextReference(1, 1) + expect(result).toBe("[Pasted text #1 +1 lines]") + }) + }) + + describe("PASTE_LINE_THRESHOLD", () => { + it("should be a positive number", () => { + expect(PASTE_LINE_THRESHOLD).toBeGreaterThan(0) + }) + + it("should be at least 5 lines", () => { + // Reasonable minimum to avoid abbreviating small pastes + expect(PASTE_LINE_THRESHOLD).toBeGreaterThanOrEqual(5) + }) + }) +}) diff --git a/cli/src/state/atoms/keyboard.ts b/cli/src/state/atoms/keyboard.ts index 33fe454c233..00022c85702 100644 --- a/cli/src/state/atoms/keyboard.ts +++ b/cli/src/state/atoms/keyboard.ts @@ -132,6 +132,63 @@ function setClipboardStatusWithTimeout(set: Setter, message: string, timeoutMs: clipboardStatusTimer = setTimeout(() => set(clipboardStatusAtom, null), timeoutMs) } +// ============================================================================ +// Pasted Text Reference Atoms +// ============================================================================ + +/** + * Threshold for when to abbreviate pasted text (number of lines) + * Pastes with this many lines or more will be abbreviated + */ +export const PASTE_LINE_THRESHOLD = 10 + +/** + * Map of pasted text reference numbers to full text content for current message + * e.g., { 1: "line1\nline2\n...", 2: "another\npaste..." } + */ +export const pastedTextReferencesAtom = atom>(new Map()) + +/** + * Current pasted text reference counter (increments with each large paste) + */ +export const pastedTextReferenceCounterAtom = atom(0) + +/** + * Format a pasted text reference for display + * @param refNumber - The reference number + * @param lineCount - Number of lines in the paste + * @returns Formatted reference string like "[Pasted text #1 +25 lines]" + */ +export function formatPastedTextReference(refNumber: number, lineCount: number): string { + return `[Pasted text #${refNumber} +${lineCount} lines]` +} + +export const addPastedTextReferenceAtom = atom(null, (get, set, text: string): number => { + const counter = get(pastedTextReferenceCounterAtom) + 1 + set(pastedTextReferenceCounterAtom, counter) + + const refs = new Map(get(pastedTextReferencesAtom)) + refs.set(counter, text) + set(pastedTextReferencesAtom, refs) + + return counter +}) + +/** + * Clear pasted text references (after message is sent) + */ +export const clearPastedTextReferencesAtom = atom(null, (_get, set) => { + set(pastedTextReferencesAtom, new Map()) + set(pastedTextReferenceCounterAtom, 0) +}) + +/** + * Get all pasted text references as an object for easier consumption + */ +export const getPastedTextReferencesAtom = atom((get) => { + return Object.fromEntries(get(pastedTextReferencesAtom)) +}) + // ============================================================================ // Core State Atoms // ============================================================================ @@ -925,18 +982,36 @@ function handleTextInputKeys(get: Getter, set: Setter, key: Key) { return } - // Paste if (key.paste) { - // Convert tabs to 2 spaces to prevent border corruption - // Tabs have variable display widths in terminals which breaks layout - const normalizedText = key.sequence.replace(/\t/g, " ") - set(insertTextAtom, normalizedText) + handlePaste(set, key.sequence) return } return } +function handlePaste(set: Setter, text: string): void { + // Quick line count check - avoid processing large text unnecessarily + let lineCount = 0 + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") lineCount++ + if (lineCount >= PASTE_LINE_THRESHOLD) break + } + lineCount++ // Account for last line (no trailing newline) + + if (lineCount >= PASTE_LINE_THRESHOLD) { + // Store original text - normalize tabs only when expanding + const actualLineCount = text.split("\n").length + const refNumber = set(addPastedTextReferenceAtom, text) + const reference = formatPastedTextReference(refNumber, actualLineCount) + set(insertTextAtom, reference + " ") + } else { + // Small paste - normalize tabs to prevent border corruption + const normalizedText = text.replace(/\t/g, " ") + set(insertTextAtom, normalizedText) + } +} + function handleGlobalHotkeys(get: Getter, set: Setter, key: Key): boolean { // Debug logging for key detection (Ctrl or Meta/Cmd keys) if (key.ctrl || key.meta || key.sequence === "\x16") { diff --git a/cli/src/state/hooks/useMessageHandler.ts b/cli/src/state/hooks/useMessageHandler.ts index b14518009d5..0f54bd4bd84 100644 --- a/cli/src/state/hooks/useMessageHandler.ts +++ b/cli/src/state/hooks/useMessageHandler.ts @@ -6,13 +6,19 @@ import { useSetAtom, useAtomValue } from "jotai" import { useCallback, useState } from "react" import { addMessageAtom } from "../atoms/ui.js" -import { imageReferencesAtom, clearImageReferencesAtom } from "../atoms/keyboard.js" +import { + imageReferencesAtom, + clearImageReferencesAtom, + pastedTextReferencesAtom, + clearPastedTextReferencesAtom, +} from "../atoms/keyboard.js" import { useWebviewMessage } from "./useWebviewMessage.js" import { useTaskState } from "./useTaskState.js" import type { CliMessage } from "../../types/cli.js" import { logs } from "../../services/logs.js" import { getTelemetryService } from "../../services/telemetry/index.js" import { processMessageImages } from "../../media/processMessageImages.js" +import { expandPastedTextReferences } from "../../media/processPastedText.js" /** * Options for useMessageHandler hook @@ -62,6 +68,8 @@ export function useMessageHandler(options: UseMessageHandlerOptions = {}): UseMe const addMessage = useSetAtom(addMessageAtom) const imageReferences = useAtomValue(imageReferencesAtom) const clearImageReferences = useSetAtom(clearImageReferencesAtom) + const pastedTextReferences = useAtomValue(pastedTextReferencesAtom) + const clearPastedTextReferences = useSetAtom(clearPastedTextReferencesAtom) const { sendMessage, sendAskResponse } = useWebviewMessage() const { hasActiveTask } = useTaskState() @@ -75,11 +83,15 @@ export function useMessageHandler(options: UseMessageHandlerOptions = {}): UseMe setIsSending(true) try { + // Expand [Pasted text #N +X lines] references with full content + const pastedTextRefsObject = Object.fromEntries(pastedTextReferences) + const expandedText = expandPastedTextReferences(trimmedText, pastedTextRefsObject) + // Convert image references Map to object for processMessageImages const imageRefsObject = Object.fromEntries(imageReferences) // Process any @path image mentions and [Image #N] references in the message - const processed = await processMessageImages(trimmedText, imageRefsObject) + const processed = await processMessageImages(expandedText, imageRefsObject) // Show any image loading errors to the user if (processed.errors.length > 0) { @@ -108,10 +120,13 @@ export function useMessageHandler(options: UseMessageHandlerOptions = {}): UseMe ...(processed.hasImages && { images: processed.images }), } - // Clear image references after processing + // Clear image and pasted text references after processing if (imageReferences.size > 0) { clearImageReferences() } + if (pastedTextReferences.size > 0) { + clearPastedTextReferences() + } // Send to extension - either as response to active task or as new task if (hasActiveTask) { @@ -137,7 +152,17 @@ export function useMessageHandler(options: UseMessageHandlerOptions = {}): UseMe setIsSending(false) } }, - [addMessage, ciMode, sendMessage, sendAskResponse, hasActiveTask, imageReferences, clearImageReferences], + [ + addMessage, + ciMode, + sendMessage, + sendAskResponse, + hasActiveTask, + imageReferences, + clearImageReferences, + pastedTextReferences, + clearPastedTextReferences, + ], ) return { diff --git a/cli/src/ui/providers/KeyboardProvider.tsx b/cli/src/ui/providers/KeyboardProvider.tsx index 16605a72195..281e9cc74cb 100644 --- a/cli/src/ui/providers/KeyboardProvider.tsx +++ b/cli/src/ui/providers/KeyboardProvider.tsx @@ -14,18 +14,15 @@ import { logs } from "../../services/logs.js" import { broadcastKeyEventAtom, setPasteModeAtom, - appendToPasteBufferAtom, - pasteBufferAtom, + kittySequenceBufferAtom, appendToKittyBufferAtom, clearKittyBufferAtom, - kittySequenceBufferAtom, kittyProtocolEnabledAtom, setKittyProtocolAtom, debugKeystrokeLoggingAtom, setDebugLoggingAtom, clearBuffersAtom, setupKeyboardAtom, - triggerClipboardImagePasteAtom, } from "../../state/atoms/keyboard.js" import { parseKittySequence, @@ -61,17 +58,14 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp // Jotai setters const broadcastKey = useSetAtom(broadcastKeyEventAtom) const setPasteMode = useSetAtom(setPasteModeAtom) - const appendToPasteBuffer = useSetAtom(appendToPasteBufferAtom) const appendToKittyBuffer = useSetAtom(appendToKittyBufferAtom) const clearKittyBuffer = useSetAtom(clearKittyBufferAtom) const setKittyProtocol = useSetAtom(setKittyProtocolAtom) const setDebugLogging = useSetAtom(setDebugLoggingAtom) const clearBuffers = useSetAtom(clearBuffersAtom) const setupKeyboard = useSetAtom(setupKeyboardAtom) - const triggerClipboardImagePaste = useSetAtom(triggerClipboardImagePasteAtom) // Jotai getters (for reading current state) - const pasteBuffer = useAtomValue(pasteBufferAtom) const kittyBuffer = useAtomValue(kittySequenceBufferAtom) const isKittyEnabled = useAtomValue(kittyProtocolEnabledAtom) const isDebugEnabled = useAtomValue(debugKeystrokeLoggingAtom) @@ -105,16 +99,21 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp isPasteRef.current = false pasteBufferRef.current = "" - if (wasPasting) { + if (wasPasting && currentBuffer) { // Normalize line endings: convert \r\n and \r to \n - // This handles different line ending formats from various terminals/platforms - const normalizedBuffer = currentBuffer ? currentBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") : "" - // Always check clipboard for image first (prioritize image over text) - // If no image found, the fallback text will be used - // This handles: Cmd+V with image file copied from Finder (terminal sends filename as text) - triggerClipboardImagePaste(normalizedBuffer || undefined) + const normalizedBuffer = currentBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + // Directly broadcast paste event - skip clipboard image check for bracketed pastes + // Clipboard image check only runs for explicit Cmd+V (handled in handleGlobalHotkeys) + broadcastKey({ + name: "", + ctrl: false, + meta: false, + shift: false, + paste: true, + sequence: normalizedBuffer, + }) } - }, [setPasteMode, triggerClipboardImagePaste]) + }, [setPasteMode, broadcastKey]) useEffect(() => { // Save original raw mode state @@ -203,7 +202,6 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp if (isPasteRef.current) { if (!usePassthrough) { pasteBufferRef.current += parsedKey.sequence - appendToPasteBuffer(parsedKey.sequence) } return } @@ -361,9 +359,7 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp // No more markers if (isPasteRef.current) { // We're in paste mode - accumulate the remaining data in paste buffer - const chunk = dataStr.slice(pos) - pasteBufferRef.current += chunk - appendToPasteBuffer(chunk) + pasteBufferRef.current += dataStr.slice(pos) } else { // Not in paste mode - write remaining data to stream keypressStream.write(data.slice(pos)) @@ -375,9 +371,7 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp if (nextMarkerPos > pos) { if (isPasteRef.current) { // We're in paste mode - accumulate data in paste buffer - const chunk = dataStr.slice(pos, nextMarkerPos) - pasteBufferRef.current += chunk - appendToPasteBuffer(chunk) + pasteBufferRef.current += dataStr.slice(pos, nextMarkerPos) } else { // Not in paste mode - write data to stream keypressStream.write(data.slice(pos, nextMarkerPos)) @@ -432,14 +426,11 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp escapeCodeTimeout, broadcastKey, setPasteMode, - appendToPasteBuffer, appendToKittyBuffer, clearKittyBuffer, clearBuffers, setKittyProtocol, - pasteBuffer, kittyBuffer, - triggerClipboardImagePaste, isKittyEnabled, isDebugEnabled, completePaste,