dev3000
Version:
AI-powered development tools with browser monitoring and MCP server integration
326 lines • 19.2 kB
JavaScript
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