fix: preserve exact short paste text

This commit is contained in:
2026-09-01 20:19:53 +09:00
parent c52c7ead19
commit 00c8df0fc9
6 changed files with 279 additions and 34 deletions
@@ -18,12 +18,13 @@
defaultKeymap,
history,
historyKeymap,
invertedEffects,
isolateHistory,
} from "@codemirror/commands";
import { onMount } from "svelte";
import type { Segment } from "$lib/generated/protocol.ts";
import {
handleComposerPaste,
measureComposerPaste,
type ComposerPasteMeasurement,
} from "$lib/workspace/console/composer-paste.ts";
import {
@@ -33,6 +34,7 @@
snapshotComposerDraft,
type ComposerDraftSnapshot,
type ComposerPaste,
type ComposerTextPaste,
} from "$lib/workspace/console/composer-draft.ts";
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
@@ -74,6 +76,37 @@
},
});
const registerTextPaste = StateEffect.define<ComposerTextPaste>();
const restoreTextPastes = StateEffect.define<readonly ComposerTextPaste[]>();
const textPasteState = StateField.define<readonly ComposerTextPaste[]>({
create: () => [],
update(textPastes, transaction) {
const restored = transaction.effects.find((effect) =>
effect.is(restoreTextPastes)
);
if (restored) return restored.value;
const retained: ComposerTextPaste[] = [];
for (const paste of textPastes) {
let touched = false;
transaction.changes.iterChangedRanges((from, to) => {
const replacesContent = from < paste.to && to > paste.from;
const insertsInside = from === to && from > paste.from && from < paste.to;
if (replacesContent || insertsInside) touched = true;
});
if (touched) continue;
retained.push({
...paste,
from: transaction.changes.mapPos(paste.from, 1),
to: transaction.changes.mapPos(paste.to, -1),
});
}
for (const effect of transaction.effects) {
if (effect.is(registerTextPaste)) retained.push(effect.value);
}
return retained;
},
});
class PasteChipWidget extends WidgetType {
readonly paste: ComposerPaste;
@@ -120,15 +153,25 @@
const pasteChips = [
pasteRegistry,
textPasteState,
invertedEffects.of((transaction) =>
transaction.docChanged
? [restoreTextPastes.of(transaction.startState.field(textPasteState))]
: []
),
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
];
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
if (!state) {
return { document: "", content: "", segments: [], pastes: [] };
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
}
return snapshotComposerDraft(state.doc.toString(), state.field(pasteRegistry));
return snapshotComposerDraft(
state.doc.toString(),
state.field(pasteRegistry),
state.field(textPasteState),
);
}
function emitChange(): void {
@@ -155,6 +198,37 @@
});
}
function insertTextPaste(content: string): void {
if (!view) return;
const selection = view.state.selection.main;
const rendered = view.state.toText(content).toString();
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: rendered },
selection: EditorSelection.cursor(selection.from + rendered.length),
effects: registerTextPaste.of({
from: selection.from,
to: selection.from + rendered.length,
rendered,
content,
}),
annotations: isolateHistory.of("full"),
userEvent: "input.paste",
});
}
function handlePasteEvent(event: ClipboardEvent): boolean {
const content = event.clipboardData?.getData("text/plain");
if (!content) return false;
const measurement = measureComposerPaste(content);
event.preventDefault();
if (measurement.presentation === "chip") {
insertPasteChip(content, measurement);
} else {
insertTextPaste(content);
}
return true;
}
function selectedClipboardContent(state: EditorState): string | null {
const selection = state.selection.main;
if (selection.empty) return null;
@@ -166,7 +240,14 @@
selectedRegistry.set(atom.key, atom);
}
}
return snapshotComposerDraft(document, selectedRegistry).content;
const selectedTextPastes = state.field(textPasteState)
.filter((paste) => paste.from >= selection.from && paste.to <= selection.to)
.map((paste) => ({
...paste,
from: paste.from - selection.from,
to: paste.to - selection.from,
}));
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
}
function deleteAdjacentPasteFromView(
@@ -229,7 +310,7 @@
}),
Prec.high(EditorView.domEventHandlers({
paste(event) {
return handleComposerPaste(event, insertPasteChip);
return handlePasteEvent(event);
},
copy(event, currentView) {
const content = selectedClipboardContent(currentView.state);
@@ -327,14 +408,28 @@
});
}
export function restoreSegments(segments: readonly Segment[]): void {
export function restoreSegments(
segments: readonly Segment[],
preserveExactText = false,
): void {
if (!view) return;
let document = "";
const effects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
const textEffects: StateEffect<ComposerTextPaste>[] = [];
let highestPasteId = nextPasteId - 1;
for (const segment of segments) {
if (segment.kind === "text") {
document += segment.content;
const rendered = view.state.toText(segment.content).toString();
const from = document.length;
document += rendered;
if (preserveExactText) {
textEffects.push(registerTextPaste.of({
from,
to: from + rendered.length,
rendered,
content: segment.content,
}));
}
} else if (segment.kind === "paste") {
const key = nextPasteKey++;
const paste: ComposerPaste = {
@@ -345,7 +440,7 @@
};
highestPasteId = Math.max(highestPasteId, paste.id);
document += composerPasteToken(key);
effects.push(registerPaste.of({ key, paste }));
pasteEffects.push(registerPaste.of({ key, paste }));
} else if (segment.kind === "file_ref") {
document += `@${segment.path}`;
}
@@ -354,7 +449,7 @@
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: EditorSelection.cursor(document.length),
effects,
effects: [...pasteEffects, ...textEffects],
userEvent: "input.restore",
});
}
@@ -80,20 +80,44 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
};
}
export interface ComposerSegmentsRequestOptions {
preserveExactText?: boolean;
}
export function buildComposerSegmentsRequest(
sourceSegments: readonly Segment[],
options: ComposerSegmentsRequestOptions = {},
): ComposerCommandResult {
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
if (!hasPaste) {
const content = sourceSegments.map(segmentContent).join("");
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
return buildComposerRequest(content);
}
if (!content.trim()) {
return { ok: false, message: "Input is empty." };
}
const segments = coalesceTextSegments(
sourceSegments.flatMap((segment) =>
segment.kind === "text"
? parseSigilSegments(segment.content)
: [segment]
),
);
return {
ok: true,
request: { kind: "user", content, segments },
};
}
const content = sourceSegments.map(segmentContent).join("");
if (!content.trim()) {
return { ok: false, message: "Input is empty." };
}
if (content.trimStart().startsWith(":")) {
const leadingText = sourceSegments[0]?.kind === "text"
? sourceSegments[0].content
: "";
if (leadingText.trimStart().startsWith(":")) {
return {
ok: false,
message:
@@ -134,6 +134,70 @@ Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries
});
});
Deno.test("short-paste Text preserves CRLF, trailing newline, and surrounding whitespace", () => {
const original = " short\r\npaste\r\n ";
const rendered = " short\npaste\n ";
const snapshot = snapshotComposerDraft(rendered, new Map(), [{
from: 0,
to: rendered.length,
rendered,
content: original,
}]);
assertEquals(snapshot.content, original);
assertEquals(snapshot.segments, [{ kind: "text", content: original }]);
assertEquals(snapshot.textPastes.length, 1);
const result = buildComposerSegmentsRequest(snapshot.segments, {
preserveExactText: snapshot.textPastes.length > 0,
});
assert(result.ok);
assertEquals(result.request, {
kind: "user",
content: original,
segments: [{ kind: "text", content: original }],
});
});
Deno.test("edited short-paste provenance falls back to visible Text", () => {
const snapshot = snapshotComposerDraft("changed", new Map(), [{
from: 0,
to: 5,
rendered: "short",
content: "short\r\n",
}]);
assertEquals(snapshot.content, "changed");
assertEquals(snapshot.segments, [{ kind: "text", content: "changed" }]);
assertEquals(snapshot.textPastes, []);
});
Deno.test("Paste content beginning with a colon remains opaque user input", () => {
const directPaste: Segment = {
kind: "paste",
id: 3,
content: ":not-a-command\r\n",
chars: 16,
lines: 2,
};
const direct = buildComposerSegmentsRequest([directPaste]);
assert(direct.ok);
assertEquals(direct.request, {
kind: "user",
content: ":not-a-command\r\n",
segments: [directPaste],
});
const afterWhitespace = buildComposerSegmentsRequest([
{ kind: "text", content: " " },
directPaste,
]);
assert(afterWhitespace.ok);
assert(afterWhitespace.request);
assertEquals(afterWhitespace.request.kind, "user");
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
});
Deno.test("plain short-paste Text retains the existing composer request path", () => {
const result = buildComposerSegmentsRequest([
{ kind: "text", content: " short\r\npaste\r\n " },
@@ -17,11 +17,19 @@ export interface ComposerPasteAtom extends ComposerPaste {
to: number;
}
export interface ComposerTextPaste {
from: number;
to: number;
rendered: string;
content: string;
}
export interface ComposerDraftSnapshot {
document: string;
content: string;
segments: Segment[];
pastes: ComposerPasteAtom[];
textPastes: ComposerTextPaste[];
}
export function composerPasteToken(key: number): string {
@@ -60,32 +68,60 @@ function appendTextSegment(segments: Segment[], content: string): void {
export function snapshotComposerDraft(
document: string,
registry: ReadonlyMap<number, ComposerPaste>,
candidateTextPastes: readonly ComposerTextPaste[] = [],
): ComposerDraftSnapshot {
const pastes = composerPasteAtoms(document, registry);
const textPastes = candidateTextPastes
.filter((paste) =>
paste.from >= 0 &&
paste.to <= document.length &&
document.slice(paste.from, paste.to) === paste.rendered
)
.sort((left, right) => left.from - right.from);
const events = [
...pastes.map((paste) => ({
kind: "paste" as const,
from: paste.from,
to: paste.to,
paste,
})),
...textPastes.map((paste) => ({
kind: "text_paste" as const,
from: paste.from,
to: paste.to,
paste,
})),
].sort((left, right) => left.from - right.from);
const segments: Segment[] = [];
let content = "";
let cursor = 0;
for (const paste of pastes) {
const text = document.slice(cursor, paste.from);
for (const event of events) {
if (event.from < cursor) continue;
const text = document.slice(cursor, event.from);
appendTextSegment(segments, text);
content += text;
if (event.kind === "paste") {
segments.push({
kind: "paste",
id: paste.id,
content: paste.content,
chars: paste.chars,
lines: paste.lines,
id: event.paste.id,
content: event.paste.content,
chars: event.paste.chars,
lines: event.paste.lines,
});
content += paste.content;
cursor = paste.to;
} else {
appendTextSegment(segments, event.paste.content);
}
content += event.paste.content;
cursor = event.to;
}
const trailingText = document.slice(cursor);
appendTextSegment(segments, trailingText);
content += trailingText;
return { document, content, segments, pastes };
return { document, content, segments, pastes, textPastes };
}
export function pasteChipLabel(paste: ComposerPaste): string {
@@ -599,13 +599,15 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
new URL("./ComposerInput.svelte", import.meta.url),
);
assert(
composerInput.includes("handleComposerPaste(event, insertPasteChip)") &&
composerInput.includes('measurement.presentation === "chip"') &&
composerInput.includes("registerTextPaste") &&
composerInput.includes("EditorView.atomicRanges") &&
composerInput.includes('key: "Backspace"') &&
composerInput.includes('key: "Delete"') &&
composerInput.includes('chip.setAttribute("aria-label", label)') &&
composerInput.includes("restoreSegments(segments: readonly Segment[])") &&
consolePage.includes("buildComposerSegmentsRequest(value.segments)") &&
composerInput.includes("preserveExactText = false") &&
consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
consolePage.includes("switchComposerTarget(target)") &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
@@ -109,7 +109,15 @@
cursor(): number;
replaceRange(from: number, to: number, content: string): void;
clear(): void;
restoreSegments(segments: readonly Segment[]): void;
restoreSegments(
segments: readonly Segment[],
preserveExactText?: boolean,
): void;
};
type ComposerDraftCache = {
segments: Segment[];
preserveExactText: boolean;
};
const EMPTY_DRAFT: ComposerDraftSnapshot = {
@@ -117,6 +125,7 @@
content: "",
segments: [],
pastes: [],
textPastes: [],
};
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
@@ -149,7 +158,7 @@
let composerInputElement = $state<
(SvelteComponent & ComposerInputHandle) | null
>(null);
const composerDrafts = new Map<string, Segment[]>();
const composerDrafts = new Map<string, ComposerDraftCache>();
let activeComposerTargetKey = untrack(
() => `${workspaceId}:${runtimeId}:${workerId}`,
);
@@ -549,9 +558,16 @@
}
}
function cachedComposerDraft(snapshot: ComposerDraftSnapshot): ComposerDraftCache {
return {
segments: [...snapshot.segments],
preserveExactText: snapshot.textPastes.length > 0,
};
}
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
draft = snapshot;
composerDrafts.set(activeComposerTargetKey, [...snapshot.segments]);
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
}
function switchComposerTarget(target: ConsoleTarget) {
@@ -560,14 +576,20 @@
if (composerInputElement) {
composerDrafts.set(
activeComposerTargetKey,
[...composerInputElement.snapshot().segments],
cachedComposerDraft(composerInputElement.snapshot()),
);
}
activeComposerTargetKey = nextKey;
const restored = composerDrafts.get(nextKey) ?? [];
const restored = composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(restored);
composerInputElement?.restoreSegments(
restored.segments,
restored.preserveExactText,
);
});
}
@@ -580,7 +602,9 @@
}
async function submitDraft(value: ComposerDraftSnapshot) {
const command = buildComposerSegmentsRequest(value.segments);
const command = buildComposerSegmentsRequest(value.segments, {
preserveExactText: value.textPastes.length > 0,
});
if (!command.ok) {
composerNotice = null;
sendError = command.message;