Skip to content

Instantly share code, notes, and snippets.

@nmc-eco
Last active September 6, 2026 08:17
Show Gist options
  • Select an option

  • Save nmc-eco/858ad096626f034faf4e1a6235a6bef1 to your computer and use it in GitHub Desktop.

Select an option

Save nmc-eco/858ad096626f034faf4e1a6235a6bef1 to your computer and use it in GitHub Desktop.
Fold / unfold answers and code blocks on Perplexity AI (using Violent Monkey)
// ==UserScript==
// @name Perplexity - Fold Answers by Default
// @version 1.2
// @description Folds/collapses all answer sections on Perplexity AI sessions by default with click-to-expand.
// @author Gemini
// @match https://www.perplexity.ai/*
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// Inject styling for the toggle header and folded state
const styles = `
/* Fold toggle bar styling */
.pplx-fold-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 8px;
cursor: pointer;
user-select: none;
font-size: 13px;
line-height: 1.4;
color: inherit;
box-sizing: border-box;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.pplx-fold-toggle:hover {
background: rgba(128, 128, 128, 0.15);
border-color: rgba(128, 128, 128, 0.3);
}
.pplx-fold-left {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.pplx-fold-chevron {
flex-shrink: 0;
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
/* Rotate chevron to indicate collapsed vs expanded */
.pplx-is-folded .pplx-fold-chevron {
transform: rotate(-90deg);
}
.pplx-fold-tag {
font-weight: 600;
font-size: 12px;
opacity: 0.9;
flex-shrink: 0;
}
.pplx-fold-preview-text {
opacity: 0.6;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 400;
}
.pplx-fold-badge-state {
font-size: 11px;
opacity: 0.5;
flex-shrink: 0;
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Hide the entire answer contents and footer when folded */
[data-workflow-final-text].pplx-is-folded > *:not(.pplx-fold-toggle) {
display: none !important;
}
/* Code block folding */
figure.pplx-code-folded > :not(figcaption) {
display: none !important;
}
figure.pplx-code-folded figcaption {
border-bottom: none !important;
}
figcaption.pplx-code-header {
cursor: pointer;
user-select: none;
}
.pplx-code-arrow {
display: inline-block;
margin-right: 6px;
font-size: 10px;
transition: transform 0.2s ease;
}
figure.pplx-code-folded .pplx-code-arrow {
transform: rotate(-90deg);
}
/* Global Fold / Unfold All Floating Button */
.pplx-global-fold-btn {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
padding: 8px 14px;
background: rgba(30, 30, 30, 0.85);
color: #fff;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
transition: all 0.2s ease;
user-select: none;
}
.pplx-global-fold-btn:hover {
transform: translateY(-2px);
background: rgba(50, 50, 50, 0.95);
}
/* ---- Prompt navigation pane ---- */
.pplx-nav-pane {
position: fixed;
top: 72px; bottom: 24px; left: 64px;
z-index: 9998; width: 250px;
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(128, 128, 128, 0.08);
color: inherit;
backdrop-filter: blur(12px);
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
font-size: 13px;
transition:
width 0.2s ease,
left 0.15s ease,
background 0.2s ease;
}
/* Folded/collapsed appearance */
.pplx-nav-pane.pplx-nav-collapsed {
width: 34px;
height: 34px;
bottom: auto;
background: rgba(253, 251, 250, 0.05);
border-color: rgba(0, 0, 0, 0.12);
}
.pplx-nav-pane.pplx-nav-collapsed .pplx-nav-list,
.pplx-nav-pane.pplx-nav-collapsed .pplx-nav-title,
.pplx-nav-pane.pplx-nav-collapsed .pplx-nav-rebuild {
display: none;
}
.pplx-nav-head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
flex-shrink: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
.pplx-nav-title {
font-weight: 600;
font-size: 11px;
letter-spacing: 0.5px;
text-transform: uppercase;
opacity: 0.65;
flex: 1;
}
.pplx-nav-rebuild,
.pplx-nav-toggle {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
}
.pplx-nav-rebuild {
opacity: 0.55;
font-size: 14px;
line-height: 1;
padding: 2px 4px;
}
.pplx-nav-rebuild:hover {
opacity: 1;
}
.pplx-nav-toggle {
opacity: 0.7;
padding: 2px 4px;
display: flex;
align-items: center;
}
.pplx-nav-caret {
transition: transform 0.2s ease;
}
.pplx-nav-collapsed .pplx-nav-caret {
transform: rotate(180deg);
}
.pplx-nav-list {
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.pplx-nav-status {
padding: 10px;
opacity: 0.6;
font-size: 12px;
}
.pplx-nav-item {
display: flex;
gap: 7px;
align-items: baseline;
text-align: left;
background: transparent;
border: 0;
color: inherit;
font: inherit;
line-height: 1.35;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
}
.pplx-nav-item:hover {
background: rgba(0, 0, 0, 0.06);
}
.pplx-nav-item.pplx-nav-active {
background: rgba(0, 0, 0, 0.10);
}
.pplx-nav-idx {
opacity: 0.45;
font-size: 11px;
min-width: 15px;
flex-shrink: 0;
}
.pplx-nav-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
[data-workflow-final-text],
[class~="group/user-bubble"] {
scroll-margin-top: 90px;
}
`;
if (typeof GM_addStyle !== 'undefined') {
GM_addStyle(styles);
} else {
const styleEl = document.createElement('style');
styleEl.textContent = styles;
document.head.appendChild(styleEl);
}
function cleanSnippet(str) {
return str ? str.replace(/\s+/g, ' ').trim() : '';
}
function foldAnswer(container) {
// If already attached, update preview if text streamed in later
if (container.dataset.pplxFoldInit) {
const previewEl = container.querySelector('.pplx-fold-preview-text');
if (previewEl && previewEl.dataset.empty === 'true') {
const text = cleanSnippet(container.querySelector('[data-renderer="lm"]')?.textContent);
if (text) {
previewEl.textContent = `— ${text.slice(0, 90)}...`;
previewEl.dataset.empty = 'false';
}
}
return;
}
const lmRenderer = container.querySelector('[data-renderer="lm"]');
const text = lmRenderer ? cleanSnippet(lmRenderer.textContent) : '';
// Mark as initialized & fold by default
container.dataset.pplxFoldInit = 'true';
container.classList.add('pplx-is-folded');
const previewSnippet = text ? `— ${text.slice(0, 90)}...` : '';
// Create the interactive toggle bar
const toggleBar = document.createElement('div');
toggleBar.className = 'pplx-fold-toggle';
toggleBar.innerHTML = `
<div class="pplx-fold-left">
<svg class="pplx-fold-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<span class="pplx-fold-tag">Answer</span>
<span class="pplx-fold-preview-text" data-empty="${!text}">${previewSnippet}</span>
</div>
<span class="pplx-fold-badge-state">Folded</span>
`;
// Click handler to toggle open/closed
toggleBar.addEventListener('click', (e) => {
e.stopPropagation();
const isFolded = container.classList.toggle('pplx-is-folded');
const stateEl = toggleBar.querySelector('.pplx-fold-badge-state');
if (stateEl) {
stateEl.textContent = isFolded ? 'Folded' : 'Expanded';
}
});
// Insert at top of answer container
container.prepend(toggleBar);
}
function foldCodeBlock(figure) {
if (figure.dataset.pplxCodeFoldInit) return;
const figcaption = figure.querySelector('figcaption');
if (!figcaption) return;
figure.dataset.pplxCodeFoldInit = 'true';
figure.classList.add('pplx-code-folded'); // Folded by default
figcaption.classList.add('pplx-code-header');
// Add a clickable toggle arrow before the language name
const arrow = document.createElement('span');
arrow.className = 'pplx-code-arrow';
arrow.textContent = '▼';
figcaption.prepend(arrow);
// Click header (excluding copy button) to toggle
figcaption.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
figure.classList.toggle('pplx-code-folded');
});
}
/* ================= Prompt navigation pane ================= */
const PROMPT_SEL = '[class~="group/user-bubble"]';
const navIndex = []; // built once per thread, never reordered
let navBuiltPath = null;
let navBuilding = false;
let navCollapsed = true;
let navActiveKey = null;
function promptText(bubble) {
const el = bubble.querySelector('span[class*="whitespace-pre-line"]') ||
bubble.querySelector('.select-text');
return cleanSnippet(el ? el.textContent : bubble.textContent);
}
function promptKey(bubble) {
const stamp = bubble.querySelector('span.text-tertiary');
const when = stamp ? cleanSnippet(stamp.textContent) : '';
return (when ? when + '|' : '') + promptText(bubble).slice(0, 120);
}
// Perplexity may scroll an inner container rather than the window
function getScroller() {
const probe = document.querySelector(PROMPT_SEL);
let el = probe ? probe.parentElement : null;
while (el && el !== document.body) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight + 40) return el;
el = el.parentElement;
}
return document.scrollingElement || document.documentElement;
}
function isWindowScroller(sc) {
return sc === document.scrollingElement || sc === document.documentElement || sc === document.body;
}
function scrollerTop(sc) {
return isWindowScroller(sc) ? window.scrollY : sc.scrollTop;
}
function setScrollerTop(sc, v, smooth) {
const behavior = smooth ? 'smooth' : 'auto';
if (isWindowScroller(sc)) window.scrollTo({ top: v, behavior });
else sc.scrollTo({ top: v, behavior });
}
const wait = ms => new Promise(r => setTimeout(r, ms));
// Sweep the whole thread top-to-bottom once, recording prompts in document order
async function buildIndex() {
if (navBuilding) return;
navBuilding = true;
navBuiltPath = location.pathname;
navIndex.length = 0;
navActiveKey = null;
renderNav('Scanning thread…');
const sc = getScroller();
const startTop = scrollerTop(sc);
const seen = Object.create(null);
const viewH = isWindowScroller(sc) ? window.innerHeight : sc.clientHeight;
const step = Math.max(200, viewH * 0.6);
setScrollerTop(sc, 0, false);
await wait(250);
let pos = 0, guard = 0;
while (guard++ < 400) {
document.querySelectorAll(PROMPT_SEL).forEach(bubble => {
const key = promptKey(bubble);
const text = promptText(bubble);
if (!text || seen[key]) return;
seen[key] = true;
navIndex.push({ key, text, ratio: 0 });
});
const max = (isWindowScroller(sc)
? document.documentElement.scrollHeight - window.innerHeight
: sc.scrollHeight - sc.clientHeight);
if (pos >= max) break;
pos = Math.min(pos + step, max);
setScrollerTop(sc, pos, false);
await wait(150);
}
// record a rough position for each, used only as a fallback when jumping
navIndex.forEach((e, i) => { e.ratio = navIndex.length > 1 ? i / (navIndex.length - 1) : 0; });
setScrollerTop(sc, 0, false);
await wait(120);
setScrollerTop(sc, startTop === 0 ? 0 : 0, true);
navBuilding = false;
renderNav();
}
async function jumpTo(entry) {
let node = null;
// exact key match (includes timestamp)
document.querySelectorAll(PROMPT_SEL).forEach(b => {
if (!node && promptKey(b) === entry.key) node = b;
});
// fallback: text-only match (timestamps drift, keys go stale)
if (!node) {
document.querySelectorAll(PROMPT_SEL).forEach(b => {
if (!node && promptText(b) === entry.text) node = b;
});
}
if (!node) {
// temporarily fold everything so ratio-based scroll is accurate
const wasExpanded = [];
document.querySelectorAll('[data-workflow-final-text]:not(.pplx-is-folded)').forEach(a => {
wasExpanded.push(a);
a.classList.add('pplx-is-folded');
});
const sc = getScroller();
const max = (isWindowScroller(sc)
? document.documentElement.scrollHeight - window.innerHeight
: sc.scrollHeight - sc.clientHeight);
setScrollerTop(sc, max * entry.ratio, false);
await wait(350);
// restore what was open
wasExpanded.forEach(a => a.classList.remove('pplx-is-folded'));
document.querySelectorAll(PROMPT_SEL).forEach(b => {
if (!node && promptText(b) === entry.text) node = b;
});
if (!node) return;
}
// unfold the answer that follows this prompt, if there is one
let sib = node.nextElementSibling, answer = null, hops = 0;
while (sib && hops++ < 6 && !answer) {
answer = sib.matches('[data-workflow-final-text]')
? sib : sib.querySelector('[data-workflow-final-text]');
sib = sib.nextElementSibling;
}
if (answer) {
answer.classList.remove('pplx-is-folded');
const st = answer.querySelector('.pplx-fold-badge-state');
if (st) st.textContent = 'Expanded';
}
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function positionNavPane() {
const pane = document.getElementById('pplx-nav-pane');
if (!pane) return;
const anchor = document.querySelector('.w-sideBarWidth') ||
document.querySelector('nav[aria-label="Main"]');
pane.style.left = ((anchor ? anchor.getBoundingClientRect().right : 0) + 8) + 'px';
}
function ensureNavPane() {
let pane = document.getElementById('pplx-nav-pane');
if (pane) return pane;
pane = document.createElement('div');
pane.id = 'pplx-nav-pane';
pane.className = 'pplx-nav-pane pplx-nav-collapsed';
pane.innerHTML = `
<div class="pplx-nav-head">
<button class="pplx-nav-toggle" title="Collapse">
<svg class="pplx-nav-caret" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span class="pplx-nav-title">Prompts</span>
<button class="pplx-nav-rebuild" title="Rescan thread">⟳</button>
</div>
<div class="pplx-nav-list"></div>
`;
pane.querySelector('.pplx-nav-toggle').addEventListener('click', () => {
navCollapsed = !navCollapsed;
pane.classList.toggle('pplx-nav-collapsed', navCollapsed);
});
pane.querySelector('.pplx-nav-rebuild').addEventListener('click', () => buildIndex());
document.body.appendChild(pane);
positionNavPane();
window.addEventListener('resize', positionNavPane);
setInterval(positionNavPane, 500);
return pane;
}
function renderNav(statusMsg) {
const pane = ensureNavPane();
const list = pane.querySelector('.pplx-nav-list');
list.textContent = '';
if (statusMsg) {
const s = document.createElement('div');
s.className = 'pplx-nav-status';
s.textContent = statusMsg;
list.appendChild(s);
return;
}
if (!navIndex.length) {
const s = document.createElement('div');
s.className = 'pplx-nav-status';
s.textContent = 'No prompts found.';
list.appendChild(s);
return;
}
navIndex.forEach((entry, i) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'pplx-nav-item' + (entry.key === navActiveKey ? ' pplx-nav-active' : '');
item.title = entry.text;
item.innerHTML = `<span class="pplx-nav-idx">${i + 1}</span><span class="pplx-nav-text"></span>`;
item.querySelector('.pplx-nav-text').textContent = entry.text;
item.addEventListener('click', () => {
navActiveKey = entry.key;
list.querySelectorAll('.pplx-nav-active').forEach(el => el.classList.remove('pplx-nav-active'));
item.classList.add('pplx-nav-active');
jumpTo(entry);
});
list.appendChild(item);
});
}
// build once per thread; re-build when the URL changes
let navBuildTimer = null;
function maybeBuildIndex() {
ensureNavPane();
if (navBuilding) return;
if (navBuiltPath === location.pathname) return;
if (!document.querySelector(PROMPT_SEL)) return; // thread not rendered yet
if (navBuildTimer) return; // already waiting to start
navBuildTimer = setTimeout(() => {
navBuildTimer = null;
if (navBuilding || navBuiltPath === location.pathname) return;
buildIndex();
}, 1200); // let the thread settle first
}
const NAV_AUTO_APPEND = true; // set false to disable auto-adding new prompts
// append prompts sent after the initial scan, without rescanning the thread
function appendNewPrompts() {
if (!NAV_AUTO_APPEND || navBuilding || navBuiltPath !== location.pathname) return;
const known = new Set(navIndex.map(e => e.text));
const bubbles = Array.from(document.querySelectorAll(PROMPT_SEL));
// only look at bubbles that come after the last already-indexed one
let start = 0;
for (let i = bubbles.length - 1; i >= 0; i--) {
if (known.has(promptText(bubbles[i]))) { start = i + 1; break; }
}
let added = false;
for (let i = start; i < bubbles.length; i++) {
const text = promptText(bubbles[i]);
if (!text || known.has(text)) continue;
navIndex.push({ key: promptKey(bubbles[i]), text, ratio: 1 });
known.add(text);
added = true;
}
if (added) {
navIndex.forEach((e, i) => { e.ratio = navIndex.length > 1 ? i / (navIndex.length - 1) : 0; });
renderNav();
}
}
let allFolded = true;
function toggleAllAnswers(shouldFold) {
const answers = document.querySelectorAll('[data-workflow-final-text]');
answers.forEach(container => {
container.classList.toggle('pplx-is-folded', shouldFold);
const stateEl = container.querySelector('.pplx-fold-badge-state');
if (stateEl) {
stateEl.textContent = shouldFold ? 'Folded' : 'Expanded';
}
});
}
function ensureGlobalToggleBtn() {
if (document.getElementById('pplx-global-toggle-btn')) return;
const btn = document.createElement('button');
btn.id = 'pplx-global-toggle-btn';
btn.className = 'pplx-global-fold-btn';
btn.textContent = '▼ Unfold All';
btn.addEventListener('click', () => {
allFolded = !allFolded;
toggleAllAnswers(allFolded);
btn.textContent = allFolded ? '▼ Unfold All' : '▲ Fold All';
});
document.body.appendChild(btn);
}
function scanAndFold() {
ensureGlobalToggleBtn();
maybeBuildIndex();
appendNewPrompts();
const answers = document.querySelectorAll('[data-workflow-final-text]');
answers.forEach(container => foldAnswer(container));
document.querySelectorAll('pre figure').forEach(fig => foldCodeBlock(fig));
}
// Run on initial load
scanAndFold();
// Use MutationObserver to handle SPA page changes and dynamically loaded threads
let scanQueued = false;
const observer = new MutationObserver(() => {
if (scanQueued) return;
scanQueued = true;
setTimeout(() => { scanQueued = false; scanAndFold(); }, 200);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment