diff --git a/.changeset/review-diff-row-height.md b/.changeset/review-diff-row-height.md new file mode 100644 index 00000000000..a614114c2af --- /dev/null +++ b/.changeset/review-diff-row-height.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep large review diffs from jumping when file rows are re-created at a different panel width. diff --git a/.changeset/vscode-paste-collapse.md b/.changeset/vscode-paste-collapse.md new file mode 100644 index 00000000000..7894a70847c --- /dev/null +++ b/.changeset/vscode-paste-collapse.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Show large pasted text in the prompt input as an expandable `[Pasted ~N lines]` block that restores the full text when expanded, copied, or sent. diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index fec13a8f530..ead27fa1779 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -40,8 +40,12 @@ function remember(key: object | undefined, width: number, height: number) { } function reserved(key: object | undefined, width: number) { - if (!key || width <= 0) return - return sizes.get(key)?.get(width) + if (!key) return undefined + const widths = sizes.get(key) + if (!widths) return undefined + // Preserve the last measured height at a new width. The capped estimate + // would shrink a tall remounted row and move the scroll position. + return widths.get(width) ?? Array.from(widths.values()).at(-1) } // A review can contain many expanded diff components. Creating one diff --git a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts index 4d6bf0b5821..e38ff344e4d 100644 --- a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts +++ b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts @@ -370,6 +370,71 @@ test("keeps the inline diff position stable while scrolling upward", async ({ pa expect(result.range).toBeLessThanOrEqual(1) }) +test("keeps the inline diff position stable when the row width changes", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 760 }) + await page.goto(inlineStoryUrl(), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector(".am-diff-content diffs-container", { state: "attached" }) + + const scroll = page.locator(".am-diff-content") + // Materialize every row at the wider layout so each row records its height. + const mounted = await scroll.evaluate(async (el) => { + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)) + const initial = Array.from(el.querySelectorAll("[data-file-path]"), (row) => row.getAttribute("data-file-path")) + while (el.scrollTop < el.scrollHeight - el.clientHeight - 1) { + el.scrollTop = Math.min(el.scrollHeight - el.clientHeight, el.scrollTop + 120) + await frame() + } + for (let index = 0; index < 30; index++) await frame() + return initial + }) + + // A width change (panel resize or scrollbar toggle) leaves the measured + // heights on a different width, so a remounted row must reuse the last + // measured height instead of collapsing to the capped estimate. + await page.setViewportSize({ width: 880, height: 760 }) + + const result = await scroll.evaluate(async (el, initial) => { + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)) + const settle = async (count: number) => { + for (let index = 0; index < count; index++) await frame() + } + const seen = new Set(initial) + let remounts = 0 + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if (!(node instanceof HTMLElement)) continue + const rows = node.matches("[data-file-path]") ? [node] : Array.from(node.querySelectorAll("[data-file-path]")) + for (const row of rows) { + const file = row.getAttribute("data-file-path") + if (seen.has(file)) remounts++ + seen.add(file) + } + } + } + }) + observer.observe(el, { childList: true, subtree: true }) + + let correction = 0 + let range = 0 + while (el.scrollTop > 0) { + const height = el.scrollHeight + const intended = Math.max(0, el.scrollTop - 80) + el.scrollTop = intended + await settle(2) + correction = Math.max(correction, Math.abs(el.scrollTop - intended)) + range = Math.max(range, Math.abs(el.scrollHeight - height)) + } + observer.disconnect() + return { correction, range, remounts } + }, mounted) + + expect(result.remounts).toBeGreaterThan(0) + expect(result.correction).toBeLessThanOrEqual(1) + expect(result.range).toBeLessThanOrEqual(1) +}) + test("keeps cached worktree reviews visible on every switch frame", async ({ page }) => { await page.setViewportSize({ width: 900, height: 760 }) await page.goto(`/iframe.html?id=${CACHE_STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) diff --git a/packages/kilo-vscode/tests/paste-collapse.spec.ts b/packages/kilo-vscode/tests/paste-collapse.spec.ts new file mode 100644 index 00000000000..32e8d9c72c2 --- /dev/null +++ b/packages/kilo-vscode/tests/paste-collapse.spec.ts @@ -0,0 +1,55 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const PLACEHOLDER = "[Pasted ~15 lines]" + +async function open(page: Page) { + await page.goto(`/iframe.html?id=prompt-input--default-420&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const input = page.locator("textarea.prompt-input") + await expect(input).toBeVisible() + await page.evaluate(() => window.postMessage({ type: "connectionState", state: "connected" }, window.origin)) + await expect(input).toBeEnabled() + return input +} + +function block(tag: string) { + return Array.from({ length: 15 }, (_, index) => `${tag}${index} ${"x".repeat(40)}`).join("\n") +} + +async function paste(page: Page, input: ReturnType, text: string) { + await page.evaluate(async (value) => navigator.clipboard.writeText(value), text) + await input.focus() + await page.keyboard.press("ControlOrMeta+V") +} + +async function clickChip(page: Page, index = 0) { + await page.evaluate((at) => (document.querySelectorAll(".prompt-input-paste")[at] as HTMLElement).click(), index) +} + +test("keeps the surviving paste chip and its backing after deleting an earlier chip", async ({ page }) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]) + const input = await open(page) + const first = block("a") + const second = block("b") + + await paste(page, input, first) + await paste(page, input, second) + await expect(page.locator(".prompt-input-paste")).toHaveCount(2) + + // Delete the first chip from the caret at its end. + await page.evaluate(() => { + const field = document.querySelector("textarea.prompt-input") as HTMLTextAreaElement + const end = field.value.indexOf("[Pasted ~15 lines]") + "[Pasted ~15 lines]".length + field.focus() + field.setSelectionRange(end, end) + }) + await input.press("Backspace") + + // The edit must shift the remaining range once, so it stays a chip. + await expect(page.locator(".prompt-input-paste")).toHaveCount(1) + await expect(input).toHaveValue(PLACEHOLDER) + + // And it must keep the second block's backing, not the deleted one's. + await clickChip(page) + await expect(input).toHaveValue(second) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts index 2f70fea067f..e4ddc7e8999 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -67,6 +67,7 @@ describe("PromptInput sandbox toggle", () => { expect(move).toBeGreaterThan(save) expect(created).toContain("text: drafts,") expect(created).toContain("browsers: references,") + expect(created).toContain("pastes: pasteDrafts,") expect(created).toContain("contexts: contextDrafts,") expect(created).toContain("saveDraft(source, text(), reviewComments(), imageAttach.images())") }) @@ -78,7 +79,7 @@ describe("PromptInput sandbox toggle", () => { expect(src).toContain("if (highlightRef) highlightRef.scrollTop = scroll") expect(src).toContain("scrollDrafts.set(draftKey(), textareaRef.scrollTop)") expect(src).toContain( - "images: imageAttach.images(),\n browsers: browsers(),\n contexts: contexts(),\n scroll: textareaRef?.scrollTop", + "images: imageAttach.images(),\n browsers: browsers(),\n pastes: paste.pastes().map((item) => item.text),\n contexts: contexts(),\n scroll: textareaRef?.scrollTop", ) expect(src).toContain("draft.text,") expect(src).toContain("draft.comments,") diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts index 3dc1cac87f3..dbb0f7d6a26 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -3,6 +3,7 @@ import { fileName, dirName, buildHighlightSegments, + buildPromptSegments, atEnd, insertSpacedText, isPromptBlocked, @@ -13,6 +14,16 @@ import { applySandboxState, applySandboxStates, memoryRest, + promptLineCount, + isCollapsiblePaste, + pastePlaceholder, + findPastePlaceholders, + shiftPastes, + rebasePastes, + pasteInsertion, + expandPastes, + textDiff, + type PasteRange, } from "../../webview-ui/src/components/chat/prompt-input-utils" import { parseMemoryCommand } from "../../webview-ui/src/utils/memory-command" @@ -361,3 +372,239 @@ describe("memoryRest", () => { expect(memoryRest(parseMemoryCommand("/memory auto on")!)).toBe("") }) }) + +describe("paste collapse thresholds", () => { + it("counts lines from newlines plus one", () => { + expect(promptLineCount("one")).toBe(1) + expect(promptLineCount("one\ntwo")).toBe(2) + expect(promptLineCount("a\nb\nc\nd\ne")).toBe(5) + }) + + it("collapses at fifteen lines or more than 4000 characters", () => { + expect(isCollapsiblePaste("a\nb\nc\nd\ne")).toBe(false) + expect(isCollapsiblePaste(Array.from({ length: 15 }, () => "a").join("\n"))).toBe(true) + expect(isCollapsiblePaste("a".repeat(4001))).toBe(true) + expect(isCollapsiblePaste("a".repeat(4000))).toBe(false) + }) + + it("builds the canonical placeholder", () => { + expect(pastePlaceholder("a\nb\nc\nd\ne")).toBe("[Pasted ~5 lines]") + expect(pastePlaceholder("a".repeat(801))).toBe("[Pasted ~1 lines]") + }) +}) + +describe("findPastePlaceholders", () => { + it("finds every placeholder with its range", () => { + const text = "[Pasted ~5 lines] then [Pasted ~12 lines]" + expect(findPastePlaceholders(text)).toEqual([ + { start: 0, end: 17 }, + { start: 23, end: 41 }, + ]) + }) + + it("ignores look-alike text that is not a placeholder", () => { + expect(findPastePlaceholders("[Pasted 5 lines]")).toEqual([]) + expect(findPastePlaceholders("Pasted ~5 lines")).toEqual([]) + }) + + it("finds no placeholder in ordinary pasted text", () => { + expect(findPastePlaceholders("hello\nworld")).toEqual([]) + }) +}) + +describe("textDiff", () => { + it("locates an insertion", () => { + expect(textDiff("abcd", "abXcd")).toEqual({ start: 2, oldEnd: 2, newEnd: 3, delta: 1 }) + }) + + it("locates a deletion", () => { + expect(textDiff("abXcd", "abcd")).toEqual({ start: 2, oldEnd: 3, newEnd: 2, delta: -1 }) + }) + + it("locates a replacement", () => { + expect(textDiff("abcd", "abXYd")).toEqual({ start: 2, oldEnd: 3, newEnd: 4, delta: 1 }) + }) +}) + +describe("shiftPastes", () => { + const paste = (id: number, start: number, text: string): PasteRange => ({ + id, + start, + end: start + "[Pasted ~5 lines]".length, + text, + }) + + it("keeps a block before the edit unchanged", () => { + const prev = "[Pasted ~5 lines] tail" + const next = "[Pasted ~5 lines] tail more" + const [moved] = shiftPastes([paste(1, 0, "body")], prev, next) + expect(moved).toEqual(paste(1, 0, "body")) + }) + + it("moves a block after an insertion", () => { + const prev = "lead [Pasted ~5 lines]" + const next = "lead more [Pasted ~5 lines]" + const [moved] = shiftPastes([paste(1, 5, "body")], prev, next) + expect(moved?.start).toBe(10) + }) + + it("moves a block after a deletion", () => { + const prev = "lead more [Pasted ~5 lines]" + const next = "lead [Pasted ~5 lines]" + const [moved] = shiftPastes([paste(1, 10, "body")], prev, next) + expect(moved?.start).toBe(5) + }) + + it("drops a block whose placeholder was edited away", () => { + const prev = "[Pasted ~5 lines]" + const next = "[Pasted ~5 line]" + expect(shiftPastes([paste(1, 0, "body")], prev, next)).toEqual([]) + }) + + it("drops a block when the edit happens inside it", () => { + const prev = "[Pasted ~5 lines]" + const next = "[Pasted ~55 lines]" + expect(shiftPastes([paste(1, 0, "body")], prev, next)).toEqual([]) + }) + + it("keeps identical placeholders addressed independently", () => { + const prev = "[Pasted ~5 lines] and [Pasted ~5 lines]" + const second = prev.indexOf("[Pasted ~5 lines]", 1) + const gap = prev.indexOf(" and ") + " and ".length + const next = prev.slice(0, gap) + " " + prev.slice(gap) + const moved = shiftPastes([paste(1, 0, "one"), paste(2, second, "two")], prev, next) + expect(moved.map((item) => item.text)).toEqual(["one", "two"]) + expect(moved[0]?.start).toBe(0) + expect(moved[1]?.start).toBe(second + 3) + }) +}) + +describe("rebasePastes", () => { + const token = (lines: number) => `[Pasted ~${lines} lines]` + const chip = (id: number, start: number, text: string, lines = 5): PasteRange => { + const mark = token(lines) + return { id, start, end: start + mark.length, text } + } + + it("keeps the second backing when the first of two identical chips is deleted", () => { + const mark = token(5) + const pastes = [chip(1, 0, "first"), chip(2, mark.length + 1, "second")] + const moved = rebasePastes(pastes, 0, mark.length + 1, 0) + expect(moved.map((item) => item.text)).toEqual(["second"]) + expect(moved[0]).toEqual(chip(2, 0, "second")) + }) + + it("keeps the survivor backing when a differently sized chip precedes it", () => { + const mark = token(5) + const big = token(10) + const pastes = [chip(1, 0, "first"), chip(2, mark.length + 1, "second", 10)] + const moved = rebasePastes(pastes, 0, mark.length + 1, 0) + expect(moved.map((item) => item.text)).toEqual(["second"]) + expect(moved[0]).toEqual(chip(2, 0, "second", 10)) + expect(moved[0]?.end).toBe(big.length) + }) + + it("shifts a chip that sits after the edit", () => { + const moved = rebasePastes([chip(1, 5, "body")], 0, 0, 4) + expect(moved).toEqual([chip(1, 9, "body")]) + }) + + it("keeps a chip that ends at the edit boundary", () => { + const mark = token(5) + const moved = rebasePastes([chip(1, 0, "body")], mark.length, mark.length, 3) + expect(moved).toEqual([chip(1, 0, "body")]) + }) + + it("drops a chip the edit overlaps", () => { + const mark = token(5) + expect(rebasePastes([chip(1, 0, "body")], 0, mark.length, 0)).toEqual([]) + }) +}) + +describe("pasteInsertion", () => { + const mark = "[Pasted ~5 lines]" + + it("lands the caret after the separator spaces, not inside the following text", () => { + const result = pasteInsertion("helloworld", 5, 5, mark) + expect(result.text).toBe(`hello ${mark} world`) + expect(result.caret).toBe(`hello ${mark} `.length) + expect(result.text.slice(result.caret)).toBe("world") + }) + + it("wraps the chip range around the placeholder only", () => { + const result = pasteInsertion("helloworld", 5, 5, mark) + expect(result.text.slice(result.start, result.end)).toBe(mark) + }) + + it("omits the prefix space when the text before already ends in whitespace", () => { + const result = pasteInsertion("hello world", 6, 6, mark) + expect(result.text).toBe(`hello ${mark} world`) + expect(result.start).toBe(6) + expect(result.text.slice(result.start, result.end)).toBe(mark) + }) + + it("replaces a selection with the chip", () => { + const result = pasteInsertion("hello world", 5, 6, mark) + expect(result.text).toBe(`hello ${mark} world`) + expect(result.text.slice(result.start, result.end)).toBe(mark) + }) +}) + +describe("expandPastes", () => { + const paste = (id: number, start: number, text: string): PasteRange => ({ + id, + start, + end: start + "[Pasted ~5 lines]".length, + text, + }) + + it("restores the full content of a single block", () => { + expect(expandPastes("[Pasted ~5 lines]", [paste(1, 0, "a\nb\nc\nd\ne")])).toBe("a\nb\nc\nd\ne") + }) + + it("restores identical placeholders to their own content, back to front", () => { + const text = "[Pasted ~5 lines] then [Pasted ~5 lines]" + const expanded = expandPastes(text, [paste(1, 0, "first"), paste(2, 23, "second")]) + expect(expanded).toBe("first then second") + }) + + it("leaves placeholder-looking text with no backing unchanged", () => { + expect(expandPastes("typed [Pasted ~5 lines]", [])).toBe("typed [Pasted ~5 lines]") + }) +}) + +describe("buildPromptSegments", () => { + const paste = (id: number, start: number, text: string): PasteRange => ({ + id, + start, + end: start + "[Pasted ~5 lines]".length, + text, + }) + + it("marks a collapsed block as a paste chip", () => { + expect(buildPromptSegments("[Pasted ~5 lines] done", new Set(), [paste(7, 0, "body")])).toEqual([ + { text: "[Pasted ~5 lines]", kind: "paste", paste: 7 }, + { text: " done", kind: "plain" }, + ]) + }) + + it("still highlights mentions around a paste", () => { + const text = "@foo.ts [Pasted ~5 lines]" + const segments = buildPromptSegments(text, new Set(["foo.ts"]), [paste(1, 8, "body")]) + expect(segments).toEqual([ + { text: "@foo.ts", kind: "mention" }, + { text: " ", kind: "plain" }, + { text: "[Pasted ~5 lines]", kind: "paste", paste: 1 }, + ]) + }) + + it("renders a placeholder with no backing as plain text", () => { + expect(buildPromptSegments("[Pasted ~5 lines]", new Set(), [])).toEqual([ + { text: "[Pasted ~5 lines]", kind: "plain" }, + ]) + }) + + it("returns an empty list for empty text", () => { + expect(buildPromptSegments("", new Set(), [])).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/use-paste-collapse.test.ts b/packages/kilo-vscode/tests/unit/use-paste-collapse.test.ts new file mode 100644 index 00000000000..49d32071023 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/use-paste-collapse.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { pastePlaceholder } from "../../webview-ui/src/components/chat/prompt-input-utils" +import { usePasteCollapse } from "../../webview-ui/src/hooks/usePasteCollapse" + +const block = (tag: string) => Array.from({ length: 15 }, (_, index) => `${tag}${index}`).join("\n") +const first = block("a") +const second = block("b") +const chip = pastePlaceholder(first) + +type Field = { value: string; selectionStart: number; selectionEnd: number } + +function field(value: string, caret = value.length) { + const el = { + value, + selectionStart: caret, + selectionEnd: caret, + focus() {}, + setSelectionRange(start: number, end: number) { + el.selectionStart = start + el.selectionEnd = end + }, + } + return el as unknown as Field & HTMLTextAreaElement +} + +function clipboard(text: string) { + return { + defaultPrevented: false, + preventDefault() {}, + clipboardData: { items: [], types: ["text/plain"], getData: () => text }, + } as unknown as ClipboardEvent +} + +function keydown(key: string) { + return { key, isComposing: false, preventDefault() {} } as unknown as KeyboardEvent +} + +function setup() { + const [text, setText] = createSignal("") + const root = createRoot((dispose) => ({ + dispose, + paste: usePasteCollapse({ enabled: () => true, text }), + text, + setText, + })) + return root +} + +describe("usePasteCollapse", () => { + it("leaves the caret after the inserted chip instead of inside the following text", () => { + const ctx = setup() + const el = field("helloworld", 5) + + ctx.paste.paste(clipboard(first), el, ctx.setText) + + expect(el.value).toBe(`hello ${chip} world`) + expect(el.value.slice(el.selectionStart)).toBe("world") + expect(el.selectionStart).toBe(el.selectionEnd) + ctx.dispose() + }) + + it("keeps the surviving backing when the first of two identical chips is backspaced", () => { + const ctx = setup() + const el = field("") + + ctx.paste.paste(clipboard(first), el, ctx.setText) + el.setSelectionRange(el.value.length, el.value.length) + ctx.paste.paste(clipboard(second), el, ctx.setText) + + expect(ctx.text()).toBe(`${chip} ${chip}`) + expect(ctx.paste.pastes().map((item) => item.text)).toEqual([first, second]) + + el.setSelectionRange(chip.length, chip.length) + const removed = ctx.paste.backspace(keydown("Backspace"), el, ctx.setText) + + expect(removed).toBe(true) + expect(el.value).toBe(chip) + expect(ctx.paste.plainText(el.value)).toBe(second) + ctx.dispose() + }) + + it("restores the right backing when a chip is expanded after an earlier chip was removed", () => { + const ctx = setup() + const el = field("") + + ctx.paste.paste(clipboard(first), el, ctx.setText) + el.setSelectionRange(el.value.length, el.value.length) + ctx.paste.paste(clipboard(second), el, ctx.setText) + + el.setSelectionRange(chip.length, chip.length) + ctx.paste.backspace(keydown("Backspace"), el, ctx.setText) + + const [only] = ctx.paste.pastes() + const expanded = ctx.paste.expand(only!.id, el, ctx.setText) + expect(expanded).toBe(true) + expect(el.value).toBe(second) + ctx.dispose() + }) + + it("writes a large expansion directly instead of through execCommand", () => { + const ctx = setup() + const el = field("") + const backing = Array.from({ length: 120 }, (_, index) => `${index} ${"x".repeat(40)}`).join("\n") + let calls = 0 + const global = globalThis as unknown as { document?: unknown } + const hadDoc = "document" in globalThis + const previous = global.document + global.document = { + execCommand: () => { + calls += 1 + return true + }, + } + try { + ctx.paste.paste(clipboard(backing), el, ctx.setText) + expect(calls).toBe(1) + + const [entry] = ctx.paste.pastes() + expect(ctx.paste.expand(entry!.id, el, ctx.setText)).toBe(true) + expect(calls).toBe(1) + expect(el.value).toBe(backing) + } finally { + if (hadDoc) global.document = previous + else delete global.document + } + ctx.dispose() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index cd4382aaa49..69455520781 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -28,6 +28,7 @@ import { SpeechToTextButton } from "../speech-to-text/SpeechToTextButton" import { canUseSpeechToText, selectedSpeechToTextModel } from "../speech-to-text/availability" import { ThinkingSelector } from "../shared/ThinkingSelector" import { useFileMention } from "../../hooks/useFileMention" +import { usePasteCollapse } from "../../hooks/usePasteCollapse" import type { MentionResult, WorktreeReference } from "../../hooks/file-mention-utils" import { isMentionEntry } from "../../hooks/file-mention-utils" import { useTerminalContext } from "../../hooks/useTerminalContext" @@ -52,7 +53,6 @@ import { cycleVariant } from "../../context/session-variant-store" import { fileName, dirName, - buildHighlightSegments, atEnd, insertSpacedText, isPromptBusy, @@ -86,6 +86,7 @@ import { mentionDrafts, isPendingDraftDiscarded, isSessionDraftDiscarded, + pasteDrafts, reviewDrafts, savePromptDraft, scrollDrafts, @@ -347,12 +348,14 @@ export const PromptInput: Component = (props) => { scroll = textareaRef?.scrollTop ?? scrollDrafts.get(key) ?? 0, browser: BrowserReference[] = browsers(), codeContexts: CodeContext[] = contexts(), - ) => savePromptDraft(key, next, comments, imgs, scroll, browser, codeContexts) + pastes = key === draftKey() ? paste.pastes().map((item) => item.text) : undefined, + ) => savePromptDraft(key, next, comments, imgs, scroll, browser, codeContexts, pastes) const readDraft = () => ({ text: text().trim(), comments: reviewComments(), images: imageAttach.images(), browsers: browsers(), + pastes: paste.pastes().map((item) => item.text), contexts: contexts(), scroll: textareaRef?.scrollTop ?? scrollDrafts.get(draftKey()) ?? 0, }) @@ -360,6 +363,12 @@ export const PromptInput: Component = (props) => { const [text, setText] = createSignal("") const [reviewComments, setReviewComments] = createSignal([]) const [browsers, setBrowsers] = createSignal([]) + // Large pastes collapse into a `[Pasted ~N lines]` chip, matching the CLI and + // JetBrains plugin. Honor the same experimental opt-out. + const paste = usePasteCollapse({ + enabled: () => globalConfig()?.experimental?.disable_paste_summary !== true, + text, + }) const [contexts, setContexts] = createSignal([]) const [enhancing, setEnhancing] = createSignal(false) const [autoApprove, setAutoApprove] = createSignal(false) @@ -478,6 +487,8 @@ export const PromptInput: Component = (props) => { } let enhanceCounter = 0 let preEnhanceText: string | null = null + // Backing text of collapsed pastes, restored alongside preEnhanceText on undo. + let preEnhancePastes: string[] | null = null createEffect(() => { const sessionID = sandboxID() @@ -565,13 +576,23 @@ export const PromptInput: Component = (props) => { codeContexts.length > 0 || drafts.has(prev) ) { - saveDraft(prev, val, comments, imgs, undefined, browser, codeContexts) + saveDraft( + prev, + val, + comments, + imgs, + undefined, + browser, + codeContexts, + untrack(paste.pastes).map((item) => item.text), + ) } } const draft = drafts.get(key) ?? "" const pending = reviewDrafts.get(key) ?? [] const scroll = scrollDrafts.get(key) ?? 0 setText(draft) + paste.load(draft, pasteDrafts.get(key) ?? []) mention.seedFromText(draft) const refs = mentionDrafts.get(key) if (refs) { @@ -584,6 +605,7 @@ export const PromptInput: Component = (props) => { imageAttach.replace(imageDrafts.get(key) ?? []) setEnhancing(false) preEnhanceText = null + preEnhancePastes = null history.reset() if (textareaRef) { textareaRef.value = draft @@ -666,10 +688,12 @@ export const PromptInput: Component = (props) => { const imgs = imageAttach.images() const browser = browsers() const scroll = textareaRef?.scrollTop ?? 0 + // Capture paste backing before tabs.add() switches the draft and clears it. + const pastes = paste.pastes().map((item) => item.text) const id = tabs?.add() if (!id) session.clearCurrentSession() const key = id ? scopeDraftKey(boxKey(), pendingDraftKey(id) ?? "new") : draftKey() - saveDraft(key, draft, comments, imgs, scroll, browser) + saveDraft(key, draft, comments, imgs, scroll, browser, undefined, pastes) } window.addEventListener("newTaskRequest", onNewTaskRequest) onCleanup(() => window.removeEventListener("newTaskRequest", onNewTaskRequest)) @@ -699,6 +723,7 @@ export const PromptInput: Component = (props) => { draft.scroll, draft.browsers, draft.contexts, + draft.pastes, ) } window.addEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft) @@ -826,12 +851,14 @@ export const PromptInput: Component = (props) => { ...(active ? imageAttach.images() : (imageDrafts.get(key) ?? [])), ] const comments = active ? reviewComments() : (reviewDrafts.get(key) ?? []) + const pastes = active ? paste.pastes().map((item) => item.text) : undefined const codeContexts = active ? contexts() : (contextDrafts.get(key) ?? []) - savePromptDraft(key, value, comments, images, undefined, undefined, codeContexts) + savePromptDraft(key, value, comments, images, undefined, undefined, codeContexts, pastes) mentionDrafts.set(key, { paths: state.paths, sessions: state.sessions }) if (!active) return enhanceCounter++ preEnhanceText = null + preEnhancePastes = null history.reset() setText(value) mention.seedFromParts(state.paths, value) @@ -964,6 +991,7 @@ export const PromptInput: Component = (props) => { return } setText(message.text) + paste.load(message.text, pasteDrafts.get(key) ?? []) if (message.paths?.length) mention.seedFromParts(message.paths, message.text) else mention.seedFromText(message.text) if (message.sessions?.length) mention.seedSessions(message.sessions, message.text) @@ -1078,6 +1106,7 @@ export const PromptInput: Component = (props) => { images: imageDrafts, scrolls: scrollDrafts, browsers: references, + pastes: pasteDrafts, contexts: contextDrafts, }, source, @@ -1226,6 +1255,17 @@ export const PromptInput: Component = (props) => { return } imageAttach.handlePaste(e) + // Collapse a large plain-text paste into a chip before the browser inserts + // it; images and files keep the default path. + if ( + textareaRef && + paste.paste(e, textareaRef, setText, () => { + adjustHeight() + syncHighlightScroll() + }) + ) { + return + } // After pasting text, the textarea content changes but the layout may not // have reflowed yet, causing the caret position to be visually out of sync. // Defer height recalculation to after the browser completes the reflow. @@ -1244,6 +1284,7 @@ export const PromptInput: Component = (props) => { const val = target.value setText(val) preEnhanceText = null + preEnhancePastes = null adjustHeight() syncHighlightScroll() history.reset() @@ -1276,10 +1317,13 @@ export const PromptInput: Component = (props) => { if (e.key === "z" && (e.metaKey || e.ctrlKey) && !e.shiftKey && preEnhanceText !== null) { e.preventDefault() const restored = preEnhanceText + const pastes = preEnhancePastes preEnhanceText = null + preEnhancePastes = null setText(restored) if (textareaRef) { textareaRef.value = restored + paste.load(restored, pastes ?? []) adjustHeight() } return @@ -1294,7 +1338,15 @@ export const PromptInput: Component = (props) => { ) return - // Skip cursor over mentions on arrow keys + // Atomic collapsed-paste removal on backspace + if (paste.backspace(e, textareaRef, setText)) { + adjustHeight() + syncHighlightScroll() + return + } + + // Skip cursor over mentions and collapsed pastes on arrow keys + paste.arrow(e, textareaRef) if (mention.handleArrowKey(e, textareaRef)) return if (slash.onKeyDown(e, textareaRef, setText, adjustHeight)) { @@ -1369,7 +1421,7 @@ export const PromptInput: Component = (props) => { const handleEnhance = () => { if (isDisabled() || enhancing() || isBusy()) return - const draft = text().trim() + const draft = paste.plainText(text()).trim() if (!draft) { const description = language.t("prompt.action.enhanceDescription") setText(description) @@ -1381,6 +1433,7 @@ export const PromptInput: Component = (props) => { return } preEnhanceText = text() + preEnhancePastes = paste.pastes().map((item) => item.text) enhanceCounter++ setEnhancing(true) vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: `enhance-${draftKey()}-${enhanceCounter}` }) @@ -1529,7 +1582,10 @@ export const PromptInput: Component = (props) => { } const handleSend = async () => { - const draft = text().trim() + // Collapsed pastes are expanded to their full content before anything reads + // the draft: sending, attachments, slash detection, and history all see the + // real text, never the placeholder. + const draft = paste.plainText(text()).trim() if ( !goal.prepare(draft, () => { setText("") @@ -1558,6 +1614,7 @@ export const PromptInput: Component = (props) => { imageDrafts.delete(draftKey()) mentionDrafts.delete(draftKey()) scrollDrafts.delete(draftKey()) + pasteDrafts.delete(draftKey()) if (textareaRef) textareaRef.style.height = "auto" return } @@ -1585,6 +1642,7 @@ export const PromptInput: Component = (props) => { imageDrafts.delete(draftKey()) mentionDrafts.delete(draftKey()) scrollDrafts.delete(draftKey()) + pasteDrafts.delete(draftKey()) if (textareaRef) textareaRef.style.height = "auto" matched.action() return @@ -1704,7 +1762,13 @@ export const PromptInput: Component = (props) => { clearDraft(key, draft) } - const clearDraft = (key: string, value = key === draftKey() ? text().trim() : (drafts.get(key) ?? "").trim()) => { + const clearDraft = (key: string, value?: string) => { + if (value === undefined) { + const active = key === draftKey() + const source = active ? text() : (drafts.get(key) ?? "") + const backing = active ? paste.pastes().map((item) => item.text) : (pasteDrafts.get(key) ?? []) + value = paste.plainTextFor(source, backing).trim() + } history.append(value) drafts.delete(key) reviewDrafts.delete(key) @@ -1713,6 +1777,7 @@ export const PromptInput: Component = (props) => { imageDrafts.delete(key) mentionDrafts.delete(key) scrollDrafts.delete(key) + pasteDrafts.delete(key) if (draftKey() !== key) return history.reset() @@ -1962,25 +2027,56 @@ export const PromptInput: Component = (props) => {