flattenToMessage now places the typed text first and appends pasted-chip content after it with a single leading space (file/line chips remain fenced provenance blocks after that), instead of prepending all attachments. A leading slash command therefore stays first and the paste reads as its continuation — `/command <pasted>` rather than `<pasted>` then the command. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
3.3 KiB
TypeScript
82 lines
3.3 KiB
TypeScript
export type Attachment = {
|
|
id: string;
|
|
kind: 'file' | 'lines' | 'paste';
|
|
filename: string;
|
|
language: string | null;
|
|
content: string;
|
|
range?: [number, number];
|
|
source: '@' | 'line-select' | 'drop' | 'paste';
|
|
};
|
|
|
|
// v1.7: caps shared between drag-drop and paste-as-attachment so both paths
|
|
// reject the same way. Match the existing 10-attachment cap in
|
|
// ChatInput.addAttachment.
|
|
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
export const PASTE_INLINE_MAX_LINES = 8;
|
|
|
|
// First-8KB null-byte scan. Returns true if the content looks binary.
|
|
// Accepts a string (post-decode), an ArrayBuffer (pre-decode), or a Uint8Array.
|
|
// For binary files like PNG, scanning bytes is more reliable than scanning
|
|
// post-UTF-8-decode strings because invalid sequences may be replaced rather
|
|
// than preserved.
|
|
export function looksBinary(content: string | ArrayBuffer | Uint8Array): boolean {
|
|
const SCAN_BYTES = 8192;
|
|
if (typeof content === 'string') {
|
|
const max = Math.min(content.length, SCAN_BYTES);
|
|
for (let i = 0; i < max; i++) {
|
|
if (content.charCodeAt(i) === 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
|
|
const max = Math.min(bytes.length, SCAN_BYTES);
|
|
for (let i = 0; i < max; i++) {
|
|
if (bytes[i] === 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const LANG_MAP: Record<string, string> = {
|
|
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
|
mjs: 'javascript', cjs: 'javascript',
|
|
py: 'python', go: 'go', rs: 'rust', rb: 'ruby', java: 'java',
|
|
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
|
|
php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
yml: 'yaml', yaml: 'yaml', json: 'json', toml: 'toml',
|
|
md: 'markdown', markdown: 'markdown', sql: 'sql', dockerfile: 'dockerfile',
|
|
html: 'html', htm: 'html', css: 'css', scss: 'scss',
|
|
};
|
|
|
|
export function inferLanguage(filename: string): string | null {
|
|
const base = filename.split('/').pop() ?? filename;
|
|
if (base.toLowerCase() === 'dockerfile') return 'dockerfile';
|
|
const m = base.match(/\.([^.]+)$/);
|
|
return m ? (LANG_MAP[m[1]!.toLowerCase()] ?? null) : null;
|
|
}
|
|
|
|
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
|
if (attachments.length === 0) return text;
|
|
// Pasted text is raw context, not code from a file — insert it verbatim with no
|
|
// ``` fence or provenance header. It trails the typed text with a leading space
|
|
// so a leading slash command / prompt stays first and the paste reads as its
|
|
// continuation. File/line chips stay fenced provenance blocks, appended after.
|
|
const pasteBlocks: string[] = [];
|
|
const fencedBlocks: string[] = [];
|
|
for (const a of attachments) {
|
|
if (a.kind === 'paste') {
|
|
pasteBlocks.push(a.content);
|
|
continue;
|
|
}
|
|
const fence = '```' + (a.language ?? '');
|
|
const header =
|
|
a.kind === 'lines'
|
|
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
|
: `// from: ${a.filename}`;
|
|
fencedBlocks.push(`${fence}\n${header}\n${a.content}\n\`\`\``);
|
|
}
|
|
// Typed text + pasted content on the same logical line (space-joined), then
|
|
// any fenced file blocks as separate paragraphs.
|
|
const lead = [text, ...pasteBlocks].filter(Boolean).join(' ');
|
|
return [lead, ...fencedBlocks].filter(Boolean).join('\n\n');
|
|
}
|