import type { MessageContent, TextContentBlock } from './types'; /** * Utility functions for formatting and displaying data */ /** * Safely converts unknown values to a formatted string for display */ export function formatValue(value: unknown): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); try { return JSON.stringify(value, null, 2); } catch { return String(value); } } /** * Formats JSON with proper indentation and returns a formatted string * Set maxLength to 0 or Infinity for no truncation */ export function formatJSON(obj: unknown, maxLength: number = 50000): string { try { const jsonString = JSON.stringify(obj, null, 2); if (maxLength > 0 && maxLength < Infinity && jsonString.length > maxLength) { return jsonString.substring(0, maxLength) + '\n... (truncated - ' + jsonString.length.toLocaleString() + ' total chars)'; } return jsonString; } catch { return String(obj); } } /** * Formats JSON without truncation */ export function formatJSONFull(obj: unknown): string { try { return JSON.stringify(obj, null, 2); } catch { return String(obj); } } /** * Escapes HTML characters to prevent XSS. */ export function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Formats large text with proper line breaks and structure. * Supports markdown-like syntax: headings, bold, italic, inline code, * fenced code blocks, bullet/numbered lists, horizontal rules, and URLs. */ export function formatLargeText(text: string): string { if (!text) return ''; const codeBlocks: string[] = []; const withPlaceholders = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => { const idx = codeBlocks.length; codeBlocks.push( `
${escapeHtml(code.replace(/\n$/, ''))}`
);
return `\x00CODEBLOCK_${idx}\x00`;
});
const escaped = escapeHtml(withPlaceholders);
const lines = escaped.split('\n');
const outputLines: string[] = [];
let inList = false;
let listType: 'ul' | 'ol' = 'ul';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cbMatch = line.match(/\x00CODEBLOCK_(\d+)\x00/);
if (cbMatch) {
if (inList) {
outputLines.push(listType === 'ul' ? '' : '');
inList = false;
}
outputLines.push(codeBlocks[parseInt(cbMatch[1], 10)]);
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
if (inList) {
outputLines.push(listType === 'ul' ? '' : '');
inList = false;
}
outputLines.push('$1')
.replace(/\bhttps?:\/\/[^\s<&]+/g, (url) => {
return `${url}`;
});
}
export function isComplexObject(value: unknown): boolean {
return value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length > 0;
}
export function truncateText(text: string, maxLength: number = 2000): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '... (' + (text.length - maxLength).toLocaleString() + ' more chars)';
}
export function formatTimestamp(timestamp: string | Date): string {
try {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return String(timestamp);
}
}
/**
* Format a duration in milliseconds to a human-readable short string (e.g. "3s", "5m", "2h")
*/
export function formatDuration(milliseconds: number): string {
if (milliseconds < 60000) return `${Math.round(milliseconds / 1000)}s`;
if (milliseconds < 3600000) return `${Math.round(milliseconds / 60000)}m`;
return `${Math.round(milliseconds / 3600000)}h`;
}
/**
* Format a timestamp string to a short time (e.g. "02:30 PM")
*/
export function formatTime(timestamp: string): string {
try {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true });
} catch {
return timestamp;
}
}
/**
* Format a timestamp to date string using toLocaleDateString()
*/
export function formatDate(timestamp: string | Date): string {
try {
return new Date(timestamp).toLocaleDateString();
} catch {
return String(timestamp);
}
}
/**
* Format a timestamp to time string using toLocaleTimeString()
*/
export function formatTimeOfDay(timestamp: string | Date): string {
try {
return new Date(timestamp).toLocaleTimeString();
} catch {
return String(timestamp);
}
}
export function formatFileSize(bytes: number): string {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
export interface XmlSegment {
type: 'text' | 'xml';
content: string;
tag?: string;
innerContent?: string;
}
export function parseXmlBlocks(text: string): XmlSegment[] {
if (!text) return [];
const result: XmlSegment[] = [];
const tagPattern = /<([a-z][a-z0-9_-]*(?:\s[^>]*)?)>/gi;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = tagPattern.exec(text)) !== null) {
const fullOpenTag = match[0];
const tagContent = match[1];
const tagName = tagContent.split(/\s/)[0];
const openPos = match.index;
const closeTag = `${tagName}>`;
let depth = 1;
let searchPos = openPos + fullOpenTag.length;
while (depth > 0 && searchPos < text.length) {
const nextOpen = text.indexOf(`<${tagName}`, searchPos);
const nextClose = text.indexOf(closeTag, searchPos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
const charAfterName = text[nextOpen + tagName.length + 1];
if (charAfterName === '>' || charAfterName === ' ' || charAfterName === '\n') {
depth++;
}
searchPos = nextOpen + tagName.length + 2;
} else {
depth--;
if (depth === 0) {
const innerStart = openPos + fullOpenTag.length;
const innerEnd = nextClose;
const blockEnd = nextClose + closeTag.length;
if (openPos > lastIndex) {
const preceding = text.substring(lastIndex, openPos).trim();
if (preceding) result.push({ type: 'text', content: preceding });
}
result.push({
type: 'xml',
content: text.substring(openPos, blockEnd),
tag: tagName,
innerContent: text.substring(innerStart, innerEnd)
});
lastIndex = blockEnd;
tagPattern.lastIndex = blockEnd;
} else {
searchPos = nextClose + closeTag.length;
}
}
}
if (depth > 0) {
tagPattern.lastIndex = openPos + fullOpenTag.length;
}
}
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex).trim();
if (remaining) result.push({ type: 'text', content: remaining });
}
return result;
}
export function hasCustomXmlBlocks(text: string): boolean {
return /<[a-z][a-z0-9_-]*(?:\s[^>]*)?>[\s\S]*?<\/[a-z][a-z0-9_-]*>/i.test(text);
}
export function getXmlTagStyle(tag: string): { bg: string; border: string; headerBg: string; text: string; icon: string } {
if (/^(system-reminder|thinking_mode|reasoning_effort|antml_thinking_mode|fast_mode_info|claude[A-Z_-])/.test(tag)) {
return { bg: 'bg-amber-50', border: 'border-amber-200', headerBg: 'bg-amber-100', text: 'text-amber-800', icon: 'settings' };
}
if (/^(functions?|function_calls?|antml_function|antml_invoke|antml_parameter|tool)/.test(tag)) {
return { bg: 'bg-emerald-50', border: 'border-emerald-200', headerBg: 'bg-emerald-100', text: 'text-emerald-800', icon: 'wrench' };
}
if (/^(local-command|command-|user-prompt)/.test(tag)) {
return { bg: 'bg-blue-50', border: 'border-blue-200', headerBg: 'bg-blue-100', text: 'text-blue-800', icon: 'terminal' };
}
if (/^(types?|examples?|skills?|context|references?)/.test(tag)) {
return { bg: 'bg-purple-50', border: 'border-purple-200', headerBg: 'bg-purple-100', text: 'text-purple-800', icon: 'database' };
}
return { bg: 'bg-gray-50', border: 'border-gray-200', headerBg: 'bg-gray-100', text: 'text-gray-700', icon: 'code' };
}
function isTextContentBlock(value: unknown): value is TextContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'text' && 'text' in value && typeof value.text === 'string';
}
export function createContentPreview(content: MessageContent | unknown, maxLength: number = 100): string {
if (typeof content === 'string') {
return content.length > maxLength ? content.substring(0, maxLength) + '...' : content;
}
if (Array.isArray(content)) {
const textContent = content.find((item) => isTextContentBlock(item))?.text || '';
if (textContent) {
return textContent.length > maxLength ? textContent.substring(0, maxLength) + '...' : textContent;
}
return `${content.length} content blocks`;
}
if (content && typeof content === 'object') {
if ('text' in content && typeof content.text === 'string') {
return content.text.length > maxLength ? content.text.substring(0, maxLength) + '...' : content.text;
}
return 'Complex content';
}
return 'No content';
}