Initial Release: OpenQode Public Alpha v1.3

This commit is contained in:
Gemini AI
2025-12-14 00:40:14 +04:00
Unverified
commit 8e8d80c110
119 changed files with 31174 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
/**
* AgentRail Component - "Pro" Protocol
* Minimalist left-rail layout for messages (Claude Code / Codex CLI style)
*
* @module ui/components/AgentRail
*/
import React from 'react';
import { Box, Text } from 'ink';
const h = React.createElement;
// ═══════════════════════════════════════════════════════════════
// ROLE COLORS - Color-coded vertical rail by role
// ═══════════════════════════════════════════════════════════════
export const RAIL_COLORS = {
system: 'yellow',
user: 'cyan',
assistant: 'gray',
error: 'red',
thinking: 'magenta',
tool: 'blue'
};
export const RAIL_ICONS = {
system: '',
user: '',
assistant: '◐',
error: '!',
thinking: '◌',
tool: '⚙'
};
// ═══════════════════════════════════════════════════════════════
// SYSTEM MESSAGE - Compact single-line format
// ═══════════════════════════════════════════════════════════════
/**
* SystemMessage - Compact system notification
* Format: " SYSTEM: Message here"
*/
export const SystemMessage = ({ content, title = 'SYSTEM' }) => {
return h(Box, { marginY: 0 },
h(Text, { color: RAIL_COLORS.system }, `${RAIL_ICONS.system} `),
h(Text, { color: RAIL_COLORS.system, bold: true }, `${title}: `),
h(Text, { color: 'gray' }, content)
);
};
// ═══════════════════════════════════════════════════════════════
// USER MESSAGE - Clean prompt style
// ═══════════════════════════════════════════════════════════════
/**
* UserMessage - Clean prompt indicator
* Format: " user message"
*/
export const UserMessage = ({ content }) => {
return h(Box, { marginTop: 1, marginBottom: 0 },
h(Text, { color: RAIL_COLORS.user, bold: true }, `${RAIL_ICONS.user} `),
h(Text, { color: 'white', wrap: 'wrap' }, content)
);
};
// ═══════════════════════════════════════════════════════════════
// ASSISTANT MESSAGE - Left rail with content
// ═══════════════════════════════════════════════════════════════
/**
* AssistantMessage - Rail-based layout (no box borders)
* Uses vertical line instead of full border
*/
export const AssistantMessage = ({ content, isStreaming = false, children }) => {
const railChar = isStreaming ? '┃' : '│';
const railColor = isStreaming ? 'yellow' : RAIL_COLORS.assistant;
return h(Box, {
flexDirection: 'row',
marginTop: 1,
marginBottom: 1
},
// Left rail (vertical line)
h(Box, {
width: 2,
flexShrink: 0,
flexDirection: 'column'
},
h(Text, { color: railColor }, railChar)
),
// Content area
h(Box, {
flexDirection: 'column',
flexGrow: 1,
paddingLeft: 1
},
children || h(Text, { wrap: 'wrap' }, content)
)
);
};
// ═══════════════════════════════════════════════════════════════
// THINKING INDICATOR - Dimmed spinner style
// ═══════════════════════════════════════════════════════════════
/**
* ThinkingIndicator - Shows AI reasoning steps
*/
export const ThinkingIndicator = ({ steps = [] }) => {
if (!steps || steps.length === 0) return null;
return h(Box, {
flexDirection: 'column',
marginBottom: 1,
paddingLeft: 2
},
h(Text, { color: RAIL_COLORS.thinking, dimColor: true },
`${RAIL_ICONS.thinking} Thinking (${steps.length} steps)`),
...steps.slice(-3).map((step, i) =>
h(Text, {
key: i,
color: 'gray',
dimColor: true,
wrap: 'truncate-end'
}, ` ${step.slice(0, 60)}${step.length > 60 ? '...' : ''}`)
)
);
};
// ═══════════════════════════════════════════════════════════════
// ERROR MESSAGE - Red rail with error content
// ═══════════════════════════════════════════════════════════════
/**
* ErrorMessage - Red-railed error display
*/
export const ErrorMessage = ({ content, title = 'Error' }) => {
return h(Box, {
flexDirection: 'row',
marginTop: 1
},
h(Box, { width: 2, flexShrink: 0 },
h(Text, { color: RAIL_COLORS.error }, '│')
),
h(Box, { flexDirection: 'column', paddingLeft: 1 },
h(Text, { color: RAIL_COLORS.error, bold: true }, `${RAIL_ICONS.error} ${title}`),
h(Text, { color: RAIL_COLORS.error, wrap: 'wrap' }, content)
)
);
};
// ═══════════════════════════════════════════════════════════════
// MESSAGE WRAPPER - Auto-selects component by role
// ═══════════════════════════════════════════════════════════════
/**
* MessageWrapper - Routes to correct component by role
*/
export const MessageWrapper = ({ role, content, meta, isStreaming, children }) => {
switch (role) {
case 'system':
return h(SystemMessage, { content, title: meta?.title });
case 'user':
return h(UserMessage, { content });
case 'assistant':
return h(AssistantMessage, { content, isStreaming, children });
case 'error':
return h(ErrorMessage, { content, title: meta?.title });
default:
return h(Text, { wrap: 'wrap' }, content);
}
};
export default {
RAIL_COLORS,
RAIL_ICONS,
SystemMessage,
UserMessage,
AssistantMessage,
ThinkingIndicator,
ErrorMessage,
MessageWrapper
};

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Box, Text } from 'ink';
const h = React.createElement;
const ChatBubble = ({ role, content, meta, width, children }) => {
// ═══════════════════════════════════════════════════════════════
// USER MESSAGE (RIGHT ALIGNED) - RAIL STYLE
// ═══════════════════════════════════════════════════════════════
if (role === 'user') {
return h(Box, { width: width, flexDirection: 'row', justifyContent: 'flex-end', marginBottom: 1, overflow: 'hidden' },
h(Box, { flexDirection: 'row', paddingRight: 1 },
h(Text, { color: 'cyan', wrap: 'wrap' }, content),
h(Box, { marginLeft: 1, borderStyle: 'single', borderLeft: false, borderTop: false, borderBottom: false, borderRightColor: 'cyan' })
)
);
}
// ═══════════════════════════════════════════════════════════════
// SYSTEM - MINIMALIST TOAST
// ═══════════════════════════════════════════════════════════════
if (role === 'system') {
return h(Box, { width: width, justifyContent: 'center', marginBottom: 1 },
h(Text, { color: 'gray', dimColor: true }, ` ${content} `)
);
}
// ═══════════════════════════════════════════════════════════════
// ERROR - RED GUTTER
// ═══════════════════════════════════════════════════════════════
if (role === 'error') {
// Strip redundant "Error: " prefix if present in content
const cleanContent = content.replace(/^Error:\s*/i, '');
return h(Box, { width: width, flexDirection: 'row', marginBottom: 1, overflow: 'hidden' },
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'red' }),
h(Text, { color: 'red', wrap: 'wrap' }, cleanContent)
);
}
// ═══════════════════════════════════════════════════════════════
// ASSISTANT - LEFT GUTTER RAIL
// ═══════════════════════════════════════════════════════════════
return h(Box, { width: width, flexDirection: 'row', marginBottom: 1, overflow: 'hidden' },
// Left Gutter
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'green' }),
// Content
h(Box, { flexDirection: 'column', paddingRight: 2, flexGrow: 1 },
children ? children : h(Text, { wrap: 'wrap' }, content)
)
);
};
export default ChatBubble;

View File

@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import * as Diff from 'diff';
const h = React.createElement;
const DiffView = ({
original = '',
modified = '',
file = 'unknown.js',
onApply,
onSkip,
width = 80,
height = 20
}) => {
// Generate diff objects
// [{ value: 'line', added: boolean, removed: boolean }]
const diff = Diff.diffLines(original, modified);
// Scroll state
const [scrollTop, setScrollTop] = useState(0);
// Calculate total lines for scrolling
const totalLines = diff.reduce((acc, part) => acc + part.value.split('\n').length - 1, 0);
const visibleLines = height - 4; // Header + Footer space
useInput((input, key) => {
if (key.upArrow) {
setScrollTop(prev => Math.max(0, prev - 1));
}
if (key.downArrow) {
setScrollTop(prev => Math.min(totalLines - visibleLines, prev + 1));
}
if (key.pageUp) {
setScrollTop(prev => Math.max(0, prev - visibleLines));
}
if (key.pageDown) {
setScrollTop(prev => Math.min(totalLines - visibleLines, prev + visibleLines));
}
if (input === 'y' || input === 'Y' || key.return) {
onApply();
}
if (input === 'n' || input === 'N' || key.escape) {
onSkip();
}
});
// Render Logic
let currentLine = 0;
const renderedLines = [];
diff.forEach((part) => {
const lines = part.value.split('\n');
// last element of split is often empty if value ends with newline
if (lines[lines.length - 1] === '') lines.pop();
lines.forEach((line) => {
currentLine++;
// Check visibility
if (currentLine <= scrollTop || currentLine > scrollTop + visibleLines) {
return;
}
let color = 'gray'; // Unchanged
let prefix = ' ';
let bg = undefined;
if (part.added) {
color = 'green';
prefix = '+ ';
} else if (part.removed) {
color = 'red';
prefix = '- ';
}
renderedLines.push(
h(Box, { key: currentLine, width: '100%' },
h(Text, { color: 'gray', dimColor: true }, `${currentLine.toString().padEnd(4)} `),
h(Text, { color: color, backgroundColor: bg, wrap: 'truncate-end' }, prefix + line)
)
);
});
});
return h(Box, {
flexDirection: 'column',
width: width,
height: height,
borderStyle: 'double',
borderColor: 'yellow'
},
// Header
h(Box, { flexDirection: 'column', paddingX: 1, borderStyle: 'single', borderBottom: true, borderTop: false, borderLeft: false, borderRight: false },
h(Text, { bold: true, color: 'yellow' }, `Reviewing: ${file}`),
h(Box, { justifyContent: 'space-between' },
h(Text, { dimColor: true }, `Lines: ${totalLines} | Changes: ${diff.filter(p => p.added || p.removed).length} blocks`),
h(Text, { color: 'blue' }, 'UP/DOWN to scroll')
)
),
// Diff Content
h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1 },
renderedLines.length > 0
? renderedLines
: h(Text, { color: 'gray' }, 'No changes detected (Files are identical)')
),
// Footer Actions
h(Box, {
borderStyle: 'single',
borderTop: true,
borderBottom: false,
borderLeft: false,
borderRight: false,
paddingX: 1,
justifyContent: 'center',
gap: 4
},
h(Text, { color: 'green', bold: true }, '[Y] Apply Changes'),
h(Text, { color: 'red', bold: true }, '[N] Discard/Skip')
)
);
};
export default DiffView;

View File

@@ -0,0 +1,177 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import fs from 'fs';
import path from 'path';
const h = React.createElement;
// Helper to sort: folders first
const sortFiles = (files, dirPath) => {
return files.sort((a, b) => {
const pathA = path.join(dirPath, a);
const pathB = path.join(dirPath, b);
try {
const statA = fs.statSync(pathA);
const statB = fs.statSync(pathB);
if (statA.isDirectory() && !statB.isDirectory()) return -1;
if (!statA.isDirectory() && statB.isDirectory()) return 1;
return a.localeCompare(b);
} catch (e) {
return 0;
}
});
};
const FileTree = ({
rootPath,
onSelect,
selectedFiles = new Set(),
isActive = false,
height = 20,
width = 30
}) => {
const [expanded, setExpanded] = useState(new Set([rootPath])); // Expanded folders
const [cursor, setCursor] = useState(rootPath); // Currently highlighted path
const [flatList, setFlatList] = useState([]); // Computed flat list for rendering (calc'd from expanded)
// Ignore list
const IGNORE_DIRS = new Set(['.git', 'node_modules', '.opencode', 'dist', 'build', 'coverage']);
// Rebuild flat list when expanded changes
// Returns array of { path, name, isDir, depth, isExpanded, hasChildren }
const buildFlatList = useCallback(() => {
const list = [];
const traverse = (currentPath, depth) => {
if (depth > 10) return; // Safety
const name = path.basename(currentPath) || (currentPath === rootPath ? '/' : currentPath);
let isDir = false;
try {
isDir = fs.statSync(currentPath).isDirectory();
} catch (e) { return; }
const isExpanded = expanded.has(currentPath);
list.push({
path: currentPath,
name: name,
isDir: isDir,
depth: depth,
isExpanded: isExpanded
});
if (isDir && isExpanded) {
try {
const children = fs.readdirSync(currentPath).filter(f => !IGNORE_DIRS.has(f) && !f.startsWith('.'));
const sorted = sortFiles(children, currentPath);
for (const child of sorted) {
traverse(path.join(currentPath, child), depth + 1);
}
} catch (e) {
// Permission error or file delete race condition
}
}
};
traverse(rootPath, 0);
return list;
}, [expanded, rootPath]);
useEffect(() => {
setFlatList(buildFlatList());
}, [buildFlatList]);
useInput((input, key) => {
if (!isActive) return;
const currentIndex = flatList.findIndex(item => item.path === cursor);
if (key.downArrow) {
const nextIndex = Math.min(flatList.length - 1, currentIndex + 1);
setCursor(flatList[nextIndex].path);
}
if (key.upArrow) {
const prevIndex = Math.max(0, currentIndex - 1);
setCursor(flatList[prevIndex].path);
}
if (key.rightArrow || key.return) {
const item = flatList[currentIndex];
if (item && item.isDir) {
if (!expanded.has(item.path)) {
setExpanded(prev => new Set([...prev, item.path]));
}
}
}
if (key.leftArrow) {
const item = flatList[currentIndex];
if (item && item.isDir && expanded.has(item.path)) {
const newExpanded = new Set(expanded);
newExpanded.delete(item.path);
setExpanded(newExpanded);
} else {
// Determine parent path to jump up
const parentPath = path.dirname(item.path);
if (parentPath && parentPath.length >= rootPath.length) {
setCursor(parentPath);
}
}
}
if (input === ' ') {
const item = flatList[currentIndex];
if (item && !item.isDir) {
// Toggle selection
if (onSelect) {
onSelect(item.path);
}
}
}
});
// Calculate viewport based on cursor
const cursorIndex = flatList.findIndex(item => item.path === cursor);
// Ensure height is valid number
const safeHeight = Math.max(5, height || 20);
const renderStart = Math.max(0, Math.min(cursorIndex - Math.floor(safeHeight / 2), flatList.length - safeHeight));
const renderEnd = Math.min(flatList.length, renderStart + safeHeight);
const visibleItems = flatList.slice(renderStart, renderEnd);
return h(Box, { flexDirection: 'column', width: width, height: safeHeight },
visibleItems.map((item) => {
const isSelected = selectedFiles.has(item.path);
const isCursor = item.path === cursor;
// Indentation
const indent = ' '.repeat(Math.max(0, item.depth));
// Icon
let icon = item.isDir
? (item.isExpanded ? '▼ ' : '▶ ')
: (isSelected ? '[x] ' : '[ ] ');
// Color logic
let color = 'white';
if (item.isDir) color = 'cyan';
if (isSelected) color = 'green';
// Cursor style
const bg = isCursor ? 'blue' : undefined;
const textColor = isCursor ? 'white' : color;
return h(Box, { key: item.path, width: '100%' },
h(Text, {
backgroundColor: bg,
color: textColor,
wrap: 'truncate'
}, `${indent}${icon}${item.name}`)
);
})
);
};
export default FileTree;

View File

@@ -0,0 +1,42 @@
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
const h = React.createElement;
const ThinkingBlock = ({
lines = [],
isThinking = false,
stats = { chars: 0 },
width = 80
}) => {
// If no thinking lines and not thinking, show nothing
if (lines.length === 0 && !isThinking) return null;
// Show only last few lines to avoid clutter
const visibleLines = lines.slice(-3);
const hiddenCount = Math.max(0, lines.length - 3);
return h(Box, {
flexDirection: 'row',
width: width,
marginBottom: 1,
overflow: 'hidden'
},
// Left Gutter (Dimmed)
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'gray', borderDimColor: true }),
h(Box, { flexDirection: 'column' },
h(Text, { color: 'gray', dimColor: true },
isThinking
? `🧠 Thinking${stats.activeAgent ? ` (${stats.activeAgent})` : ''}... (${stats.chars} chars)`
: `💭 Thought Process (${stats.chars} chars)`
),
visibleLines.map((line, i) =>
h(Text, { key: i, color: 'gray', dimColor: true, wrap: 'truncate' }, ` ${line}`)
),
hiddenCount > 0 ? h(Text, { color: 'gray', dimColor: true, italic: true }, ` ...${hiddenCount} more`) : null
)
);
};
export default ThinkingBlock;

View File

@@ -0,0 +1,208 @@
/**
* TimeoutRow Component - "Pro" Protocol
* Interactive component for timeout recovery actions
*
* @module ui/components/TimeoutRow
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
const { useState } = React;
const h = React.createElement;
// ═══════════════════════════════════════════════════════════════
// TIMEOUT ROW - Non-destructive timeout handling
// ═══════════════════════════════════════════════════════════════
/**
* TimeoutRow Component
* Displays interactive recovery options when a request times out
*
* @param {Object} props
* @param {Function} props.onRetry - Called when user selects Retry
* @param {Function} props.onCancel - Called when user selects Cancel
* @param {Function} props.onSaveLogs - Called when user selects Save Logs
* @param {string} props.lastGoodText - Last successful text before timeout
* @param {number} props.elapsedTime - Time elapsed before timeout (seconds)
*/
export const TimeoutRow = ({
onRetry,
onCancel,
onSaveLogs,
lastGoodText = '',
elapsedTime = 120
}) => {
const [selectedAction, setSelectedAction] = useState(0);
const actions = [
{ key: 'r', label: '[R]etry', color: 'yellow', action: onRetry },
{ key: 'c', label: '[C]ancel', color: 'gray', action: onCancel },
{ key: 's', label: '[S]ave Logs', color: 'blue', action: onSaveLogs }
];
// Handle keyboard input
useInput((input, key) => {
const lowerInput = input.toLowerCase();
// Direct key shortcuts
if (lowerInput === 'r' && onRetry) {
onRetry();
return;
}
if (lowerInput === 'c' && onCancel) {
onCancel();
return;
}
if (lowerInput === 's' && onSaveLogs) {
onSaveLogs();
return;
}
// Arrow key navigation
if (key.leftArrow) {
setSelectedAction(prev => Math.max(0, prev - 1));
}
if (key.rightArrow) {
setSelectedAction(prev => Math.min(actions.length - 1, prev + 1));
}
// Enter to confirm selected action
if (key.return) {
const action = actions[selectedAction]?.action;
if (action) action();
}
});
return h(Box, {
flexDirection: 'column',
marginTop: 1,
paddingLeft: 2
},
// Warning indicator
h(Box, { marginBottom: 0 },
h(Text, { color: 'yellow', bold: true }, '⚠ '),
h(Text, { color: 'yellow' }, `Request timed out (${elapsedTime}s)`)
),
// Action buttons
h(Box, { marginTop: 0 },
...actions.map((action, i) =>
h(Box, { key: action.key, marginRight: 2 },
h(Text, {
color: i === selectedAction ? 'white' : action.color,
inverse: i === selectedAction,
bold: i === selectedAction
}, ` ${action.label} `)
)
)
),
// Context info (dimmed)
lastGoodText ? h(Box, { marginTop: 0 },
h(Text, { color: 'gray', dimColor: true },
`${lastGoodText.split('\n').length} paragraphs preserved`)
) : null
);
};
// ═══════════════════════════════════════════════════════════════
// RUN STATES - State machine for assistant responses
// ═══════════════════════════════════════════════════════════════
export const RUN_STATES = {
IDLE: 'idle',
STREAMING: 'streaming',
WAITING_FOR_TOOL: 'waiting_for_tool',
COMPLETE: 'complete',
TIMED_OUT: 'timed_out',
CANCELLED: 'cancelled'
};
/**
* Create a new Run object
* @param {string} id - Unique run ID
* @param {string} prompt - Original user prompt
* @returns {Object} New run object
*/
export function createRun(id, prompt) {
return {
id,
prompt,
state: RUN_STATES.IDLE,
partialText: '',
lastCheckpoint: '',
startTime: Date.now(),
lastActivityTime: Date.now(),
tokensReceived: 0,
error: null,
metadata: {}
};
}
/**
* Update run state with new data
* @param {Object} run - Current run object
* @param {Object} updates - Updates to apply
* @returns {Object} Updated run object
*/
export function updateRun(run, updates) {
return {
...run,
...updates,
lastActivityTime: Date.now()
};
}
/**
* Checkpoint the run for potential resume
* @param {Object} run - Current run object
* @returns {Object} Run with checkpoint set
*/
export function checkpointRun(run) {
// Find last complete paragraph for clean resume point
const text = run.partialText || '';
const paragraphs = text.split('\n\n');
const completeParagraphs = paragraphs.slice(0, -1).join('\n\n');
return {
...run,
lastCheckpoint: completeParagraphs || text
};
}
/**
* Calculate overlap for resume deduplication
* @param {string} checkpoint - Last checkpointed text
* @param {string} newText - New text from resumed generation
* @returns {string} Deduplicated combined text
*/
export function deduplicateResume(checkpoint, newText) {
if (!checkpoint || !newText) return newText || checkpoint || '';
// Find overlap at end of checkpoint / start of newText
const checkpointLines = checkpoint.split('\n');
const newLines = newText.split('\n');
// Look for matching lines to find overlap point
let overlapStart = 0;
for (let i = 0; i < newLines.length && i < 10; i++) {
const line = newLines[i].trim();
if (line && checkpointLines.some(cl => cl.trim() === line)) {
overlapStart = i + 1;
break;
}
}
// Return checkpoint + non-overlapping new content
const uniqueNewContent = newLines.slice(overlapStart).join('\n');
return checkpoint + (uniqueNewContent ? '\n\n' + uniqueNewContent : '');
}
export default {
TimeoutRow,
RUN_STATES,
createRun,
updateRun,
checkpointRun,
deduplicateResume
};

View File

@@ -0,0 +1,174 @@
/**
* Text Formatter Utilities - "Pro" Protocol
* Sanitizes text before rendering to remove debug noise and HTML entities
*
* @module ui/utils/textFormatter
*/
import he from 'he';
// ═══════════════════════════════════════════════════════════════
// SANITIZATION PATTERNS
// ═══════════════════════════════════════════════════════════════
// Debug log patterns to strip
const DEBUG_PATTERNS = [
/\d+\s+[A-Z]:\\[^\n]+/g, // Windows paths: "xx E:\path\to\file"
/\[\d{4}-\d{2}-\d{2}[^\]]+\]/g, // Timestamps: "[2024-01-01 12:00:00]"
/DEBUG:\s*[^\n]+/gi, // DEBUG: messages
/^>\s*undefined$/gm, // Stray undefined
/^\s*at\s+[^\n]+$/gm, // Stack trace lines
];
// HTML entities that commonly appear in AI output
const ENTITY_MAP = {
'&#39;': "'",
'&quot;': '"',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&nbsp;': ' ',
'&#x27;': "'",
'&#x2F;': '/',
};
// ═══════════════════════════════════════════════════════════════
// CORE SANITIZERS
// ═══════════════════════════════════════════════════════════════
/**
* Decode HTML entities to clean text
* @param {string} text - Raw text with possible HTML entities
* @returns {string} Clean text
*/
export function decodeEntities(text) {
if (!text || typeof text !== 'string') return '';
// First pass: common entities via map
let result = text;
for (const [entity, char] of Object.entries(ENTITY_MAP)) {
result = result.replace(new RegExp(entity, 'g'), char);
}
// Second pass: use 'he' library for comprehensive decoding
try {
result = he.decode(result);
} catch (e) {
// Fallback if he fails
}
return result;
}
/**
* Strip debug noise from text
* @param {string} text - Text with possible debug output
* @returns {string} Clean text without debug noise
*/
export function stripDebugNoise(text) {
if (!text || typeof text !== 'string') return '';
let result = text;
for (const pattern of DEBUG_PATTERNS) {
result = result.replace(pattern, '');
}
// Clean up resulting empty lines
result = result.replace(/\n{3,}/g, '\n\n');
return result.trim();
}
/**
* Fix broken list formatting
* @param {string} text - Text with potentially broken lists
* @returns {string} Text with fixed list formatting
*/
export function fixListFormatting(text) {
if (!text || typeof text !== 'string') return '';
// Fix bullet points that got mangled
let result = text
.replace(/•\s*([a-z])/g, '• $1') // Fix stuck bullets
.replace(/(\d+)\.\s*([a-z])/g, '$1. $2') // Fix numbered lists
.replace(/:\s*\n\s*•/g, ':\n\n•') // Add spacing before lists
.replace(/([.!?])\s*•/g, '$1\n\n•'); // Add line break before bullets
return result;
}
/**
* Ensure proper paragraph spacing
* @param {string} text - Text to process
* @returns {string} Text with proper paragraph breaks
*/
export function ensureParagraphSpacing(text) {
if (!text || typeof text !== 'string') return '';
// Ensure sentences starting new topics get proper breaks
let result = text
.replace(/([.!?])\s*([A-Z][a-z])/g, '$1\n\n$2') // New sentence, new paragraph
.replace(/\n{4,}/g, '\n\n\n'); // Max 3 newlines
return result;
}
// ═══════════════════════════════════════════════════════════════
// MAIN PIPELINE
// ═══════════════════════════════════════════════════════════════
/**
* Full sanitization pipeline for content before rendering
* @param {string} text - Raw text from AI or system
* @returns {string} Clean, formatted text ready for display
*/
export function cleanContent(text) {
if (!text || typeof text !== 'string') return '';
let result = text;
// Step 1: Decode HTML entities
result = decodeEntities(result);
// Step 2: Strip debug noise
result = stripDebugNoise(result);
// Step 3: Fix list formatting
result = fixListFormatting(result);
// Step 4: Normalize whitespace
result = result.replace(/\r\n/g, '\n').trim();
return result;
}
/**
* Format for single-line display (status messages, etc)
* @param {string} text - Text to format
* @param {number} maxLength - Maximum length before truncation
* @returns {string} Single-line formatted text
*/
export function formatSingleLine(text, maxLength = 80) {
if (!text || typeof text !== 'string') return '';
let result = cleanContent(text);
// Collapse to single line
result = result.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
// Truncate if needed
if (result.length > maxLength) {
result = result.slice(0, maxLength - 3) + '...';
}
return result;
}
export default {
cleanContent,
decodeEntities,
stripDebugNoise,
fixListFormatting,
ensureParagraphSpacing,
formatSingleLine
};