Gemini Suite

Add features to Gemini

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Gemini Suite
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Add features to Gemini
// @author       81standard
// @match        https://gemini.google.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // ■ 設定とメニュー登録
    // ==========================================

    const SCRIPTS = [
        { id: 'enable_selector', name: 'Model Selector',             def: true },
        { id: 'enable_history',  name: 'History & Navigation Booster', def: true },
        { id: 'enable_saver',    name: 'Input Auto-Saver',             def: true },
        { id: 'enable_paste',    name: 'Plain Text Paste',             def: false }, // 初期値をfalse(オフ)に変更
    ];

    GM_registerMenuCommand("Setting(スクリプト設定を開く)", openSettingsDialog);

    runEnabledScripts();

    function runEnabledScripts() {
        if (GM_getValue('enable_selector', true)) Module_ModelSelector();
        if (GM_getValue('enable_history', true))  Module_HistoryBooster();
        if (GM_getValue('enable_saver', true))    Module_AutoSaver();
        if (GM_getValue('enable_paste', false))   Module_PlainTextPaste(); // ここも同期
    }


    // ==========================================
    // ■ モジュール 1: Model Selector
    // ==========================================
    function Module_ModelSelector() {
        const pageLang = document.documentElement.lang || navigator.language;
        const isJapanese = pageLang.toLowerCase().startsWith('ja');

        const CONFIG = {
            ja: {
                dialogTitle: "送信モードを選択",
                models: [
                    { name: "Gemini 2.0 Flash (高速)", keyword: "高速" },
                    { name: "Gemini Pro",             keyword: "Pro" },
                    { name: "Gemini Thinking (思考)", keyword: "思考" }
                ]
            },
            en: {
                dialogTitle: "Select Model & Send",
                models: [
                    { name: "Gemini 2.0 Flash (Fast)", keyword: "Fast" },
                    { name: "Gemini Pro",              keyword: "Pro" },
                    { name: "Gemini Thinking",         keyword: "Thinking" }
                ]
            }
        };

        const CURRENT_UI = isJapanese ? CONFIG.ja : CONFIG.en;
        const MODELS = CURRENT_UI.models;
        const STORAGE_KEY = 'gemini_selector_last_index';
        let selectedIndex = 0;

        const MODE_MENU_TRIGGER = '[data-test-id="bard-mode-menu-button"]';
        const SEND_BUTTON_SELECTOR = 'button[aria-label*="送信"], button[aria-label*="Send"], button[aria-label*="Submit"]';

        const style = document.createElement('style');
        style.textContent = `
            #gemini-model-selector-dialog {
                position: fixed; bottom: 120px; left: 50%; transform: translateX(-50%);
                background: #202124; border: 1px solid #5f6368; border-radius: 8px;
                padding: 0; z-index: 99999; box-shadow: 0 4px 20px rgba(0,0,0,0.7);
                color: #e8eaed; font-family: 'Google Sans', sans-serif;
                min-width: 250px; display: flex; flex-direction: column;
            }
            .gms-title {
                font-size: 12px; color: #9aa0a6; padding: 8px 16px;
                background: #303134; border-bottom: 1px solid #5f6368;
            }
            .gms-option {
                padding: 12px 16px; cursor: pointer; font-size: 14px;
                border-left: 4px solid transparent;
            }
            .gms-option:hover { background-color: #3c4043; }
            .gms-option.selected {
                background-color: #3c4043; border-left: 4px solid #8ab4f8; font-weight: 500;
            }
        `;
        document.head.appendChild(style);

        let isDialogVisible = false;

        document.addEventListener('keydown', function(e) {
            if (isDialogVisible) {
                if (['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(e.key)) {
                    e.preventDefault();
                    e.stopImmediatePropagation();
                    if (e.key === 'ArrowUp') {
                        selectedIndex = (selectedIndex - 1 + MODELS.length) % MODELS.length;
                        renderDialog();
                    } else if (e.key === 'ArrowDown') {
                        selectedIndex = (selectedIndex + 1) % MODELS.length;
                        renderDialog();
                    } else if (e.key === 'Enter') {
                        confirmAndAction(true);
                    } else if (e.key === 'Escape') {
                        confirmAndAction(false);
                    }
                }
                return;
            }

            const target = e.target;
            const editableElement = target.closest('[contenteditable="true"]');
            const isInput = (editableElement !== null) || target.tagName === 'TEXTAREA';

            if (isInput && e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
                const sendBtn = document.querySelector(SEND_BUTTON_SELECTOR);
                if (sendBtn && !sendBtn.disabled) {
                    e.preventDefault();
                    e.stopPropagation();
                    syncWithNativeUI();
                    showDialog();
                }
            }
        }, true);

        document.addEventListener('mousedown', function(e) {
            if (isDialogVisible) {
                const dialog = document.getElementById('gemini-model-selector-dialog');
                if (dialog && !dialog.contains(e.target)) {
                    confirmAndAction(false);
                }
            }
        }, true);

        function syncWithNativeUI() {
            const triggerBtn = document.querySelector(MODE_MENU_TRIGGER);
            if (!triggerBtn) return;
            const currentText = triggerBtn.innerText;
            const foundIndex = MODELS.findIndex(m => currentText.includes(m.keyword));
            if (foundIndex !== -1) {
                selectedIndex = foundIndex;
            } else {
                let saved = parseInt(localStorage.getItem(STORAGE_KEY));
                selectedIndex = (isNaN(saved) || saved >= MODELS.length) ? 0 : saved;
            }
        }

        function showDialog() {
            if (document.getElementById('gemini-model-selector-dialog')) return;
            const dialog = document.createElement('div');
            dialog.id = 'gemini-model-selector-dialog';
            const title = document.createElement('div');
            title.className = 'gms-title';
            title.innerText = CURRENT_UI.dialogTitle;
            dialog.appendChild(title);
            document.body.appendChild(dialog);
            isDialogVisible = true;
            renderDialog();
        }

        function closeDialog() {
            const dialog = document.getElementById('gemini-model-selector-dialog');
            if (dialog) dialog.remove();
            isDialogVisible = false;
        }

        function renderDialog() {
            const dialog = document.getElementById('gemini-model-selector-dialog');
            if (!dialog) return;
            const oldOptions = dialog.querySelectorAll('.gms-option');
            oldOptions.forEach(el => el.remove());
            MODELS.forEach((model, index) => {
                const div = document.createElement('div');
                div.className = `gms-option ${index === selectedIndex ? 'selected' : ''}`;
                div.textContent = model.name;
                div.onclick = (e) => {
                    e.stopPropagation();
                    selectedIndex = index;
                    confirmAndAction(true);
                };
                dialog.appendChild(div);
            });
        }

        async function confirmAndAction(isSending) {
            localStorage.setItem(STORAGE_KEY, selectedIndex);
            const targetModel = MODELS[selectedIndex];
            closeDialog();
            await performModelSwitch(targetModel);
            if (isSending) {
                const sendBtn = document.querySelector(SEND_BUTTON_SELECTOR);
                if (sendBtn) sendBtn.click();
            }
        }

        async function performModelSwitch(targetModel) {
            const triggerBtn = document.querySelector(MODE_MENU_TRIGGER);
            if (triggerBtn) {
                if (!triggerBtn.innerText.includes(targetModel.keyword)) {
                    triggerBtn.click();
                    let foundElement = null;
                    for (let i = 0; i < 20; i++) {
                        await sleep(100);
                        const xpath = `//*[contains(text(), '${targetModel.keyword}')]`;
                        const result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
                        for (let j = 0; j < result.snapshotLength; j++) {
                            const el = result.snapshotItem(j);
                            if (el.offsetParent === null || el.tagName === 'P') continue;
                            if (el.closest('.markdown') || el.closest('.conversation-container')) continue;
                            if (el.closest('#gemini-model-selector-dialog')) continue;
                            const clickableParent = el.closest('button, [role="menuitem"], [role="option"]');
                            foundElement = clickableParent || el;
                            break;
                        }
                        if (foundElement) break;
                    }
                    if (foundElement) {
                        foundElement.click();
                        await sleep(300);
                    }
                }
            }
        }

        function sleep(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        }
    }


    // ==========================================
    // ■ モジュール 2: History & Navigation Booster
    // ==========================================
    function Module_HistoryBooster() {
        // 前半:サイドバーのリンク修正(ここは変更なし)
        const fixSidebarLinks = () => {
            const baseUrl = window.location.origin + window.location.pathname.split('/app')[0] + '/app/';
            const items = document.querySelectorAll('div[data-test-id="conversation"]:not([data-link-ready])');

            items.forEach(item => {
                const jslog = item.getAttribute('jslog');
                if (!jslog) return;
                const idWithPrefix = jslog.match(/c_[a-z0-9]{16}/);

                if (idWithPrefix) {
                    const pureId = idWithPrefix[0].replace('c_', '');
                    const targetUrl = baseUrl + pureId;

                    item.setAttribute('data-link-ready', 'true');

                    const ghostLink = document.createElement('a');
                    ghostLink.href = targetUrl;
                    ghostLink.style.position = 'absolute';
                    ghostLink.style.inset = '0';
                    ghostLink.style.zIndex = '10';
                    ghostLink.setAttribute('aria-hidden', 'true');
                    ghostLink.classList.add('gemini-history-link');

                    if (getComputedStyle(item).position === 'static') {
                        item.style.position = 'relative';
                    }

                    ghostLink.addEventListener('click', (e) => {
                        if (!e.ctrlKey && !e.metaKey && e.button === 0) {
                            e.preventDefault();
                            item.click();
                        }
                    });

                    item.appendChild(ghostLink);
                }
            });
        };

        const observer = new MutationObserver(fixSidebarLinks);
        observer.observe(document.documentElement, { childList: true, subtree: true });
        setInterval(fixSidebarLinks, 2000);

        // 後半:あいまい検索ロジック(★ここを変更)
        const normalize = (str) => {
            return str.replace(/\s+/g, '').replace(/[…\.]{2,}$/, '').toLowerCase();
        };

document.addEventListener('click', function(e) {
    // 左クリックのみ
    if (e.button !== 0) return;

    const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
    const isCtrl = e.ctrlKey && !e.altKey && !e.metaKey; // Ctrl+クリック(要望)

    if (!isAlt && !isCtrl) return;

    // 本物のリンクはブラウザ標準挙動に任せる(2重で開く事故防止)
    if (e.target.closest('a[href]')) return;

    const targetItem = e.target.closest('.conversation-container, [role="option"]');
    if (!targetItem || e.target.closest('nav')) return;

    e.preventDefault();
    e.stopPropagation();
    e.stopImmediatePropagation();

    const normalize = (str) => str.replace(/\s+/g, '').replace(/[…\.]{2,}$/, '').toLowerCase();

    let searchRawTitle = targetItem.innerText.split('\n')[0].trim();
    const searchTitle = normalize(searchRawTitle);
    if (!searchTitle) return;

    const allLinks = document.querySelectorAll('a[href*="/app/"]');
    let foundUrl = null;
    let debugTitles = [];

    for (const link of allLinks) {
        const rawText = link.innerText || link.getAttribute('aria-label') || "";
        if (!rawText) continue;

        const linkTitle = normalize(rawText);
        if (debugTitles.length < 5 && linkTitle.length > 2) {
            debugTitles.push(rawText.substring(0, 20));
        }

        if (searchTitle.includes(linkTitle) || linkTitle.includes(searchTitle)) {
            foundUrl = link.href;
            break;
        }
    }

    if (!foundUrl) {
        // Altのときだけアラート(Ctrlで誤爆した時に邪魔になりやすいので)
        if (isAlt) {
            let msg = `「${searchRawTitle}」に対応するリンクが見つかりませんでした。\n\n`;
            msg += `【可能性】\n・サイドバーの読み込みがまだ(スクロールしてください)\n・タイトルが大きく省略されている\n\n`;
            msg += `【候補】\n${debugTitles.join('\n')}\n...`;
            alert(msg);
        }
        return;
    }

    if (isAlt) {
        // フォアグラウンド新規タブ(従来通り)
        window.open(foundUrl, '_blank');
        return;
    }

    // Ctrl+左クリック: バックグラウンド新規タブ
    if (typeof GM_openInTab === 'function') {
        GM_openInTab(foundUrl, { active: false, insert: true, setParent: true });
    } else {
        // 念のためフォールバック(環境によっては前面になることがあります)
        const w = window.open(foundUrl, '_blank');
        if (w) { w.blur(); window.focus(); }
    }
}, true);
    }

    // ==========================================
    // ■ モジュール 3: Input Auto-Saver
    // ==========================================
    function Module_AutoSaver() {
        const KEY_PREFIX = 'gemini_draft_v9_';
        let lastSavedHTML = '';
        let currentInputRef = null;
        let currentPath = window.location.pathname;

        function getNormalizedPath() {
            return window.location.pathname.replace(/\/app\/c_/, '/app/');
        }

        function getStorageKey() {
            return KEY_PREFIX + getNormalizedPath();
        }

        function fixAddressBarUrl() {
            const path = window.location.pathname;
            if (path.includes('/app/c_')) {
                const cleanPath = path.replace(/\/app\/c_/, '/app/');
                history.replaceState(null, '', cleanPath + window.location.search);
                currentPath = cleanPath;
            }
        }

        function clearSavedDraft() {
            const key = getStorageKey();
            localStorage.removeItem(key);
            lastSavedHTML = '';
        }

        function saveDraft(html) {
            const key = getStorageKey();
            localStorage.setItem(key, html);
            lastSavedHTML = html;
        }

        function restoreDraft(inputArea) {
            const key = getStorageKey();
            const savedHTML = localStorage.getItem(key);

            if (inputArea.innerText.trim() === '') {
                if (savedHTML) {
                    inputArea.innerHTML = savedHTML;
                    inputArea.dispatchEvent(new Event('input', { bubbles: true }));
                    try {
                        const range = document.createRange();
                        const sel = window.getSelection();
                        range.selectNodeContents(inputArea);
                        range.collapse(false);
                        sel.removeAllRanges();
                        sel.addRange(range);
                    } catch(e) {}
                    lastSavedHTML = savedHTML;
                } else {
                    lastSavedHTML = '';
                }
            }
        }

        function setupInputListeners(inputArea) {
            const handleInput = () => {
                const currentHTML = inputArea.innerHTML;
                const currentText = inputArea.innerText;
                const isEmpty = currentText.replace(/\s|[\u3000]/g, '').length === 0;

                if (isEmpty) {
                    if (localStorage.getItem(getStorageKey()) !== null) {
                        clearSavedDraft();
                    }
                } else {
                    if (currentHTML !== lastSavedHTML) {
                        saveDraft(currentHTML);
                    }
                }
            };

            inputArea.addEventListener('input', handleInput);
            inputArea.addEventListener('keyup', handleInput);

            inputArea.addEventListener('keydown', function(e) {
                if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
                    setTimeout(clearSavedDraft, 100);
                }
            });
        }

        document.addEventListener('click', function(e) {
            const btn = e.target.closest('button');
            if (btn) {
                const label = btn.getAttribute('aria-label') || '';
                if (label.includes('送信') || label.includes('Send')) {
                    setTimeout(clearSavedDraft, 100);
                }
            }
        }, true);

        const observer = new MutationObserver((mutations) => {
            fixAddressBarUrl();
            const newPath = window.location.pathname;
            if (newPath !== currentPath) {
                currentPath = newPath;
                if (currentInputRef) {
                    currentInputRef.innerText = '';
                    restoreDraft(currentInputRef);
                }
            }
            const inputArea = document.querySelector('div[contenteditable="true"]');
            if (inputArea) {
                if (inputArea !== currentInputRef) {
                    currentInputRef = inputArea;
                    restoreDraft(inputArea);
                    setupInputListeners(inputArea);
                }
            }
        });
        observer.observe(document.documentElement, { childList: true, subtree: true });
    }


    // ==========================================
    // ■ モジュール 4: Plain Text Paste
    // ==========================================
    function Module_PlainTextPaste() {
        document.addEventListener('paste', function(e) {
            if (e.target.isContentEditable || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') {
                e.preventDefault();
                const text = (e.originalEvent || e).clipboardData.getData('text/plain');
                document.execCommand('insertText', false, text);
            }
        }, true);
    }


    // ==========================================
    // ■ 設定画面のUIロジック
    // ==========================================
    function openSettingsDialog() {
        if (document.getElementById('gemini-suite-overlay')) return;

        const style = document.createElement('style');
        style.id = 'gemini-suite-style';
        style.textContent = `
            #gemini-suite-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 10000; }
            #gemini-suite-dialog {
                position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
                background: var(--md-sys-color-surface, #fff); color: var(--md-sys-color-on-surface, #000);
                padding: 24px; border-radius: 16px; width: 380px; box-shadow: 0 10px 30px rgba(0,0,0,0.3);
                display: flex; flex-direction: column; gap: 12px; font-family: 'Google Sans', sans-serif;
            }
            .gs-title { font-size: 18px; font-weight: 500; margin-bottom: 8px; color: var(--md-sys-color-on-surface); }
            .gs-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; cursor: pointer; border-bottom: 1px solid rgba(128,128,128,0.1); }
            .gs-row:last-of-type { border-bottom: none; }
            .gs-checkbox { width: 20px; height: 20px; cursor: pointer; accent-color: #0b57d0; }
            .gs-label { flex: 1; cursor: pointer; font-size: 14px; color: var(--md-sys-color-on-surface-variant); }
            .gs-btn-area { text-align: right; margin-top: 16px; }
            .gs-btn {
                padding: 8px 20px; background: #0b57d0; color: white; border: none;
                border-radius: 20px; cursor: pointer; font-weight: 500; font-size: 14px;
                transition: background 0.2s;
            }
            .gs-btn:hover { background: #0842a0; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
        `;
        document.head.appendChild(style);

        const overlay = document.createElement('div');
        overlay.id = 'gemini-suite-overlay';

        const dialog = document.createElement('div');
        dialog.id = 'gemini-suite-dialog';
        dialog.innerHTML = `<div class="gs-title">Gemini Suite Settings</div>`;

        SCRIPTS.forEach(script => {
            const isChecked = GM_getValue(script.id, script.def) ? 'checked' : '';
            const row = document.createElement('label');
            row.className = 'gs-row';
            row.innerHTML = `
                <input type="checkbox" class="gs-checkbox" data-id="${script.id}" ${isChecked}>
                <span class="gs-label">${script.name}</span>
            `;
            dialog.appendChild(row);
        });

        const btnArea = document.createElement('div');
        btnArea.className = 'gs-btn-area';
        btnArea.innerHTML = `<button class="gs-btn">Save & Reload</button>`;
        dialog.appendChild(btnArea);

        overlay.appendChild(dialog);
        document.body.appendChild(overlay);

        btnArea.querySelector('button').addEventListener('click', () => {
            dialog.querySelectorAll('.gs-checkbox').forEach(box => {
                GM_setValue(box.dataset.id, box.checked);
            });
            overlay.remove();
            style.remove();
            location.reload();
        });

        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                overlay.remove();
                style.remove();
            }
        });
    }

})();