diff --git a/web/app/components/CodeViewer.test.tsx b/web/app/components/CodeViewer.test.tsx new file mode 100644 index 0000000..be2568e --- /dev/null +++ b/web/app/components/CodeViewer.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; + +// Test the escapeHtml and string regex patterns used in CodeViewer +// We test the logic directly since the component uses internal functions + +describe('CodeViewer escapeHtml', () => { + // Replicate the escapeHtml function from CodeViewer + const escapeHtml = (str: string) => str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + it('escapes double quotes for attribute safety', () => { + expect(escapeHtml('class="foo"')).toBe('class="foo"'); + }); + + it('escapes single quotes for attribute safety', () => { + expect(escapeHtml("class='foo'")).toBe("class='foo'"); + }); + + it('escapes HTML tags', () => { + expect(escapeHtml('
')).toBe('<div>'); + }); + + it('escapes ampersands', () => { + expect(escapeHtml('a && b')).toBe('a && b'); + }); +}); + +describe('CodeViewer string regex patterns', () => { + // Test the improved string patterns + const doubleQuotePattern = /"(?:[^"\\]|\\.)*"/; + const singleQuotePattern = /'(?:[^'\\]|\\.)*'/; + const backtickPattern = /`(?:[^`\\]|\\.)*`/; + + describe('double-quoted strings', () => { + it('matches simple double-quoted strings', () => { + expect('"hello"'.match(doubleQuotePattern)?.[0]).toBe('"hello"'); + }); + + it('matches strings with escaped quotes', () => { + expect('"He said \\"hello\\""'.match(doubleQuotePattern)?.[0]).toBe('"He said \\"hello\\""'); + }); + + it('matches strings with escaped backslashes', () => { + expect('"path\\\\to\\\\file"'.match(doubleQuotePattern)?.[0]).toBe('"path\\\\to\\\\file"'); + }); + + it('matches empty strings', () => { + expect('""'.match(doubleQuotePattern)?.[0]).toBe('""'); + }); + }); + + describe('single-quoted strings', () => { + it('matches simple single-quoted strings', () => { + expect("'hello'".match(singleQuotePattern)?.[0]).toBe("'hello'"); + }); + + it('matches strings with escaped quotes', () => { + expect("'It\\'s fine'".match(singleQuotePattern)?.[0]).toBe("'It\\'s fine'"); + }); + + it('matches empty strings', () => { + expect("''".match(singleQuotePattern)?.[0]).toBe("''"); + }); + }); + + describe('backtick strings', () => { + it('matches simple backtick strings', () => { + expect('`hello`'.match(backtickPattern)?.[0]).toBe('`hello`'); + }); + + it('matches strings with escaped backticks', () => { + expect('`use \\`code\\``'.match(backtickPattern)?.[0]).toBe('`use \\`code\\``'); + }); + + it('matches empty strings', () => { + expect('``'.match(backtickPattern)?.[0]).toBe('``'); + }); + }); +}); diff --git a/web/app/components/CodeViewer.tsx b/web/app/components/CodeViewer.tsx index f9f4aae..5f9cc4a 100644 --- a/web/app/components/CodeViewer.tsx +++ b/web/app/components/CodeViewer.tsx @@ -82,39 +82,68 @@ export function CodeViewer({ code, fileName, language }: CodeViewerProps) { const detectedLanguage = language || getLanguageFromFileName(fileName); - // Basic syntax highlighting for common tokens + // Single-pass syntax highlighting to avoid corrupting HTML class attributes const highlightCode = (code: string): string => { - // Escape HTML - let highlighted = code + // Escape HTML helper + const escapeHtml = (str: string) => str .replace(/&/g, '&') .replace(//g, '>'); - - // Common patterns for many languages - const patterns = [ - // Strings - { regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g, class: 'text-green-400' }, - // Comments - { regex: /(\/\/.*$)/gm, class: 'text-gray-500 italic' }, - { regex: /(\/\*[\s\S]*?\*\/)/g, class: 'text-gray-500 italic' }, - { regex: /(#.*$)/gm, class: 'text-gray-500 italic' }, - // Numbers - { regex: /\b(\d+\.?\d*)\b/g, class: 'text-purple-400' }, - // Keywords (common across many languages) - { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/g, class: 'text-blue-400' }, - // Boolean and null values - { regex: /\b(true|false|null|undefined|nil|None|True|False)\b/g, class: 'text-orange-400' }, - // Function calls (basic) - { regex: /(\w+)(?=\s*\()/g, class: 'text-yellow-400' }, - // Types/Classes (PascalCase) - { regex: /\b([A-Z][a-zA-Z0-9]*)\b/g, class: 'text-cyan-400' }, + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + // Define token patterns with priorities (first match wins) + // Order matters: strings and comments first to avoid highlighting inside them + const tokenPatterns = [ + { regex: /"(?:[^"\\]|\\.)*"/, className: 'text-green-400' }, // double-quoted strings + { regex: /'(?:[^'\\]|\\.)*'/, className: 'text-green-400' }, // single-quoted strings + { regex: /`(?:[^`\\]|\\.)*`/, className: 'text-green-400' }, // backtick strings + { regex: /\/\/.*$/, className: 'text-gray-500 italic' }, // single-line comments + { regex: /\/\*[\s\S]*?\*\//, className: 'text-gray-500 italic' }, // multi-line comments + { regex: /#.*$/, className: 'text-gray-500 italic' }, // hash comments + { regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default|fn|pub|mod|use|mut|match|loop|impl|trait|where|type|readonly|override)\b/, className: 'text-blue-400' }, // keywords + { regex: /\b(true|false|null|undefined|nil|None|True|False|NULL)\b/, className: 'text-orange-400' }, // literals + { regex: /\b\d+\.?\d*\b/, className: 'text-purple-400' }, // numbers + { regex: /\b[A-Z][a-zA-Z0-9]*\b/, className: 'text-cyan-400' }, // PascalCase (types/classes) ]; - patterns.forEach(({ regex, class: className }) => { - highlighted = highlighted.replace(regex, `$&`); - }); + // Build a combined regex that matches any token + const combinedPattern = new RegExp( + tokenPatterns.map(p => `(${p.regex.source})`).join('|'), + 'gm' + ); + + let result = ''; + let lastIndex = 0; + + // Single pass through the string + for (const match of code.matchAll(combinedPattern)) { + // Add non-matched text before this match (escaped) + if (match.index! > lastIndex) { + result += escapeHtml(code.slice(lastIndex, match.index)); + } + + // Find which pattern matched (first non-undefined capture group) + const matchedText = match[0]; + let className = ''; + for (let i = 0; i < tokenPatterns.length; i++) { + if (match[i + 1] !== undefined) { + className = tokenPatterns[i].className; + break; + } + } + + // Add the highlighted token (escape the matched text too) + result += `${escapeHtml(matchedText)}`; + lastIndex = match.index! + matchedText.length; + } + + // Add remaining text after last match + if (lastIndex < code.length) { + result += escapeHtml(code.slice(lastIndex)); + } - return highlighted; + return result; }; const handleCopy = async () => { diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index 9908607..9e1fc85 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -706,11 +706,15 @@ export default function Index() { {request.response?.body?.usage && ( <> - {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()} tokens + {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0)).toLocaleString()} in - {request.response.body.usage.cache_read_input_tokens && ( + + {(request.response.body.usage.output_tokens || 0).toLocaleString()} out + + {request.response.body.usage.cache_read_input_tokens && + ((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0)) > 0 && ( - {request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached + {Math.round((request.response.body.usage.cache_read_input_tokens / ((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.cache_read_input_tokens || 0))) * 100)}% cached )} diff --git a/web/app/utils/formatters.test.ts b/web/app/utils/formatters.test.ts new file mode 100644 index 0000000..74a468e --- /dev/null +++ b/web/app/utils/formatters.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { escapeHtml, formatLargeText } from './formatters'; + +describe('escapeHtml', () => { + it('escapes ampersands', () => { + expect(escapeHtml('a & b')).toBe('a & b'); + }); + + it('escapes less than', () => { + expect(escapeHtml('a < b')).toBe('a < b'); + }); + + it('escapes greater than', () => { + expect(escapeHtml('a > b')).toBe('a > b'); + }); + + it('escapes double quotes', () => { + expect(escapeHtml('He said "hello"')).toBe('He said "hello"'); + }); + + it('escapes single quotes', () => { + expect(escapeHtml("It's fine")).toBe("It's fine"); + }); + + it('escapes all special characters together', () => { + expect(escapeHtml('')).toBe( + '<script>"alert('xss')&"</script>' + ); + }); +}); + +describe('formatLargeText', () => { + it('returns empty string for empty input', () => { + expect(formatLargeText('')).toBe(''); + }); + + it('wraps simple text in paragraph tags', () => { + expect(formatLargeText('Hello world')).toBe('

Hello world

'); + }); + + it('converts single newlines to br tags', () => { + expect(formatLargeText('Line1\nLine2')).toBe('

Line1
Line2

'); + }); + + it('converts double newlines to paragraph breaks with proper nesting', () => { + const result = formatLargeText('Line1\n\nLine2'); + expect(result).toBe('

Line1

Line2

'); + }); + + it('handles multiple paragraph breaks correctly', () => { + const result = formatLargeText('Para1\n\nPara2\n\nPara3'); + expect(result).toBe('

Para1

Para2

Para3

'); + }); + + it('escapes HTML in the input', () => { + const result = formatLargeText(''); + expect(result).toContain('<script>'); + expect(result).not.toContain('