mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep large review diffs from jumping when file rows are re-created at a different panel width.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user