Grok Chat Navigation Improvements

Keyboard navigation and message interaction for Grok chat

// ==UserScript==
// @name         Grok Chat Navigation Improvements
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      1.2
// @description Keyboard navigation and message interaction for Grok chat
// @author       cdr-x
// @match        https://grok.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    let selectedIdx = -1;
    let isTogglingCodeEditor = false;

    // Inject CSS for selection styling and z-index handling with rainbow effect
    const style = document.createElement('style');
    style.textContent = `
        div.relative.group.flex.flex-col {
            transition: outline 0.2s ease, transform 0.2s ease;
            transform: scale(1);
            z-index: 1;
        }
        .grok-vim-selected {
            outline: 2px solid;
            animation: rainbowHighlight 2s linear infinite;
            z-index: 10 !important;
            transform: scale(1.01);
            position: relative;
        }
        @keyframes rainbowHighlight {
            0% { outline-color: #569cd6; }
            12.5% { outline-color: #da70d6; }
            25% { outline-color: #d4d4d4; }
            37.5% { outline-color: #ce9178; }
            50% { outline-color: #179fff; }
            62.5% { outline-color: #3dc9b0; }
            75% { outline-color: #ffd700; }
            87.5% { outline-color: #608b4e; }
            100% { outline-color: #1e90ff; }
        }
    `;
    document.head.appendChild(style);

    // ### Helper Functions

    // Get the scrollable container
    function getScrollContainer() {
        const candidates = Array.from(document.querySelectorAll("div.overflow-y-auto"));
        if (!candidates.length) return null;
        let container = candidates.find(div =>
            div.className.includes("flex-col") && div.className.includes("items-center") && div.className.includes("px-5")
        );
        return container || candidates[0];
    }

    // Get the input area at the bottom
    function getInputArea() {
        return document.querySelector("div.absolute.bottom-0");
    }

    // Retrieve all message boxes with more specific selector
    function getMessageBoxes() {
        const container = getScrollContainer();
        if (!container) return [];
        return Array.from(container.querySelectorAll("div.relative.group.flex.flex-col")).filter(box =>
            box.querySelector(".message-bubble")
        );
    }

    // Highlight the selected message with improved scrolling logic
    function highlightSelected({ scrollIntoView = false } = {}) {
        const boxes = getMessageBoxes();
        const container = getScrollContainer();

        boxes.forEach(box => {
            box.classList.remove('grok-vim-selected');
            box.style.zIndex = '1';
            box.style.outline = '';
        });

        if (selectedIdx >= 0 && selectedIdx < boxes.length) {
            const box = boxes[selectedIdx];
            box.classList.add('grok-vim-selected');
            box.style.zIndex = '10';
            if (scrollIntoView && container) {
                const boxRect = box.getBoundingClientRect();
                const containerRect = container.getBoundingClientRect();
                if (boxRect.top < containerRect.top || boxRect.bottom > containerRect.bottom) {
                    box.scrollIntoView({ block: "center", behavior: "smooth" });
                }
            }
        }
    }

    // Scroll selected message bottom to 15% above the effective visible area
    function scrollSelectedToBottom15() {
        const container = getScrollContainer();
        const boxes = getMessageBoxes();
        if (!container || selectedIdx < 0 || selectedIdx >= boxes.length) return;
        const box = boxes[selectedIdx];
        const inputBar = getInputArea();
        let offset = 20; // default padding
        if (inputBar) {
            offset = inputBar.getBoundingClientRect().height + 20;
        }
        const boxBottom = box.offsetTop + box.offsetHeight;
        const desiredScrollTop = boxBottom - (container.clientHeight - offset);
        const maxScroll = container.scrollHeight - container.clientHeight;
        const scrollTop = Math.max(0, Math.min(desiredScrollTop, maxScroll));
        container.scrollTo({ top: scrollTop, behavior: "smooth" });
    }

    // Check if currently editing the selected message
    function isEditingMessage() {
        const ae = document.activeElement;
        if (!ae || ae.tagName.toLowerCase() !== "textarea") return false;
        const box = ae.closest("div.relative.group.flex.flex-col");
        if (!box) return false;
        const boxes = getMessageBoxes();
        const idx = boxes.indexOf(box);
        return (idx >= 0 && idx === selectedIdx);
    }

    // Check if in text input mode (bottom input area)
    function isInTextInputMode() {
        const ae = document.activeElement;
        return ae && ae.tagName.toLowerCase() === "textarea" && !isEditingMessage();
    }

    // Get the first and last visible message indices based on scroll position
    function getVisibleMessageIndices() {
        const container = getScrollContainer();
        if (!container) return { first: -1, last: -1 };
        const boxes = getMessageBoxes();
        const viewportTop = container.scrollTop;
        const viewportBottom = viewportTop + container.clientHeight;
        let first = -1;
        let last = -1;
        for (let i = 0; i < boxes.length; i++) {
            const box = boxes[i];
            const boxTop = box.offsetTop;
            const boxBottom = boxTop + box.offsetHeight;
            if (boxBottom > viewportTop && boxTop < viewportBottom) {
                if (first === -1) first = i;
                last = i;
            } else if (boxTop >= viewportBottom) {
                break;
            }
        }
        return { first, last };
    }

    // ### Keyboard Event Listener
    let lastEndKeyTime = 0;
    let lastHomeKeyTime = 0;
    document.addEventListener('keydown', (event) => {
        const boxes = getMessageBoxes();
        if (!boxes.length) return;

        // Ctrl+I focuses the main chat input box
        if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'i') {
            const inputBox = document.querySelector('div.absolute.bottom-0 textarea');
            if (inputBox) {
                inputBox.focus();
                event.preventDefault();
                return;
            }
        }

        const editing = isEditingMessage();

        if (editing) {
            if ((event.ctrlKey || event.metaKey) && event.key === 'End') {
                const textarea = document.activeElement;
                if (textarea.tagName.toLowerCase() === "textarea") {
                    textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
                    scrollSelectedToBottom15();
                    event.preventDefault();
                }
            } else if ((event.ctrlKey || event.metaKey) && event.key === 'Home') {
                const textarea = document.activeElement;
                if (textarea.tagName.toLowerCase() === "textarea") {
                    textarea.selectionStart = textarea.selectionEnd = 0;
                    boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
                    event.preventDefault();
                }
            } else if (event.key === 'Escape') {
                // Robustly find the cancel button after the textarea
                const textarea = document.activeElement;
                if (textarea && textarea.tagName.toLowerCase() === 'textarea') {
                    // Find the closest message box
                    const messageBox = textarea.closest('div.relative.group.flex.flex-col');
                    // Find the button group after the textarea
                    let cancelButton = null;
                    if (messageBox) {
                        // Find the parent of textarea, then the next sibling div with buttons
                        let parent = textarea.parentElement;
                        while (parent && parent !== messageBox && !cancelButton) {
                            let sibling = parent.nextElementSibling;
                            while (sibling && !cancelButton) {
                                // Look for a div with flex-row and buttons
                                if (sibling.matches && sibling.matches('div.flex.flex-row')) {
                                    const btns = Array.from(sibling.querySelectorAll('button'));
                                    if (btns.length > 0) {
                                        cancelButton = btns[0]; // First button is always cancel
                                        break;
                                    }
                                }
                                sibling = sibling.nextElementSibling;
                            }
                            parent = parent.parentElement;
                        }
                    }
                    if (!cancelButton && messageBox) {
                        // Fallback: look for any visible button with 2+ buttons after textarea
                        const btns = Array.from(messageBox.querySelectorAll('textarea ~ div button'));
                        if (btns.length > 0) cancelButton = btns[0];
                    }
                    if (cancelButton) {
                        cancelButton.click();
                        event.preventDefault();
                        setTimeout(() => {
                            highlightSelected({ scrollIntoView: false });
                        }, 100);
                    }
                }
            }
            return;
        } else {
            if (event.key === 'Escape') {
                const ae = document.activeElement;
                // If in chat input box (bottom input area), defocus and select previously selected message
                if (ae && ae.tagName.toLowerCase() === 'textarea' && isInTextInputMode()) {
                    ae.blur();
                    if (window._grokPrevSelectedIdx !== undefined && window._grokPrevSelectedIdx >= 0 && window._grokPrevSelectedIdx < boxes.length) {
                        selectedIdx = window._grokPrevSelectedIdx;
                        highlightSelected({ scrollIntoView: true });
                    }
                    event.preventDefault();
                    return;
                }
                const aside = document.querySelector('aside');
                if (aside && aside.offsetParent !== null) {
                    const closeButton = aside.querySelector('div.flex.justify-end > button');
                    if (closeButton) {
                        closeButton.click();
                        event.preventDefault();
                    }
                } else if (selectedIdx >= 0) {
                    const selectedBox = boxes[selectedIdx];
                    let toggleElem = selectedBox.querySelector('.pl-3.pr-5.py-3.flex.gap-2');
                    if (!toggleElem) {
                        toggleElem = selectedBox.querySelector('div.message-bubble > div.py-1 > button');
                    }
                    if (toggleElem) {
                        toggleElem.click();
                        event.preventDefault();
                    } else {
                        selectedIdx = -1;
                        highlightSelected();
                        const container = getScrollContainer();
                        if (container) container.focus();
                        event.preventDefault();
                    }
                }
            } else if (!isInTextInputMode()) {
                if (event.key === 'Home') {
                    const now = Date.now();
                    if (selectedIdx === -1) {
                        selectedIdx = 0;
                        highlightSelected({ scrollIntoView: true });
                    } else {
                        // Double-tap Home: if within 800 ms, go to first message
                        if (now - lastHomeKeyTime < 800) {
                            selectedIdx = 0;
                            highlightSelected({ scrollIntoView: true });
                            boxes[0].scrollIntoView({ block: 'start', behavior: 'smooth' });
                        } else {
                            boxes[selectedIdx].scrollIntoView({ block: 'start', behavior: 'smooth' });
                        }
                        lastHomeKeyTime = now;
                    }
                    event.preventDefault();
                } else if (event.key === 'End') {
                    const now = Date.now();
                    if (selectedIdx === -1) {
                        selectedIdx = boxes.length - 1;
                        highlightSelected();
                        scrollSelectedToBottom15();
                    } else {
                        // Double-tap End: if within 800 ms, go to last message
                        if (now - lastEndKeyTime < 800) {
                            selectedIdx = boxes.length - 1;
                            highlightSelected({ scrollIntoView: true });
                            scrollSelectedToBottom15();
                        } else {
                            scrollSelectedToBottom15();
                        }
                        lastEndKeyTime = now;
                    }
                    event.preventDefault();
                } else if (event.key === 'j' || event.key === 'ArrowDown') {
                    if (selectedIdx === -1) {
                        const { first } = getVisibleMessageIndices();
                        selectedIdx = first !== -1 ? first : 0;
                    } else {
                        selectedIdx = Math.min(selectedIdx + 1, boxes.length - 1);
                    }
                    highlightSelected({ scrollIntoView: true });
                    event.preventDefault();
                } else if (event.key === 'k' || event.key === 'ArrowUp') {
                    if (selectedIdx === -1) {
                        const { last } = getVisibleMessageIndices();
                        selectedIdx = last !== -1 ? last : boxes.length - 1;
                    } else {
                        selectedIdx = Math.max(selectedIdx - 1, 0);
                    }
                    highlightSelected({ scrollIntoView: true });
                    event.preventDefault();
                } else if (selectedIdx >= 0) {
                    const box = boxes[selectedIdx];
                    // Robustly find the edit/copy/regenerate buttons regardless of language
                    const visibleButtons = Array.from(box.querySelectorAll('button')).filter(btn => btn.offsetParent !== null);
                    if (event.key === 'e' && !event.ctrlKey) {
                        // Select the first visible button (Edit)
                        if (visibleButtons.length > 0) {
                            visibleButtons[0].click();
                            event.preventDefault();
                        }
                    } else if (event.key === 'c' && !event.ctrlKey) {
                        // Try to find the copy button by aria-label or icon
                        let copyBtn = visibleButtons.find(btn => {
                            const label = btn.getAttribute('aria-label') || '';
                            return /copy|コピー|копировать|copier|kopieren|copia/i.test(label);
                        });
                        if (!copyBtn) {
                            // Fallback: if regenerate button exists, try next button
                            let regenIdx = visibleButtons.findIndex(btn => {
                                const label = btn.getAttribute('aria-label') || '';
                                return /regenerate|再生成|再生成|regenerar|erneuern|regenerieren/i.test(label);
                            });
                            if (regenIdx !== -1 && visibleButtons[regenIdx + 1]) {
                                copyBtn = visibleButtons[regenIdx + 1];
                            }
                        }
                        if (copyBtn) {
                            copyBtn.click();
                            event.preventDefault();
                        }
                    } else if (event.key === 'r' && !event.ctrlKey) {
                        // Try to find the regenerate button by aria-label or icon
                        let regenBtn = visibleButtons.find(btn => {
                            const label = btn.getAttribute('aria-label') || '';
                            return /regenerate|再生成|再生成|regenerar|erneuern|regenerieren/i.test(label);
                        });
                        if (!regenBtn) {
                            // Fallback: look for a button with a refresh/rotate icon
                            regenBtn = visibleButtons.find(btn => {
                                const svg = btn.querySelector('svg');
                                if (!svg) return false;
                                return /rotate|refresh|regenerate|arrow/i.test(svg.outerHTML);
                            });
                        }
                        if (regenBtn) {
                            regenBtn.click();
                            event.preventDefault();
                        }
                    }
                }
            }
        }
        // Track previous selectedIdx for chat input Escape
        if (!isInTextInputMode()) {
            window._grokPrevSelectedIdx = selectedIdx;
        }
    });

    // ### Mouse Click Event Listener
    document.addEventListener('click', (event) => {
        if (event.target.closest('aside')) return;
        const boxes = getMessageBoxes();
        let found = false;
        boxes.forEach((box, i) => {
            if (box.contains(event.target)) {
                selectedIdx = i;
                highlightSelected({ scrollIntoView: false });
                found = true;
            }
        });
        if (!found) {
            selectedIdx = -1;
            highlightSelected();
        }
    }, true);

    // ### Scroll Event Listener
    function handleScroll() {
        if (isEditingMessage() || isTogglingCodeEditor) return;
        const boxes = getMessageBoxes();
        if (!boxes.length) return;
        const container = getScrollContainer();
        const scrollTop = container.scrollTop;
        const viewportHeight = container.clientHeight;
        if (selectedIdx >= 0 && selectedIdx < boxes.length) {
            const box = boxes[selectedIdx];
            const boxTop = box.offsetTop;
            const boxHeight = box.offsetHeight;
            if (boxTop + boxHeight <= scrollTop || boxTop >= scrollTop + viewportHeight) {
                selectedIdx = -1;
                highlightSelected();
            }
        }
    }

    function installScrollListener() {
        const container = getScrollContainer();
        if (container) {
            let scheduled = null;
            container.addEventListener('scroll', () => {
                if (scheduled) cancelAnimationFrame(scheduled);
                scheduled = requestAnimationFrame(handleScroll);
            });
        }
    }

    // ### Initialization
    setTimeout(() => {
        installScrollListener();
        handleScroll();
    }, 250);

    // ### DOM Mutation Observer
    const observer = new MutationObserver(() => {
        highlightSelected({ scrollIntoView: false });
    });
    observer.observe(document.body, { childList: true, subtree: true });

    // ### Debugging Handle
    window.grokVimNav = { getScrollContainer, getMessageBoxes, highlightSelected, handleScroll };
})();