diff --git a/src/equicordplugins/consoleViewer/components/ConsoleIcon.tsx b/src/equicordplugins/consoleViewer/components/ConsoleIcon.tsx
new file mode 100644
index 00000000..47ec3de5
--- /dev/null
+++ b/src/equicordplugins/consoleViewer/components/ConsoleIcon.tsx
@@ -0,0 +1,14 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+export function ConsoleLogIcon() {
+ return ``;
+}
diff --git a/src/equicordplugins/consoleViewer/components/ConsoleModal.tsx b/src/equicordplugins/consoleViewer/components/ConsoleModal.tsx
new file mode 100644
index 00000000..7bb6600d
--- /dev/null
+++ b/src/equicordplugins/consoleViewer/components/ConsoleModal.tsx
@@ -0,0 +1,323 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import ErrorBoundary from "@components/ErrorBoundary";
+import { Button, React, ReactDOM } from "@webpack/common";
+
+import { clearLogs, downloadLogs, getCapturedLogs, LogEntry } from "../utils/consoleLogger";
+
+const timeFormat = new Intl.DateTimeFormat(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false
+});
+const ErrorFallback = () => (
+
Failed to load Console Viewer
+);
+function ConsoleViewerModal() {
+ const [logs, setLogs] = React.useState([]);
+ const [search, setSearch] = React.useState("");
+ const [filter, setFilter] = React.useState("all");
+ const [autoScroll, setAutoScroll] = React.useState(true);
+ const logContainerRef = React.useRef(null);
+ const modalRef = React.useRef(null);
+ React.useEffect(() => {
+ function updateLogs() {
+ setLogs([...getCapturedLogs()]);
+ }
+ updateLogs();
+ window.addEventListener("console-captured", updateLogs);
+ return () => {
+ window.removeEventListener("console-captured", updateLogs);
+ };
+ }, []);
+ React.useEffect(() => {
+ if (autoScroll && logContainerRef.current) {
+ logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
+ }
+ }, [logs, autoScroll]);
+ React.useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ closeConsoleViewer();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+ React.useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
+ closeConsoleViewer();
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+ const filteredLogs = React.useMemo(() => {
+ return logs.filter(log => {
+ if (filter !== "all" && log.type !== filter) return false;
+
+ if (search) {
+ const searchLower = search.toLowerCase();
+ const contentStr = typeof log.content === "string"
+ ? log.content.toLowerCase()
+ : JSON.stringify(log.content || "").toLowerCase();
+
+ return contentStr.includes(searchLower);
+ }
+
+ return true;
+ });
+ }, [logs, filter, search]);
+ const handleScroll = React.useCallback((e: React.UIEvent) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
+ const atBottom = scrollHeight - scrollTop - clientHeight < 50;
+ setAutoScroll(atBottom);
+ }, []);
+ const renderLogEntry = (log: LogEntry, index: number) => {
+ let contentDisplay: React.ReactNode;
+
+ if (typeof log.content === "string") {
+ contentDisplay = log.content;
+ } else if (Array.isArray(log.content)) {
+ contentDisplay = log.content.map((item, i) => (
+
+ {typeof item === "object" && item !== null
+ ? JSON.stringify(item, null, 2)
+ : String(item ?? "")}
+
+ ));
+ } else if (typeof log.content === "object" && log.content !== null) {
+ try {
+ contentDisplay = JSON.stringify(log.content, null, 2);
+ } catch {
+ contentDisplay = "[Object]";
+ }
+ } else {
+ contentDisplay = String(log.content ?? "");
+ }
+
+ return (
+
+
+ {timeFormat.format(log.timestamp)}
+
+
{log.type.toUpperCase()}
+
{contentDisplay}
+
+ );
+ };
+ const toggleAutoScroll = React.useCallback(() => {
+ setAutoScroll(prev => !prev);
+ }, []);
+ const handleClear = React.useCallback(() => {
+ clearLogs();
+ setLogs([]);
+ }, []);
+ const clearSearch = React.useCallback(() => {
+ setSearch("");
+ }, []);
+
+ return (
+ <>
+
+
+
+
Console Viewer
+
+
+
+
+
+
+
+
+
+ {["all", "log", "info", "warn", "error"].map(type => (
+
+ ))}
+
+
+
+ setSearch(e.target.value)}
+ />
+ {search && (
+
+ )}
+
+
+
+
+ {filteredLogs.length > 0 ? (
+ filteredLogs.map(renderLogEntry)
+ ) : (
+
+ {search || filter !== "all" ? "No logs match your filters" : "No console logs captured yet"}
+
+ )}
+
+
+
+
+ {filteredLogs.length} {filteredLogs.length === 1 ? "log" : "logs"}
+ {(search || filter !== "all") ? " (filtered)" : ""}
+
+
+
+
+
+
+ >
+ );
+}
+let modalContainer: HTMLDivElement | null = null;
+const ReactRender = {
+ canUseCreateRoot() {
+ return typeof ReactDOM.createRoot === "function";
+ },
+ render(element: React.ReactElement, container: HTMLElement) {
+ if (this.canUseCreateRoot()) {
+ try {
+ const root = ReactDOM.createRoot(container);
+ root.render(element);
+ return {
+ unmount: () => {
+ try {
+ root.unmount();
+ } catch (e) {
+ console.error("[Console Viewer] Error unmounting with createRoot:", e);
+ this.fallbackUnmount(container);
+ }
+ }
+ };
+ } catch (e) {
+ console.error("[Console Viewer] Error using createRoot:", e);
+ }
+ }
+ try {
+ const root = ReactDOM.hydrateRoot(container, element);
+ return {
+ unmount: () => root.unmount()
+ };
+ } catch (e) {
+ console.error("[Console Viewer] Error rendering component:", e);
+ throw e;
+ }
+ },
+ fallbackUnmount(container: HTMLElement) {
+ try {
+ if (ReactRender.canUseCreateRoot()) {
+ console.warn("[Console Viewer] Attempting to unmount using root.unmount");
+ } else {
+ console.warn("[Console Viewer] Falling back to container removal for unmounting.");
+ try {
+ container.remove();
+ } catch (e) {
+ console.error("[Console Viewer] Failed to remove container:", e);
+ }
+ }
+ } catch (e) {
+ console.error("[Console Viewer] Error in fallback unmount:", e);
+ }
+ }
+};
+let currentRenderer: { unmount: () => void; } | null = null;
+export function openConsoleViewer() {
+ try {
+ if (!modalContainer) {
+ modalContainer = document.createElement("div");
+ modalContainer.className = "console-viewer-root";
+ document.body.appendChild(modalContainer);
+ }
+ currentRenderer = ReactRender.render(
+
+
+ ,
+ modalContainer
+ );
+ document.body.classList.add("console-viewer-open");
+ } catch (error) {
+ console.error("[Console Viewer] Failed to open modal:", error);
+ }
+}
+export function closeConsoleViewer() {
+ try {
+ if (currentRenderer) {
+ currentRenderer.unmount();
+ currentRenderer = null;
+ }
+ if (modalContainer) {
+ document.body.removeChild(modalContainer);
+ modalContainer = null;
+ }
+ document.body.classList.remove("console-viewer-open");
+ document.querySelectorAll(".console-viewer-backdrop").forEach(el => el.remove());
+ } catch (error) {
+ console.error("[Console Viewer] Failed to close modal:", error);
+ document.querySelectorAll(".console-viewer-backdrop, .console-viewer-root").forEach(el => el.remove());
+ document.body.classList.remove("console-viewer-open");
+ modalContainer = null;
+ currentRenderer = null;
+ }
+}
diff --git a/src/equicordplugins/consoleViewer/index.tsx b/src/equicordplugins/consoleViewer/index.tsx
new file mode 100644
index 00000000..1a7b4ad6
--- /dev/null
+++ b/src/equicordplugins/consoleViewer/index.tsx
@@ -0,0 +1,88 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import "./styles.css";
+
+import { EquicordDevs } from "@utils/constants";
+import { LazyComponent } from "@utils/react";
+import definePlugin from "@utils/types";
+import { findByCode } from "@webpack";
+
+import { ConsoleLogIcon } from "./components/ConsoleIcon";
+import { openConsoleViewer } from "./components/ConsoleModal";
+import settings from "./settings";
+import { startConsoleCapture, stopConsoleCapture } from "./utils/consoleLogger";
+
+type CommandReturnValue = any;
+type Argument = any;
+type CommandContext = any;
+
+const HeaderBarIcon = LazyComponent(() => {
+ const filter = ".Icon";
+ return findByCode(filter);
+});
+
+export default definePlugin({
+ name: "Console Viewer",
+ description: "View and search console logs in a clean UI modal",
+ authors: [EquicordDevs.SteelTech],
+ dependencies: [],
+
+ settings,
+
+ commands: [{
+ name: "console",
+ description: "Open the console viewer",
+ execute: (args: Argument[], ctx: CommandContext) => {
+ openConsoleViewer();
+ return {
+ send: false,
+ result: "Opened console viewer"
+ } as CommandReturnValue;
+ }
+ }],
+
+ patches: [],
+
+ start() {
+ setTimeout(() => {
+ startConsoleCapture();
+
+ if (settings.store?.iconLocation === "toolbar") {
+ this.showToolbarIcon();
+ } else {
+ this.showToolbarIcon();
+ }
+ }, 0);
+ },
+
+ stop() {
+ stopConsoleCapture();
+ this.removeToolbarIcon();
+ },
+
+ showToolbarIcon() {
+ const toolbar = document.querySelector(".toolbar-3_r2xA");
+ if (!toolbar || document.querySelector(".console-viewer-btn")) return;
+
+ const button = document.createElement("div");
+ button.classList.add("console-viewer-btn", "iconWrapper-2awDjA", "clickable-ZD7xvu");
+ button.innerHTML = ConsoleLogIcon();
+ button.addEventListener("click", openConsoleViewer);
+ button.setAttribute("role", "button");
+ button.setAttribute("aria-label", "Console Viewer");
+ button.setAttribute("tabindex", "0");
+
+ toolbar.prepend(button);
+ },
+
+ removeToolbarIcon() {
+ document.querySelector(".console-viewer-btn")?.remove();
+ },
+
+ showChatIcon() { },
+ removeChatIcon() { }
+});
diff --git a/src/equicordplugins/consoleViewer/settings.tsx b/src/equicordplugins/consoleViewer/settings.tsx
new file mode 100644
index 00000000..f33c9263
--- /dev/null
+++ b/src/equicordplugins/consoleViewer/settings.tsx
@@ -0,0 +1,40 @@
+import { definePluginSettings } from "@api/Settings";
+import { OptionType } from "@utils/types";
+
+const settings = definePluginSettings({
+ maxLogEntries: {
+ type: OptionType.NUMBER,
+ description: "Maximum number of log entries to keep (0 for unlimited)",
+ default: 1000,
+ },
+
+ iconLocation: {
+ description: "Where to show the Console Log icon",
+ type: OptionType.SELECT,
+ options: [
+ { label: "Toolbar", value: "toolbar", default: true },
+ { label: "Chat input", value: "chat" }
+ ],
+ restartNeeded: true
+ },
+
+ groupSimilarLogs: {
+ type: OptionType.BOOLEAN,
+ description: "Group similar consecutive logs",
+ default: true
+ },
+
+ preserveLogsBetweenSessions: {
+ type: OptionType.BOOLEAN,
+ description: "Save logs between plugin restarts or Discord sessions",
+ default: false
+ },
+
+ syntaxHighlighting: {
+ type: OptionType.BOOLEAN,
+ description: "Enable syntax highlighting for objects and code",
+ default: true
+ }
+});
+
+export default settings;
diff --git a/src/equicordplugins/consoleViewer/styles.css b/src/equicordplugins/consoleViewer/styles.css
new file mode 100644
index 00000000..dae33831
--- /dev/null
+++ b/src/equicordplugins/consoleViewer/styles.css
@@ -0,0 +1,289 @@
+.console-viewer-btn {
+ cursor: pointer;
+ color: var(--interactive-normal);
+ margin: 0 8px;
+}
+
+.console-viewer-btn:hover {
+ color: var(--interactive-hover);
+}
+
+.console-viewer-root {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 10000;
+}
+
+.console-viewer-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+}
+
+.console-viewer-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0 0 0 / 85%);
+ z-index: 1000;
+}
+
+.console-viewer-modal {
+ width: 800px;
+ height: 600px;
+ max-width: 90vw;
+ max-height: 90vh;
+ background-color: var(--background-primary);
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: 0 0 20px rgb(0 0 0 / 50%);
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 10001;
+ animation: console-viewer-slide-in 250ms ease;
+}
+
+body.console-viewer-open {
+ overflow: hidden;
+}
+
+.console-viewer-close-button {
+ background: none;
+ border: none;
+ color: var(--interactive-normal);
+ font-size: 24px;
+ font-weight: bold;
+ cursor: pointer;
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ margin-left: 8px;
+}
+
+.console-viewer-close-button:hover {
+ background-color: var(--background-modifier-hover);
+ color: var(--interactive-hover);
+}
+
+.console-viewer-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--background-modifier-accent);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.console-viewer-title {
+ font-size: 20px;
+ font-weight: 600;
+}
+
+.console-viewer-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.console-viewer-toolbar {
+ padding: 8px;
+ border-bottom: 1px solid var(--background-modifier-accent);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.console-viewer-filters {
+ display: flex;
+ gap: 4px;
+}
+
+.console-viewer-search {
+ width: 200px;
+ position: relative;
+}
+
+.console-viewer-search-input {
+ width: 100%;
+ background-color: var(--background-tertiary);
+ border: none;
+ border-radius: 4px;
+ padding: 8px 10px;
+ color: var(--text-normal);
+ font-size: 14px;
+ outline: none;
+}
+
+.console-viewer-search-input:focus {
+ box-shadow: 0 0 0 1px var(--brand-experiment);
+}
+
+.console-viewer-search-clear {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: var(--interactive-normal);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.console-viewer-search-clear:hover {
+ color: var(--interactive-hover);
+}
+
+.console-viewer-logs {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ font-family: Consolas, 'Courier New', monospace;
+ font-size: 14px;
+ background-color: var(--background-secondary);
+}
+
+.console-viewer-log-entry {
+ padding: 4px 8px;
+ border-radius: 4px;
+ margin-bottom: 4px;
+ display: flex;
+ align-items: flex-start;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+.console-viewer-log-time {
+ color: var(--text-muted);
+ margin-right: 8px;
+ flex-shrink: 0;
+}
+
+.console-viewer-log-badge {
+ margin-right: 8px;
+ padding: 1px 4px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 700;
+ flex-shrink: 0;
+ min-width: 50px;
+ text-align: center;
+}
+
+.console-viewer-log-content {
+ flex: 1;
+ font-family: Consolas, 'Courier New', monospace;
+ overflow-wrap: break-word;
+}
+
+.console-viewer-log-log {
+ background-color: var(--background-tertiary);
+}
+
+.console-viewer-log-log .console-viewer-log-badge {
+ background-color: var(--interactive-normal);
+ color: var(--interactive-active);
+}
+
+.console-viewer-log-info {
+ background-color: var(--info-warning-background);
+}
+
+.console-viewer-log-info .console-viewer-log-badge {
+ background-color: var(--info-positive-foreground);
+ color: var(--info-positive-text);
+}
+
+.console-viewer-log-warn {
+ background-color: var(--info-warning-background);
+}
+
+.console-viewer-log-warn .console-viewer-log-badge {
+ background-color: var(--info-warning-foreground);
+ color: var(--info-warning-text);
+}
+
+.console-viewer-log-error {
+ background-color: var(--info-danger-background);
+}
+
+.console-viewer-log-error .console-viewer-log-badge {
+ background-color: var(--info-danger-foreground);
+ color: var(--info-danger-text);
+}
+
+.console-viewer-arg {
+ margin-right: 6px;
+}
+
+.console-viewer-no-logs {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.console-viewer-status {
+ padding: 8px;
+ border-top: 1px solid var(--background-modifier-accent);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.console-viewer-count {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.console-viewer-auto-scroll {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.console-viewer-error {
+ padding: 16px;
+ color: var(--text-danger);
+ text-align: center;
+ font-size: 16px;
+ background-color: var(--background-primary);
+ border-radius: 8px;
+}
+
+@keyframes console-viewer-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes console-viewer-slide-in {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
diff --git a/src/equicordplugins/consoleViewer/utils/consoleLogger.ts b/src/equicordplugins/consoleViewer/utils/consoleLogger.ts
new file mode 100644
index 00000000..ef777cb3
--- /dev/null
+++ b/src/equicordplugins/consoleViewer/utils/consoleLogger.ts
@@ -0,0 +1,116 @@
+/*
+ * Vencord, a Discord client mod
+ * Copyright (c) 2025 Vendicated and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import settings from "../settings";
+
+export interface LogEntry {
+ timestamp: number;
+ type: "log" | "info" | "warn" | "error";
+ content: any;
+ stack?: string;
+}
+
+const logs: LogEntry[] = [];
+const originalConsole = {
+ log: console.log,
+ info: console.info,
+ warn: console.warn,
+ error: console.error
+};
+
+const consoleLogEvent = new Event("console-captured");
+
+const DEFAULT_MAX_LOG_ENTRIES = 1000;
+let isInitialized = false;
+
+export function startConsoleCapture() {
+ isInitialized = true;
+
+ // Wrap console methods
+ console.log = function (...args) {
+ captureLog("log", ...args);
+ return originalConsole.log.apply(console, args);
+ };
+
+ console.info = function (...args) {
+ captureLog("info", ...args);
+ return originalConsole.info.apply(console, args);
+ };
+
+ console.warn = function (...args) {
+ captureLog("warn", ...args);
+ return originalConsole.warn.apply(console, args);
+ };
+
+ console.error = function (...args) {
+ captureLog("error", ...args);
+ return originalConsole.error.apply(console, args);
+ };
+}
+
+export function stopConsoleCapture() {
+ console.log = originalConsole.log;
+ console.info = originalConsole.info;
+ console.warn = originalConsole.warn;
+ console.error = originalConsole.error;
+}
+
+function getSetting(key: string, defaultValue: T): T {
+ try {
+ if (!isInitialized || !settings.store) return defaultValue;
+ return settings.store[key] ?? defaultValue;
+ } catch (e) {
+ return defaultValue;
+ }
+}
+
+function captureLog(type: "log" | "info" | "warn" | "error", ...args) {
+ // Create log entry
+ const entry: LogEntry = {
+ timestamp: Date.now(),
+ type,
+ content: args.length === 1 ? args[0] : args,
+ };
+
+ if (type === "error" && args[0] instanceof Error) {
+ entry.stack = args[0].stack;
+ }
+
+ logs.push(entry);
+
+ const maxEntries = getSetting("maxLogEntries", DEFAULT_MAX_LOG_ENTRIES);
+ if (maxEntries > 0 && logs.length > maxEntries) {
+ logs.splice(0, logs.length - maxEntries);
+ }
+
+ window.dispatchEvent(consoleLogEvent);
+}
+
+export function getCapturedLogs(): LogEntry[] {
+ return logs;
+}
+
+export function clearLogs() {
+ logs.length = 0;
+ window.dispatchEvent(consoleLogEvent);
+}
+
+export function downloadLogs() {
+ const content = JSON.stringify(logs, null, 2);
+ const blob = new Blob([content], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+
+ const filename = `console-logs-${new Date().toISOString().replace(/:/g, "-")}.json`;
+
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ }, 60000);
+}