Keyboard Shortcuts for Perplexity.ai: GSAP-powered scrolling, edit message, focus input, set sources (Academic/Social/GitHub), Search, Research.
// ==UserScript== // @name Perplexity Power Shortcuts // @namespace http://tampermonkey.net/ // @version 1.4.0 // @description Keyboard Shortcuts for Perplexity.ai: GSAP-powered scrolling, edit message, focus input, set sources (Academic/Social/GitHub), Search, Research. // @author Brian Hurd // @match https://perplexity.ai/* // @match https://www.perplexity.ai/* // @grant none // @run-at document-end // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/ScrollToPlugin.min.js // ==/UserScript== // Alt+t → Scroll to top of main thread // Alt+z → Scroll to bottom of main thread // Alt+j → Scroll up one message block // Alt+k → Scroll down one message block // Alt+e → Edit lowest user message (clicks [data-testid="edit-query-button"]; simulates hover if needed; falls back to Search if none found) // Alt+Shift+s → Click Search mode (SVG d^="M11 2.125a8.378 8.378") // Alt+r → Click Research mode (SVG d^="M8.175 13.976a.876.876") // Alt+w → Focus chat input (#ask-input contenteditable) // Alt+a → Set Sources → Academic (First: Set Sources SVG d^="M3 12a9 9 0 1 0", then submenu data-testid="source-toggle-scholar") // Alt+s → Set Sources → Social (submenu data-testid="source-toggle-social") // Alt+g → Set Sources → GitHub (submenu by testid if present or text "GitHub") // Alt+n → Start new conversation // #1 // Css tweaks (hide download comet) (function() { var target = document.querySelector('div.h-bannerHeight.rounded-sm'); if (target) target.style.display = 'none'; })(); // #2 // on page reload, automatically run the “Research + Social” sequence (same as your alt+g logic) on page load, waiting 1500 ms. (function () { function clickSegmentedControlBySVGPath(pathPrefix) { const segButtons = Array.from(document.querySelectorAll('button[role="radio"]')); for (const btn of segButtons) { const svg = btn.querySelector('svg'); if (svg) { const path = svg.querySelector('path'); if (path && path.getAttribute('d') && path.getAttribute('d').startsWith(pathPrefix)) { if (btn.getAttribute('aria-checked') !== "true") { btn.click(); return true; } } } } return false; } function openSourcesMenuAndClickSubmenuByTestId(testid) { const sourcesBtn = document.querySelector('button[data-testid="sources-switcher-button"]'); if (!sourcesBtn) return false; sourcesBtn.click(); setTimeout(() => { const menu = document.querySelector('div[role="menu"], ul[role="menu"]'); if (menu) { const item = menu.querySelector(`[data-testid="${testid}"]`); if (item) item.click(); } }, 250); return true; } function sendEscapeKey() { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, })); } function doAltGSequence() { clickSegmentedControlBySVGPath('M8.175 13.976a.876.876'); setTimeout(() => { openSourcesMenuAndClickSubmenuByTestId('source-toggle-social'); setTimeout(() => { sendEscapeKey(); }, 350); }, 250); } if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(doAltGSequence, 1500); } else { window.addEventListener('DOMContentLoaded', function () { setTimeout(doAltGSequence, 1500); }); } })(); // #3 // Alt+t → Scroll to top of main thread // Alt+z → Scroll to bottom of main thread // Alt+j → Scroll up one message block // Alt+k → Scroll down one message block // Alt+e → Edit lowest user message (clicks [data-testid="edit-query-button"]; simulates hover if needed; falls back to Search if none found) // Alt+Shift+s → Click Search mode (SVG d^="M11 2.125a8.378 8.378") // Alt+r → Click Research mode (SVG d^="M8.175 13.976a.876.876") // Alt+w → Focus chat input (#ask-input contenteditable) // Alt+p → Set Sources → Academic (submenu data-testid="source-toggle-scholar") // Alt+s → Set Sources → Social (submenu data-testid="source-toggle-social") // Alt+g → Set Sources → GitHub (submenu by testid if present or text "GitHub") (function () { 'use strict'; /* Ensure ScrollTo works, but never block if GSAP isn't ready */ function ensureScrollToReady() { const start = Date.now(); (function waitAndRegister() { const gs = window.gsap; const plug = window.ScrollToPlugin; if (gs && plug) { try { if (!(gs.plugins && gs.plugins.ScrollToPlugin)) { gs.registerPlugin(plug); } return; // success } catch (e) { // keep polling until timeout } } if (Date.now() - start < 4000) setTimeout(waitAndRegister, 100); })(); } // Kick off registration (safe even if @require already loaded) ensureScrollToReady(); const __SCROLL_CACHE = { el: null }; function getScrollableContainer(forceRecalc = false) { const isScrollable = (el) => { if (!el) return false; const st = getComputedStyle(el); const oy = st.overflowY || st.overflow || ''; return /(auto|scroll)/.test(oy) && (el.scrollHeight - el.clientHeight > 2); }; const nearestScrollable = (el) => { let cur = el; while (cur && cur !== document.body && cur !== document.documentElement) { if (isScrollable(cur)) return cur; cur = cur.parentElement; } return null; }; // Use cached container if still valid if (!forceRecalc && __SCROLL_CACHE.el && document.contains(__SCROLL_CACHE.el) && isScrollable(__SCROLL_CACHE.el)) { return __SCROLL_CACHE.el; } // Prefer anchors that are guaranteed to be inside the chat thread const anchorSelectors = [ 'div[id^="Markdown-Content-"]', // assistant message root '.mb-xs.group.relative.flex.items-end', // user prompt group '.group.relative.flex.items-end.mb-xs', 'button[data-testid="edit-query-button"]' // climb from edit btn if present ]; let best = null; document.querySelectorAll(anchorSelectors.join(',')).forEach(a => { const sc = a.tagName === 'BUTTON' ? nearestScrollable(a.closest('.group') || a) : nearestScrollable(a); if (sc && (!best || sc.clientHeight > best.clientHeight)) best = sc; }); // Known selector(s) as a fallback if (!best) { const known = document.querySelector( '.scrollable-container.scrollbar-subtle.flex.flex-1.basis-0.overflow-auto, .scrollbar-subtle.flex.flex-1.basis-0.overflow-auto' ); if (isScrollable(known)) best = known; } // Generic fallback: biggest visible scrollable region if (!best) { const candidates = Array.from(document.querySelectorAll('div,main,section')).filter(n => { try { const st = getComputedStyle(n); const oy = st.overflowY || st.overflow || ''; return /(auto|scroll)/.test(oy) && n.clientHeight >= window.innerHeight * 0.5 && (n.scrollHeight - n.clientHeight > 2); } catch { return false; } }).sort((a, b) => b.clientHeight - a.clientHeight); if (candidates[0]) best = candidates[0]; } if (!best) best = document.scrollingElement || document.documentElement; __SCROLL_CACHE.el = best; return best; } function scrollToTop() { const c = getScrollableContainer(); if (!c) return; scrollToPosition(c, 0); } function scrollToBottom() { const c = getScrollableContainer(); if (!c) return; const isRoot = c === document.documentElement || c === document.body || c === document.scrollingElement; const target = (window.gsap && window.ScrollToPlugin && isRoot) ? window : c; if (window.gsap && window.ScrollToPlugin) { try { if (!(gsap.plugins && gsap.plugins.ScrollToPlugin)) gsap.registerPlugin(ScrollToPlugin); gsap.to(target, { duration: 0.55, scrollTo: { y: "max" }, ease: "power4.out", overwrite: "auto" }); return; } catch (_) {} } try { (isRoot ? window : c).scrollTo({ top: c.scrollHeight, behavior: 'smooth' }); } catch (_) { c.scrollTop = c.scrollHeight; } } function scrollToPosition(container, top) { if (!container) return; const isRoot = container === document.documentElement || container === document.body || container === document.scrollingElement; const target = (window.gsap && window.ScrollToPlugin && isRoot) ? window : container; let usedGSAP = false; if (window.gsap && window.ScrollToPlugin) { try { if (!(gsap.plugins && gsap.plugins.ScrollToPlugin)) gsap.registerPlugin(ScrollToPlugin); gsap.to(target, { duration: 0.55, scrollTo: { y: top }, ease: "power4.out", overwrite: "auto" }); usedGSAP = true; } catch (_) {} } if (!usedGSAP) { try { if (isRoot && 'scrollTo' in window) window.scrollTo({ top, behavior: 'smooth' }); else container.scrollTo({ top, behavior: 'smooth' }); } catch (_) { if (isRoot) (document.scrollingElement || document.documentElement).scrollTop = top; else container.scrollTop = top; } } } /* Build a unified, ordered list of “message anchors”: - user prompt top: .group.relative.flex.items-end.mb-xs - assistant answer top: #Markdown-Content-* */ function getMessageAnchors() { const set = new Set(); // User header/group container (contains Edit/Copy button group) document.querySelectorAll( '.group.relative.flex.items-end.mb-xs, .mb-xs.group.relative.flex.items-end' ).forEach(el => set.add(el)); // If present, also climb from Edit button → group (covers future class changes) document.querySelectorAll('button[data-testid="edit-query-button"]').forEach(btn => { const grp = btn.closest('.group') || btn.closest('.mb-xs'); if (grp) set.add(grp); }); // Assistant content root(s) document.querySelectorAll('div[id^="Markdown-Content-"]').forEach(el => set.add(el)); // Fallbacks if DOM shifts if (set.size === 0) { document.querySelectorAll( 'div[role="textbox"][data-lexical-editor="true"], [data-testid="message"], [data-anchor^="message-"]' ).forEach(el => set.add(el)); } return Array.from(set); } function getTopRelativeToContainer(el, sc) { const scRect = sc.getBoundingClientRect(); const rect = el.getBoundingClientRect(); return sc.scrollTop + (rect.top - scRect.top); } function getSortedAnchorTops(sc) { const anchors = getMessageAnchors(); const tops = anchors .map(el => ({ el, top: Math.round(getTopRelativeToContainer(el, sc)) })) .filter(d => Number.isFinite(d.top)) .sort((a, b) => a.top - b.top); // De-dup nearly-equal tops (e.g., if two anchors share the same top) const dedup = []; for (const d of tops) { if (!dedup.length || Math.abs(d.top - dedup[dedup.length - 1].top) > 2) dedup.push(d); } return dedup; } // Scroll helpers using the anchors. // Alt+J: go to the start of the current message; if already near it, go to the previous. // Alt+K: go to the next message anchor. function scrollUpOneMessage() { const sc = getScrollableContainer(); if (!sc) return; const anchors = getSortedAnchorTops(sc); if (!anchors.length) return; const y = sc.scrollTop; const tol = 8; // treat within 8px as “at” an anchor const snap = 24; // if we’re more than 24px past the anchor, snap back to it // Last anchor at/above current position let idx = -1; for (let i = 0; i < anchors.length; i++) { if (anchors[i].top <= y + tol) idx = i; else break; } let target; if (idx === -1) { // We are above the first anchor: go to the very first anchor target = anchors[0].top; } else if (y > anchors[idx].top + snap) { // We’re inside the current message: go to its start target = anchors[idx].top; } else { // We’re already at (or very near) the current start: go to previous target = idx > 0 ? anchors[idx - 1].top : 0; } scrollToPosition(sc, Math.max(0, target - 50)); } function scrollDownOneMessage() { const sc = getScrollableContainer(); if (!sc) return; const anchors = getSortedAnchorTops(sc); if (!anchors.length) return; const y = sc.scrollTop; const tol = 8; // First anchor strictly below current position let target = null; for (let i = 0; i < anchors.length; i++) { if (anchors[i].top > y + tol) { target = anchors[i].top; break; } } scrollToPosition(sc, target != null ? target : sc.scrollHeight); } // Edit lowest user message function isVisible(el) { if (!el) return false; const rect = el.getBoundingClientRect(); return !!(rect.width && rect.height && rect.bottom > 0 && rect.right > 0 && rect.top < (window.innerHeight || document.documentElement.clientHeight) && rect.left < (window.innerWidth || document.documentElement.clientWidth)); } function simulateHover(el) { if (!el) return; el.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true })); el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); el.dispatchEvent(new PointerEvent('mouseover', { bubbles: true })); } function editLowestMessage() { const btns = Array.from(document.querySelectorAll('button[data-testid="edit-query-button"]')); if (!btns.length) return false; let target = null; let maxBottom = -Infinity; for (const btn of btns) { const rect = btn.getBoundingClientRect(); if (isVisible(btn) && rect.bottom > maxBottom) { target = btn; maxBottom = rect.bottom; } } if (!target) target = btns[btns.length - 1]; if (!target) return false; const group = target.closest('.mb-xs.group.relative.flex.items-end'); if (group) simulateHover(group); setTimeout(() => { target.click(); }, 120); return true; } // Focus chat input function focusChatInput() { const el = document.querySelector('#ask-input[contenteditable="true"]'); if (!el) return false; el.focus(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); return true; } // Search/Research mode by SVG path (segment control) function clickSegmentedControlBySVGPath(pathPrefix) { const segButtons = Array.from(document.querySelectorAll('button[role="radio"]')); for (const btn of segButtons) { const svg = btn.querySelector('svg'); if (svg) { const path = svg.querySelector('path'); if (path && path.getAttribute('d') && path.getAttribute('d').startsWith(pathPrefix)) { if (btn.getAttribute('aria-checked') !== "true") { btn.click(); return true; } } } } return false; } // Sources menu helpers // Sources menu helpers with Escape key auto-close function sendEscapeKey() { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, })); } // testidOrText: either the data-testid or text to match // byTestId: if true, use data-testid; else, use text match function openSourcesMenuAndClickAndClose(testidOrText, byTestId) { const sourcesBtn = document.querySelector('button[data-testid="sources-switcher-button"]'); if (!sourcesBtn) return false; sourcesBtn.click(); setTimeout(() => { const menu = document.querySelector('div[role="menu"], ul[role="menu"]'); let clicked = false; if (menu) { if (byTestId) { const item = menu.querySelector(`[data-testid="${testidOrText}"]`); if (item) { item.click(); clicked = true; } } // Fallback to text if not found or not using testid if (!clicked && !byTestId) { const items = Array.from(menu.querySelectorAll('[role="menuitem"],button,[data-testid]')); const target = items.find(n => (n.innerText || n.textContent || '').toLowerCase().includes(testidOrText.toLowerCase())); if (target) { target.click(); clicked = true; } } } setTimeout(sendEscapeKey, 250); }, 250); return true; } /* Keyboard handler with stronger suppression so site scripts don't swallow keys */ document.addEventListener('keydown', function (e) { if (!e.altKey || e.repeat) return; const key = e.key.toLowerCase(); // Modes / menus if (e.shiftKey && key === 's') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); clickSegmentedControlBySVGPath('M11 2.125a8.378'); return; } if (!e.shiftKey && key === 'a') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); openSourcesMenuAndClickAndClose('source-toggle-scholar', true); return; } if (!e.shiftKey && key === 's') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); openSourcesMenuAndClickAndClose('source-toggle-social', true); return; } if (!e.shiftKey && key === 'g') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); openSourcesMenuAndClickAndClose('source-toggle-github', true); setTimeout(() => openSourcesMenuAndClickAndClose('github', false), 600); return; } if (!e.shiftKey && key === 'r') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); clickSegmentedControlBySVGPath('M8.175 13.976a.876.876'); return; } if (!e.shiftKey && key === 'e') { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (!editLowestMessage()) { clickSegmentedControlBySVGPath('M11 2.125a8.378'); } return; } // Scrolling and focus switch (key) { case 't': e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); scrollToTop(); break; case 'z': e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); scrollToBottom(); break; case 'j': e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); scrollUpOneMessage(); break; case 'k': e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); scrollDownOneMessage(); break; case 'w': e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); focusChatInput(); break; } }, true); })(); // #4 // alt+n starts new conversation (function() { document.addEventListener('keydown', function(e) { if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.repeat && e.key.toLowerCase() === 'n') { e.preventDefault(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'i', code: 'KeyI', keyCode: 73, which: 73, ctrlKey: true, bubbles: true, })); } }, true); })();