mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user