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('