feat(tui): expand a collapsed paste on a second identical paste (#12816)

* feat(tui): expand a collapsed paste on a second identical paste

* chore: retrigger review

* chore(tui): add paste expansion changeset

* fix(cli): target paste changeset

* fix(tui): refresh autocomplete after expanding a paste

Call auto()?.onInput on the expand-placeholder path so open autocomplete
matches onContentChange when a second identical paste expands text.
This commit is contained in:
Igor Šćekić
2026-08-03 21:46:57 +02:00
committed by GitHub
parent 3f7831f2a3
commit 63a38f5c7a
5 changed files with 183 additions and 2 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
A second identical large paste expands its collapsed prompt placeholder.
@@ -1015,7 +1015,7 @@ describe("session prompt queue", () => {
} finally {
server.stop(true)
}
}, 10_000)
}, 30_000)
test("drop returns false for the actively running prompt", async () => {
const sessionID = SessionID.make("session_drop_active")
+10 -1
View File
@@ -33,7 +33,7 @@ import { promptOffsetWidth } from "../../prompt/display"
import { createStore, produce, unwrap } from "solid-js/store"
import { usePromptHistory, type PromptInfo } from "../../prompt/history"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { expandPastedPlaceholder, expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -1320,6 +1320,15 @@ export function Prompt(props: PromptProps) {
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
// kilocode_change start - a second identical paste expands the collapsed placeholder
if (expandPastedPlaceholder(input, promptPartTypeId, store.extmarkToPartIndex, store.prompt.parts, pastedContent)) {
const value = input.plainText
setStore("prompt", "input", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
return
}
// kilocode_change end
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
+42
View File
@@ -27,3 +27,45 @@ export function expandTrackedPastedText(text: string, ranges: { start: number; e
.sort((a, b) => b.start - a.start)
.reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text)
}
type PastePlaceholderInput = {
extmarks: {
getAllForTypeId(typeId: number): { id: number; start: number; end: number }[]
delete(id: number): boolean
}
setSelection(start: number, end: number): void
deleteSelection(): boolean
insertText(text: string): void
cursorOffset: number
}
/**
* Replace a collapsed paste placeholder with its literal text.
* Returns true when a placeholder holds exactly the pasted content.
* The caller's content-change sync drops the text part that loses its extmark.
*/
export function expandPastedPlaceholder(
input: PastePlaceholderInput,
typeId: number,
extmarkToPartIndex: ReadonlyMap<number, number>,
parts: readonly unknown[],
content: string,
) {
const match = input.extmarks
.getAllForTypeId(typeId)
.filter((extmark) => {
const partIndex = extmarkToPartIndex.get(extmark.id)
if (partIndex === undefined) return false
const part = parts[partIndex]
return isPastedTextPart(part) && part.text === content
})
.sort((a, b) => a.start - b.start)[0]
if (!match) return false
input.extmarks.delete(match.id)
input.setSelection(match.start, match.end)
input.deleteSelection()
input.cursorOffset = match.start
input.insertText(content)
return true
}
@@ -0,0 +1,125 @@
import { expect, test } from "bun:test"
import { TextareaRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { promptOffsetWidth } from "../../src/prompt/display"
import { expandPastedPlaceholder, expandTrackedPastedText } from "../../src/prompt/part"
const CONTENT = "line1\nline2\nline3\nline4\nline5\nline6"
const PLACEHOLDER = "[Pasted ~6 lines]"
const OTHER = "other1\nother2\nother3\nother4\nother5"
const OTHER_PLACEHOLDER = "[Pasted ~5 lines]"
async function createPrompt() {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const input = new TextareaRenderable(setup.renderer as any, { id: "prompt" })
const typeId = input.extmarks.registerType("prompt-part")
const parts: unknown[] = []
const map = new Map<number, number>()
// mirrors pasteText in src/component/prompt/index.tsx
function collapse(text: string, virtualText: string) {
const start = input.cursorOffset
const end = start + promptOffsetWidth(virtualText)
input.insertText(virtualText + " ")
const id = input.extmarks.create({ start, end, virtual: true, typeId })
map.set(id, parts.length)
parts.push({ type: "text", text, source: { text: { start, end, value: virtualText } } })
return id
}
// mirrors the inputText assembly in submit
function submitText() {
return expandTrackedPastedText(
input.plainText,
input.extmarks.getAllForTypeId(typeId).flatMap((extmark) => {
const partIndex = map.get(extmark.id)
const part = partIndex === undefined ? undefined : (parts[partIndex] as { type: string; text: string })
if (part?.type !== "text") return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
}
return { setup, input, typeId, parts, map, collapse, submitText }
}
test("expands the placeholder on an identical second paste", async () => {
const { setup, input, typeId, parts, map, collapse, submitText } = await createPrompt()
try {
collapse(CONTENT, PLACEHOLDER)
expect(input.plainText).toBe(`${PLACEHOLDER} `)
const before = submitText()
expect(before).toBe(`${CONTENT} `)
expect(expandPastedPlaceholder(input, typeId, map, parts, CONTENT)).toBe(true)
expect(input.plainText).toBe(`${CONTENT} `)
expect(input.plainText.split("line1").length - 1).toBe(1)
expect(input.extmarks.getAllForTypeId(typeId)).toHaveLength(0)
expect(submitText()).toBe(before)
} finally {
setup.renderer.destroy()
}
})
test("leaves the placeholder alone when the second paste differs", async () => {
const { setup, input, typeId, parts, map, collapse } = await createPrompt()
try {
collapse(CONTENT, PLACEHOLDER)
expect(expandPastedPlaceholder(input, typeId, map, parts, OTHER)).toBe(false)
expect(input.plainText).toBe(`${PLACEHOLDER} `)
expect(input.extmarks.getAllForTypeId(typeId)).toHaveLength(1)
} finally {
setup.renderer.destroy()
}
})
test("ignores a small paste that never collapsed", async () => {
const { setup, input, typeId, parts, map } = await createPrompt()
try {
input.insertText("short paste")
expect(expandPastedPlaceholder(input, typeId, map, parts, "short paste")).toBe(false)
expect(input.plainText).toBe("short paste")
} finally {
setup.renderer.destroy()
}
})
test("expands only the matching placeholder", async () => {
const { setup, input, typeId, parts, map, collapse } = await createPrompt()
try {
collapse(CONTENT, PLACEHOLDER)
const otherId = collapse(OTHER, OTHER_PLACEHOLDER)
expect(expandPastedPlaceholder(input, typeId, map, parts, CONTENT)).toBe(true)
expect(input.plainText).toBe(`${CONTENT} ${OTHER_PLACEHOLDER} `)
const marks = input.extmarks.getAllForTypeId(typeId)
expect(marks).toHaveLength(1)
expect(marks[0].id).toBe(otherId)
expect(input.plainText.slice(marks[0].start, marks[0].end)).toBe(OTHER_PLACEHOLDER)
} finally {
setup.renderer.destroy()
}
})
test("expands the earlier of two identical placeholders", async () => {
const { setup, input, typeId, parts, map, collapse } = await createPrompt()
try {
collapse(CONTENT, PLACEHOLDER)
const secondId = collapse(CONTENT, PLACEHOLDER)
expect(expandPastedPlaceholder(input, typeId, map, parts, CONTENT)).toBe(true)
expect(input.plainText).toBe(`${CONTENT} ${PLACEHOLDER} `)
const marks = input.extmarks.getAllForTypeId(typeId)
expect(marks).toHaveLength(1)
expect(marks[0].id).toBe(secondId)
} finally {
setup.renderer.destroy()
}
})