Skip to content

Instantly share code, notes, and snippets.

@alexchexes
Last active February 20, 2026 22:57
Show Gist options
  • Select an option

  • Save alexchexes/273ad5fa78a018f00ad2aeb7f9494a5c to your computer and use it in GitHub Desktop.

Select an option

Save alexchexes/273ad5fa78a018f00ad2aeb7f9494a5c to your computer and use it in GitHub Desktop.
ChatGPT improved syntax highlighting (with support for Vue.js code)
// ==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
Copy link

cmeeren commented Nov 30, 2025

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)?

@alexchexes
Copy link
Author

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment