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)
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 (/^(<!DOCTYPE|<html|<div|<head|<body)/.test(joined)) return 'html';
|
||||
if (/^\.\w+\s*\{|^@(media|keyframes|import)/.test(joined)) return 'css';
|
||||
if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE) /i.test(joined)) return 'sql';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Detect if a block of lines looks like code */
|
||||
function looksLikeCode(lines: string[]): boolean {
|
||||
if (lines.length < 2) return false;
|
||||
// If text contains markdown formatting, it's probably prose, not code
|
||||
const joined = lines.join('\n');
|
||||
if (/\*\*[^*]+\*\*/.test(joined) || /^#{1,6}\s/m.test(joined) || /^\s*[-*+]\s/m.test(joined)) return false;
|
||||
let codeSignals = 0;
|
||||
const patterns = [
|
||||
/^(import|export|const|let|var|function|class|interface|type|enum|struct|fn|pub|use|def|from|module|package|namespace)\s/,
|
||||
/[{};]\s*$/,
|
||||
/^\s*(if|else|for|while|return|match|switch|case|break|continue)\b/,
|
||||
/^\s*(\/\/|\/\*)/,
|
||||
/^\s*#\s*(?:include|define|ifdef|ifndef|endif|pragma|import)\b/,
|
||||
/[├└│┬─]──/,
|
||||
/^\s+\w+\(.*\)/,
|
||||
/^\s*<\/?[A-Z]\w*/,
|
||||
/=>\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());
|
||||
}
|
||||
|
||||
125
src/lib/__tests__/autoFormat.test.ts
Normal file
125
src/lib/__tests__/autoFormat.test.ts
Normal file
@@ -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(['<!DOCTYPE html>', '<html>', '<body></body>'])).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 <div />;',
|
||||
'};',
|
||||
])).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);
|
||||
});
|
||||
});
|
||||
100
src/lib/autoFormat.ts
Normal file
100
src/lib/autoFormat.ts
Normal file
@@ -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 (/^(<!DOCTYPE|<html|<div|<head|<body)/.test(joined)) return 'html';
|
||||
if (/^\.\w+\s*\{|^@(media|keyframes|import)/.test(joined)) return 'css';
|
||||
if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE) /i.test(joined)) return 'sql';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Detect if a block of lines looks like code */
|
||||
export function looksLikeCode(lines: string[]): boolean {
|
||||
if (lines.length < 2) return false;
|
||||
// If text contains markdown formatting, it's probably prose, not code
|
||||
const joined = lines.join('\n');
|
||||
if (/\*\*[^*]+\*\*/.test(joined) || /^#{1,6}\s/m.test(joined) || /^\s*[-*+]\s/m.test(joined)) return false;
|
||||
let codeSignals = 0;
|
||||
const patterns = [
|
||||
/^(import|export|const|let|var|function|class|interface|type|enum|struct|fn|pub|use|def|from|module|package|namespace)\s/,
|
||||
/[{};]\s*$/,
|
||||
/^\s*(if|else|for|while|return|match|switch|case|break|continue)\b/,
|
||||
/^\s*(\/\/|\/\*)/,
|
||||
/^\s*#\s*(?:include|define|ifdef|ifndef|endif|pragma|import)\b/,
|
||||
/[├└│┬─]──/,
|
||||
/^\s+\w+\(.*\)/,
|
||||
/^\s*<\/?[A-Z]\w*/,
|
||||
/=>\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');
|
||||
}
|
||||
Reference in New Issue
Block a user