-
-
Save alexchexes/273ad5fa78a018f00ad2aeb7f9494a5c to your computer and use it in GitHub Desktop.
| // ==UserScript== | |
| // @name ChatGPT Better Syntax Highlighting | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2026-02-20.2 | |
| // @updateURL https://gist.github.com/alexchexes/273ad5fa78a018f00ad2aeb7f9494a5c/raw/chatgpt-better-syntax-highlighting.user.js | |
| // @downloadURL https://gist.github.com/alexchexes/273ad5fa78a018f00ad2aeb7f9494a5c/raw/chatgpt-better-syntax-highlighting.user.js | |
| // @description Automatically highlights unhighlighted code blocks with auto language recognition (via highlightjs). Handles chat switching, applies syntax highlighting to new messages on user interaction ("send" click or Enter keypress). Allows to re-highlight the block by clicking on its title. | |
| // @author alexchexes | |
| // @match https://chat.openai.com/* | |
| // @match https://chatgpt.com/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=openai.com | |
| // @require https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js | |
| // @grant GM_addStyle | |
| // ==/UserScript== | |
| /* global hljs */ | |
| (function () { | |
| /*-------------------------------------------------------------* | |
| * Imporoved Syntax Highlighting for Dark Theme * | |
| * Manually adjusted base16-hardcore theme from @highlightjs * | |
| * YOU CAN CHANGE THIS AS YOU WANT, OR REPLACE WITH OTHER THEME * | |
| * As for now this is only for DARK chatGPT theme * | |
| *--------------------------------------------------------------*/ | |
| let darkThemeCss = /* css */ ` | |
| .hljs-comment { | |
| color: #888 !important; | |
| } | |
| .hljs-tag { | |
| color: #aaa !important; | |
| } | |
| .hljs-operator, | |
| .hljs-punctuation, | |
| .hljs-subst { | |
| color: #cdcdcd !important; | |
| } | |
| .hljs-operator { | |
| opacity: 0.7 !important; | |
| } | |
| .hljs-bullet, | |
| .hljs-deletion, | |
| .hljs-name, | |
| .hljs-template-variable { | |
| color: #e8b882 !important; | |
| } | |
| .hljs-variable { | |
| color: #bfc9da !important; | |
| } | |
| .hljs-variable.language_ { | |
| color: #FD971F !important; | |
| } | |
| .hljs-tag > .hljs-name { | |
| color: #FF649C !important; | |
| } | |
| .hljs-tag > .hljs-attr { | |
| color: #9DCF73 !important; | |
| } | |
| .hljs-tag > .hljs-string { | |
| color: #e7ca72 !important; | |
| } | |
| .hljs-selector-tag { | |
| color: #FF347F !important; | |
| } | |
| .hljs-selector-class { | |
| color: #DFA768 !important; | |
| } | |
| .hljs-attr { | |
| color: #D3D3D3 !important; | |
| } | |
| .hljs-link, | |
| .hljs-literal, | |
| .hljs-symbol, | |
| .hljs-variable.constant_ { | |
| color: #a785ec !important; | |
| } | |
| .hljs-number { | |
| color: #8EF8B1 !important; | |
| } | |
| .hljs-class .hljs-title, | |
| .hljs-title, | |
| .hljs-title.class_, | |
| .hljs-selector-id, | |
| .hljs-selector-attr, | |
| .hljs-function .hljs-title:last-child { | |
| color: #A6E22E !important; | |
| } | |
| .hljs-strong { | |
| font-weight: 700 !important; | |
| color: #A6E22E !important; | |
| } | |
| .hljs-code, | |
| .hljs-string, | |
| .hljs-title.class_.inherited__ { | |
| color: #e6db74 !important; | |
| } | |
| .hljs-addition { | |
| color: #44e044 !important; | |
| } | |
| .hljs-deletion { | |
| color: #ff4b4b !important; | |
| } | |
| .hljs-doctag, | |
| .hljs-keyword.hljs-atrule, | |
| .hljs-quote, | |
| .hljs-regexp { | |
| color: #9ac2ca !important; | |
| } | |
| .hljs-built_in { | |
| color: #66D9EF !important; | |
| font-style: italic !important; | |
| } | |
| .hljs-attribute, | |
| .hljs-function .hljs-title, | |
| .hljs-section, | |
| .hljs-title.function_, | |
| .ruby .hljs-property { | |
| color: #66d9ef !important; | |
| } | |
| .hljs-property { | |
| color: #ffe4d3 !important; | |
| } | |
| .diff .hljs-meta, | |
| .hljs-keyword, | |
| .hljs-template-tag { | |
| color: #ff3982 !important; | |
| } | |
| .hljs-type { | |
| color: #9EF0FF !important; | |
| font-style: italic !important; | |
| } | |
| .hljs-emphasis { | |
| color: #ff3982 !important; | |
| font-style: italic !important; | |
| } | |
| .hljs-meta, | |
| .hljs-meta .hljs-keyword, | |
| .hljs-meta .hljs-string { | |
| color: #7E9BAC !important; | |
| } | |
| .hljs-meta .hljs-keyword, | |
| .hljs-meta-keyword { | |
| font-weight: 700 !important; | |
| } | |
| .hljs-params .hljs-variable { | |
| color: #FD971F !important; | |
| font-style: italic !important; | |
| } | |
| .hljs-selector-pseudo { | |
| color: #FFFAB8 !important; | |
| } | |
| `; | |
| const element = document.querySelector("html"); // Select the <html> element | |
| if (!element.classList.contains("light")) { | |
| GM_addStyle(darkThemeCss); | |
| } | |
| const __CODE_AND_TITLE_COMMON_PARENT = | |
| "div:has(#code-block-viewer):has(div.sticky)"; | |
| const __CODE_INSIDE_COMMON_PARENT = "#code-block-viewer .cm-content"; | |
| const __TITLE_INSIDE_COMMON_PARENT = "div.sticky div:has(>svg)"; | |
| const __CODE_TITLE_SELECTOR__ = | |
| "div:has(#code-block-viewer):has(div.sticky) > div.sticky div:has(>svg)"; | |
| const __SEND_BTN_SELECTOR__ = '[aria-label="Send prompt"]'; | |
| const __HISTORY_ITEM_SELECTOR__ = 'ol > li[data-testid*="history-item-"]'; | |
| const additionalCss = ` | |
| ${__CODE_TITLE_SELECTOR__} { | |
| cursor: pointer; | |
| } | |
| ${__CODE_TITLE_SELECTOR__}:hover .text-token-text-primary:has(>svg) { | |
| cursor: pointer; | |
| color: #f39c12; | |
| } | |
| .hljs_pre_in_user_message { | |
| padding: 2px 7px; | |
| border-radius: 11px; | |
| font-size: 14px; | |
| } | |
| html.dark .hljs_pre_in_user_message { | |
| background: #000000a1 !important; | |
| } | |
| html.light .hljs_pre_in_user_message { | |
| background: #f9f9f9 !important; | |
| } | |
| `; | |
| GM_addStyle(additionalCss); | |
| hljs.configure({ | |
| ignoreUnescapedHTML: true, | |
| }); | |
| // Utility: Debounce function for better performance | |
| const debounce = (func, delay) => { | |
| let timer; | |
| return (...args) => { | |
| clearTimeout(timer); | |
| timer = setTimeout(() => func(...args), delay); | |
| }; | |
| }; | |
| // Function to highlight a single <pre> and its <code> | |
| const highlightPreElement = ( | |
| /** @type {HTMLElement} */ preElement, | |
| force = false, | |
| language = null, | |
| titleElement = null | |
| ) => { | |
| const codeElement = preElement.querySelector("code"); | |
| if (!codeElement) { | |
| return; | |
| } | |
| // Check if the code block already has hljs tokens (like hljs-string) and is not in force mode | |
| if (!force && codeElement.querySelector('[class*="hljs-"]')) { | |
| return; | |
| } | |
| // Skip already processed blocks unless force is true | |
| if (!force && codeElement.hasAttribute("data-highlighted")) { | |
| return; | |
| } | |
| // If the <code> tag is a direct child of the <pre> - apply class for code in user message | |
| if (codeElement.parentElement === preElement) { | |
| preElement.classList.add("hljs_pre_in_user_message"); | |
| } | |
| codeElement.removeAttribute("data-highlighted"); | |
| // Remove existing language classes to allow auto-detection or apply a specific language | |
| codeElement.className = codeElement.className | |
| .split(" ") | |
| .filter((cls) => !cls.startsWith("language-") && cls !== "hljs") | |
| .join(" "); | |
| if (language) { | |
| // if we passed language and title, it means we want to apply initial language. Set title accordingly | |
| if (titleElement) { | |
| titleElement.innerHTML = language; | |
| } | |
| // Ensure Highlight.js supports the language | |
| if (hljs.getLanguage(language)) { | |
| codeElement.classList.add(`language-${language}`); | |
| codeElement.classList.remove("whitespace-pre!"); // remove openai class that doesn't allows wrapping lines | |
| } else { | |
| console.warn(`Language '${language}' not supported by Highlight.js`); | |
| return; | |
| } | |
| } | |
| // Trigger Highlight.js auto-detection or language-specific highlighting | |
| hljs.highlightElement(codeElement); | |
| // if titleElement provided, change the language name displayed in it | |
| if (titleElement) { | |
| const appliedLanguage = | |
| codeElement.className.match(/language-([^\s]+)/)?.[1]; | |
| if (appliedLanguage) { | |
| titleElement.innerHTML = appliedLanguage; | |
| } | |
| } | |
| // Mark this block as highlighted | |
| codeElement.setAttribute("data-highlighted", "true"); | |
| }; | |
| const LANG_MAP = { | |
| vue: "xml", | |
| html: "xml", | |
| }; | |
| /** | |
| * Replace only the direct text node inside `el`, preserving all element children (e.g. <svg>). | |
| * If there isn't a non-empty direct text node, it appends one. | |
| * @param {HTMLElement} el | |
| * @param {string} text | |
| */ | |
| const replaceDirectTextNode = (el, text) => { | |
| const nodes = Array.from(el.childNodes); | |
| // Prefer a non-whitespace text node (e.g. "JavaScript") | |
| let tn = nodes.find( | |
| (n) => n.nodeType === Node.TEXT_NODE && n.nodeValue.trim() !== "" | |
| ); | |
| // If none, fall back to any text node (often whitespace from formatting) | |
| if (!tn) { | |
| tn = nodes | |
| .slice() | |
| .reverse() | |
| .find((n) => n.nodeType === Node.TEXT_NODE); | |
| } | |
| if (tn) { | |
| // Preserve surrounding whitespace, only swap the actual label | |
| const leading = tn.nodeValue.match(/^\s*/)?.[0] ?? ""; | |
| const trailing = tn.nodeValue.match(/\s*$/)?.[0] ?? ""; | |
| tn.nodeValue = `${leading}${text}${trailing}`; | |
| } else { | |
| el.appendChild(document.createTextNode(text)); | |
| } | |
| }; | |
| const highlightCmEditorCode = ( | |
| /** @type {HTMLElement} */ codeElement, | |
| /** @type {HTMLElement} */ titleElement = null, | |
| /** @type {boolean} */ force = false | |
| ) => { | |
| if (codeElement.hasAttribute("data-highlighted") && !force) { | |
| return; | |
| } | |
| if (!titleElement) { | |
| titleElement = codeElement | |
| .closest(__CODE_AND_TITLE_COMMON_PARENT) | |
| ?.querySelector(__TITLE_INSIDE_COMMON_PARENT); | |
| // if (titleElement) { | |
| // console.log("auto-found title element"); | |
| // } else { | |
| // console.log("title element not found..."); | |
| // } | |
| } | |
| if (titleElement?.querySelector("svg > circle")) { | |
| // console.log("has spinner, probably not finished generating, exiting..."); | |
| return; | |
| } | |
| // check for saved initial language name | |
| let language; | |
| if (titleElement.hasAttribute("initial-language")) { | |
| language = titleElement.getAttribute("initial-language"); | |
| } else { | |
| language = titleElement.textContent; | |
| console.log('got lang from title: ', language); | |
| // save the current displayed lang name to attribute | |
| titleElement.setAttribute("initial-language", language); | |
| } | |
| let lang = language?.toLowerCase().trim(); | |
| if (LANG_MAP[lang] !== undefined) { | |
| lang = LANG_MAP[lang]; | |
| } | |
| // If Highlight.js supports the language, use that language. Otherwise - autodetect | |
| let autodetect = false; | |
| if (lang && hljs.getLanguage(lang)) { | |
| codeElement.classList.add(`language-${lang}`); | |
| } else { | |
| if (lang) { | |
| console.log( | |
| `${lang} is not supported by Highlight.js, falling back to autodetection...` | |
| ); | |
| } | |
| autodetect = true; | |
| } | |
| codeElement.textContent = codeElement.innerText; | |
| // Trigger Highlight.js auto-detection OR language-specific highlighting | |
| hljs.highlightElement(codeElement); | |
| // if titleElement provided, change the language name displayed in it | |
| let appliedLanguage; | |
| if (autodetect) { | |
| appliedLanguage = codeElement.className.match(/language-([^\s]+)/)?.[1]; | |
| } else { | |
| appliedLanguage = language; | |
| } | |
| if (appliedLanguage) { | |
| if (titleElement) { | |
| replaceDirectTextNode(titleElement, appliedLanguage); // ✅ keeps svg | |
| } | |
| codeElement.setAttribute("data-highlighted", "true"); | |
| } else { | |
| console.warn("Highlight.js failed to highlight"); | |
| } | |
| }; | |
| // Function to highlight all unhighlighted <pre> elements on the page | |
| const highlightAllOnPage = () => { | |
| document.querySelectorAll("pre").forEach((preElement) => { | |
| highlightPreElement(preElement); | |
| }); | |
| document.querySelectorAll(".cm-content").forEach((cmCode) => { | |
| highlightCmEditorCode(cmCode); | |
| }); | |
| }; | |
| const handleCmCodeTitleClick = (/** @type {HTMLElement} */ titleElement) => { | |
| // Get common parent for this title and a code block and then get code block, then and apply the language | |
| const commonParent = titleElement.closest(__CODE_AND_TITLE_COMMON_PARENT); | |
| const codeContainer = commonParent?.querySelector( | |
| __CODE_INSIDE_COMMON_PARENT | |
| ); | |
| if (codeContainer) { | |
| highlightCmEditorCode(codeContainer, titleElement); | |
| } | |
| }; | |
| const handlePreClick = (/** @type {HTMLElement} */ preElement) => { | |
| // Check if the <code> inside <pre> is already highlighted | |
| const codeElement = preElement.querySelector("code"); | |
| if ( | |
| preElement.hasAttribute("data-highlighted") || | |
| (codeElement && codeElement.hasAttribute("data-highlighted")) | |
| ) { | |
| return; // Prevent unnecessary processing | |
| } | |
| // Highlight the clicked <pre> and its code block | |
| highlightPreElement(preElement); | |
| // Highlight all other unhighlighted <pre> elements on the page | |
| highlightAllOnPage(); | |
| }; | |
| const handleCmContentClick = (/** @type {HTMLElement} */ cmElem) => { | |
| // Check if the <code> inside <pre> is already highlighted | |
| if (cmElem.hasAttribute("data-highlighted")) { | |
| return; // Prevent unnecessary processing | |
| } | |
| // Highlight the clicked <pre> and its code block | |
| highlightCmEditorCode(cmElem); | |
| // Highlight all other unhighlighted <pre> elements on the page | |
| highlightAllOnPage(); | |
| }; | |
| // Function to handle body clicks | |
| const handleBodySingleClick = (/** @type {MouseEvent} */ event) => { | |
| // don't handle double-clicks here | |
| if (event.detail > 1) return; | |
| // don't handle clicks that are part of text selection process | |
| if (document.getSelection().type === "Range") return; | |
| /** @type {HTMLElement} */ | |
| const target = event.target; | |
| // Clicks on codeblock title | |
| const titleElement = target.closest(__CODE_TITLE_SELECTOR__); | |
| if (titleElement) { | |
| handleCmCodeTitleClick(titleElement); | |
| return; | |
| } | |
| const cmElem = target.closest(".cm-content"); | |
| if (cmElem) { | |
| handleCmContentClick(cmElem); | |
| return; | |
| } | |
| // Clicks on <pre> (but not title inside pre) | |
| const preElement = target.closest("pre"); | |
| if (preElement) { | |
| handlePreClick(preElement); | |
| return; | |
| } | |
| // Handle chat selection clicks | |
| if (target.closest(__HISTORY_ITEM_SELECTOR__)) { | |
| delayedHighlightAllOnPage(); | |
| return; | |
| } | |
| // Handle send button clicks | |
| if (target.closest(__SEND_BTN_SELECTOR__)) { | |
| delayedHighlightAllOnPage(); | |
| return; | |
| } | |
| }; | |
| // Function to handle double-clicks on <pre> elements | |
| const handleBodyDoubleClick = (event) => { | |
| const titleElement = event.target.closest(__CODE_TITLE_SELECTOR__); | |
| if (!titleElement) { | |
| return; | |
| } | |
| const preElement = event.target.closest("pre"); | |
| if (!preElement) return; | |
| highlightPreElement(preElement, true, null, titleElement); // Force re-highlight | |
| }; | |
| // Function to handle actions with a delay (for dynamically loaded content) | |
| const delayedHighlightAllOnPage = debounce(() => { | |
| highlightAllOnPage(); | |
| }, 2500); // Adjust timeout as needed | |
| // Function to handle Enter key presses | |
| const handleBodyEnterPress = (event) => { | |
| if (event.key === "Enter" && !event.shiftKey) { | |
| delayedHighlightAllOnPage(); | |
| } | |
| }; | |
| // Attach the click, double-click, and keydown event listeners to the body | |
| document.body.addEventListener("click", handleBodySingleClick); | |
| document.body.addEventListener("dblclick", handleBodyDoubleClick); // New double-click listener | |
| document.body.addEventListener("keydown", handleBodyEnterPress); | |
| // Initial pass: Delayed highlighting for initial dynamic content load | |
| delayedHighlightAllOnPage(); | |
| })(); |
@cmeeren Hi, yes - initially I did this using a DOM MutationObserver, but it was hell-heavy because their UI, built with Next, changes very quickly on each interaction. So, even without any userscripts, it starts lagging as sh*t when the chat grows, even on powerful PCs. Eventually, I decided to make it lightweight and detect changes more "bluntly" based on the user pressing Enter or clicking the "Send" button: we just wait a couple of seconds and then apply the "re-highlight" logic. That's why this may work sometimes and sometimes not. It's not a great design, but given the heavy DOM problem, it's a compromise - though I might try to improve it, I just didn't have enough time for that.
Anyways, as you noticed, you can always trigger "re-highlight" by simply clicking on any of the code blocks at any moment - personally I've gotten used to this and stopped even noticing that sometimes highlighting doesn't trigger automatically...
Still, let's see if I find time to make auto-highlighting more reliable.
For me, it does not seem to work for new responses: I do not get code highlighted correctly until I reload the chat, or interact with the site by clicking anywhere.
Perhaps it could be triggered when the DOM is changed (with a suitable debounce)?