UNPKG

dev3000

Version:

AI-powered development tools with browser monitoring and MCP server integration

326 lines 19.2 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import chalk from "chalk"; import { createReadStream, unwatchFile, watchFile } from "fs"; import { Box, render, Text, useInput, useStdout } from "ink"; import { useEffect, useRef, useState } from "react"; import { LOG_COLORS } from "./constants/log-colors.js"; // Compact ASCII logo for very small terminals const COMPACT_LOGO = "d3k"; // Full ASCII logo lines as array for easier rendering const FULL_LOGO = [" ▐▌▄▄▄▄ █ ▄ ", " ▐▌ █ █▄▀ ", "▗▞▀▜▌▀▀▀█ █ ▀▄ ", "▝▚▄▟▌▄▄▄█ █ █ "]; const TUIApp = ({ appPort: initialAppPort, mcpPort, logFile, commandName, serversOnly, version, projectName, onStatusUpdate, onAppPortUpdate }) => { const [logs, setLogs] = useState([]); const [scrollOffset, setScrollOffset] = useState(0); const [initStatus, setInitStatus] = useState("Initializing..."); const [appPort, setAppPort] = useState(initialAppPort); const logIdCounter = useRef(0); const [clearFromLogId, setClearFromLogId] = useState(0); // Track log ID to clear from const { stdout } = useStdout(); const ctrlCMessageDefault = "^L clear ^C quit"; const [ctrlCMessage, setCtrlCMessage] = useState(ctrlCMessageDefault); const [terminalSize, setTerminalSize] = useState(() => ({ width: stdout?.columns || 80, height: stdout?.rows || 24 })); useEffect(() => { if (!stdout) { return; } const handleResize = () => { setTerminalSize({ width: stdout.columns || 80, height: stdout.rows || 24 }); }; stdout.on("resize", handleResize); return () => { if (typeof stdout.off === "function") { stdout.off("resize", handleResize); } else { stdout.removeListener("resize", handleResize); } }; }, [stdout]); // Get terminal dimensions with fallbacks const termWidth = terminalSize.width; const termHeight = terminalSize.height; // Determine if we should use compact mode const isCompact = termWidth < 80 || termHeight < 20; const isVeryCompact = termWidth < 60 || termHeight < 15; // Provide status update function to parent useEffect(() => { onStatusUpdate((status) => { // Check if this is the "Press Ctrl+C again" warning if (status?.includes("Press Ctrl+C again")) { // Update the bottom Ctrl+C message with warning emoji setCtrlCMessage("⚠️ ^C again to quit"); // Clear the init status since we don't want it in the header anymore setInitStatus(null); // Reset after 3 seconds setTimeout(() => { setCtrlCMessage(ctrlCMessageDefault); }, 3000); } else { setInitStatus(status); } }); }, [onStatusUpdate]); // Provide app port update function to parent useEffect(() => { onAppPortUpdate((port) => { setAppPort(port); }); }, [onAppPortUpdate]); // Calculate available lines for logs dynamically based on terminal height and mode const calculateMaxVisibleLogs = () => { if (isVeryCompact) { // In very compact mode, use most of the screen for logs, account for bottom status line return Math.max(3, termHeight - 8); } else if (isCompact) { // In compact mode, reduce header size, account for bottom status line return Math.max(3, termHeight - 10); } else { // Normal mode calculation - account for all UI elements const headerBorderLines = 2; // Top border (with title) + bottom border const headerContentLines = 5; // Logo is 4 lines tall, +1 for padding const logBoxBorderLines = 2; // Top and bottom border of log box const logBoxHeaderLines = 2; // "Logs (X total)" text (no blank line after) const logBoxFooterLines = scrollOffset > 0 ? 2 : 0; // "(X lines below)" when scrolled const bottomStatusLine = 1; // Log path and quit message const safetyBuffer = 1; // Small buffer to prevent header from being pushed up const totalReservedLines = headerBorderLines + headerContentLines + logBoxBorderLines + logBoxHeaderLines + logBoxFooterLines + bottomStatusLine + safetyBuffer; return Math.max(3, termHeight - totalReservedLines); } }; const maxVisibleLogs = calculateMaxVisibleLogs(); useEffect(() => { let logStream; let buffer = ""; const appendLog = (line) => { const newLog = { id: logIdCounter.current++, content: line }; setLogs((prevLogs) => { const updated = [...prevLogs, newLog]; // Keep only last 1000 logs to prevent memory issues if (updated.length > 1000) { return updated.slice(-1000); } return updated; }); // Auto-scroll to bottom setScrollOffset(0); }; // Create a read stream for the log file logStream = createReadStream(logFile, { encoding: "utf8", start: 0 }); logStream.on("data", (chunk) => { buffer += chunk.toString(); const lines = buffer.split("\n"); // Keep the last incomplete line in buffer buffer = lines.pop() || ""; // Add complete lines to logs for (const line of lines) { if (line.trim()) { appendLog(line); } } }); logStream.on("error", (error) => { appendLog(chalk.red(`Error reading log file: ${error.message}`)); }); // Watch for new content watchFile(logFile, { interval: 100 }, (curr, prev) => { if (curr.size > prev.size) { // File has grown, read new content const stream = createReadStream(logFile, { encoding: "utf8", start: prev.size }); let watchBuffer = ""; stream.on("data", (chunk) => { watchBuffer += chunk.toString(); const lines = watchBuffer.split("\n"); watchBuffer = lines.pop() || ""; for (const line of lines) { if (line.trim()) { appendLog(line); } } }); } }); // Cleanup return () => { if (logStream) { logStream.destroy(); } unwatchFile(logFile); }; }, [logFile]); // Handle keyboard input useInput((input, key) => { if (key.ctrl && input === "c") { // Send SIGINT to trigger main process shutdown handler process.kill(process.pid, "SIGINT"); } else if (key.ctrl && input === "l") { // Ctrl-L: Clear logs box - set clear point to last log ID const lastLogId = logs.length > 0 ? logs[logs.length - 1].id : logIdCounter.current; setClearFromLogId(lastLogId); setScrollOffset(0); // Reset scroll to bottom } else if (key.upArrow) { const filteredCount = logs.filter((log) => log.id > clearFromLogId).length; setScrollOffset((prev) => Math.min(prev + 1, Math.max(0, filteredCount - maxVisibleLogs))); } else if (key.downArrow) { setScrollOffset((prev) => Math.max(0, prev - 1)); } else if (key.pageUp) { const filteredCount = logs.filter((log) => log.id > clearFromLogId).length; setScrollOffset((prev) => Math.min(prev + maxVisibleLogs, Math.max(0, filteredCount - maxVisibleLogs))); } else if (key.pageDown) { setScrollOffset((prev) => Math.max(0, prev - maxVisibleLogs)); } else if (input === "g" && key.shift) { // Shift+G to go to end setScrollOffset(0); } else if (input === "g" && !key.shift) { // g to go to beginning const filteredCount = logs.filter((log) => log.id > clearFromLogId).length; setScrollOffset(Math.max(0, filteredCount - maxVisibleLogs)); } }); // Calculate visible logs - filter to only show logs after the clear point const filteredLogs = logs.filter((log) => log.id > clearFromLogId); const visibleLogs = filteredLogs.slice(Math.max(0, filteredLogs.length - maxVisibleLogs - scrollOffset), filteredLogs.length - scrollOffset); // Render compact header for small terminals const renderCompactHeader = () => (_jsx(Box, { borderStyle: "single", borderColor: "#A18CE5", paddingX: 1, marginBottom: 1, children: _jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#A18CE5", bold: true, children: COMPACT_LOGO }), _jsxs(Text, { children: [" v", version, " "] }), initStatus && _jsxs(Text, { dimColor: true, children: ["- ", initStatus] })] }), !isVeryCompact && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["App: localhost:", appPort, " | MCP: localhost:", mcpPort] }), _jsxs(Text, { dimColor: true, children: ["\uD83D\uDCF8 http://localhost:", mcpPort, "/logs", projectName ? `?project=${encodeURIComponent(projectName)}` : ""] })] }))] }) })); // Render normal header const renderNormalHeader = () => { // Create custom top border with title embedded (like Claude Code) const title = ` ${commandName} v${version} ${initStatus ? `- ${initStatus} ` : ""}`; const borderChar = "─"; const leftPadding = 2; // Account for border characters and padding const availableWidth = termWidth - 2; // -2 for corner characters const titleLength = title.length; const rightBorderLength = Math.max(0, availableWidth - titleLength - leftPadding); const topBorderLine = `╭${borderChar.repeat(leftPadding)}${title}${borderChar.repeat(rightBorderLength)}╮`; return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "#A18CE5", children: topBorderLine }), _jsx(Box, { borderStyle: "round", borderColor: "#A18CE5", borderTop: false, paddingX: 1, paddingY: 1, children: _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Box, { flexDirection: "column", alignItems: "flex-start", children: FULL_LOGO.map((line) => (_jsx(Text, { color: "#A18CE5", bold: true, children: line }, line))) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { color: "cyan", children: ["\uD83C\uDF10 App: http://localhost:", appPort] }), _jsxs(Text, { color: "cyan", children: ["\uD83E\uDD16 MCP: http://localhost:", mcpPort] }), _jsxs(Text, { color: "cyan", children: ["\uD83D\uDCF8 Logs: http://localhost:", mcpPort, "/logs", projectName ? `?project=${encodeURIComponent(projectName)}` : ""] }), serversOnly && (_jsx(Text, { color: "cyan", children: "\uD83D\uDDA5\uFE0F Servers-only mode - use Chrome extension for browser monitoring" }))] })] }) })] })); }; return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [isCompact ? renderCompactHeader() : renderNormalHeader(), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, flexGrow: 1, minHeight: 0, children: [!isVeryCompact && (_jsxs(Text, { color: "gray", dimColor: true, children: ["Logs (", filteredLogs.length, " total", scrollOffset > 0 && `, scrolled up ${scrollOffset} lines`, ")"] })), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: visibleLogs.length === 0 ? (_jsx(Text, { dimColor: true, children: "Waiting for logs..." })) : (visibleLogs.map((log) => { // Parse log line to colorize different parts const parts = log.content.match(/^\[(.*?)\] \[(.*?)\] (?:\[(.*?)\] )?(.*)$/); if (parts) { let [, timestamp, source, type, message] = parts; // Extract HTTP method from SERVER logs as a secondary tag if (source === "SERVER" && !type && message) { const methodMatch = message.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s/); if (methodMatch) { type = methodMatch[1]; message = message.slice(type.length + 1); // Remove method from message } } // Replace warning emoji in ERROR/WARNING messages for consistent terminal rendering if (message && (type === "ERROR" || type === "WARNING")) { message = message.replace(/⚠/g, "[!]"); } // In very compact mode, simplify the output if (isVeryCompact) { const shortSource = source === "BROWSER" ? "B" : "S"; const shortType = type ? type.split(".")[0].charAt(0) : ""; return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { dimColor: true, children: ["[", shortSource, "]"] }), shortType && _jsxs(Text, { dimColor: true, children: ["[", shortType, "]"] }), _jsxs(Text, { children: [" ", message] })] }, log.id)); } // Use shared color constants const sourceColor = source === "BROWSER" ? LOG_COLORS.BROWSER : LOG_COLORS.SERVER; const typeColors = { NETWORK: LOG_COLORS.NETWORK, ERROR: LOG_COLORS.ERROR, WARNING: LOG_COLORS.WARNING, INFO: LOG_COLORS.INFO, LOG: LOG_COLORS.LOG, DEBUG: LOG_COLORS.DEBUG, SCREENSHOT: LOG_COLORS.SCREENSHOT, DOM: LOG_COLORS.DOM, CDP: LOG_COLORS.CDP, CHROME: LOG_COLORS.CHROME, CRASH: LOG_COLORS.CRASH, REPLAY: LOG_COLORS.REPLAY, NAVIGATION: LOG_COLORS.NAVIGATION, INTERACTION: LOG_COLORS.INTERACTION, GET: LOG_COLORS.SERVER, POST: LOG_COLORS.SERVER, PUT: LOG_COLORS.SERVER, DELETE: LOG_COLORS.SERVER, PATCH: LOG_COLORS.SERVER, HEAD: LOG_COLORS.SERVER, OPTIONS: LOG_COLORS.SERVER }; // In compact mode, skip padding if (isCompact) { return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { dimColor: true, children: ["[", timestamp, "]"] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, bold: true, children: ["[", source.charAt(0), "]"] }), type && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsxs(Text, { color: typeColors[type] || "#A0A0A0", children: ["[", type, "]"] })] })), _jsxs(Text, { children: [" ", message] })] }, log.id)); } // Normal mode with minimal padding // Single space after source const sourceSpacing = ""; // Single space after type const typeSpacing = ""; return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { dimColor: true, children: ["[", timestamp, "]"] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, bold: true, children: ["[", source, "]"] }), type ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [sourceSpacing, " "] }), _jsxs(Text, { color: typeColors[type] || "#A0A0A0", children: ["[", type, "]"] }), _jsxs(Text, { children: [typeSpacing, " "] })] })) : (_jsx(Text, { children: " " })), _jsx(Text, { children: message })] }, log.id)); } // Fallback for unparsed lines return (_jsx(Text, { wrap: "truncate-end", children: log.content }, log.id)); })) }), !isVeryCompact && logs.length > maxVisibleLogs && scrollOffset > 0 && (_jsxs(Text, { dimColor: true, children: ["(", scrollOffset, " lines below)"] }))] }), _jsxs(Box, { paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: "#A18CE5", children: ["\u23F5\u23F5", " ", isVeryCompact ? logFile.split("/").slice(-2, -1)[0] || "logs" // Just show directory name : logFile.replace(process.env.HOME || "", "~")] }), _jsx(Text, { color: "#A18CE5", children: ctrlCMessage })] })] })); }; export async function runTUI(options) { return new Promise((resolve, reject) => { try { let statusUpdater = null; let appPortUpdater = null; const app = render(_jsx(TUIApp, { ...options, onStatusUpdate: (fn) => { statusUpdater = fn; }, onAppPortUpdate: (fn) => { appPortUpdater = fn; } }), { exitOnCtrlC: false }); // Give React time to set up the updaters setTimeout(() => { resolve({ app, updateStatus: (status) => { if (statusUpdater) { statusUpdater(status); } }, updateAppPort: (port) => { if (appPortUpdater) { appPortUpdater(port); } } }); }, 100); } catch (error) { console.error("Error in runTUI render:", error); reject(error); } }); } //# sourceMappingURL=tui-interface-impl.js.map