diff --git a/.changeset/tui-paste-expand-placeholder.md b/.changeset/tui-paste-expand-placeholder.md new file mode 100644 index 0000000000..07b963ca0b --- /dev/null +++ b/.changeset/tui-paste-expand-placeholder.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +A second identical large paste expands its collapsed prompt placeholder. \ No newline at end of file diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index b8efcedf5a..ab59902401 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -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") diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e237c0c1b6..8aae80247e 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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) { diff --git a/packages/tui/src/prompt/part.ts b/packages/tui/src/prompt/part.ts index 0027b9aaed..b8b0e7c1a0 100644 --- a/packages/tui/src/prompt/part.ts +++ b/packages/tui/src/prompt/part.ts @@ -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, + 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 +} diff --git a/packages/tui/test/prompt/paste-expand.test.ts b/packages/tui/test/prompt/paste-expand.test.ts new file mode 100644 index 0000000000..a644eabba5 --- /dev/null +++ b/packages/tui/test/prompt/paste-expand.test.ts @@ -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() + + // 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() + } +})