New: Message renderer (OpenCode-style code boxes) + Retry handler (exponential backoff)
This commit is contained in:
@@ -61,6 +61,15 @@ import {
|
|||||||
formatFileOperation,
|
formatFileOperation,
|
||||||
separator
|
separator
|
||||||
} from '../lib/agent-prompt.mjs';
|
} from '../lib/agent-prompt.mjs';
|
||||||
|
import {
|
||||||
|
formatCodeBox,
|
||||||
|
formatFileDelivery,
|
||||||
|
formatPath,
|
||||||
|
truncateHeight,
|
||||||
|
formatTodoItem,
|
||||||
|
formatTaskChecklist,
|
||||||
|
getToolProgress
|
||||||
|
} from '../lib/message-renderer.mjs';
|
||||||
|
|
||||||
// Initialize debug logger from CLI args
|
// Initialize debug logger from CLI args
|
||||||
const debugLogger = initFromArgs();
|
const debugLogger = initFromArgs();
|
||||||
|
|||||||
265
lib/message-renderer.mjs
Normal file
265
lib/message-renderer.mjs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Enhanced Message Renderer - OpenCode-inspired code display and file delivery
|
||||||
|
* Based on: https://github.com/opencode-ai/opencode
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* A. Code blocks with language-aware syntax styling
|
||||||
|
* B. File delivery with full paths and status icons
|
||||||
|
* C. Animated todo/task progress with checkmarks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
const { useState, useEffect, useRef } = React;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// A. CODE BLOCK RENDERING
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a code block with filename header (OpenCode style)
|
||||||
|
* @param {string} filename - File name to display in header
|
||||||
|
* @param {string} code - Code content
|
||||||
|
* @param {string} language - Language for syntax highlighting
|
||||||
|
* @param {number} width - Width of the box
|
||||||
|
* @returns {string} Formatted code block
|
||||||
|
*/
|
||||||
|
export function formatCodeBox(filename, code, language = '', width = 60) {
|
||||||
|
// Get language from filename extension if not provided
|
||||||
|
if (!language && filename) {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
language = getLanguageFromExt(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxWidth = Math.min(width, 80);
|
||||||
|
const headerPadding = Math.max(0, boxWidth - filename.length - 5);
|
||||||
|
|
||||||
|
const header = `┌─ ${filename} ${'─'.repeat(headerPadding)}┐`;
|
||||||
|
const footer = `└${'─'.repeat(boxWidth)}┘`;
|
||||||
|
|
||||||
|
// Format code lines with left border
|
||||||
|
const lines = code.split('\n').map(line => {
|
||||||
|
const truncated = line.length > boxWidth - 4 ? line.substring(0, boxWidth - 7) + '...' : line;
|
||||||
|
const padding = Math.max(0, boxWidth - 2 - truncated.length);
|
||||||
|
return `│ ${truncated}${' '.repeat(padding)}│`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${header}\n${lines.join('\n')}\n${footer}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get language identifier from file extension
|
||||||
|
*/
|
||||||
|
function getLanguageFromExt(ext) {
|
||||||
|
const map = {
|
||||||
|
'js': 'javascript',
|
||||||
|
'mjs': 'javascript',
|
||||||
|
'jsx': 'jsx',
|
||||||
|
'ts': 'typescript',
|
||||||
|
'tsx': 'tsx',
|
||||||
|
'py': 'python',
|
||||||
|
'rb': 'ruby',
|
||||||
|
'go': 'go',
|
||||||
|
'rs': 'rust',
|
||||||
|
'java': 'java',
|
||||||
|
'c': 'c',
|
||||||
|
'cpp': 'cpp',
|
||||||
|
'h': 'c',
|
||||||
|
'css': 'css',
|
||||||
|
'scss': 'scss',
|
||||||
|
'html': 'html',
|
||||||
|
'json': 'json',
|
||||||
|
'yaml': 'yaml',
|
||||||
|
'yml': 'yaml',
|
||||||
|
'md': 'markdown',
|
||||||
|
'sh': 'bash',
|
||||||
|
'sql': 'sql'
|
||||||
|
};
|
||||||
|
return map[ext] || 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate content to max height (OpenCode pattern)
|
||||||
|
*/
|
||||||
|
export function truncateHeight(content, maxLines = 10) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
if (lines.length > maxLines) {
|
||||||
|
return lines.slice(0, maxLines - 1).join('\n') + `\n... (${lines.length - maxLines + 1} more lines)`;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// B. FILE DELIVERY WITH FULL PATHS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file operation notification (OpenCode style)
|
||||||
|
* Shows: icon + action + full path
|
||||||
|
*/
|
||||||
|
export function formatFileDelivery(action, filePath, options = {}) {
|
||||||
|
const { bytesWritten, linesChanged, isError = false } = options;
|
||||||
|
|
||||||
|
// Action icons and labels (OpenCode toolName pattern)
|
||||||
|
const actions = {
|
||||||
|
write: { icon: '📝', label: 'Written', verb: 'Wrote' },
|
||||||
|
create: { icon: '✨', label: 'Created', verb: 'Created' },
|
||||||
|
edit: { icon: '✏️', label: 'Edited', verb: 'Edited' },
|
||||||
|
delete: { icon: '🗑️', label: 'Deleted', verb: 'Deleted' },
|
||||||
|
read: { icon: '📖', label: 'Read', verb: 'Read' },
|
||||||
|
patch: { icon: '🔧', label: 'Patched', verb: 'Patched' },
|
||||||
|
view: { icon: '👁️', label: 'Viewing', verb: 'View' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionInfo = actions[action] || { icon: '📁', label: action, verb: action };
|
||||||
|
|
||||||
|
// Build the notification
|
||||||
|
let notification = `${actionInfo.icon} ${actionInfo.verb}: ${filePath}`;
|
||||||
|
|
||||||
|
// Add metadata if available
|
||||||
|
const meta = [];
|
||||||
|
if (bytesWritten) meta.push(`${formatBytes(bytesWritten)}`);
|
||||||
|
if (linesChanged) meta.push(`${linesChanged} lines`);
|
||||||
|
|
||||||
|
if (meta.length > 0) {
|
||||||
|
notification += ` (${meta.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
notification = `❌ Failed to ${action}: ${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file path for display (remove working dir prefix)
|
||||||
|
*/
|
||||||
|
export function formatPath(fullPath, workingDir = process.cwd()) {
|
||||||
|
if (fullPath.startsWith(workingDir)) {
|
||||||
|
return fullPath.slice(workingDir.length).replace(/^[\/\\]/, '');
|
||||||
|
}
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// C. TODO/TASK PROGRESS WITH ANIMATION
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a todo item with status
|
||||||
|
*/
|
||||||
|
export function formatTodoItem(item, options = {}) {
|
||||||
|
const { animated = true, index = 0 } = options;
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
pending: '○', // Empty circle
|
||||||
|
inProgress: '◐', // Half circle (animated)
|
||||||
|
completed: '●', // Filled circle
|
||||||
|
failed: '✗' // Cross
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
pending: 'gray',
|
||||||
|
inProgress: 'yellow',
|
||||||
|
completed: 'green',
|
||||||
|
failed: 'red'
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = item.status || (item.completed ? 'completed' : 'pending');
|
||||||
|
const icon = icons[status] || icons.pending;
|
||||||
|
|
||||||
|
// Animation frames for in-progress
|
||||||
|
const spinFrames = ['◐', '◓', '◑', '◒'];
|
||||||
|
const animatedIcon = animated && status === 'inProgress'
|
||||||
|
? spinFrames[Math.floor(Date.now() / 200) % spinFrames.length]
|
||||||
|
: icon;
|
||||||
|
|
||||||
|
const prefix = index > 0 ? `${index}. ` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: `${prefix}${animatedIcon} ${item.text || item.content}`,
|
||||||
|
color: colors[status],
|
||||||
|
icon: animatedIcon
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a task checklist (multiple todos)
|
||||||
|
*/
|
||||||
|
export function formatTaskChecklist(tasks, options = {}) {
|
||||||
|
const { title = 'Tasks', showProgress = true } = options;
|
||||||
|
|
||||||
|
const completed = tasks.filter(t => t.completed || t.status === 'completed').length;
|
||||||
|
const total = tasks.length;
|
||||||
|
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
const barWidth = 20;
|
||||||
|
const filledWidth = Math.round((completed / total) * barWidth);
|
||||||
|
const progressBar = showProgress
|
||||||
|
? `[${'█'.repeat(filledWidth)}${'░'.repeat(barWidth - filledWidth)}] ${percent}%`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let output = `📋 ${title} ${progressBar}\n`;
|
||||||
|
output += '─'.repeat(40) + '\n';
|
||||||
|
|
||||||
|
// Items
|
||||||
|
tasks.forEach((task, i) => {
|
||||||
|
const item = formatTodoItem(task, { index: i + 1 });
|
||||||
|
output += ` ${item.text}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool progress messages (OpenCode getToolAction pattern)
|
||||||
|
*/
|
||||||
|
export function getToolProgress(toolName) {
|
||||||
|
const actions = {
|
||||||
|
'bash': 'Running command...',
|
||||||
|
'write': 'Writing file...',
|
||||||
|
'edit': 'Editing file...',
|
||||||
|
'read': 'Reading file...',
|
||||||
|
'view': 'Viewing file...',
|
||||||
|
'search': 'Searching...',
|
||||||
|
'grep': 'Searching content...',
|
||||||
|
'glob': 'Finding files...',
|
||||||
|
'list': 'Listing directory...',
|
||||||
|
'patch': 'Applying patch...',
|
||||||
|
'fetch': 'Fetching URL...',
|
||||||
|
'task': 'Preparing task...',
|
||||||
|
'agent': 'Delegating to agent...'
|
||||||
|
};
|
||||||
|
return actions[toolName.toLowerCase()] || 'Working...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// EXPORTS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Code display
|
||||||
|
formatCodeBox,
|
||||||
|
truncateHeight,
|
||||||
|
getLanguageFromExt,
|
||||||
|
|
||||||
|
// File delivery
|
||||||
|
formatFileDelivery,
|
||||||
|
formatPath,
|
||||||
|
|
||||||
|
// Todo/Task progress
|
||||||
|
formatTodoItem,
|
||||||
|
formatTaskChecklist,
|
||||||
|
getToolProgress
|
||||||
|
};
|
||||||
204
lib/retry-handler.mjs
Normal file
204
lib/retry-handler.mjs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Enhanced Retry & Timeout Handler
|
||||||
|
* Based on: Mini-Agent retry.py
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Exponential backoff strategy
|
||||||
|
* - Configurable max retries and delays
|
||||||
|
* - Visual callback for retry UI
|
||||||
|
* - Timeout handling with graceful recovery
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry configuration class
|
||||||
|
*/
|
||||||
|
export class RetryConfig {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.enabled = options.enabled ?? true;
|
||||||
|
this.maxRetries = options.maxRetries ?? 3;
|
||||||
|
this.initialDelay = options.initialDelay ?? 1000; // ms
|
||||||
|
this.maxDelay = options.maxDelay ?? 60000; // ms
|
||||||
|
this.exponentialBase = options.exponentialBase ?? 2.0;
|
||||||
|
this.timeout = options.timeout ?? 120000; // 120s default
|
||||||
|
this.retryableErrors = options.retryableErrors ?? [
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'ECONNRESET',
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'network',
|
||||||
|
'timeout',
|
||||||
|
'429', // Rate limit
|
||||||
|
'500', // Server error
|
||||||
|
'502', // Bad gateway
|
||||||
|
'503', // Service unavailable
|
||||||
|
'504' // Gateway timeout
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate delay time using exponential backoff
|
||||||
|
* @param {number} attempt - Current attempt (0-indexed)
|
||||||
|
* @returns {number} Delay in milliseconds
|
||||||
|
*/
|
||||||
|
calculateDelay(attempt) {
|
||||||
|
const delay = this.initialDelay * Math.pow(this.exponentialBase, attempt);
|
||||||
|
return Math.min(delay, this.maxDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is retryable
|
||||||
|
*/
|
||||||
|
isRetryable(error) {
|
||||||
|
const errorString = String(error?.code || error?.message || error).toLowerCase();
|
||||||
|
return this.retryableErrors.some(code =>
|
||||||
|
errorString.includes(String(code).toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a retry wrapper for async functions
|
||||||
|
* @param {Function} fn - Async function to wrap
|
||||||
|
* @param {RetryConfig} config - Retry configuration
|
||||||
|
* @param {Function} onRetry - Callback on each retry (error, attempt, delay)
|
||||||
|
* @returns {Function} Wrapped function with retry logic
|
||||||
|
*/
|
||||||
|
export function withRetry(fn, config = new RetryConfig(), onRetry = null) {
|
||||||
|
return async function (...args) {
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
// Create abort controller for timeout
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass abort signal if function accepts it
|
||||||
|
const result = await fn(...args, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// Check if we should retry
|
||||||
|
const isTimeout = error.name === 'AbortError' ||
|
||||||
|
error.message?.includes('timed out');
|
||||||
|
const isRetryable = config.isRetryable(error) || isTimeout;
|
||||||
|
|
||||||
|
if (!isRetryable || attempt >= config.maxRetries) {
|
||||||
|
throw new RetryExhaustedError(lastError, attempt + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delay
|
||||||
|
const delay = config.calculateDelay(attempt);
|
||||||
|
|
||||||
|
// Call retry callback for UI updates
|
||||||
|
if (onRetry) {
|
||||||
|
onRetry(error, attempt + 1, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retry
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry exhausted error
|
||||||
|
*/
|
||||||
|
export class RetryExhaustedError extends Error {
|
||||||
|
constructor(lastException, attempts) {
|
||||||
|
super(`Retry failed after ${attempts} attempts. Last error: ${lastException?.message || lastException}`);
|
||||||
|
this.name = 'RetryExhaustedError';
|
||||||
|
this.lastException = lastException;
|
||||||
|
this.attempts = attempts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for specified milliseconds
|
||||||
|
*/
|
||||||
|
export function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format retry status for display
|
||||||
|
* @param {number} attempt - Current attempt number
|
||||||
|
* @param {number} maxRetries - Maximum retries
|
||||||
|
* @param {number} delay - Delay in ms before next retry
|
||||||
|
* @returns {string} Formatted status string
|
||||||
|
*/
|
||||||
|
export function formatRetryStatus(attempt, maxRetries, delay) {
|
||||||
|
const delaySeconds = (delay / 1000).toFixed(1);
|
||||||
|
return `⟳ Retry ${attempt}/${maxRetries} in ${delaySeconds}s...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timeout message
|
||||||
|
* @param {number} timeoutMs - Timeout in milliseconds
|
||||||
|
* @returns {string} Formatted timeout message
|
||||||
|
*/
|
||||||
|
export function formatTimeoutMessage(timeoutMs) {
|
||||||
|
const seconds = Math.round(timeoutMs / 1000);
|
||||||
|
return `⏱ Request timed out (${seconds}s)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create progress callback for retry UI
|
||||||
|
* @param {Function} setStatus - State setter for status message
|
||||||
|
* @returns {Function} Callback function for withRetry
|
||||||
|
*/
|
||||||
|
export function createRetryCallback(setStatus) {
|
||||||
|
return (error, attempt, delay) => {
|
||||||
|
const seconds = (delay / 1000).toFixed(1);
|
||||||
|
const errorMsg = error.message || 'Connection error';
|
||||||
|
setStatus({
|
||||||
|
type: 'retry',
|
||||||
|
attempt,
|
||||||
|
delay: seconds,
|
||||||
|
message: `⟳ ${errorMsg} - Retrying in ${seconds}s... (${attempt}/3)`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced fetch with built-in retry and timeout
|
||||||
|
*/
|
||||||
|
export async function fetchWithRetry(url, options = {}, config = new RetryConfig()) {
|
||||||
|
const fetchFn = async (fetchUrl, fetchOptions, { signal }) => {
|
||||||
|
const response = await fetch(fetchUrl, {
|
||||||
|
...fetchOptions,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && config.retryableErrors.includes(String(response.status))) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
return withRetry(fetchFn, config)(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default export
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
RetryConfig,
|
||||||
|
RetryExhaustedError,
|
||||||
|
withRetry,
|
||||||
|
sleep,
|
||||||
|
formatRetryStatus,
|
||||||
|
formatTimeoutMessage,
|
||||||
|
createRetryCallback,
|
||||||
|
fetchWithRetry
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user