From b7c18d5f3c696a16edb9629282a00fcd14d6671c Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Tue, 3 Mar 2026 21:04:06 +0000 Subject: [PATCH] refactor: extract autoFormat utils from ChatMessage to lib with tests - Move guessLanguage, looksLikeCode, autoFormatText to src/lib/autoFormat.ts - Add comprehensive test suite (23 tests) covering language detection, code detection, and auto-formatting - Reduce ChatMessage.tsx by ~90 lines - Total tests: 238 (up from 215) --- package-lock.json | 4 +- package.json | 2 +- src/components/ChatMessage.tsx | 97 +-------------------- src/lib/__tests__/autoFormat.test.ts | 125 +++++++++++++++++++++++++++ src/lib/autoFormat.ts | 100 +++++++++++++++++++++ 5 files changed, 229 insertions(+), 99 deletions(-) create mode 100644 src/lib/__tests__/autoFormat.test.ts create mode 100644 src/lib/autoFormat.ts diff --git a/package-lock.json b/package-lock.json index 49f0922..fac27e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pinchchat", - "version": "1.68.2", + "version": "1.68.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinchchat", - "version": "1.68.2", + "version": "1.68.3", "license": "MIT", "dependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/package.json b/package.json index 299d94d..f0b906c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinchchat", - "version": "1.68.2", + "version": "1.68.3", "description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.", "type": "module", "repository": { diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx index 63041f5..ede542b 100644 --- a/src/components/ChatMessage.tsx +++ b/src/components/ChatMessage.tsx @@ -15,6 +15,7 @@ import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webho import { t, getLocale } from '../lib/i18n'; import { useLocale } from '../hooks/useLocale'; import { stripWebhookScaffolding, hasWebhookScaffolding, hasWebchatEnvelope, stripWebchatEnvelope } from '../lib/systemEvent'; +import { autoFormatText } from '../lib/autoFormat'; // ChevronDown, ChevronRight, Wrench still used by InternalOnlyMessage /** Avatar image with fallback to Bot icon on load error */ @@ -72,102 +73,6 @@ function Timestamp({ ts, className }: { ts: number; className?: string }) { ); } -/** Guess a language hint from content patterns */ -function guessLanguage(lines: string[]): string { - const joined = lines.join('\n'); - if (/^import .+ from ['"]/.test(joined) || /^export (function|const|default|class|interface|type) /.test(joined) || /React\./.test(joined) || /<\w+[\s/>]/.test(joined) && /className=/.test(joined)) return 'tsx'; - if (/^(import|export|const|let|var|function|class|interface|type) /.test(joined) || /=>\s*{/.test(joined) || /: (string|number|boolean|any)\b/.test(joined)) return 'typescript'; - if (/^(use |fn |let mut |pub |impl |struct |enum |mod |crate::)/.test(joined) || /-> (Self|Result|Option|Vec|String|bool|i32|u32)/.test(joined)) return 'rust'; - if (/^(def |class |import |from .+ import |if __name__)/.test(joined) || /self\.\w+/.test(joined) && !/this\./.test(joined)) return 'python'; - if (/^\s*(server|location|upstream|proxy_pass|listen \d)/.test(joined)) return 'nginx'; - if (/^\[.*\]\s*$/.test(lines[0] || '') && /=/.test(joined)) return 'ini'; - if (/^(apiVersion|kind|metadata|spec):/.test(joined)) return 'yaml'; - if (/^\{/.test(joined.trim()) && /\}$/.test(joined.trim())) return 'json'; - if (/^#!\/(bin|usr)/.test(joined) || /^\s*(if \[|then|fi|echo |export |source )/.test(joined)) return 'bash'; - if (/^(\s*[{(]/, - /\.\w+\(.*\)\s*[;,]?\s*$/, - ]; - for (const line of lines) { - for (const pat of patterns) { - if (pat.test(line)) { codeSignals++; break; } - } - } - return codeSignals / lines.length > 0.3; -} - -/** Auto-wrap unformatted code/terminal output in fenced code blocks */ -function autoFormatText(text: string): string { - // Already has code fences — leave as-is - if (text.includes('```')) return text; - - const lines = text.split('\n'); - - // If most of the text looks like code, wrap the whole thing - const nonEmptyLines = lines.filter(l => l.trim()); - if (nonEmptyLines.length >= 3 && looksLikeCode(nonEmptyLines)) { - const lang = guessLanguage(nonEmptyLines); - return '```' + lang + '\n' + text + '\n```'; - } - - // Otherwise, detect contiguous code blocks within prose - const result: string[] = []; - let codeBuffer: string[] = []; - - const flushCode = () => { - if (codeBuffer.length >= 3 && looksLikeCode(codeBuffer)) { - const lang = guessLanguage(codeBuffer); - result.push('```' + lang); - result.push(...codeBuffer); - result.push('```'); - } else { - result.push(...codeBuffer); - } - codeBuffer = []; - }; - - const isCodeLine = (line: string): boolean => { - return /^[\s]+(import|export|const|let|var|function|return|if|else|for)/.test(line) - || /[{};]\s*$/.test(line) - || /^\s*\/\//.test(line) - || /[├└│┬─]──/.test(line) - || /^\s+\w+\(.*\)/.test(line); - }; - - for (const line of lines) { - if (isCodeLine(line) || (codeBuffer.length > 0 && (line.trim() === '' || /^\s{2,}/.test(line)))) { - codeBuffer.push(line); - } else { - flushCode(); - result.push(line); - } - } - flushCode(); - - return result.join('\n'); -} - function getTextBlocks(blocks: MessageBlock[]): MessageBlock[] { return blocks.filter(b => b.type === 'text' && b.text.trim()); } diff --git a/src/lib/__tests__/autoFormat.test.ts b/src/lib/__tests__/autoFormat.test.ts new file mode 100644 index 0000000..8aa47e6 --- /dev/null +++ b/src/lib/__tests__/autoFormat.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'vitest'; +import { guessLanguage, looksLikeCode, autoFormatText } from '../autoFormat'; + +describe('guessLanguage', () => { + it('detects TypeScript/TSX', () => { + expect(guessLanguage(['import React from "react"', 'export default App'])).toBe('tsx'); + }); + + it('detects TypeScript', () => { + expect(guessLanguage(['const x: string = "hello"', 'export function foo() {}'])).toBe('typescript'); + }); + + it('detects Rust', () => { + expect(guessLanguage(['fn main() {', ' println!("hello");', '}'])).toBe('rust'); + }); + + it('detects Python', () => { + expect(guessLanguage(['def hello():', ' print("world")'])).toBe('python'); + }); + + it('detects YAML', () => { + expect(guessLanguage(['apiVersion: v1', 'kind: Service', 'metadata:', ' name: foo'])).toBe('yaml'); + }); + + it('detects JSON', () => { + expect(guessLanguage(['{ "key": "value" }'])).toBe('json'); + }); + + it('detects bash', () => { + expect(guessLanguage(['#!/bin/bash', 'echo "hello"'])).toBe('bash'); + }); + + it('detects HTML', () => { + expect(guessLanguage(['', '', ''])).toBe('html'); + }); + + it('detects CSS', () => { + expect(guessLanguage(['.container {', ' display: flex;', '}'])).toBe('css'); + }); + + it('detects SQL', () => { + expect(guessLanguage(['SELECT * FROM users', 'WHERE id = 1'])).toBe('sql'); + }); + + it('detects nginx', () => { + expect(guessLanguage(['server {', ' listen 80;', ' location / {'])).toBe('nginx'); + }); + + it('detects INI', () => { + expect(guessLanguage(['[section]', 'key=value'])).toBe('ini'); + }); + + it('returns empty string for plain text', () => { + expect(guessLanguage(['Hello world', 'This is just text'])).toBe(''); + }); +}); + +describe('looksLikeCode', () => { + it('returns false for single line', () => { + expect(looksLikeCode(['one line'])).toBe(false); + }); + + it('returns true for code-like lines', () => { + expect(looksLikeCode([ + 'import React from "react";', + 'const App = () => {', + ' return
;', + '};', + ])).toBe(true); + }); + + it('returns false for markdown prose', () => { + expect(looksLikeCode([ + '# Hello', + 'This is **bold** text', + '- item one', + '- item two', + ])).toBe(false); + }); + + it('returns false for plain text', () => { + expect(looksLikeCode([ + 'Just a normal paragraph.', + 'Nothing special here.', + 'Move along.', + ])).toBe(false); + }); + + it('detects tree output as code', () => { + expect(looksLikeCode([ + '├── src/', + '│ ├── index.ts', + '│ └── utils.ts', + '└── package.json', + ])).toBe(true); + }); +}); + +describe('autoFormatText', () => { + it('returns text unchanged if it already has code fences', () => { + const text = 'Here is code:\n```js\nconsole.log("hi")\n```'; + expect(autoFormatText(text)).toBe(text); + }); + + it('wraps a full code block', () => { + const code = 'import foo from "bar";\nconst x = 1;\nexport default x;'; + const result = autoFormatText(code); + expect(result).toContain('```'); + expect(result).toContain('import foo from "bar"'); + }); + + it('leaves plain text unchanged', () => { + const text = 'Hello world.\nThis is a normal message.'; + expect(autoFormatText(text)).toBe(text); + }); + + it('handles empty string', () => { + expect(autoFormatText('')).toBe(''); + }); + + it('handles single line', () => { + const text = 'Just one line'; + expect(autoFormatText(text)).toBe(text); + }); +}); diff --git a/src/lib/autoFormat.ts b/src/lib/autoFormat.ts new file mode 100644 index 0000000..4737f81 --- /dev/null +++ b/src/lib/autoFormat.ts @@ -0,0 +1,100 @@ +/** + * Auto-detection and formatting of code blocks in plain text messages. + * Wraps unformatted code/terminal output in fenced code blocks with language hints. + */ + +/** Guess the programming language from a block of lines */ +export function guessLanguage(lines: string[]): string { + const joined = lines.join('\n'); + if (/^import .+ from ['"]/.test(joined) || /^export (function|const|default|class|interface|type) /.test(joined) || /React\./.test(joined) || /<\w+[\s/>]/.test(joined) && /className=/.test(joined)) return 'tsx'; + if (/^(import|export|const|let|var|function|class|interface|type) /.test(joined) || /=>\s*{/.test(joined) || /: (string|number|boolean|any)\b/.test(joined)) return 'typescript'; + if (/^(use |fn |let mut |pub |impl |struct |enum |mod |crate::)/.test(joined) || /-> (Self|Result|Option|Vec|String|bool|i32|u32)/.test(joined)) return 'rust'; + if (/^(def |class |import |from .+ import |if __name__)/.test(joined) || /self\.\w+/.test(joined) && !/this\./.test(joined)) return 'python'; + if (/^\s*(server|location|upstream|proxy_pass|listen \d)/.test(joined)) return 'nginx'; + if (/^\[.*\]\s*$/.test(lines[0] || '') && /=/.test(joined)) return 'ini'; + if (/^(apiVersion|kind|metadata|spec):/.test(joined)) return 'yaml'; + if (/^\{/.test(joined.trim()) && /\}$/.test(joined.trim())) return 'json'; + if (/^#!\/(bin|usr)/.test(joined) || /^\s*(if \[|then|fi|echo |export |source )/.test(joined)) return 'bash'; + if (/^(\s*[{(]/, + /\.\w+\(.*\)\s*[;,]?\s*$/, + ]; + for (const line of lines) { + for (const pat of patterns) { + if (pat.test(line)) { codeSignals++; break; } + } + } + return codeSignals / lines.length > 0.3; +} + +/** Auto-wrap unformatted code/terminal output in fenced code blocks */ +export function autoFormatText(text: string): string { + // Already has code fences — leave as-is + if (text.includes('```')) return text; + + const lines = text.split('\n'); + + // If most of the text looks like code, wrap the whole thing + const nonEmptyLines = lines.filter(l => l.trim()); + if (nonEmptyLines.length >= 3 && looksLikeCode(nonEmptyLines)) { + const lang = guessLanguage(nonEmptyLines); + return '```' + lang + '\n' + text + '\n```'; + } + + // Otherwise, detect contiguous code blocks within prose + const result: string[] = []; + let codeBuffer: string[] = []; + + const flushCode = () => { + if (codeBuffer.length >= 3 && looksLikeCode(codeBuffer)) { + const lang = guessLanguage(codeBuffer); + result.push('```' + lang); + result.push(...codeBuffer); + result.push('```'); + } else { + result.push(...codeBuffer); + } + codeBuffer = []; + }; + + const isCodeLine = (line: string): boolean => { + return /^[\s]+(import|export|const|let|var|function|return|if|else|for)/.test(line) + || /[{};]\s*$/.test(line) + || /^\s*\/\//.test(line) + || /[├└│┬─]──/.test(line) + || /^\s+\w+\(.*\)/.test(line); + }; + + for (const line of lines) { + if (isCodeLine(line) || (codeBuffer.length > 0 && (line.trim() === '' || /^\s{2,}/.test(line)))) { + codeBuffer.push(line); + } else { + flushCode(); + result.push(line); + } + } + flushCode(); + + return result.join('\n'); +}