Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/abbreviate-large-pasted-text.md
Original file line number Diff line number Diff line change
@@ -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
159 changes: 159 additions & 0 deletions cli/src/media/__tests__/processPastedText.test.ts
Original file line number Diff line number Diff line change
@@ -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 ")
})
})
})
28 changes: 28 additions & 0 deletions cli/src/media/processPastedText.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ok for now, but a much better approach for the future, in my opinion, would be to refactor the input field to be an array of objects, for example:

type PasteItem = {
  type: "paste";
  text: string;
}

type ImageItem = {
  type: "image";
  pathToFile: string;
}

type StringItem = {
  type: "string";
  value: string;
}

type InputFieldValue = PasteItem | ImageItem | StringItem;

type InputFieldState = InputFieldValue[];

// then, in the component
const [inputFieldState, setInputFieldState] = useState<InputFieldState>([]);

// refactor the renderer to correctly render input field state
function render(state: InputFieldState): string

// refactor state updaters to correctly handle the new shape
function handleX(cursorPosition: number, event: unknown): InputFieldState

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking back at this, I agree. This is the better approach. I will get a follow up done.


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<number, string>): 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, " ")
})
}
161 changes: 161 additions & 0 deletions cli/src/state/atoms/__tests__/keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading