diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte index 11b161b4..51a85c24 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.svelte +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -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(); + const restoreTextPastes = StateEffect.define(); + const textPasteState = StateField.define({ + 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[] = []; 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", }); } diff --git a/web/workspace/src/lib/workspace/console/composer-command.ts b/web/workspace/src/lib/workspace/console/composer-command.ts index dfc828b0..e8fcfc91 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.ts @@ -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(""); - 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(""); 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: diff --git a/web/workspace/src/lib/workspace/console/composer-draft.test.ts b/web/workspace/src/lib/workspace/console/composer-draft.test.ts index 973654e7..66bf303f 100644 --- a/web/workspace/src/lib/workspace/console/composer-draft.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-draft.test.ts @@ -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 " }, diff --git a/web/workspace/src/lib/workspace/console/composer-draft.ts b/web/workspace/src/lib/workspace/console/composer-draft.ts index 7a204a10..b47de02d 100644 --- a/web/workspace/src/lib/workspace/console/composer-draft.ts +++ b/web/workspace/src/lib/workspace/console/composer-draft.ts @@ -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, + 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; - segments.push({ - kind: "paste", - id: paste.id, - content: paste.content, - chars: paste.chars, - lines: paste.lines, - }); - content += paste.content; - cursor = paste.to; + + if (event.kind === "paste") { + segments.push({ + kind: "paste", + id: event.paste.id, + content: event.paste.content, + chars: event.paste.chars, + lines: event.paste.lines, + }); + } 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 { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 1271fbf9..572c9e15 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -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")'), diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index a497b64d..fb1018ea 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -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(EMPTY_DRAFT); @@ -149,7 +158,7 @@ let composerInputElement = $state< (SvelteComponent & ComposerInputHandle) | null >(null); - const composerDrafts = new Map(); + const composerDrafts = new Map(); 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;