diff --git a/bin/opencode-ink.mjs b/bin/opencode-ink.mjs index d1def34..af99698 100644 --- a/bin/opencode-ink.mjs +++ b/bin/opencode-ink.mjs @@ -61,6 +61,15 @@ import { formatFileOperation, separator } from '../lib/agent-prompt.mjs'; +import { + formatCodeBox, + formatFileDelivery, + formatPath, + truncateHeight, + formatTodoItem, + formatTaskChecklist, + getToolProgress +} from '../lib/message-renderer.mjs'; // Initialize debug logger from CLI args const debugLogger = initFromArgs(); diff --git a/lib/message-renderer.mjs b/lib/message-renderer.mjs new file mode 100644 index 0000000..b8fd4d1 --- /dev/null +++ b/lib/message-renderer.mjs @@ -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 +}; diff --git a/lib/retry-handler.mjs b/lib/retry-handler.mjs new file mode 100644 index 0000000..c5ba95d --- /dev/null +++ b/lib/retry-handler.mjs @@ -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 +};