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, defaultKeymap,
history, history,
historyKeymap, historyKeymap,
invertedEffects,
isolateHistory, isolateHistory,
} from "@codemirror/commands"; } from "@codemirror/commands";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Segment } from "$lib/generated/protocol.ts"; import type { Segment } from "$lib/generated/protocol.ts";
import { import {
handleComposerPaste, measureComposerPaste,
type ComposerPasteMeasurement, type ComposerPasteMeasurement,
} from "$lib/workspace/console/composer-paste.ts"; } from "$lib/workspace/console/composer-paste.ts";
import { import {
@@ -33,6 +34,7 @@
snapshotComposerDraft, snapshotComposerDraft,
type ComposerDraftSnapshot, type ComposerDraftSnapshot,
type ComposerPaste, type ComposerPaste,
type ComposerTextPaste,
} from "$lib/workspace/console/composer-draft.ts"; } from "$lib/workspace/console/composer-draft.ts";
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.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 { class PasteChipWidget extends WidgetType {
readonly paste: ComposerPaste; readonly paste: ComposerPaste;
@@ -120,15 +153,25 @@
const pasteChips = [ const pasteChips = [
pasteRegistry, pasteRegistry,
textPasteState,
invertedEffects.of((transaction) =>
transaction.docChanged
? [restoreTextPastes.of(transaction.startState.field(textPasteState))]
: []
),
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)), EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)), EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
]; ];
function currentSnapshot(state = view?.state): ComposerDraftSnapshot { function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
if (!state) { 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 { 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 { function selectedClipboardContent(state: EditorState): string | null {
const selection = state.selection.main; const selection = state.selection.main;
if (selection.empty) return null; if (selection.empty) return null;
@@ -166,7 +240,14 @@
selectedRegistry.set(atom.key, atom); 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( function deleteAdjacentPasteFromView(
@@ -229,7 +310,7 @@
}), }),
Prec.high(EditorView.domEventHandlers({ Prec.high(EditorView.domEventHandlers({
paste(event) { paste(event) {
return handleComposerPaste(event, insertPasteChip); return handlePasteEvent(event);
}, },
copy(event, currentView) { copy(event, currentView) {
const content = selectedClipboardContent(currentView.state); 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; if (!view) return;
let document = ""; let document = "";
const effects: StateEffect<{ key: number; paste: ComposerPaste }>[] = []; const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
const textEffects: StateEffect<ComposerTextPaste>[] = [];
let highestPasteId = nextPasteId - 1; let highestPasteId = nextPasteId - 1;
for (const segment of segments) { for (const segment of segments) {
if (segment.kind === "text") { 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") { } else if (segment.kind === "paste") {
const key = nextPasteKey++; const key = nextPasteKey++;
const paste: ComposerPaste = { const paste: ComposerPaste = {
@@ -345,7 +440,7 @@
}; };
highestPasteId = Math.max(highestPasteId, paste.id); highestPasteId = Math.max(highestPasteId, paste.id);
document += composerPasteToken(key); document += composerPasteToken(key);
effects.push(registerPaste.of({ key, paste })); pasteEffects.push(registerPaste.of({ key, paste }));
} else if (segment.kind === "file_ref") { } else if (segment.kind === "file_ref") {
document += `@${segment.path}`; document += `@${segment.path}`;
} }
@@ -354,7 +449,7 @@
view.dispatch({ view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document }, changes: { from: 0, to: view.state.doc.length, insert: document },
selection: EditorSelection.cursor(document.length), selection: EditorSelection.cursor(document.length),
effects, effects: [...pasteEffects, ...textEffects],
userEvent: "input.restore", userEvent: "input.restore",
}); });
} }
@@ -80,20 +80,44 @@ export function buildComposerRequest(value: string): ComposerCommandResult {
}; };
} }
export interface ComposerSegmentsRequestOptions {
preserveExactText?: boolean;
}
export function buildComposerSegmentsRequest( export function buildComposerSegmentsRequest(
sourceSegments: readonly Segment[], sourceSegments: readonly Segment[],
options: ComposerSegmentsRequestOptions = {},
): ComposerCommandResult { ): ComposerCommandResult {
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste"); const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
if (!hasPaste) { if (!hasPaste) {
const content = sourceSegments.map(segmentContent).join(""); const content = sourceSegments.map(segmentContent).join("");
return buildComposerRequest(content); 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(""); const content = sourceSegments.map(segmentContent).join("");
if (!content.trim()) { if (!content.trim()) {
return { ok: false, message: "Input is empty." }; 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 { return {
ok: false, ok: false,
message: 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", () => { Deno.test("plain short-paste Text retains the existing composer request path", () => {
const result = buildComposerSegmentsRequest([ const result = buildComposerSegmentsRequest([
{ kind: "text", content: " short\r\npaste\r\n " }, { kind: "text", content: " short\r\npaste\r\n " },
@@ -17,11 +17,19 @@ export interface ComposerPasteAtom extends ComposerPaste {
to: number; to: number;
} }
export interface ComposerTextPaste {
from: number;
to: number;
rendered: string;
content: string;
}
export interface ComposerDraftSnapshot { export interface ComposerDraftSnapshot {
document: string; document: string;
content: string; content: string;
segments: Segment[]; segments: Segment[];
pastes: ComposerPasteAtom[]; pastes: ComposerPasteAtom[];
textPastes: ComposerTextPaste[];
} }
export function composerPasteToken(key: number): string { export function composerPasteToken(key: number): string {
@@ -60,32 +68,60 @@ function appendTextSegment(segments: Segment[], content: string): void {
export function snapshotComposerDraft( export function snapshotComposerDraft(
document: string, document: string,
registry: ReadonlyMap<number, ComposerPaste>, registry: ReadonlyMap<number, ComposerPaste>,
candidateTextPastes: readonly ComposerTextPaste[] = [],
): ComposerDraftSnapshot { ): ComposerDraftSnapshot {
const pastes = composerPasteAtoms(document, registry); 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[] = []; const segments: Segment[] = [];
let content = ""; let content = "";
let cursor = 0; let cursor = 0;
for (const paste of pastes) { for (const event of events) {
const text = document.slice(cursor, paste.from); if (event.from < cursor) continue;
const text = document.slice(cursor, event.from);
appendTextSegment(segments, text); appendTextSegment(segments, text);
content += text; content += text;
segments.push({
kind: "paste", if (event.kind === "paste") {
id: paste.id, segments.push({
content: paste.content, kind: "paste",
chars: paste.chars, id: event.paste.id,
lines: paste.lines, content: event.paste.content,
}); chars: event.paste.chars,
content += paste.content; lines: event.paste.lines,
cursor = paste.to; });
} else {
appendTextSegment(segments, event.paste.content);
}
content += event.paste.content;
cursor = event.to;
} }
const trailingText = document.slice(cursor); const trailingText = document.slice(cursor);
appendTextSegment(segments, trailingText); appendTextSegment(segments, trailingText);
content += trailingText; content += trailingText;
return { document, content, segments, pastes }; return { document, content, segments, pastes, textPastes };
} }
export function pasteChipLabel(paste: ComposerPaste): string { 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), new URL("./ComposerInput.svelte", import.meta.url),
); );
assert( assert(
composerInput.includes("handleComposerPaste(event, insertPasteChip)") && composerInput.includes('measurement.presentation === "chip"') &&
composerInput.includes("registerTextPaste") &&
composerInput.includes("EditorView.atomicRanges") && composerInput.includes("EditorView.atomicRanges") &&
composerInput.includes('key: "Backspace"') && composerInput.includes('key: "Backspace"') &&
composerInput.includes('key: "Delete"') && composerInput.includes('key: "Delete"') &&
composerInput.includes('chip.setAttribute("aria-label", label)') && composerInput.includes('chip.setAttribute("aria-label", label)') &&
composerInput.includes("restoreSegments(segments: readonly Segment[])") && composerInput.includes("preserveExactText = false") &&
consolePage.includes("buildComposerSegmentsRequest(value.segments)") && consolePage.includes("buildComposerSegmentsRequest(value.segments, {") &&
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
consolePage.includes("composerDrafts.set(activeComposerTargetKey") && consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
consolePage.includes("switchComposerTarget(target)") && consolePage.includes("switchComposerTarget(target)") &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'), consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
@@ -109,7 +109,15 @@
cursor(): number; cursor(): number;
replaceRange(from: number, to: number, content: string): void; replaceRange(from: number, to: number, content: string): void;
clear(): 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 = { const EMPTY_DRAFT: ComposerDraftSnapshot = {
@@ -117,6 +125,7 @@
content: "", content: "",
segments: [], segments: [],
pastes: [], pastes: [],
textPastes: [],
}; };
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT); let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
@@ -149,7 +158,7 @@
let composerInputElement = $state< let composerInputElement = $state<
(SvelteComponent & ComposerInputHandle) | null (SvelteComponent & ComposerInputHandle) | null
>(null); >(null);
const composerDrafts = new Map<string, Segment[]>(); const composerDrafts = new Map<string, ComposerDraftCache>();
let activeComposerTargetKey = untrack( let activeComposerTargetKey = untrack(
() => `${workspaceId}:${runtimeId}:${workerId}`, () => `${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) { function handleComposerChange(snapshot: ComposerDraftSnapshot) {
draft = snapshot; draft = snapshot;
composerDrafts.set(activeComposerTargetKey, [...snapshot.segments]); composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
} }
function switchComposerTarget(target: ConsoleTarget) { function switchComposerTarget(target: ConsoleTarget) {
@@ -560,14 +576,20 @@
if (composerInputElement) { if (composerInputElement) {
composerDrafts.set( composerDrafts.set(
activeComposerTargetKey, activeComposerTargetKey,
[...composerInputElement.snapshot().segments], cachedComposerDraft(composerInputElement.snapshot()),
); );
} }
activeComposerTargetKey = nextKey; activeComposerTargetKey = nextKey;
const restored = composerDrafts.get(nextKey) ?? []; const restored = composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
void tick().then(() => { void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return; if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(restored); composerInputElement?.restoreSegments(
restored.segments,
restored.preserveExactText,
);
}); });
} }
@@ -580,7 +602,9 @@
} }
async function submitDraft(value: ComposerDraftSnapshot) { async function submitDraft(value: ComposerDraftSnapshot) {
const command = buildComposerSegmentsRequest(value.segments); const command = buildComposerSegmentsRequest(value.segments, {
preserveExactText: value.textPastes.length > 0,
});
if (!command.ok) { if (!command.ok) {
composerNotice = null; composerNotice = null;
sendError = command.message; sendError = command.message;