Merge pull request #14170 from Kilo-Org/feat/vscode-paste-collapse

feat(vscode): collapse large pastes in the prompt input
This commit is contained in:
Marius
2026-09-16 09:06:09 +02:00
committed by GitHub
36 changed files with 1213 additions and 53 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep large review diffs from jumping when file rows are re-created at a different panel width.
+5
View File
@@ -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.
+6 -2
View File
@@ -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
@@ -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" })
@@ -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<Page["locator"]>, 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)
})
@@ -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,")
@@ -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([])
})
})
@@ -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()
})
})
@@ -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<PromptInputProps> = (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<PromptInputProps> = (props) => {
const [text, setText] = createSignal("")
const [reviewComments, setReviewComments] = createSignal<ReviewCommentEntry[]>([])
const [browsers, setBrowsers] = createSignal<BrowserReference[]>([])
// 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<CodeContext[]>([])
const [enhancing, setEnhancing] = createSignal(false)
const [autoApprove, setAutoApprove] = createSignal(false)
@@ -478,6 +487,8 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
draft.scroll,
draft.browsers,
draft.contexts,
draft.pastes,
)
}
window.addEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft)
@@ -826,12 +851,14 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
images: imageDrafts,
scrolls: scrollDrafts,
browsers: references,
pastes: pasteDrafts,
contexts: contextDrafts,
},
source,
@@ -1226,6 +1255,17 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (props) => {
const val = target.value
setText(val)
preEnhanceText = null
preEnhancePastes = null
adjustHeight()
syncHighlightScroll()
history.reset()
@@ -1276,10 +1317,13 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
<div class="prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true" dir="auto">
<Index each={buildHighlightSegments(text(), highlightMentions())}>
<Index each={paste.segments(text(), highlightMentions())}>
{(seg) => (
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
<span
class="prompt-input-file-mention"
classList={{
"prompt-input-file-mention--file": isPathMention(seg().text) && !isModelMention(seg().text),
}}
onClick={(e) => {
if (!isPathMention(seg().text)) return
if (isModelMention(seg().text)) return
if (mention.mentionedSessions().has(seg().text.replace(/^@/, ""))) return
e.preventDefault()
e.stopPropagation()
vscode.postMessage({ type: "openFile", filePath: seg().text.replace(/^@/, "") })
}}
>
{seg().text}
</span>
<Show
when={seg().kind !== "paste"}
fallback={
<span
class="prompt-input-paste"
title={language.t("prompt.paste.expand")}
onClick={(e) => {
if (readonly()) return
if (!textareaRef) return
e.preventDefault()
e.stopPropagation()
paste.expand(seg().paste!, textareaRef, setText, () => {
// A large expansion writes the textarea value directly
// and skips the input handler. Reset the enhance and
// history state a manual edit would reset, but leave
// mention, slash, and ghost alone: restored paste
// content is not new input, so it should not rerun
// autocomplete or request a suggestion.
preEnhanceText = null
preEnhancePastes = null
history.reset()
adjustHeight()
syncHighlightScroll()
})
}}
>
{seg().text}
</span>
}
>
<Show when={seg().kind === "mention"} fallback={<span>{seg().text}</span>}>
<span
class="prompt-input-file-mention"
classList={{
"prompt-input-file-mention--file": isPathMention(seg().text) && !isModelMention(seg().text),
}}
onClick={(e) => {
if (!isPathMention(seg().text)) return
if (isModelMention(seg().text)) return
if (mention.mentionedSessions().has(seg().text.replace(/^@/, ""))) return
e.preventDefault()
e.stopPropagation()
vscode.postMessage({ type: "openFile", filePath: seg().text.replace(/^@/, "") })
}}
>
{seg().text}
</span>
</Show>
</Show>
)}
</Index>
@@ -2014,6 +2110,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
syncGhost()
}}
onPaste={handlePaste}
onCopy={(e) => {
if (paste.clipboard(e, textareaRef, setText)) syncGhost()
}}
onCut={(e) => {
if (!paste.clipboard(e, textareaRef, setText, true)) return
adjustHeight()
syncHighlightScroll()
syncGhost()
}}
onClick={syncGhost}
onFocus={() => {
hold.claim()
@@ -86,6 +86,186 @@ export function atEnd(start: number, end: number, len: number): boolean {
return start === end && end === len
}
/** A collapsed paste: the full text lives here, the input only carries the placeholder. */
export type PasteRange = {
id: number
start: number
end: number
text: string
}
export type PromptSegment = {
text: string
kind: "plain" | "mention" | "paste"
/** Paste id for a collapsed block, so a click can find its backing text. */
paste?: number
}
/** Number of lines a pasted block occupies, matching the CLI's newline count plus one. */
export function promptLineCount(text: string): number {
return (text.match(/\n/g)?.length ?? 0) + 1
}
/**
* Whether a pasted block collapses into a `[Pasted ~N lines]` placeholder.
*
* The VS Code composer has more room than the CLI, so these thresholds are
* higher than the CLI's five lines or 800 characters. Fifteen lines is just
* past the ~11 lines the 200px composer shows before scrolling, and 4000
* characters still catches a single enormous line that would otherwise wrap
* into a wall of text. The line rule catches tall pastes of short lines that a
* character count alone would miss.
*/
export function isCollapsiblePaste(text: string): boolean {
return promptLineCount(text) >= 15 || text.length > 4000
}
/**
* The placeholder shown for a collapsed paste. Kept as a stable English token so
* every client renders and can rediscover the same block; it is literal text the
* user could have typed, so anything without backing text is sent unchanged.
*/
export function pastePlaceholder(text: string): string {
return `[Pasted ~${promptLineCount(text)} lines]`
}
const PASTE_PLACEHOLDER = /^\[Pasted ~\d+ lines\]$/
const PASTE_TOKEN = /\[Pasted ~\d+ lines\]/g
/** Every placeholder occurrence in `text`, in order, with its range. */
export function findPastePlaceholders(text: string): { start: number; end: number }[] {
return Array.from(text.matchAll(PASTE_TOKEN), (match) => {
const start = match.index ?? 0
return { start, end: start + match[0].length }
})
}
function validPaste(text: string, paste: PasteRange): boolean {
if (paste.start < 0 || paste.end > text.length || paste.start >= paste.end) return false
return PASTE_PLACEHOLDER.test(text.slice(paste.start, paste.end))
}
/** The single edited span between two versions of the same text. */
export function textDiff(prev: string, next: string): { start: number; oldEnd: number; newEnd: number; delta: number } {
let start = 0
const min = Math.min(prev.length, next.length)
while (start < min && prev.charCodeAt(start) === next.charCodeAt(start)) start++
let oldEnd = prev.length
let newEnd = next.length
while (oldEnd > start && newEnd > start && prev.charCodeAt(oldEnd - 1) === next.charCodeAt(newEnd - 1)) {
oldEnd--
newEnd--
}
return { start, oldEnd, newEnd, delta: newEnd - oldEnd }
}
/**
* Move paste ranges across an edit. A block the edit only repositions keeps its
* backing text; a range the edit touches is dropped, because the placeholder it
* pointed at no longer exists. Ranges that no longer spell a placeholder are
* dropped too, so native undo or a programmatic rewrite cannot leave a stale
* range pointing at unrelated text.
*/
export function shiftPastes(pastes: readonly PasteRange[], prev: string, next: string): PasteRange[] {
if (prev === next) return [...pastes]
const diff = textDiff(prev, next)
const out: PasteRange[] = []
for (const paste of pastes) {
if (paste.end <= diff.start) {
if (validPaste(next, paste)) out.push(paste)
continue
}
if (paste.start >= diff.oldEnd) {
const moved = { ...paste, start: paste.start + diff.delta, end: paste.end + diff.delta }
if (validPaste(next, moved)) out.push(moved)
continue
}
}
return out
}
/**
* Move paste ranges across an edit the caller already knows exactly: it replaces
* `[start, end)` with `length` characters of new text. Unlike `shiftPastes`,
* which infers the edited span from two text versions, this uses the real span,
* so two identical placeholders cannot be confused with one another.
*/
export function rebasePastes(pastes: readonly PasteRange[], start: number, end: number, length: number): PasteRange[] {
const delta = length - (end - start)
const out: PasteRange[] = []
for (const paste of pastes) {
if (paste.end <= start) {
out.push(paste)
continue
}
if (paste.start >= end) out.push({ ...paste, start: paste.start + delta, end: paste.end + delta })
}
return out
}
/**
* Build the text for a collapsed paste inserted at `[start, end)` together with
* the chip range and the caret it should leave behind. The placeholder gains a
* separating space on either side when the neighbouring text needs one, so the
* chip range excludes those spaces while the caret lands after all of them.
*/
export function pasteInsertion(
text: string,
start: number,
end: number,
placeholder: string,
): { text: string; inserted: string; start: number; end: number; caret: number } {
const before = text.slice(0, start)
const after = text.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const inserted = `${prefix}${placeholder}${suffix}`
const rangeStart = before.length + prefix.length
return {
text: `${before}${inserted}${after}`,
inserted,
start: rangeStart,
end: rangeStart + placeholder.length,
caret: before.length + inserted.length,
}
}
/** Replace every collapsed block in `text` with its full backing text. */
export function expandPastes(text: string, pastes: readonly PasteRange[]): string {
let result = text
const ordered = [...pastes].filter(validPaste.bind(null, text)).sort((a, b) => b.start - a.start)
for (const paste of ordered) {
result = result.slice(0, paste.start) + paste.text + result.slice(paste.end)
}
return result
}
/**
* Split prompt text into plain runs, mention tokens, and collapsed paste chips.
* Paste ranges win over mention detection because their text is never a mention.
*/
export function buildPromptSegments(text: string, paths: Set<string>, pastes: readonly PasteRange[]): PromptSegment[] {
const segments: PromptSegment[] = []
const ordered = [...pastes].filter(validPaste.bind(null, text)).sort((a, b) => a.start - b.start)
let cursor = 0
for (const paste of ordered) {
if (paste.start < cursor) continue
if (paste.start > cursor) {
for (const part of buildHighlightSegments(text.slice(cursor, paste.start), paths)) {
segments.push({ text: part.text, kind: part.highlight ? "mention" : "plain" })
}
}
segments.push({ text: text.slice(paste.start, paste.end), kind: "paste", paste: paste.id })
cursor = paste.end
}
if (cursor < text.length) {
for (const part of buildHighlightSegments(text.slice(cursor), paths)) {
segments.push({ text: part.text, kind: part.highlight ? "mention" : "plain" })
}
}
return segments
}
export function insertSpacedText(
text: string,
value: string,
@@ -0,0 +1,288 @@
import { createEffect, createSignal, type Accessor } from "solid-js"
import {
buildHighlightSegments,
buildPromptSegments,
expandPastes,
findPastePlaceholders,
isCollapsiblePaste,
pasteInsertion,
pastePlaceholder,
rebasePastes,
shiftPastes,
type PasteRange,
type PromptSegment,
} from "../components/chat/prompt-input-utils"
export interface PasteCollapse {
/** Collapsed blocks in the current text, in text order. */
pastes: Accessor<PasteRange[]>
/** Split the current text for the highlight overlay, chips included. */
segments: (text: string, paths: Set<string>) => PromptSegment[]
/** The text with every collapsed block restored to its full content. */
plainText: (text: string) => string
/** Restore collapsed blocks for arbitrary text paired with stored backing. */
plainTextFor: (text: string, texts: readonly string[]) => string
/** Claim a large plain-text clipboard paste. Returns true when it was collapsed. */
paste: (
event: ClipboardEvent,
textarea: HTMLTextAreaElement,
setText: (value: string) => void,
after?: () => void,
) => boolean
/** Restore one collapsed block at the caret, in place. */
expand: (id: number, textarea: HTMLTextAreaElement, setText: (value: string) => void, after?: () => void) => boolean
/** Replace the tracked blocks, e.g. when a saved draft is restored. */
load: (text: string, texts: readonly string[]) => void
/** Delete a whole collapsed block on backspace, like a mention token. */
backspace: (
event: KeyboardEvent,
textarea: HTMLTextAreaElement | undefined,
setText: (value: string) => void,
) => boolean
/** Skip the caret over a collapsed block on ArrowLeft/ArrowRight. */
arrow: (event: KeyboardEvent, textarea: HTMLTextAreaElement | undefined) => boolean
/** Copy (or cut) a selection with collapsed blocks expanded. */
clipboard: (
event: ClipboardEvent,
textarea: HTMLTextAreaElement | undefined,
setText: (value: string) => void,
cut?: boolean,
) => boolean
}
/** Inserts above this size skip execCommand, which turns superlinear for large writes. */
const directLimit = 2048
/**
* Collapses large pasted blocks behind a `[Pasted ~N lines]` chip in the prompt
* input. The chip is literal text in the textarea, so the overlay and textarea
* stay aligned; the full content is tracked here by range and restored on
* expand, copy, and send.
*/
export function usePasteCollapse(opts: { enabled: Accessor<boolean>; text: Accessor<string> }): PasteCollapse {
const [pastes, setPastes] = createSignal<PasteRange[]>([])
let counter = 0
let prev = ""
let pendingArrow: ReturnType<typeof setTimeout> | undefined
const reconcile = (value: string) => {
if (value === prev) return
const next = shiftPastes(pastes(), prev, value)
prev = value
setPastes(next)
}
createEffect(() => reconcile(opts.text()))
const write = (
textarea: HTMLTextAreaElement,
start: number,
end: number,
value: string,
expected: string,
setText: (value: string) => void,
) => {
// Mark the resulting text before the edit. execCommand raises an input event
// that runs the reconcile effect, and that effect must treat this text as
// already applied or it shifts the ranges a second time.
prev = expected
textarea.focus()
textarea.setSelectionRange(start, end)
if (value.length > directLimit) {
// execCommand is superlinear for large inserts (seconds for ~100 KB) and
// fires an input event that reparses the whole prompt. Set the value
// directly instead; the caller still gets the resulting text and range.
textarea.value = expected
} else {
try {
document.execCommand("insertText", false, value)
} catch {
// execCommand is unavailable in some hosts; the direct write below covers it.
}
if (textarea.value !== expected) textarea.value = expected
}
setText(expected)
// The caller knows the exact edited span, so shift by it instead of inferring
// the span from a diff, which cannot tell two identical chips apart.
setPastes(rebasePastes(pastes(), start, end, value.length))
}
const paste = (
event: ClipboardEvent,
textarea: HTMLTextAreaElement,
setText: (value: string) => void,
after?: () => void,
): boolean => {
if (!opts.enabled() || event.defaultPrevented) return false
const data = event.clipboardData
if (!data) return false
// Files and images keep their own paste paths.
if (Array.from(data.items ?? []).some((item) => item.kind === "file")) return false
if (Array.from(data.types ?? []).includes("Files")) return false
const value = data.getData("text/plain")
if (!value) return false
const text = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (!text || !isCollapsiblePaste(text)) return false
event.preventDefault()
const current = textarea.value
const start = textarea.selectionStart ?? current.length
const end = textarea.selectionEnd ?? start
const placeholder = pastePlaceholder(text)
const insertion = pasteInsertion(current, start, end, placeholder)
write(textarea, start, end, insertion.inserted, insertion.text, setText)
// write() has already moved the older ranges; append the new block.
const entry: PasteRange = { id: ++counter, start: insertion.start, end: insertion.end, text }
setPastes([...pastes(), entry].sort((a, b) => a.start - b.start))
textarea.setSelectionRange(insertion.caret, insertion.caret)
after?.()
return true
}
const expand = (
id: number,
textarea: HTMLTextAreaElement,
setText: (value: string) => void,
after?: () => void,
): boolean => {
const entry = pastes().find((item) => item.id === id)
if (!entry) return false
const current = textarea.value
if (entry.end > current.length) return false
const expected = current.slice(0, entry.start) + entry.text + current.slice(entry.end)
// write() rebases against this exact edit, which drops this block and shifts
// the blocks after it, so the remaining ranges are already correct here.
write(textarea, entry.start, entry.end, entry.text, expected, setText)
const caret = entry.start + entry.text.length
textarea.setSelectionRange(caret, caret)
after?.()
return true
}
const load = (text: string, texts: readonly string[]) => {
const marks = findPastePlaceholders(text)
// Save and restore always happen together, so a mismatch means the text was
// edited outside this control. Keep no backing rather than pair the wrong
// content with a chip.
const items: PasteRange[] = []
if (marks.length === texts.length) {
for (let index = 0; index < marks.length; index++) {
const full = texts[index]
if (!full) continue
const mark = marks[index]!
items.push({ id: ++counter, start: mark.start, end: mark.end, text: full })
}
}
prev = text
setPastes(items)
}
const plainTextFor = (text: string, texts: readonly string[]) => {
const marks = findPastePlaceholders(text)
// A mismatch means the text was edited outside this control. Keep the text
// as-is rather than pair the wrong content with a chip.
if (marks.length !== texts.length) return text
const items: PasteRange[] = []
for (let index = 0; index < marks.length; index++) {
const full = texts[index]
if (!full) continue
const mark = marks[index]!
items.push({ id: index, start: mark.start, end: mark.end, text: full })
}
return expandPastes(text, items)
}
const backspace = (
event: KeyboardEvent,
textarea: HTMLTextAreaElement | undefined,
setText: (value: string) => void,
): boolean => {
if (!textarea || event.key !== "Backspace" || event.isComposing) return false
if (textarea.selectionStart !== textarea.selectionEnd) return false
const cursor = textarea.selectionStart ?? 0
const entry = pastes().find((item) => item.end === cursor)
if (!entry) return false
event.preventDefault()
const current = textarea.value
// Take a single trailing space with the block so a removed chip leaves no gap.
const end = current[entry.end] === " " ? entry.end + 1 : entry.end
const expected = current.slice(0, entry.start) + current.slice(end)
write(textarea, entry.start, end, "", expected, setText)
textarea.setSelectionRange(entry.start, entry.start)
return true
}
const arrow = (event: KeyboardEvent, textarea: HTMLTextAreaElement | undefined): boolean => {
if (!textarea) return false
if (pendingArrow) clearTimeout(pendingArrow)
pendingArrow = undefined
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return false
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return false
if (textarea.selectionStart !== textarea.selectionEnd) return false
const value = textarea.value
const from = textarea.selectionStart ?? 0
const forward = event.key === "ArrowRight"
pendingArrow = setTimeout(() => {
pendingArrow = undefined
if (textarea.value !== value) return
const at = textarea.selectionStart ?? 0
if (at === from) return
for (const item of pastes()) {
if (at > item.start && at < item.end) {
const target = forward ? item.end : item.start
textarea.setSelectionRange(target, target)
return
}
}
}, 0)
return false
}
const clipboard = (
event: ClipboardEvent,
textarea: HTMLTextAreaElement | undefined,
setText: (value: string) => void,
cut = false,
): boolean => {
if (!textarea) return false
const value = textarea.value
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? 0
if (start === end) return false
const inSelection = pastes()
.filter((item) => item.start >= start && item.end <= end)
.map((item) => ({ ...item, start: item.start - start, end: item.end - start }))
if (inSelection.length === 0) return false
event.clipboardData?.setData("text/plain", expandPastes(value.slice(start, end), inSelection))
event.preventDefault()
if (cut) write(textarea, start, end, "", value.slice(0, start) + value.slice(end), setText)
return true
}
return {
pastes,
segments: (text, paths) => {
const list = pastes()
// Fast path with no collapsed blocks: keep the original segment build so
// typing stays on the same code path it had before this feature.
if (list.length === 0) {
return buildHighlightSegments(text, paths).map((part) => ({
text: part.text,
kind: part.highlight ? ("mention" as const) : ("plain" as const),
}))
}
return buildPromptSegments(text, paths, list)
},
plainText: (text) => expandPastes(text, pastes()),
plainTextFor,
paste,
expand,
load,
backspace,
arrow,
clipboard,
}
}
+1
View File
@@ -197,6 +197,7 @@ export const dict = {
"prompt.action.send.recording": "تفريغ وإرسال",
"prompt.action.stop": "توقف",
"prompt.action.enhance": "تحسين النص",
"prompt.paste.expand": "انقر لتوسيع النص الملصق",
"prompt.action.autoApprove.enable": "تفعيل الموافقة التلقائية",
"prompt.action.autoApprove.disable": "تعطيل الموافقة التلقائية",
"prompt.action.autoApprove.enabled": "الموافقة التلقائية مفعلة. ستتم الموافقة على طلبات الأذونات تلقائياً.",
+1
View File
@@ -201,6 +201,7 @@ export const dict = {
"prompt.action.send.recording": "Transcrever e enviar",
"prompt.action.stop": "Parar",
"prompt.action.enhance": "Melhorar prompt",
"prompt.paste.expand": "Clique para expandir o texto colado",
"prompt.action.autoApprove.enable": "Ativar aprovação automática",
"prompt.action.autoApprove.disable": "Desativar aprovação automática",
"prompt.action.autoApprove.enabled":
+1
View File
@@ -202,6 +202,7 @@ export const dict = {
"prompt.action.send.recording": "Transkribuj i pošalji",
"prompt.action.stop": "Zaustavi",
"prompt.action.enhance": "Poboljšaj prompt",
"prompt.paste.expand": "Kliknite da proširite zalijepljeni tekst",
"prompt.action.autoApprove.enable": "Uključi automatsko odobravanje",
"prompt.action.autoApprove.disable": "Isključi automatsko odobravanje",
"prompt.action.autoApprove.enabled":
+1
View File
@@ -201,6 +201,7 @@ export const dict = {
"prompt.action.send.recording": "Transskriber og send",
"prompt.action.stop": "Stop",
"prompt.action.enhance": "Forbedr prompt",
"prompt.paste.expand": "Klik for at udvide den indsatte tekst",
"prompt.action.autoApprove.enable": "Aktiver automatisk godkendelse",
"prompt.action.autoApprove.disable": "Deaktiver automatisk godkendelse",
"prompt.action.autoApprove.enabled":
@@ -207,6 +207,7 @@ export const dict = {
"prompt.action.send.recording": "Transkribieren und senden",
"prompt.action.stop": "Stopp",
"prompt.action.enhance": "Prompt verbessern",
"prompt.paste.expand": "Klicken, um eingefügten Text zu erweitern",
"prompt.action.autoApprove.enable": "Automatische Genehmigung aktivieren",
"prompt.action.autoApprove.disable": "Automatische Genehmigung deaktivieren",
"prompt.action.autoApprove.enabled":
@@ -198,6 +198,7 @@ export const dict = {
"prompt.action.send.recording": "Transcribe and send",
"prompt.action.stop": "Stop",
"prompt.action.enhance": "Enhance prompt",
"prompt.paste.expand": "Click to expand pasted text",
"prompt.action.indexing": "Indexing settings",
"prompt.action.autoApprove.enable": "Enable auto-approve",
"prompt.action.autoApprove.disable": "Disable auto-approve",
+1
View File
@@ -202,6 +202,7 @@ export const dict = {
"prompt.action.send.recording": "Transcribir y enviar",
"prompt.action.stop": "Detener",
"prompt.action.enhance": "Mejorar prompt",
"prompt.paste.expand": "Haz clic para expandir el texto pegado",
"prompt.action.autoApprove.enable": "Activar aprobación automática",
"prompt.action.autoApprove.disable": "Desactivar aprobación automática",
"prompt.action.autoApprove.enabled":
+1
View File
@@ -202,6 +202,7 @@ export const dict = {
"prompt.action.send.recording": "رونویسی و ارسال",
"prompt.action.stop": "توقف",
"prompt.action.enhance": "بهبود پرامپت",
"prompt.paste.expand": "برای بازکردن متن جایگذاری‌شده کلیک کنید",
"prompt.action.indexing": "تنظیمات ایندکس‌گذاری",
"prompt.action.autoApprove.enable": "فعال‌سازی تأیید خودکار",
"prompt.action.autoApprove.disable": "غیرفعال‌سازی تأیید خودکار",
+1
View File
@@ -202,6 +202,7 @@ export const dict = {
"prompt.action.send.recording": "Transcrire et envoyer",
"prompt.action.stop": "Arrêter",
"prompt.action.enhance": "Améliorer le prompt",
"prompt.paste.expand": "Cliquez pour développer le texte collé",
"prompt.action.autoApprove.enable": "Activer l'approbation automatique",
"prompt.action.autoApprove.disable": "Désactiver l'approbation automatique",
"prompt.action.autoApprove.enabled":
+1
View File
@@ -179,6 +179,7 @@ export const dict = {
"prompt.action.send.blocked": "Rispondi alla domanda in sospeso o ignorala prima di continuare",
"prompt.action.stop": "Ferma",
"prompt.action.enhance": "Migliora prompt",
"prompt.paste.expand": "Fai clic per espandere il testo incollato",
"prompt.action.indexing": "Impostazioni indicizzazione",
"prompt.action.autoApprove.enable": "Abilita approvazione automatica",
"prompt.action.autoApprove.disable": "Disabilita approvazione automatica",
+1
View File
@@ -201,6 +201,7 @@ export const dict = {
"prompt.action.send.recording": "文字起こしして送信",
"prompt.action.stop": "停止",
"prompt.action.enhance": "プロンプトを改善",
"prompt.paste.expand": "クリックして貼り付けたテキストを展開",
"prompt.action.autoApprove.enable": "自動承認を有効化",
"prompt.action.autoApprove.disable": "自動承認を無効化",
"prompt.action.autoApprove.enabled": "自動承認が有効です。権限リクエストは自動的に承認されます。",
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"prompt.action.send.recording": "텍스트 변환 및 전송",
"prompt.action.stop": "중지",
"prompt.action.enhance": "프롬프트 개선",
"prompt.paste.expand": "붙여넣은 텍스트를 확장하려면 클릭",
"prompt.action.autoApprove.enable": "자동 승인 사용",
"prompt.action.autoApprove.disable": "자동 승인 사용 안 함",
"prompt.action.autoApprove.enabled": "자동 승인이 켜져 있습니다. 권한 요청이 자동으로 승인됩니다.",
+1
View File
@@ -202,6 +202,7 @@ export const dict = {
"prompt.action.send.recording": "Transcriberen en verzenden",
"prompt.action.stop": "Stop",
"prompt.action.enhance": "Prompt verbeteren",
"prompt.paste.expand": "Klik om geplakte tekst uit te vouwen",
"prompt.action.indexing": "Indexeringsinstellingen",
"prompt.action.autoApprove.enable": "Automatisch goedkeuren inschakelen",
"prompt.action.autoApprove.disable": "Automatisch goedkeuren uitschakelen",
+1
View File
@@ -204,6 +204,7 @@ export const dict = {
"prompt.action.send.recording": "Transkriber og send",
"prompt.action.stop": "Stopp",
"prompt.action.enhance": "Forbedre prompt",
"prompt.paste.expand": "Klikk for å utvide den innlimte teksten",
"prompt.action.autoApprove.enable": "Aktiver automatisk godkjenning",
"prompt.action.autoApprove.disable": "Deaktiver automatisk godkjenning",
"prompt.action.autoApprove.enabled":
+1
View File
@@ -201,6 +201,7 @@ export const dict = {
"prompt.action.send.recording": "Transkrybuj i wyślij",
"prompt.action.stop": "Zatrzymaj",
"prompt.action.enhance": "Ulepsz prompt",
"prompt.paste.expand": "Kliknij, aby rozwinąć wklejony tekst",
"prompt.action.autoApprove.enable": "Włącz automatyczne zatwierdzanie",
"prompt.action.autoApprove.disable": "Wyłącz automatyczne zatwierdzanie",
"prompt.action.autoApprove.enabled":
+1
View File
@@ -200,6 +200,7 @@ export const dict = {
"prompt.action.send.recording": "Расшифровать и отправить",
"prompt.action.stop": "Остановить",
"prompt.action.enhance": "Улучшить промпт",
"prompt.paste.expand": "Нажмите, чтобы развернуть вставленный текст",
"prompt.action.autoApprove.enable": "Включить автоодобрение",
"prompt.action.autoApprove.disable": "Отключить автоодобрение",
"prompt.action.autoApprove.enabled": "Автоодобрение включено. Запросы разрешений будут одобряться автоматически.",
+1
View File
@@ -200,6 +200,7 @@ export const dict = {
"prompt.action.send.recording": "ถอดเสียงและส่ง",
"prompt.action.stop": "หยุด",
"prompt.action.enhance": "ปรับปรุงพรอมต์",
"prompt.paste.expand": "คลิกเพื่อขยายข้อความที่วาง",
"prompt.action.autoApprove.enable": "เปิดใช้การอนุมัติอัตโนมัติ",
"prompt.action.autoApprove.disable": "ปิดใช้การอนุมัติอัตโนมัติ",
"prompt.action.autoApprove.enabled": "เปิดใช้การอนุมัติอัตโนมัติแล้ว คำขอสิทธิ์จะได้รับการอนุมัติโดยอัตโนมัติ",
+1
View File
@@ -201,6 +201,7 @@ export const dict = {
"prompt.action.send.recording": "Yazıya dök ve gönder",
"prompt.action.stop": "Durdur",
"prompt.action.enhance": "Komutu geliştir",
"prompt.paste.expand": "Yapıştırılan metni genişletmek için tıklayın",
"prompt.action.indexing": "İndeksleme ayarları",
"prompt.action.autoApprove.enable": "Otomatik onayı etkinleştir",
"prompt.action.autoApprove.disable": "Otomatik onayı devre dışı bırak",
+1
View File
@@ -202,6 +202,7 @@ export const dict = {
"prompt.action.send.recording": "Транскрибувати та надіслати",
"prompt.action.stop": "Зупинити",
"prompt.action.enhance": "Покращити запит",
"prompt.paste.expand": "Натисніть, щоб розгорнути вставлений текст",
"prompt.action.indexing": "Налаштування індексування",
"prompt.action.autoApprove.enable": "Увімкнути автоматичне схвалення",
"prompt.action.autoApprove.disable": "Вимкнути автоматичне схвалення",
+1
View File
@@ -200,6 +200,7 @@ export const dict = {
"prompt.action.send.recording": "转录并发送",
"prompt.action.stop": "停止",
"prompt.action.enhance": "优化提示词",
"prompt.paste.expand": "点击展开粘贴的文本",
"prompt.action.enhanceDescription":
"'增强提示'按钮通过提供额外上下文、澄清或重新表述来帮助改进您的请求。尝试在此处输入请求,然后再次点击按钮查看其工作原理。",
"prompt.action.sandbox.enable": "启用沙盒",
+1
View File
@@ -195,6 +195,7 @@ export const dict = {
"prompt.action.send.recording": "轉錄並傳送",
"prompt.action.stop": "停止",
"prompt.action.enhance": "改善提示詞",
"prompt.paste.expand": "點擊展開貼上的文字",
"prompt.action.autoApprove.enable": "啟用自動核准",
"prompt.action.autoApprove.disable": "停用自動核准",
"prompt.action.autoApprove.enabled": "自動核准已啟用。權限請求將自動獲准。",
@@ -445,6 +445,36 @@
text-decoration: underline;
}
/* Collapsed large paste. The text is identical to the textarea's placeholder, so
the overlay stays aligned; only the chip decoration is added here. */
.prompt-input-paste {
color: var(--vscode-textPreformat-foreground, var(--vscode-foreground));
background-color: color-mix(
in srgb,
var(--vscode-textPreformat-foreground, var(--vscode-foreground)) 12%,
transparent
);
border-radius: 3px;
box-shadow: 0 0 0 0.5px
color-mix(in srgb, var(--vscode-textPreformat-foreground, var(--vscode-foreground)) 30%, transparent);
pointer-events: auto;
cursor: pointer;
transition:
background-color 0.15s,
box-shadow 0.15s;
}
.prompt-input-paste:hover {
background-color: color-mix(
in srgb,
var(--vscode-textPreformat-foreground, var(--vscode-foreground)) 22%,
transparent
);
box-shadow: 0 0 0 0.5px
color-mix(in srgb, var(--vscode-textPreformat-foreground, var(--vscode-foreground)) 45%, transparent);
text-decoration: underline;
}
.prompt-input-ghost-text {
color: var(--vscode-editorGhostText-foreground, rgba(255, 255, 255, 0.35));
display: inline-flex;
@@ -60,6 +60,7 @@ export interface ExperimentalConfig {
primary_tools?: string[]
continue_loop_on_deny?: boolean
mcp_timeout?: number
disable_paste_summary?: boolean
}
export interface SandboxConfig {
@@ -11,6 +11,9 @@ export const reviewDrafts = new Map<string, ReviewCommentEntry[]>()
export const contextDrafts = new Map<string, CodeContext[]>()
export const imageDrafts = new Map<string, ImageAttachment[]>()
export const scrollDrafts = new Map<string, number>()
/** Full text of collapsed pastes per draft key, in text order, so a restored
* draft can expand its `[Pasted ~N lines]` chips again. */
export const pasteDrafts = new Map<string, string[]>()
const discarded = new Set<string>()
const discardedSessions = new Set<string>()
const sending = new Set<string>()
@@ -23,6 +26,7 @@ export function savePromptDraft(
scroll = 0,
browsers: BrowserReference[] = [],
contexts: CodeContext[] = [],
pastes?: string[],
) {
if (!text) mentionDrafts.delete(key)
if (text) drafts.set(key, text)
@@ -33,6 +37,10 @@ export function savePromptDraft(
else imageDrafts.delete(key)
if (browsers.length > 0) browserDrafts.set(key, browsers)
else browserDrafts.delete(key)
if (pastes !== undefined) {
if (pastes.length > 0) pasteDrafts.set(key, pastes)
else pasteDrafts.delete(key)
}
if (contexts.length > 0) contextDrafts.set(key, contexts)
else contextDrafts.delete(key)
if (text || comments.length > 0 || images.length > 0 || browsers.length > 0 || contexts.length > 0)
@@ -43,7 +51,16 @@ export function savePromptDraft(
function remove(raw: string | undefined) {
if (!raw) return
const suffix = `:${raw}`
for (const map of [drafts, browserDrafts, reviewDrafts, contextDrafts, imageDrafts, scrollDrafts, mentionDrafts]) {
for (const map of [
drafts,
browserDrafts,
reviewDrafts,
contextDrafts,
imageDrafts,
scrollDrafts,
mentionDrafts,
pasteDrafts,
]) {
for (const key of map.keys()) {
if (typeof key === "string" && key.endsWith(suffix)) map.delete(key)
}
@@ -69,37 +69,44 @@ export function clearPromptDraftRoutes(id?: string): void {
}
}
export function movePromptDraft<T, C, I, S, B, X>(
/**
* Move one draft value from `source` to `target`. Required stores never
* overwrite an existing target value; optional stores do.
*/
function move<V>(map: Map<string, V>, source: string, target: string, overwrite: boolean): V | undefined {
const value = map.get(source)
if (value === undefined) return undefined
if (overwrite || !map.has(target)) map.set(target, value)
map.delete(source)
return value
}
export function movePromptDraft<T, C, I, S, B, P, X>(
stores: {
text: Map<string, T>
comments: Map<string, C>
images: Map<string, I>
scrolls: Map<string, S>
browsers?: Map<string, B>
pastes?: Map<string, P>
contexts?: Map<string, X>
},
source: string,
target: string,
): { text?: T; comments?: C; images?: I; scroll?: S; browsers?: B; contexts?: X } {
const draft = {
text: stores.text.get(source),
comments: stores.comments.get(source),
images: stores.images.get(source),
scroll: stores.scrolls.get(source),
...(stores.browsers?.has(source) ? { browsers: stores.browsers.get(source) } : {}),
...(stores.contexts?.has(source) ? { contexts: stores.contexts.get(source) } : {}),
): { text?: T; comments?: C; images?: I; scroll?: S; browsers?: B; pastes?: P; contexts?: X } {
const hasBrowsers = stores.browsers?.has(source) ?? false
const hasPastes = stores.pastes?.has(source) ?? false
const hasContexts = stores.contexts?.has(source) ?? false
const browsers = stores.browsers ? move(stores.browsers, source, target, true) : undefined
const pastes = stores.pastes ? move(stores.pastes, source, target, true) : undefined
const contexts = stores.contexts ? move(stores.contexts, source, target, true) : undefined
return {
text: move(stores.text, source, target, false),
comments: move(stores.comments, source, target, false),
images: move(stores.images, source, target, false),
scroll: move(stores.scrolls, source, target, false),
...(hasBrowsers ? { browsers } : {}),
...(hasPastes ? { pastes } : {}),
...(hasContexts ? { contexts } : {}),
}
if (draft.text !== undefined && !stores.text.has(target)) stores.text.set(target, draft.text)
if (draft.comments !== undefined && !stores.comments.has(target)) stores.comments.set(target, draft.comments)
if (draft.images !== undefined && !stores.images.has(target)) stores.images.set(target, draft.images)
if (draft.scroll !== undefined && !stores.scrolls.has(target)) stores.scrolls.set(target, draft.scroll)
if (draft.browsers !== undefined) stores.browsers?.set(target, draft.browsers)
if (draft.contexts !== undefined) stores.contexts?.set(target, draft.contexts)
stores.text.delete(source)
stores.comments.delete(source)
stores.images.delete(source)
stores.scrolls.delete(source)
stores.browsers?.delete(source)
stores.contexts?.delete(source)
return draft
}