[FEATURE] Customizable Code Snippet Colors / Syntax Highlighting Theme

Status Open
Maintainer reply None cached
Activity 15 comments · opened Apr 15, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

Claude Code currently uses fixed syntax highlighting colors for code snippets in the CLI output. For users with custom terminal color schemes (particularly dark backgrounds), certain code colors can be difficult or impossible to read due to insufficient contrast.

Specific Issue: With a dark blue iTerm background, some of Claude Code's default dark syntax highlighting colors are nearly invisible, making code snippets hard to review and understand.

Visual Example

See attached screenshot showing the readability issue: the dark blue text used for markdown file previews is completely unreadable against a dark blue terminal background. The large block of text in the middle of the output is effectively invisible, making it difficult to review file contents without physically highlighting text with your cursor before taking action.

<img width="470" height="350" alt="Image" src="https://github.com/user-attachments/assets/0721b4bc-ffbc-42cd-9e3f-76325aa51d5a" />

Current Limitations

(according to a Claude-driven diagnosis of the issue/options)

  • No ability to customize individual syntax highlighting colors
  • No theme selection (light/dark variants, preset themes)
  • Only option is CLAUDE_CODE_SYNTAX_HIGHLIGHT=false to completely disable highlighting
  • Must modify terminal color scheme globally, affecting all applications

Proposed Solution

Option 1: Theme Selection (Minimal)

Add preset themes that work well with common terminal backgrounds:

{
  "display": {
    "syntaxTheme": "light" | "dark" | "high-contrast" | "none"
  }
}

Option 2: Color Overrides (Moderate)

Allow users to override specific syntax highlighting categories:

{
  "display": {
    "syntaxColors": {
      "keyword": "#ff79c6",
      "string": "#50fa7b",
      "comment": "#6272a4",
      "function": "#8be9fd",
      "number": "#bd93f9"
    }
  }
}

Option 3: Named Theme Support (Advanced)

Support popular terminal themes (Dracula, Solarized, Nord, etc.):

{
  "display": {
    "syntaxTheme": "dracula" | "solarized-dark" | "nord" | "gruvbox"
  }
}

Alternative Solutions

Have tried editing all color settings in iterm.

Priority

Medium - Would be very helpful

Feature Category

Configuration and settings

Use Case Example

_No response_

Additional Context

_No response_

View original on GitHub ↗

15 Comments

tylerwarren757 · 6 months ago

Related to #47809 (error text readability), but this request proposes a broader solution for all text colors via customizable themes.

Jason-Mendes · 4 months ago

+1 on this, running Claude Code through cmux on macOS. Code blocks in the assistant's responses render as essentially monochrome plain text, which makes longer code/SQL/TypeScript responses noticeably harder to scan than the same content in the VS Code editor or gh CLI.

For context, here's what gh issue view does today (highlighted, readable):

  • CREATE TABLE / NOT NULL / DEFAULT: distinct keyword color
  • string literals ("id", "user_id"): separate color
  • numeric literals: separate color
  • types (INTEGER, TIMESTAMP): separate color

vs. what Claude Code currently does for the same code block in chat: every token paints the same.

A few concrete observations after digging through the binary today (claude v2.1.149):

  1. The current themes/*.json schema only exposes UI element slots (autoAccept, plan, diffAdded, bashBorder, claude, user, assistant, etc.). No keyword / string / number / function / comment slots exist.
  2. No evidence Claude Code ships a highlighter for fenced code blocks in CLI messages today (no hljs-* tokens, no Shiki/Cardinal bundles loaded). So this is "add the feature", not "expose existing settings".
  3. The OP's Option 2 (token-level overrides via display.syntaxColors) is the most useful for users who already maintain a personal terminal theme (Dracula, Nord, custom). Pairing that with a small set of named presets (Option 3) handles the "I just want it to look like VS Code" case without forcing users to author hex codes.

Use case (mine): reviewing large SQL CREATE TABLE diffs and TypeScript Prisma queries inside the Claude Code session without context-switching to VS Code. Today I copy/paste the block into another tool to read it properly.

Happy to test pre-release builds if it helps.

frederickbeaulieu · 4 months ago

+1

dsGonzal · 3 months ago

+1

chiragmRoado · 3 months ago

+1 here. Need this please

aditya-neuraco · 3 months ago

+1

brandonrq506 · 3 months ago

+1

jriboux-advisa · 2 months ago

+1

patakihara · 2 months ago

+1

rhan1 · 2 months ago

+1 I need this desperately

AdaoNatalino · 2 months ago

+1

aspiryx · 2 months ago

+1

breferrari · 1 month ago

Hitting this on v2.1.234, with a bit more detail on where the boundary actually sits now that diff syntax highlighting has shipped:

  • The ~/.claude/themes/*.json system genuinely works for diff backgrounds — diffAdded/diffRemoved/diffAddedWord/diffRemovedWord all apply exactly as documented.
  • The foreground syntax coloring inside a diff (keywords, strings, etc.) comes from a separate, fixed "Monokai Extended" highlighter that ignores the theme file entirely. Confirmed this by setting diffAdded/diffRemoved to neon values in a custom theme: only the row background changed, the syntax-colored text stayed identical.
  • The only control surface today is still all-or-nothing: ctrl+t in the /theme picker, or "syntaxHighlightingDisabled": true in settings.json. Turning it off makes a fully custom theme consistent end-to-end, but drops all language-aware coloring, not just the part that clashes.

+1 for Option 2 in the original post (per-token overrides, e.g. keyword/string/function/comment) so a custom theme can own syntax colors the same way it already owns everything else in the UI.

mndxc · 1 month ago

+1

Jason-Mendes · 13 days ago

Follow-up to my comment above (2026-05-24, v2.1.149), now on v2.1.287.

1. Why code looked monochrome for me. My ~/.claude/settings.json had "syntaxHighlightingDisabled": true. The binary describes it as "Whether to disable syntax highlighting in diffs", and the /theme picker toggles it with ctrl+t, so it is easy to switch off without noticing. With it back to false, diffs and code blocks are highlighted again. The token colors are still fixed per theme though: there is no keyword/string/comment slot in custom themes, so the ask of this issue still stands.

2. A workaround with Claude Mods (2.1.287). I wrote a mod that draws code with a custom token palette:

  • Code blocks in replies (sql, ts/tsx/js/jsx, py, rs, tf/hcl fences) and Edit/Write diffs of those file types, in the VS Code Dark Modern palette (from microsoft/vscode dark_vs.json + dark_plus.json).
  • Diff rows on faint backgrounds, #002800 / #3F0001 (delta's dark defaults), and changed words on #003800 / #670000. I picked these by measurement: on a #141414 background every palette color keeps WCAG contrast of 4.5:1 or more on the row tints. Stronger tints (GitHub's 40% word background, delta's emph colors) dropped keywords and comments to 2.4 to 3.4.
  • Everything else (prose, the reply bullet, the tool row header) is still drawn by Claude Code, and nothing stored is changed.

Things I learned writing it, in case they help others:

  • ui.render's next(e) returns an opaque { type: 'engine', ref }, so you cannot edit the engine's tree; the mod splits a reply into prose (drawn by next with rewritten text) and its own code blocks.
  • ToolResult was never raised for Edit rows in my sessions; ToolUse was. The mod redraws the diff under the ToolUse row, passing the engine an output with an empty structuredPatch, and paints the "Added N lines" summary into the row's empty result line.
  • Mods are early access and rollout-gated: right now a new process prints hooks modules are turned off for installed plugins in this process: the rollout switch served off, so this may not load for everyone yet.

Install: put the files below in ~/.claude/mods/code-colors/ (same layout), then either run claude --plugin-dir ~/.claude/mods/code-colors or add "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/code-colors" to the env block of ~/.claude/settings.json. Palettes and tints are constants at the top of highlight.ts and diff.ts.

<details>
<summary><code>.claude-plugin/plugin.json</code></summary>

{ "name": "code-colors", "version": "0.1.0", "description": "Colors SQL and TypeScript code blocks in replies with a custom palette" }

</details>

<details>
<summary><code>hooks/hooks.json</code></summary>

{ "modules": ["./register.tsx"] }

</details>

<details>
<summary><code>hooks/register.tsx</code></summary>

import type { Register, RenderNode } from 'claude-code'

import { CHANGED_WORD_TINT, DIFF_TINT, diffRows, readPatch, summarize, type DiffRow } from './diff'
import { DEFAULT_PALETTE, PALETTE, PALETTES, splitFences, toLines, type Lang, type PaletteName } from './highlight'
import { langFromPath } from './languages'

/**
 * code-colors: draws code in Claude's replies and in Edit/Write diffs with our
 * own palette (anthropics/claude-code#48636): VS Code Dark Modern by default, or
 * the palette named after a fence's language (```ts modern). Languages are in
 * languages.ts (SQL, TS/JS/JSX, Python, Rust, Terraform/HCL).
 *
 * Replies: the engine's drawing is opaque (`{ type: 'engine', ref }`), so each
 * prose segment is drawn by the engine through `next` (keeping the reply bullet
 * and Markdown styling) and only the code segments are ours.
 * Diffs: the Edit/Write diff and its summary line are ours, on delta's faint
 * diff backgrounds, changed words on a slightly stronger tint. Render-only: nothing stored is changed.
 */

const CODE_INDENT = 2 // lines up with the prose after the reply bullet

export const register: Register = on => {
  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
    if (e.surface !== 'terminal') return next(e)
    const segments = splitFences(e.props.text)
    if (!segments.some(segment => segment.kind === 'code')) return next(e)

    const { Box, Text } = $.ui.resolve(e)
    const codeBlock = (source: string, lang: Lang, paletteName: PaletteName) => (
      <Box flexDirection="column" paddingLeft={CODE_INDENT}>
        {toLines(source, lang).map(tokens => (
          <Text>
            {tokens.length === 0
              ? ' '
              : tokens.map(({ kind, text }) => {
                  const color = PALETTES[paletteName][kind]
                  return color ? <Text color={color}>{text}</Text> : text
                })}
          </Text>
        ))}
      </Box>
    )

    const children: RenderNode[] = []
    for (const segment of segments) {
      if (segment.kind === 'code') {
        children.push(codeBlock(segment.source, segment.lang, segment.palette ?? DEFAULT_PALETTE))
        continue
      }
      const text = segment.text.trim()
      if (!text) continue
      const isFirstOfReply = e.props.isFirstOfReply && children.length === 0
      children.push(await next({ ...e, props: { ...e.props, text, isFirstOfReply } }))
    }
    return (
      <Box flexDirection="column" rowGap={1}>
        {children}
      </Box>
    )
  }).catch(($, e, next) => next(e))

  // ToolUse, not ToolResult: the engine raises ToolResult only under a standalone
  // tool row, and Edit rows here never got one (seen with a probe on both sites).
  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
    if (e.surface !== 'terminal' || e.props.isErrored || !FILE_EDIT_TOOLS.has(e.props.tool)) return next(e)
    const patch = readPatch(e.props.output)
    const lang = patch && langFromPath(patch.filePath)
    if (!patch || !lang) return next(e)

    const { Box, Text } = $.ui.resolve(e)
    const rows = diffRows(patch.hunks, lang)
    const gutter = Math.max(...rows.map(row => (row.kind === 'gap' ? 0 : String(row.lineNo).length)))
    // The engine draws the header from an output with no patch; its result row is then left empty.
    const output = { ...(e.props.output as Record<string, unknown>), structuredPatch: [] }
    const engineRow = await next({ ...e, props: { ...e.props, output } })

    const tokenText = (row: Exclude<DiffRow, { kind: 'gap' }>) =>
      row.tokens.map(({ kind, text, isChanged }) => {
        const color = PALETTE[kind]
        const word = isChanged && row.kind !== 'context' ? CHANGED_WORD_TINT[row.kind] : undefined
        if (!color && !word) return text
        return <Text {...(color ? { color } : {})} {...(word ? { backgroundColor: word } : {})}>{text}</Text>
      })
    const line = (row: DiffRow) => {
      if (row.kind === 'gap') return <Text dimColor>{' '.repeat(gutter)} ...</Text>
      const tint = row.kind === 'context' ? undefined : DIFF_TINT[row.kind]
      return (
        <Box flexGrow={1} {...(tint ? { backgroundColor: tint } : {})}>
          <Text>
            <Text dimColor>{`${String(row.lineNo).padStart(gutter)} ${DIFF_SIGN[row.kind]} `}</Text>
            {tokenText(row)}
          </Text>
        </Box>
      )
    }

    return (
      <Box flexDirection="column">
        {engineRow}
        <Box flexDirection="column" paddingLeft={DIFF_INDENT}>
          <Box position="absolute" top={-1} left={SUMMARY_COLUMN}>
            <Text>{summarize(rows).split(/(\d+)/).map(part => (/^\d+$/.test(part) ? <Text bold>{part}</Text> : part))}</Text>
          </Box>
          {rows.map(line)}
        </Box>
      </Box>
    )
  }).catch(($, e, next) => next(e))
}

const FILE_EDIT_TOOLS = new Set(['Edit', 'Write'])

const DIFF_INDENT = 4 // lines up with the engine's result text under the tool row

/** Where the engine wrote "Added 1 line" after the row's corner glyph: the summary is painted over that empty row. */
const SUMMARY_COLUMN = 5

const DIFF_SIGN: Record<'add' | 'remove' | 'context', string> = { add: '+', remove: '-', context: ' ' }

</details>

<details>
<summary><code>hooks/highlight.ts</code></summary>

/**
 * highlight.ts: the pure half of code-colors. Splits a reply's markdown into
 * prose and code fences of the languages in languages.ts, tokenizes code with
 * those rules, and holds the palettes. No `$`, no IO; register.tsx draws it.
 */

import { FENCE_LANGS, LANGUAGES, type Lang, type Token, type TokenKind } from './languages'

export type { Lang, Token, TokenKind } from './languages'

export type PaletteName = 'github' | 'modern' | 'minimal'

export type Palette = Record<TokenKind, string | undefined>

export type Segment =
  | { kind: 'prose'; text: string }
  | { kind: 'code'; lang: Lang; source: string; palette?: PaletteName }

/** Token colors; `undefined` keeps the terminal's default color. */
export const PALETTES: Record<PaletteName, Palette> = {
  // GitHub Dark: primer/github-vscode-theme src/theme.js, @primer/primitives 7.10.0 dark scale
  github: {
    keyword: '#FF7B72', // red[3]
    type: '#FFA657', // orange[2], entity.name
    string: '#A5D6FF', // blue[1]
    number: '#79C0FF', // blue[2], constant
    comment: '#8B949E', // gray[3]
    function: '#D2A8FF', // purple[2]
    punct: undefined,
    plain: undefined,
  },
  // VS Code Default Dark Modern: microsoft/vscode theme-defaults dark_vs.json + dark_plus.json
  modern: {
    keyword: '#569CD6',
    type: '#4EC9B0',
    string: '#CE9178',
    number: '#B5CEA8',
    comment: '#6A9955',
    function: '#DCDCAA',
    punct: undefined,
    plain: undefined,
  },
  // GitHub Dark hues on keywords, strings and comments only
  minimal: {
    keyword: '#FF7B72',
    type: undefined,
    string: '#A5D6FF',
    number: undefined,
    comment: '#8B949E',
    function: undefined,
    punct: undefined,
    plain: undefined,
  },
}

export const DEFAULT_PALETTE: PaletteName = 'modern'

export const PALETTE: Palette = PALETTES[DEFAULT_PALETTE]

const isPaletteName = (name: string): name is PaletteName => Object.hasOwn(PALETTES, name)

const FENCE = /^```([\w+-]*)(?:[ \t]+([\w-]+))?[ \t]*\n([\s\S]*?)\n```[ \t]*(?:\n|$)/gm

export function splitFences(markdown: string): Segment[] {
  const segments: Segment[] = []
  let proseStart = 0
  for (const match of markdown.matchAll(FENCE)) {
    const lang = FENCE_LANGS[(match[1] ?? '').toLowerCase()]
    if (!lang) continue // its whole fence stays inside the surrounding prose
    const prose = markdown.slice(proseStart, match.index)
    if (prose) segments.push({ kind: 'prose', text: prose })
    const palette = match[2] ?? ''
    const source = match[3] ?? ''
    segments.push(isPaletteName(palette) ? { kind: 'code', lang, source, palette } : { kind: 'code', lang, source })
    proseStart = match.index + match[0].length
  }
  const rest = markdown.slice(proseStart)
  if (rest) segments.push({ kind: 'prose', text: rest })
  return segments
}

function matchAt(source: string, at: number, lang: Lang): Token {
  const { rules, classifyWord } = LANGUAGES[lang]
  for (const { kind, pattern } of rules) {
    pattern.lastIndex = at
    const match = pattern.exec(source)
    if (!match) continue
    const text = match[0]
    if (kind !== 'word') return { kind, text }
    return { kind: classifyWord(text, source.slice(0, at), source.slice(at + text.length)), text }
  }
  return { kind: 'plain', text: source.charAt(at) }
}

/** Tokens in source order; joined, their texts are `source` exactly. */
export function tokenize(source: string, lang: Lang): Token[] {
  const tokens: Token[] = []
  for (let at = 0; at < source.length; ) {
    const token = matchAt(source, at, lang)
    const last = tokens.at(-1)
    if (token.kind === 'plain' && last?.kind === 'plain') tokens[tokens.length - 1] = { kind: 'plain', text: last.text + token.text }
    else tokens.push(token)
    at += token.text.length
  }
  return tokens
}

/** The tokens of each source line; a token spanning lines is split and keeps its kind. */
export function toLines(source: string, lang: Lang): Token[][] {
  const lines: Token[][] = [[]]
  for (const { kind, text } of tokenize(source, lang)) {
    text.split('\n').forEach((piece, i) => {
      if (i > 0) lines.push([])
      if (piece) lines.at(-1)?.push({ kind, text: piece })
    })
  }
  return lines
}

</details>

<details>
<summary><code>hooks/languages.ts</code></summary>

/**
 * languages.ts: what each language's tokens are. A rule table per language
 * (tried in order at each position, sticky regexes) and a word classifier that
 * sees the text before and after the word. highlight.ts runs these; the fence
 * tags and file extensions that pick a language live here too.
 */

export type Lang = 'sql' | 'ts' | 'python' | 'rust' | 'hcl'

export type TokenKind = 'keyword' | 'type' | 'string' | 'number' | 'comment' | 'function' | 'punct' | 'plain'

export type Token = { kind: TokenKind; text: string; isChanged?: true }

type Rule = { kind: TokenKind | 'word'; pattern: RegExp }

type LanguageSpec = { rules: Rule[]; classifyWord: (word: string, before: string, after: string) => TokenKind }

const words = (list: string) => new Set(list.split(/\s+/))

const isCall = (after: string) => /^\s*\(/.test(after)
const isDefinitionName = (before: string) => /\b(?:fn|def|function)\s+$/.test(before.slice(-12))
const isPascalCase = (word: string) => /^[A-Z]/.test(word)

const NUMBER = /\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?\b/y

const SQL_KEYWORDS = words(
  `select from where and or not null is in as on join left right inner outer full cross group by order having limit offset
   insert into values update set delete create table alter drop add column index unique primary key foreign references
   default constraint check if exists distinct union all case when then else end with returning cascade begin commit
   rollback transaction view trigger asc desc between like true false`,
)
const SQL_TYPES = words(
  `integer int bigint smallint serial bigserial text varchar char boolean bool timestamp timestamptz date time interval
   numeric decimal real float double uuid json jsonb blob bytea`,
)

const TS_KEYWORDS = words(
  `const let var function return if else for while do switch case break continue new delete typeof instanceof in of
   class extends implements interface type enum export import from as async await try catch finally throw yield
   default void this super null undefined true false readonly public private protected static declare keyof satisfies`,
)
const TS_PRIMITIVES = words('string number boolean unknown never any object bigint symbol')

const PYTHON_KEYWORDS = words(
  `def class return if elif else for while in not and or is None True False import from as with try except finally
   raise yield lambda pass break continue global nonlocal async await del assert match case self`,
)
const PYTHON_TYPES = words('int float str bool list dict set tuple bytes object complex frozenset')

const RUST_KEYWORDS = words(
  `fn let mut pub struct enum impl trait use mod crate self super match if else for while loop return as in ref move
   where async await dyn const static type unsafe extern true false break continue`,
)
const RUST_PRIMITIVES = words('i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 bool char str')

const HCL_KEYWORDS = words(
  `resource data variable output locals module provider terraform backend required_providers dynamic moved import check
   for in if true false null`,
)
const HCL_TYPES = words('string number bool list map set object tuple any')

export const LANGUAGES: Record<Lang, LanguageSpec> = {
  sql: {
    rules: [
      { kind: 'comment', pattern: /--[^\n]*|\/\*[\s\S]*?\*\//y },
      { kind: 'string', pattern: /'(?:[^']|'')*'/y },
      { kind: 'number', pattern: NUMBER },
      { kind: 'word', pattern: /[A-Za-z_][\w$]*/y },
      { kind: 'punct', pattern: /[(),;.*=<>+\-/%]/y },
    ],
    classifyWord: word => {
      const lower = word.toLowerCase()
      if (SQL_TYPES.has(lower)) return 'type'
      return SQL_KEYWORDS.has(lower) ? 'keyword' : 'plain'
    },
  },
  ts: {
    rules: [
      { kind: 'comment', pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\//y },
      { kind: 'string', pattern: /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\[\s\S])*`/y },
      { kind: 'number', pattern: NUMBER },
      { kind: 'word', pattern: /[A-Za-z_$][\w$]*/y },
      { kind: 'punct', pattern: /[{}()[\];,.:=<>+\-*/%!?&|^~@]/y },
    ],
    classifyWord: (word, before, after) => {
      if (TS_KEYWORDS.has(word)) return 'keyword'
      if (TS_PRIMITIVES.has(word) || isPascalCase(word)) return 'type'
      if (/<\/?$/.test(before)) return 'keyword' // a JSX html tag: <div>, </div>
      return isDefinitionName(before) || isCall(after) ? 'function' : 'plain'
    },
  },
  python: {
    rules: [
      { kind: 'comment', pattern: /#[^\n]*/y },
      { kind: 'string', pattern: /[rRbBuUfF]{0,2}(?:"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')/y },
      { kind: 'function', pattern: /@[A-Za-z_][\w.]*/y }, // a decorator
      { kind: 'number', pattern: NUMBER },
      { kind: 'word', pattern: /[A-Za-z_]\w*/y },
      { kind: 'punct', pattern: /[{}()[\];,.:=<>+\-*/%!&|^~@]/y },
    ],
    classifyWord: (word, before, after) => {
      if (PYTHON_KEYWORDS.has(word)) return 'keyword'
      if (PYTHON_TYPES.has(word) || isPascalCase(word)) return 'type'
      return isDefinitionName(before) || isCall(after) ? 'function' : 'plain'
    },
  },
  rust: {
    rules: [
      { kind: 'comment', pattern: /\/\/[^\n]*|\/\*[\s\S]*?\*\//y },
      { kind: 'string', pattern: /b?"(?:[^"\\]|\\[\s\S])*"|r(#*)"[\s\S]*?"\1/y },
      { kind: 'string', pattern: /'(?:\\.|[^\\'\n])'/y }, // a char
      { kind: 'keyword', pattern: /'[A-Za-z_]\w*/y }, // a lifetime
      { kind: 'number', pattern: /\d[\d_]*(?:\.\d[\d_]*)?(?:[iu](?:8|16|32|64|128|size)|f32|f64)?\b/y },
      { kind: 'word', pattern: /[A-Za-z_]\w*/y },
      { kind: 'punct', pattern: /[{}()[\];,.:=<>+\-*/%!&|^~?#@]/y },
    ],
    classifyWord: (word, before, after) => {
      if (RUST_KEYWORDS.has(word)) return 'keyword'
      if (RUST_PRIMITIVES.has(word) || isPascalCase(word)) return 'type'
      if (/^![^=]/.test(after)) return 'function' // a macro: println!, vec!
      return isDefinitionName(before) || isCall(after) ? 'function' : 'plain'
    },
  },
  hcl: {
    rules: [
      { kind: 'comment', pattern: /#[^\n]*|\/\/[^\n]*|\/\*[\s\S]*?\*\//y },
      { kind: 'string', pattern: /"(?:[^"\\\n]|\\.)*"/y },
      { kind: 'number', pattern: NUMBER },
      { kind: 'word', pattern: /[A-Za-z_][\w-]*/y },
      { kind: 'punct', pattern: /[{}()[\],.:=<>+\-*/%!?&|]/y },
    ],
    classifyWord: (word, _before, after) => {
      if (HCL_KEYWORDS.has(word)) return 'keyword'
      if (HCL_TYPES.has(word)) return 'type'
      return isCall(after) ? 'function' : 'plain'
    },
  },
}

/** Fence tags we color; every other fence is left to the engine. */
export const FENCE_LANGS: Record<string, Lang> = {
  sql: 'sql',
  ts: 'ts', typescript: 'ts', tsx: 'ts', js: 'ts', javascript: 'ts', jsx: 'ts', mjs: 'ts', cjs: 'ts',
  py: 'python', python: 'python',
  rs: 'rust', rust: 'rust',
  tf: 'hcl', terraform: 'hcl', hcl: 'hcl', tfvars: 'hcl',
}

const EXTENSION_LANGS: Record<string, Lang> = {
  sql: 'sql',
  ts: 'ts', tsx: 'ts', mts: 'ts', cts: 'ts', js: 'ts', jsx: 'ts', mjs: 'ts', cjs: 'ts',
  py: 'python', pyi: 'python',
  rs: 'rust',
  tf: 'hcl', tfvars: 'hcl', hcl: 'hcl',
}

/** The language a file is colored as, from its extension; `undefined` leaves it to the engine. */
export function langFromPath(path: string): Lang | undefined {
  const match = /\.([A-Za-z]+)$/.exec(path)
  return match?.[1] ? EXTENSION_LANGS[match[1].toLowerCase()] : undefined
}

</details>

<details>
<summary><code>hooks/diff.ts</code></summary>

/**
 * diff.ts: the pure half of the diff redraw. Reads an Edit/Write result's
 * `structuredPatch` (validated, since the engine hands it over as `unknown`)
 * and turns it into numbered, tokenized rows for register.tsx to draw.
 */

import { tokenize, type Lang, type Token } from './highlight'

export type Hunk = { oldStart: number; newStart: number; lines: string[] }

export type Patch = { filePath: string; hunks: Hunk[] }

export type DiffRow =
  | { kind: 'gap' }
  | { kind: 'add' | 'remove' | 'context'; lineNo: number; tokens: Token[] }

const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null

function isHunk(value: unknown): value is Hunk {
  return (
    isRecord(value) &&
    typeof value.oldStart === 'number' &&
    typeof value.newStart === 'number' &&
    Array.isArray(value.lines) &&
    value.lines.every(line => typeof line === 'string')
  )
}

/** An Edit/Write result's patch, or `undefined` when it carries none worth drawing. */
export function readPatch(output: unknown): Patch | undefined {
  if (!isRecord(output) || typeof output.filePath !== 'string') return undefined
  const hunks: unknown = output.structuredPatch
  if (!Array.isArray(hunks) || hunks.length === 0 || !hunks.every(isHunk)) return undefined
  return { filePath: output.filePath, hunks }
}

const NO_NEWLINE_MARKER = '\\'

function hunkRows({ oldStart, newStart, lines }: Hunk, lang: Lang): DiffRow[] {
  let oldLine = oldStart
  let newLine = newStart
  const rows: DiffRow[] = []
  for (const line of lines) {
    const sign = line.charAt(0)
    if (sign === NO_NEWLINE_MARKER) continue
    const tokens = tokenize(line.slice(1), lang)
    if (sign === '-') rows.push({ kind: 'remove', lineNo: oldLine++, tokens })
    else if (sign === '+') rows.push({ kind: 'add', lineNo: newLine++, tokens })
    else {
      rows.push({ kind: 'context', lineNo: newLine++, tokens })
      oldLine++
    }
  }
  return rows
}

/** Each token split at whitespace, so words are compared without the spaces around them. */
const splitAtWhitespace = (tokens: Token[]): Token[] =>
  tokens.flatMap(({ kind, text }) => text.split(/(\s+)/).filter(Boolean).map(piece => ({ kind, text: piece })))

const isSpace = (token: Token) => !token.text.trim()
const wordKey = (token: Token) => `${token.kind}:${token.text}`

/** The longest common subsequence of two word lists, as the kept indices of each side. */
function commonWords(a: Token[], b: Token[]): [Set<number>, Set<number>] {
  const lengths = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
  const at = (i: number, j: number) => lengths[i]?.[j] ?? 0
  const same = (i: number, j: number) => wordKey(a[i] ?? { kind: 'plain', text: '' }) === wordKey(b[j] ?? { kind: 'plain', text: '\0' })
  for (let i = a.length - 1; i >= 0; i--) {
    const row = lengths[i]
    for (let j = b.length - 1; row && j >= 0; j--) row[j] = same(i, j) ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1))
  }
  const keptA = new Set<number>()
  const keptB = new Set<number>()
  for (let i = 0, j = 0; i < a.length && j < b.length; ) {
    if (same(i, j)) {
      keptA.add(i++)
      keptB.add(j++)
    } else if (at(i + 1, j) >= at(i, j + 1)) i++
    else j++
  }
  return [keptA, keptB]
}

/**
 * Marks the words of `pieces` outside `kept` (indices into its non-space words);
 * a space is marked only between two marked words, so a changed run is one block.
 */
function markChanged(pieces: Token[], kept: Set<number>): Token[] {
  const wordIndex = new Map<number, number>()
  pieces.forEach((piece, i) => !isSpace(piece) && wordIndex.set(i, wordIndex.size))
  const isChangedWord = (i: number) => wordIndex.has(i) && !kept.has(wordIndex.get(i) ?? -1)
  return pieces.map((piece, i) => {
    const changed = isSpace(piece) ? isChangedWord(i - 1) && isChangedWord(i + 1) : isChangedWord(i)
    return changed ? { ...piece, isChanged: true } : piece
  })
}

function markPair(removed: Token[], added: Token[]): [Token[], Token[]] {
  const removedPieces = splitAtWhitespace(removed)
  const addedPieces = splitAtWhitespace(added)
  const [keptRemoved, keptAdded] = commonWords(removedPieces.filter(p => !isSpace(p)), addedPieces.filter(p => !isSpace(p)))
  return [markChanged(removedPieces, keptRemoved), markChanged(addedPieces, keptAdded)]
}

/** Pairs each run of removed lines with the added lines after it and marks the words that differ. */
function markChangedWords(rows: DiffRow[]): DiffRow[] {
  const marked = [...rows]
  for (let start = 0; start < marked.length; start++) {
    let removeEnd = start
    while (marked[removeEnd]?.kind === 'remove') removeEnd++
    let addEnd = removeEnd
    while (marked[addEnd]?.kind === 'add') addEnd++
    const pairs = Math.min(removeEnd - start, addEnd - removeEnd)
    for (let k = 0; k < pairs; k++) {
      const removed = marked[start + k]
      const added = marked[removeEnd + k]
      if (removed?.kind !== 'remove' || added?.kind !== 'add') continue
      const [removedTokens, addedTokens] = markPair(removed.tokens, added.tokens)
      marked[start + k] = { ...removed, tokens: removedTokens }
      marked[removeEnd + k] = { ...added, tokens: addedTokens }
    }
    start = Math.max(start, addEnd - 1)
  }
  return marked
}

/** Rows of every hunk, a `gap` between hunks; each line is tokenized on its own. */
export function diffRows(hunks: Hunk[], lang: Lang): DiffRow[] {
  return hunks.flatMap((hunk, i) => [...(i > 0 ? [{ kind: 'gap' } as const] : []), ...markChangedWords(hunkRows(hunk, lang))])
}

const lines = (count: number) => `${count} ${count === 1 ? 'line' : 'lines'}`

/** The result's first line, as the engine words it: "Added 1 line, removed 2 lines". */
export function summarize(rows: DiffRow[]): string {
  const added = rows.filter(row => row.kind === 'add').length
  const removed = rows.filter(row => row.kind === 'remove').length
  const parts = [added > 0 ? `added ${lines(added)}` : '', removed > 0 ? `removed ${lines(removed)}` : ''].filter(Boolean)
  const text = parts.join(', ')
  return text.charAt(0).toUpperCase() + text.slice(1)
}

/**
 * Diff line backgrounds: delta's dark-theme defaults (dandavison/delta src/color.rs,
 * DARK_THEME_PLUS_COLOR / DARK_THEME_MINUS_COLOR). Measured against the palette on
 * the #141414 background: every syntax color keeps WCAG contrast >= 4.5:1 (lowest:
 * comment green, 4.83 / 5.20). Measurements: python3 tools/tints.py.
 */
export const DIFF_TINT: Record<'add' | 'remove', string> = { add: '#002800', remove: '#3F0001' }

/**
 * Changed-word background: the strongest pure green/red where every syntax color
 * except comments keeps WCAG contrast >= 4.5:1 on it (comments about 4.0), so the
 * syntax colors are kept. Chosen over a stronger tint with white text and over an
 * underline, compared side by side.
 */
export const CHANGED_WORD_TINT: Record<'add' | 'remove', string> = { add: '#003800', remove: '#670000' }

</details>

A built-in way to set token colors (theme slots for keyword/string/comment/number/type/function) would make this unnecessary.