feat(ui): attach large text pastes as virtual files

Offer to turn sufficiently large plain-text clipboard pastes into
pasted-context-N.txt attachments instead of inserting them into the
composer, with ask/attach/inline composer settings.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-04 11:38:02 +00:00
co-authored by Serhii Dziupin
parent f47110c66f
commit ef11ab5b14
33 changed files with 458 additions and 11 deletions
+123 -4
View File
@@ -82,7 +82,13 @@ import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from
import {
assignImageAttachmentFilenames,
buildAttachmentCitationText,
nextPastedContextFilename,
} from './attachmentCitations';
import {
createPastedContextFile,
isLargePlainTextPaste,
} from './composer/largeTextPaste';
import type { LargeTextPasteBehavior } from '@/stores/useUIStore';
import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
import {
classifyMention,
@@ -288,6 +294,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const messageRef = React.useRef(message);
const currentChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set());
const largeTextPasteToastIdRef = React.useRef<string | number | null>(null);
const largeTextPasteOfferIdRef = React.useRef(0);
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -358,6 +366,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior);
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
@@ -1723,14 +1732,124 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const imageFiles = Array.from(fileMap.values());
const pastedText = e.clipboardData.getData('text');
const sessionReady = Boolean(currentSessionId || newSessionDraftOpen);
if (imageFiles.length === 0) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
const behavior: LargeTextPasteBehavior = largeTextPasteBehavior;
const shouldOfferLargePaste = sessionReady
&& inputMode === 'normal'
&& behavior !== 'inline'
&& isLargePlainTextPaste(pastedText);
if (!shouldOfferLargePaste) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
return;
}
// Must run synchronously — ComposerEditor does not consume paste.
e.preventDefault();
const pasteInline = () => {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
insertTextAtSelection(
pastedText,
getFileMentionInputSourceForInsertedText(pastedText),
);
};
const attachAsFile = async () => {
const filename = nextPastedContextFilename([
...attachedFiles.map((file) => file.filename),
...pendingPastedAttachmentFilenamesRef.current,
]);
const citationText = buildAttachmentCitationText([filename]);
const textarea = composerRef.current;
const selectionStart = textarea?.getSelection().start ?? message.length;
const selectionEnd = textarea?.getSelection().end ?? message.length;
const insertionText = withInlineInsertionBoundaries(
citationText,
message.slice(0, selectionStart),
message.slice(selectionEnd),
);
insertTextAtSelection(
insertionText,
getFileMentionInputSourceForInsertedText(insertionText),
);
const file = createPastedContextFile(pastedText, filename);
pendingPastedAttachmentFilenamesRef.current.add(filename);
try {
await addAttachedFile(file);
} catch (error) {
console.error('Clipboard text attach failed', error);
toast.error(
error instanceof Error
? error.message
: t('chat.chatInput.toast.clipboardTextAttachFailed'),
);
} finally {
pendingPastedAttachmentFilenamesRef.current.delete(filename);
}
};
if (behavior === 'attach') {
await attachAsFile();
return;
}
const offerId = largeTextPasteOfferIdRef.current + 1;
largeTextPasteOfferIdRef.current = offerId;
if (largeTextPasteToastIdRef.current !== null) {
// Invalidate first so a synchronous onDismiss from dismiss()
// cannot apply the superseded paste.
toast.dismiss(largeTextPasteToastIdRef.current);
largeTextPasteToastIdRef.current = null;
}
const resolveLargePaste = (action: 'attach' | 'inline') => {
if (offerId !== largeTextPasteOfferIdRef.current) {
return;
}
// Invalidate this offer so a later onDismiss cannot double-apply.
largeTextPasteOfferIdRef.current += 1;
largeTextPasteToastIdRef.current = null;
if (action === 'attach') {
void attachAsFile();
return;
}
pasteInline();
};
largeTextPasteToastIdRef.current = toast.info(
t('chat.chatInput.toast.largeTextPaste.title'),
{
description: t('chat.chatInput.toast.largeTextPaste.description'),
duration: Infinity,
action: {
label: t('chat.chatInput.toast.largeTextPaste.attach'),
onClick: () => resolveLargePaste('attach'),
},
cancel: {
label: t('chat.chatInput.toast.largeTextPaste.inline'),
onClick: () => resolveLargePaste('inline'),
},
onDismiss: () => {
// Dismissing without a choice keeps the paste — insert inline
// so clipboard content is not lost.
resolveLargePaste('inline');
},
},
);
return;
}
if (!currentSessionId && !newSessionDraftOpen) {
if (!sessionReady) {
if (pastedText.includes('@')) {
markFileMentionPasteSuppression();
}
@@ -1771,7 +1890,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
pendingPastedAttachmentFilenamesRef.current.delete(filename);
}
}
}, [addAttachedFile, attachedFiles, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
}, [addAttachedFile, attachedFiles, currentSessionId, inputMode, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]);
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
@@ -5,6 +5,7 @@ import {
buildAttachmentCitationText,
findAttachmentCitationRanges,
isGenericImageFilename,
nextPastedContextFilename,
} from '../attachmentCitations';
describe('attachment citations', () => {
@@ -53,4 +54,10 @@ describe('attachment citations', () => {
['desktop.jpg'],
)).toEqual([{ start: 8, end: 21 }]);
});
test('assigns sequential pasted-context filenames', () => {
expect(nextPastedContextFilename([])).toBe('pasted-context-1.txt');
expect(nextPastedContextFilename(['pasted-context-1.txt', 'notes.md'])).toBe('pasted-context-2.txt');
expect(nextPastedContextFilename(['PASTED-CONTEXT-2.TXT'])).toBe('pasted-context-1.txt');
});
});
@@ -144,6 +144,20 @@ export const assignImageAttachmentFilenames = (
});
};
/** Next unused `pasted-context-N.txt` name for a large text paste attachment. */
export const nextPastedContextFilename = (existingFilenames: string[]): string => {
const used = new Set(existingFilenames.map(normalizeFilenameKey));
for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) {
const candidate = `pasted-context-${index}.txt`;
if (!used.has(normalizeFilenameKey(candidate))) {
return candidate;
}
}
return `pasted-context-${Date.now()}.txt`;
};
export const buildAttachmentCitationText = (filenames: string[]): string => (
filenames.map((filename) => `[${filename}]`).join(' ')
);
@@ -18,6 +18,16 @@ belongs to one of them.
| `attachments/` | Files: paths, drop payloads |
| `ui/` | Presentation |
| `text.ts` | How inserted text meets the text already there |
| `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files |
`ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown
links, clipboard images (attach + citation), and large plain-text pastes.
Large pastes (about 2,000 characters or 25 lines) follow the composer setting
`largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an
in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket
citation, and sends it through the same attachment pipeline as a manually
picked `.txt` file. Short text, images, and URL wraps keep their existing
paths.
## The prompt language
@@ -119,8 +129,8 @@ hardware.
The package has no DOM test environment, so coverage stops at the state and
logic layers: the language, the submit assembly, path and drop handling, text
splicing, message history, and the CodeMirror language extension at the
`EditorState` level.
splicing, large-paste detection, message history, and the CodeMirror language
extension at the `EditorState` level.
Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by
tests** and are verified by hand. Do not report a change to them as validated
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test';
import {
LARGE_TEXT_PASTE_CHAR_THRESHOLD,
LARGE_TEXT_PASTE_LINE_THRESHOLD,
createPastedContextFile,
isLargePlainTextPaste,
} from '../largeTextPaste';
describe('large text paste helpers', () => {
test('treats short text as not large', () => {
expect(isLargePlainTextPaste('hello world')).toBe(false);
expect(isLargePlainTextPaste('line1\nline2\nline3')).toBe(false);
});
test('treats empty and whitespace-only pastes as not large', () => {
expect(isLargePlainTextPaste('')).toBe(false);
expect(isLargePlainTextPaste(' \n\t ')).toBe(false);
});
test('detects pastes at the character threshold', () => {
const text = 'a'.repeat(LARGE_TEXT_PASTE_CHAR_THRESHOLD);
expect(isLargePlainTextPaste(text)).toBe(true);
expect(isLargePlainTextPaste(text.slice(0, -1))).toBe(false);
});
test('detects pastes at the line threshold', () => {
const lines = Array.from({ length: LARGE_TEXT_PASTE_LINE_THRESHOLD }, (_, index) => `line ${index}`);
expect(isLargePlainTextPaste(lines.join('\n'))).toBe(true);
expect(isLargePlainTextPaste(lines.slice(0, -1).join('\n'))).toBe(false);
});
test('honors custom thresholds', () => {
expect(isLargePlainTextPaste('abcdef', { charThreshold: 5 })).toBe(true);
expect(isLargePlainTextPaste('a\nb\nc', { lineThreshold: 3 })).toBe(true);
expect(isLargePlainTextPaste('a\nb', { lineThreshold: 3, charThreshold: 100 })).toBe(false);
});
test('creates a text/plain file with the given name', async () => {
const file = createPastedContextFile('architecture notes', 'pasted-context-1.txt');
expect(file.name).toBe('pasted-context-1.txt');
expect(file.type.startsWith('text/plain')).toBe(true);
expect(await file.text()).toBe('architecture notes');
});
});
@@ -0,0 +1,55 @@
/**
* Large plain-text paste → virtual file attachment helpers.
*
* Detect when clipboard text is large enough that inserting it into the
* composer would clutter the prompt, and build an in-memory text/plain File
* the attachment pipeline can send like any other .txt attachment.
*/
export const LARGE_TEXT_PASTE_CHAR_THRESHOLD = 2000;
export const LARGE_TEXT_PASTE_LINE_THRESHOLD = 25;
const countLines = (text: string): number => {
let lines = 1;
for (let index = 0; index < text.length; index += 1) {
if (text.charCodeAt(index) === 10) {
lines += 1;
}
}
return lines;
};
/**
* Whether pasted plain text should be offered (or auto-handled) as a file
* attachment instead of being inserted into the composer.
*
* Empty / whitespace-only pastes are never large. Thresholds are OR'd:
* character count or line count is enough.
*/
export const isLargePlainTextPaste = (
text: string,
options?: {
charThreshold?: number;
lineThreshold?: number;
},
): boolean => {
if (!text || !text.trim()) {
return false;
}
const charThreshold = options?.charThreshold ?? LARGE_TEXT_PASTE_CHAR_THRESHOLD;
const lineThreshold = options?.lineThreshold ?? LARGE_TEXT_PASTE_LINE_THRESHOLD;
if (text.length >= charThreshold) {
return true;
}
return countLines(text) >= lineThreshold;
};
export const createPastedContextFile = (text: string, filename: string): File => (
new File([text], filename, {
type: 'text/plain',
lastModified: Date.now(),
})
);
@@ -206,6 +206,7 @@ const ChatSectionContent: React.FC = () => {
'followUpBehavior',
'persistDraft',
'inputSpellcheck',
'largeTextPaste',
]}
/>
);
@@ -3,7 +3,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { ThemeMode } from '@/types/theme';
import { useUIStore } from '@/stores/useUIStore';
import { useUIStore, type LargeTextPasteBehavior } from '@/stores/useUIStore';
import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -275,11 +275,26 @@ const FOLLOW_UP_BEHAVIOR_OPTIONS: Option<FollowUpBehavior>[] = [
},
];
const LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS: Option<LargeTextPasteBehavior>[] = [
{
id: 'ask',
labelKey: 'settings.openchamber.visual.option.largeTextPaste.ask.label',
},
{
id: 'attach',
labelKey: 'settings.openchamber.visual.option.largeTextPaste.attach.label',
},
{
id: 'inline',
labelKey: 'settings.openchamber.visual.option.largeTextPaste.inline.label',
},
];
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -372,6 +387,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled);
const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior);
const setLargeTextPasteBehavior = useUIStore(state => state.setLargeTextPasteBehavior);
const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
@@ -665,6 +682,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('reasoning')
|| shouldShow('followUpBehavior')
|| shouldShow('persistDraft')
|| shouldShow('largeTextPaste')
|| shouldShow('showToolFileIcons')
|| shouldShow('expandedTools')
|| (!isMobile && shouldShow('inputSpellcheck'));
@@ -687,6 +705,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('dotfiles')
|| shouldShow('fileViewerPreview')
|| shouldShow('persistDraft')
|| shouldShow('largeTextPaste')
|| shouldShow('showToolFileIcons')
|| shouldShow('showTurnChangedFiles')
|| (!isMobile && shouldShow('inputSpellcheck'))
@@ -2036,7 +2055,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsSection>
)}
{(shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck'))) && (
{(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && (
<SettingsSection
title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.composer"
@@ -2061,6 +2080,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="chat.spellcheck"
/>
)}
{shouldShow('largeTextPaste') && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.largeTextPaste')}
info={t('settings.openchamber.visual.field.largeTextPasteHint')}
settingsItem="chat.large-text-paste"
>
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}>
{LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS.map((option) => (
<SettingsRadioOption
key={option.id}
selected={largeTextPasteBehavior === option.id}
onSelect={() => setLargeTextPasteBehavior(option.id)}
label={tUnsafe(option.labelKey)}
ariaLabel={t('settings.openchamber.visual.field.largeTextPasteOptionAria', { option: tUnsafe(option.labelKey) })}
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
)}
</SettingsSection>
)}
</>