Created
April 23, 2026 22:50
-
-
Save chriswingler/e8511fda1956c456df012919e2513fcd to your computer and use it in GitHub Desktop.
Claude Code (claude.ai/code) — userscript that adds Shiki syntax highlighting to bash output, "Used [tool]" bodies, command previews, and python heredocs inside bash. Works in verbose mode too.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Claude Code – Shiki syntax highlighting for tool blocks | |
| // @namespace https://github.com/ | |
| // @version 1.0.0 | |
| // @description Adds Shiki syntax highlighting to bash output, "Used [tool]" bodies, the "$ ..." command previews on Bash cards, and python heredocs embedded inside bash commands. Tested against the Claude Code beta UI (claude.ai/code) in both normal and verbose modes. | |
| // @match https://claude.ai/code/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| /* | |
| Notes | |
| ----- | |
| - The Code chat is heavily virtualized: only ~7 entries are mounted in the DOM at once, | |
| so blocks unmount/remount as you scroll. The MutationObserver re-enhances on remount. | |
| - "Used [tool]" bodies are rendered by an internal React component ("zm" in the bundle) | |
| that exposes `{ tool, summary, diffMeta }` props. We read `tool` off the React fiber so | |
| we can render `tool.input` / `tool.output` as separate, language-typed code blocks. | |
| - Bash command previews ("$ ...") use a different class (`text-assistant-primary`) than | |
| the bodies (`text-assistant-secondary`), and are siblings, not children, so we enhance | |
| them with their own selector. | |
| - `python3 -c "..."` style heredocs get their inner source rendered as Python instead of | |
| being treated as one long bash string. | |
| */ | |
| (async () => { | |
| const shiki = await import('https://esm.sh/shiki@1.0.0'); | |
| const theme = matchMedia('(prefers-color-scheme: dark)').matches | |
| ? 'github-dark' : 'github-light'; | |
| // Warm grammars so the first highlight per language is fast. | |
| for (const lang of ['bash', 'python', 'json', 'sql', 'diff', 'powershell', 'console']) { | |
| try { await shiki.codeToHtml('', { lang, theme }); } catch {} | |
| } | |
| const esc = s => String(s).replace(/[&<>"]/g, | |
| c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); | |
| // Map well-known tool input keys to languages. | |
| const langForKey = k => | |
| k === 'code' ? 'python' | |
| : k === 'sql' ? 'sql' | |
| : (k === 'shell' || k === 'command') ? 'bash' | |
| : (k === 'patch' || k === 'diff') ? 'diff' | |
| : null; | |
| // For tool.output, sniff JSON, otherwise fall back to bash (handles shell/log output well). | |
| const guessOutputLang = s => { | |
| const t = (s || '').trim(); | |
| if (t.startsWith('{') || t.startsWith('[')) { | |
| try { JSON.parse(t); return 'json'; } catch {} | |
| } | |
| return 'bash'; | |
| }; | |
| const hl = async (text, lang) => | |
| lang ? await shiki.codeToHtml(text, { lang, theme }) | |
| : `<pre>${esc(text)}</pre>`; | |
| // python3 -c "<body>" / python -c '<body>' → render the inner body as Python. | |
| const PY_RE = /(python3?\s+-c\s+)("([\s\S]*?)"|'([\s\S]*?)')/; | |
| const hlBashWithPython = async (cmd) => { | |
| const m = cmd.match(PY_RE); | |
| if (!m) return await hl(cmd, 'bash'); | |
| const before = cmd.slice(0, m.index + m[1].length + 1); // include the opening quote | |
| const py = m[3] !== undefined ? m[3] : m[4]; | |
| const after = cmd.slice(m.index + m[0].length - 1); // start at the closing quote | |
| return ( | |
| `<div>${await hl(before, 'bash')}</div>` + | |
| `<div style="border-left:2px solid rgba(255,255,255,0.08);padding-left:8px;margin:4px 0">` + | |
| `${await hl(py, 'python')}` + | |
| `</div>` + | |
| `<div>${await hl(after, 'bash')}</div>` | |
| ); | |
| }; | |
| // Enhancer #1 – the "Used [tool]" body div, driven from React fiber's `tool` prop. | |
| const enhanceUsedTool = async (body) => { | |
| const fk = Object.keys(body).find(k => k.startsWith('__reactFiber')); | |
| if (!fk) return; | |
| let f = body[fk]; | |
| while (f && !(f.memoizedProps && f.memoizedProps.tool)) f = f.return; | |
| const tool = f?.memoizedProps?.tool; | |
| if (!tool) return; | |
| const sig = [ | |
| tool.id || '', | |
| tool.status || '', | |
| typeof tool.output === 'string' ? tool.output.length : 0, | |
| tool.input ? Object.keys(tool.input).length : 0 | |
| ].join('|'); | |
| if (body.dataset.shikiToolSig === sig) return; | |
| body.dataset.shikiToolDone = '1'; | |
| body.dataset.shikiToolSig = sig; | |
| const parts = []; | |
| // Render input args | |
| if (tool.input && typeof tool.input === 'object') { | |
| for (const [k, v] of Object.entries(tool.input)) { | |
| if (typeof v === 'string' && (v.includes('\n') || v.length > 60 || langForKey(k))) { | |
| const lang = langForKey(k); | |
| const html = (lang === 'bash' && PY_RE.test(v)) | |
| ? await hlBashWithPython(v) | |
| : await hl(v, lang); | |
| parts.push( | |
| `<div style="margin-top:8px">` + | |
| `<div style="opacity:0.6;font-size:12px;margin-bottom:4px">${esc(k)}:</div>` + | |
| `${html}` + | |
| `</div>` | |
| ); | |
| } else { | |
| parts.push( | |
| `<div><span style="opacity:0.6">${esc(k)}:</span> ` + | |
| `${esc(typeof v === 'string' ? v : JSON.stringify(v))}</div>` | |
| ); | |
| } | |
| } | |
| } | |
| // Render output | |
| if (typeof tool.output === 'string' && tool.output.length) { | |
| parts.push( | |
| `<div style="margin-top:8px;padding-top:8px;` + | |
| `border-top:1px solid rgba(255,255,255,0.08)">` + | |
| `<div style="opacity:0.6;font-size:12px;margin-bottom:4px">output:</div>` + | |
| `${await hl(tool.output, guessOutputLang(tool.output))}` + | |
| `</div>` | |
| ); | |
| } | |
| body.innerHTML = parts.join(''); | |
| body.querySelectorAll('pre').forEach(p => Object.assign(p.style, { | |
| background: 'transparent', margin: 0, padding: 0, | |
| whiteSpace: 'pre-wrap', wordBreak: 'break-word' | |
| })); | |
| }; | |
| // Enhancer #2 – the "$ ..." command preview at the top of every Bash card. | |
| const enhancePreview = async (preview) => { | |
| if (preview.dataset.shikiPreviewDone) return; | |
| const text = preview.textContent; | |
| if (!text) return; | |
| preview.dataset.shikiPreviewDone = '1'; | |
| preview.dataset.shikiPreviewLen = String(text.length); | |
| let prefix = '', cmd = text; | |
| const m = text.match(/^(\s*\$\s)/); | |
| if (m) { prefix = m[1]; cmd = text.slice(m[1].length); } | |
| const html = PY_RE.test(cmd) | |
| ? await hlBashWithPython(cmd) | |
| : await hl(cmd, 'bash'); | |
| preview.innerHTML = | |
| (prefix ? `<span style="opacity:0.6">${esc(prefix.trim())} </span>` : '') + html; | |
| preview.style.background = 'transparent'; | |
| preview.querySelectorAll('pre').forEach(p => Object.assign(p.style, { | |
| background: 'transparent', margin: 0, padding: 0, | |
| whiteSpace: 'pre-wrap', wordBreak: 'break-word', display: 'inline' | |
| })); | |
| }; | |
| // Selectors for both real shapes the chat uses for tool body text. | |
| const TOOL_BODY_SELECTOR = | |
| 'div.text-assistant-secondary.whitespace-pre-wrap.break-words,' + | |
| 'div.text-assistant-secondary.whitespace-pre-wrap.break-all,' + | |
| 'pre.text-assistant-secondary.whitespace-pre-wrap'; | |
| const PREVIEW_SELECTOR = | |
| 'div.text-assistant-primary.break-words.whitespace-pre-wrap'; | |
| const scan = (root = document) => { | |
| if (!root || !root.querySelectorAll) return; | |
| root.querySelectorAll(TOOL_BODY_SELECTOR).forEach(enhanceUsedTool); | |
| root.querySelectorAll(PREVIEW_SELECTOR).forEach(el => { | |
| const t = el.textContent; | |
| if (!t) return; | |
| const inBashCard = el.parentElement?.classList.contains('text-code'); | |
| if (inBashCard || /^\s*\$\s/.test(t)) enhancePreview(el); | |
| }); | |
| }; | |
| scan(); | |
| // Single observer that catches new nodes AND streamed character data. | |
| new MutationObserver(muts => { | |
| for (const m of muts) { | |
| if (m.type === 'childList') { | |
| for (const n of m.addedNodes) if (n.nodeType === 1) scan(n); | |
| } else if (m.type === 'characterData') { | |
| const host = m.target.parentElement?.closest( | |
| '[data-shiki-tool-done],[data-shiki-preview-done]' | |
| ); | |
| if (host) { | |
| delete host.dataset.shikiToolDone; | |
| delete host.dataset.shikiToolSig; | |
| delete host.dataset.shikiPreviewDone; | |
| delete host.dataset.shikiPreviewLen; | |
| scan(host.parentElement || document); | |
| } else { | |
| const candidate = m.target.parentElement?.closest( | |
| TOOL_BODY_SELECTOR + ',' + PREVIEW_SELECTOR | |
| ); | |
| if (candidate) scan(candidate.parentElement || document); | |
| } | |
| } | |
| } | |
| }).observe(document.body, { subtree: true, childList: true, characterData: true }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment