ChatGPT.com Advanced Message Culler

Culls messages from ChatGPT conversations via an overlay. Helpful for those who have low-end hardwares

// ==UserScript==
// @name         ChatGPT.com Advanced Message Culler
// @namespace    https://chatgpt.com/
// @version      1.5.0
// @description  Culls messages from ChatGPT conversations via an overlay. Helpful for those who have low-end hardwares
// @author       sytesn, ChatGPT
// @match        https://chatgpt.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const PIN_STORAGE_KEY = 'chatgpt_message_pins';
    const SETTINGS_KEYS = [
        'culler-limit',
        'culler-enabled',
        'culler-mode',
        'culler-fade',
        'panel-left',
        'panel-top'
    ];

    const getPinnedIds = () => JSON.parse(localStorage.getItem(PIN_STORAGE_KEY) || '[]');
    const setPinnedIds = (ids) => localStorage.setItem(PIN_STORAGE_KEY, JSON.stringify(ids));

    let MESSAGE_LIMIT = parseInt(localStorage.getItem('culler-limit')) || 50;
    let cullingEnabled = localStorage.getItem('culler-enabled') === 'true';
    let mode = localStorage.getItem('culler-mode') || "hide";
    let fadeEnabled = localStorage.getItem('culler-fade') !== 'false';
    let multiSelect = false;

    const style = document.createElement('style');
    style.textContent = `
        #culler-panel {
            position: fixed;
            top: ${localStorage.getItem('panel-top') || '10px'};
            left: ${localStorage.getItem('panel-left') || '10px'};
            z-index: 9999;
            background-color: #111;
            padding: 10px;
            border-radius: 8px;
            font-family: Arial, sans-serif;
            font-size: 13px;
            color: #fff;
            transition: opacity 0.3s ease;
            max-width: 300px;
            resize: both;
            overflow: auto;
        }
        .fade {
            transition: opacity 0.4s ease;
            opacity: 0;
        }
        .fade-in {
            opacity: 1 !important;
        }
        #culler-panel input, #culler-panel select {
            background-color: #222;
            color: #fff;
            border: 1px solid #444;
            border-radius: 4px;
            padding: 2px 4px;
        }
        #culler-panel button {
            margin-top:6px;
            padding:4px 8px;
            border: none;
            border-radius: 4px;
            background-color: #444;
            color: #fff;
            cursor: pointer;
        }
        .top-right-btn {
            position: absolute;
            top: 4px;
            right: 6px;
            background: transparent;
            font-size: 14px;
            padding: 0;
        }
        .pin-btn {
            position: absolute;
            top: 4px;
            right: 4px;
            background: #444;
            border: none;
            color: #fff;
            border-radius: 4px;
            padding: 2px 6px;
            font-size: 12px;
            cursor: pointer;
        }
        #pin-panel {
            position: fixed;
            top: 60px;
            right: 10px;
            width: 300px;
            max-height: 70vh;
            overflow-y: auto;
            background: #1e1e1e;
            color: white;
            border-radius: 8px;
            padding: 10px;
            z-index: 9999;
            box-shadow: 0 0 10px #000;
            display: none;
        }
        .pin-message {
            background: #2e2e2e;
            border-radius: 6px;
            padding: 8px;
            margin-bottom: 6px;
            font-size: 13px;
            position: relative;
        }
        .pin-message button {
            position: absolute;
            top: 4px;
            right: 4px;
            background: #900;
            border: none;
            color: white;
            border-radius: 4px;
            padding: 2px 6px;
            font-size: 12px;
            cursor: pointer;
        }
    `;
    document.head.appendChild(style);

    const panel = document.createElement('div');
    panel.id = 'culler-panel';
    panel.innerHTML = `
        <button class="top-right-btn" id="overlay-minimize">✕</button>
        <div style="margin-bottom:6px;font-weight:bold;">Message Culler</div>
        <label><input type="checkbox" id="culler-enable"> Enable</label><br>
        <label>Keep last <input type="number" id="culler-limit" value="${MESSAGE_LIMIT}" style="width: 50px;"></label><br>
        <label>Mode:
            <select id="culler-mode">
                <option value="hide">Hide</option>
                <option value="remove">Remove</option>
            </select>
        </label><br>
        <label>Fade: <input type="checkbox" id="culler-fade" ${fadeEnabled ? 'checked' : ''}></label><br>
        <button id="culler-refresh">Refresh Now</button>
        <button id="pin-toggle">📌 View Pins</button><br>
        <button id="multi-select-toggle">Multi-Select: Off</button>
        <button id="export-btn">Export</button>
        <button id="import-btn">Import</button>
    `;
    document.body.appendChild(panel);

    // Draggable support
    (function makeDraggable(el) {
        let isDragging = false, offsetX = 0, offsetY = 0;
        el.addEventListener('mousedown', (e) => {
            if (["INPUT", "SELECT", "BUTTON"].includes(e.target.tagName)) return;
            isDragging = true;
            offsetX = e.clientX - el.offsetLeft;
            offsetY = e.clientY - el.offsetTop;
        });
        document.addEventListener('mousemove', (e) => {
            if (isDragging) {
                el.style.left = (e.clientX - offsetX) + 'px';
                el.style.top = (e.clientY - offsetY) + 'px';
                localStorage.setItem('panel-left', el.style.left);
                localStorage.setItem('panel-top', el.style.top);
            }
        });
        document.addEventListener('mouseup', () => isDragging = false);
    })(panel);

    const enableCheckbox = panel.querySelector('#culler-enable');
    const limitInput = panel.querySelector('#culler-limit');
    const modeSelect = panel.querySelector('#culler-mode');
    const fadeCheckbox = panel.querySelector('#culler-fade');
    const refreshBtn = panel.querySelector('#culler-refresh');
    const multiToggle = panel.querySelector('#multi-select-toggle');

    enableCheckbox.checked = cullingEnabled;
    modeSelect.value = mode;

    enableCheckbox.addEventListener('change', () => {
        cullingEnabled = enableCheckbox.checked;
        localStorage.setItem('culler-enabled', cullingEnabled);
        applyCulling();
    });

    limitInput.addEventListener('change', () => {
        const oldLimit = MESSAGE_LIMIT;
        MESSAGE_LIMIT = parseInt(limitInput.value, 10) || 50;
        localStorage.setItem('culler-limit', MESSAGE_LIMIT);
        applyCulling(oldLimit);
    });

    modeSelect.addEventListener('change', () => {
        mode = modeSelect.value;
        localStorage.setItem('culler-mode', mode);
        applyCulling();
    });

    fadeCheckbox.addEventListener('change', () => {
        fadeEnabled = fadeCheckbox.checked;
        localStorage.setItem('culler-fade', fadeEnabled);
    });

    refreshBtn.addEventListener('click', () => applyCulling());

    multiToggle.addEventListener('click', () => {
        multiSelect = !multiSelect;
        multiToggle.textContent = `Multi-Select: ${multiSelect ? 'On' : 'Off'}`;
    });

    document.getElementById('overlay-minimize').addEventListener('click', () => {
        panel.style.display = 'none';
        showRestoreBtn();
    });

    function showRestoreBtn() {
        const btn = document.createElement('button');
        btn.textContent = 'Show Overlay';
        btn.style = 'position:fixed;top:10px;left:10px;z-index:9999;background:#333;color:#fff;padding:6px;border:none;border-radius:4px;';
        btn.onclick = () => {
            panel.style.display = 'block';
            btn.remove();
        };
        document.body.appendChild(btn);
    }

    document.getElementById('export-btn').addEventListener('click', () => {
        const data = {};
        SETTINGS_KEYS.forEach(k => data[k] = localStorage.getItem(k));
        data.pins = getPinnedIds();
        const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.download = 'chatgpt_culler_backup.json';
        a.href = url;
        a.click();
        URL.revokeObjectURL(url);
    });

    document.getElementById('import-btn').addEventListener('click', () => {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.json';
        input.onchange = e => {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = e => {
                try {
                    const data = JSON.parse(e.target.result);
                    SETTINGS_KEYS.forEach(k => {
                        if (data[k] !== undefined) localStorage.setItem(k, data[k]);
                    });
                    if (Array.isArray(data.pins)) setPinnedIds(data.pins);
                    location.reload();
                } catch (e) {
                    alert('Invalid file!');
                }
            };
            reader.readAsText(file);
        };
        input.click();
    });

    // Command palette
    document.addEventListener('keydown', (e) => {
        if (e.ctrlKey && e.shiftKey && e.key === 'K') {
            const cmd = prompt('Culler Command: hide/show/refresh');
            if (cmd === 'hide') panel.style.display = 'none';
            if (cmd === 'show') panel.style.display = 'block';
            if (cmd === 'refresh') applyCulling();
        }
    });

    const pinPanel = document.createElement('div');
    pinPanel.id = 'pin-panel';
    pinPanel.innerHTML = `<h2>Pinned Messages</h2><div id="pin-container"></div>`;
    document.body.appendChild(pinPanel);

    document.getElementById('pin-toggle').addEventListener('click', () => {
        pinPanel.style.display = pinPanel.style.display === 'none' ? 'block' : 'none';
        updatePinnedPanel();
    });

    function getMessageElements() {
        return Array.from(document.querySelectorAll('article[data-testid^="conversation-turn-"]'));
    }

    function applyCulling(previousLimit = MESSAGE_LIMIT) {
        const messages = getMessageElements();
        const pinned = new Set(getPinnedIds());
        const visible = messages.filter(msg => !pinned.has(msg.dataset.testid));
        const limitCutoff = visible.length - MESSAGE_LIMIT;

        messages.forEach((msg, i) => {
            const id = msg.dataset.testid;
            const isPinned = pinned.has(id);
            const visibleIndex = visible.indexOf(msg);

            ensurePinButton(msg);

            if (!cullingEnabled || isPinned || visibleIndex >= limitCutoff) {
                if (fadeEnabled && previousLimit < MESSAGE_LIMIT && visibleIndex >= limitCutoff && visibleIndex < visible.length - previousLimit) {
                    msg.classList.add('fade');
                    msg.style.display = '';
                    setTimeout(() => msg.classList.add('fade-in'), 10);
                    setTimeout(() => msg.classList.remove('fade', 'fade-in'), 410);
                } else {
                    msg.style.display = '';
                }
                msg.classList.remove('culled');
            } else {
                if (mode === "remove") {
                    if (!msg.dataset.removed) {
                        msg.dataset.removed = "true";
                        msg.remove();
                    }
                } else {
                    if (fadeEnabled) {
                        msg.classList.add('fade');
                        msg.classList.remove('fade-in');
                        setTimeout(() => msg.style.display = 'none', 400);
                    } else {
                        msg.style.display = "none";
                    }
                    msg.classList.add("culled");
                }
            }
        });
    }

    function ensurePinButton(msg) {
        if (msg.querySelector('.pin-btn')) return;
        const btn = document.createElement('button');
        btn.className = 'pin-btn';
        const id = msg.dataset.testid;
        const pinned = new Set(getPinnedIds());
        btn.textContent = pinned.has(id) ? 'Unpin' : 'Pin';

        btn.addEventListener('click', (e) => {
            e.stopPropagation();
            const ids = new Set(getPinnedIds());

            if (multiSelect) {
                const allVisible = getMessageElements().filter(el => el.style.display !== 'none');
                allVisible.forEach(el => {
                    const elId = el.dataset.testid;
                    if (!ids.has(elId)) ids.add(elId);
                });
            } else {
                if (ids.has(id)) ids.delete(id); else ids.add(id);
            }

            setPinnedIds([...ids]);
            applyCulling();
        });

        msg.style.position = 'relative';
        msg.appendChild(btn);
    }

    function updatePinnedPanel() {
        const pinned = new Set(getPinnedIds());
        const container = document.getElementById('pin-container');
        container.innerHTML = '';
        getMessageElements().forEach(msg => {
            if (pinned.has(msg.dataset.testid)) {
                const clone = msg.cloneNode(true);
                clone.querySelector('.pin-btn')?.remove();
                const id = msg.dataset.testid;
                const unpinBtn = document.createElement('button');
                unpinBtn.textContent = 'Unpin';
                unpinBtn.onclick = () => {
                    const ids = new Set(getPinnedIds());
                    ids.delete(id);
                    setPinnedIds([...ids]);
                    updatePinnedPanel();
                    applyCulling();
                };
                clone.appendChild(unpinBtn);
                clone.className = 'pin-message';
                container.appendChild(clone);
            }
        });
    }

    new MutationObserver(() => {
        if (cullingEnabled) applyCulling();
    }).observe(document.body, { childList: true, subtree: true });

    setTimeout(() => applyCulling(), 1000);
})();