SLOPSHOPPER

claude-colorizer

Recolors the terminal tab and background by Claude Code session state (working, waiting, done, error).

newrows
★ 1v0.1.0MITupdated 2026-10-08cjodo/claude-colorizer
A shopper browsing a rack in a slop shop
README

claude-colorizer

Color visualization for Claude Code, in the spirit of nvim-colorizer. Written in Go with no dependencies.

claude-colorizer show and statusline output

  • Statusline swatches. Colors mentioned in the conversation (Claude's replies, code it writes, your prompts) appear as live swatches under the prompt. It reads #rgb, #rrggbb(aa), rgb()/rgba(), hsl()/hsla() and oklch(). Turn them off with "statusline": {"swatches": false}.
  • State colors. The terminal tab and background tint change as the session moves through idle → working → needs you → done / error.
  • State indicator. The current state also appears as a colored dot at the start of the statusline (● working), on the line Claude Code already reserves under the prompt. It works in any terminal and isn't overwritten the way the title glyph can be.
  • show / try. Highlight color literals in any file (claude-colorizer show styles.css), or preview a color as your terminal background (claude-colorizer try '#1e1e2e').

Quick start

This is the recommended setup: it turns on everything (state colors, statusline swatches and inline highlighting). You need Go 1.22+ and Claude Code.

  1. Clone the repo somewhere permanent. The plugin is installed from this directory, so don't delete it afterwards.
   git clone https://github.com/cjodo/claude-colorizer ~/repos/claude-colorizer
   cd ~/repos/claude-colorizer
  1. Install the binary and the statusline. --no-hooks leaves the hooks to the plugin (step 4), so each event doesn't fire twice.
   make install ARGS="--no-hooks"
  1. Add the repo as a plugin marketplace. Inside Claude Code, run:
   /plugin marketplace add ~/repos/claude-colorizer
  1. Install the plugin from that marketplace. Use name@marketplace, not the path: /plugin install ~/repos/claude-colorizer fails with Marketplace "..." not found.
   /plugin install claude-colorizer@claude-colorizer

Check it with /plugin list: claude-colorizer@claude-colorizer should be listed as enabled.

  1. Restart Claude Code. /reload-plugins picks up the plugin, but the statusline and inline highlighting start with the next session.
  1. Set up your terminal, if needed. Ghostty, iTerm2, Alacritty and Windows Terminal need nothing. Kitty and WezTerm need one line of config for tab colors, and tmux needs passthrough. See Terminal support and tmux. Run claude-colorizer detect to see which terminal was detected.
  1. Try it. Ask Claude to "emit some colors: just hex values". You should see:
  2. each hex value in the reply drawn on its own color (inline highlighting),
  3. swatches for those colors under the prompt (statusline),
  4. the tab or background turn blue while Claude works and green when it's done (state colors), with ● working / ● done at the start of the statusline.

If something is missing, check the table below:

MissingLikely cause
Inline highlightingPlugin not installed or not restarted (steps 4–5).
Swatches / ● workingStatusline not installed (step 2), or Claude Code not restarted.
Tab colorTerminal config (step 6), or the terminal has no tab color support.
Every event twiceHooks registered by both make install and the plugin: run claude-colorizer uninstall && claude-colorizer install --no-hooks.

To change colors, see Configuration. The sections below cover each part in detail, including installing without the plugin.

Terminal support

Ghostty is the most tested terminal: it's the one claude-colorizer is developed in. The other drivers follow each terminal's documented escape sequences and have unit tests, but see less real use. If you use one of them, see CONTRIBUTING.md for a short test checklist and how to report results or add a terminal.

TerminalTab colorBackground tintTitle fallback
Kitty✅ remote control¹✅ OSC 11—
Ghostty— (no escape exists)✅ OSC 11✅ glyph
WezTerm✅ user var + Lua²✅ OSC 11—
iTerm2✅ OSC 6✅ OSC 1337—
Warp——✅ glyph
Alacritty— (no tabs)✅ OSC 11✅ glyph
Windows Terminal—✅ OSC 11✅ glyph
other xterm-like—✅ OSC 11✅ glyph

Statusline swatches use 24-bit SGR colors, which work in all of them.

Program status (OSC 7501). Every state change is also reported with the Program Status Protocol, which is not tied to any terminal. Terminals that implement it (Ghostty, through libghostty) can show the session state natively, with no glyph or config. The others ignore it. The report carries app=claude-code, the title Claude Code · <dir>, and for permission prompts state=blocked:kind=permission plus Claude's notification message. Inside tmux it needs passthrough enabled (see tmux). To turn it off, set "status": false.

Each terminal is a driver implementing terminal.Terminal (internal/terminal/drivers.go). Detection reads environment variables (internal/terminal/detect.go). Inside tmux it asks tmux which terminal each client tty attached to the session is running instead, because the environment describes the terminal that started the tmux server. If different terminals are attached, tab colors are sent to all of them. To override detection, set CLAUDE_COLORIZER_TERMINAL=kitty|ghostty|wezterm|iterm2|warp|alacritty|windows-terminal|generic. Run claude-colorizer detect to see what was picked.

¹ Kitty: add allow_remote_control yes to kitty.conf (or socket-only plus listen_on). Without it, kitty ignores the tab color and only the background changes.

² WezTerm: the color is published as the user var claude_colorizer_tab. To paint the tab, add this to wezterm.lua:

wezterm.on('format-tab-title', function(tab)
  local c = tab.active_pane.user_vars.claude_colorizer_tab
  if c and c ~= '' then
    return { { Background = { Color = c } }, { Foreground = { Color = '#000000' } },
             { Text = ' ' .. tab.active_pane.title .. ' ' } }
  end
end)

Title fallback: Claude Code sets the terminal title itself and may overwrite the glyph. If your version supports CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1, set it. If you don't want the glyph, set "title": false.

tmux

Background tint and title work without any setup, because tmux understands those sequences and applies them to the pane. Everything else has to reach the outer terminal through tmux passthrough:

  • tab colors (Kitty, iTerm2, WezTerm)
  • program status reports (OSC 7501)

claude-colorizer wraps these for passthrough automatically when $TMUX is set, but tmux drops them unless passthrough is on. Add this to ~/.tmux.conf (or ~/.config/tmux/tmux.conf):

set -g allow-passthrough all

Reload it in a running tmux with tmux source-file ~/.tmux.conf.

ValueEffect
offDefault. Tab colors and status reports are dropped.
onPassed through only from panes currently visible.
allPassed through from every pane, including windows you aren't viewing.

Use all so a Claude session in a background window can still mark its tab as blocked or done while you are elsewhere. on drops those updates until you switch back to the window. The tradeoff is that any program in any pane can then send escape sequences straight to the outer terminal.

To try it in the current pane only, without editing your config:

tmux set -p allow-passthrough all

Check the global value with tmux show -gv allow-passthrough, and the detected terminal with claude-colorizer detect (it prints tmux: yes).

Background opacity. tmux paints a tinted pane with an explicit color in every cell, and terminals normally draw explicit cell colors fully opaque, so a translucent terminal turns solid while the tint is on. There are two fixes:

  • Keep the tint on Claude's pane and have the terminal apply its opacity to explicit cell colors too. In Ghostty 1.2+, add this to its config:
  background-opacity-cells = true

Other explicitly colored cells (the tmux status bar, editor themes) then turn translucent as well.

  • Tint the whole terminal instead, for terminals without such an option:
  { "tmuxBackground": "terminal" }

This needs passthrough enabled, and the tint covers every pane in the window rather than just Claude's. The default is "pane".

Install

For the full setup with the plugin, follow Quick start. This section covers installing without the plugin, which gives you state colors and swatches but no inline highlighting.

You need Go 1.22+ and Claude Code.

git clone <this repo> && cd claude-colorizer
make install

That's the whole setup. make install does two things:

  1. Runs go install, which builds the binary into $(go env GOPATH)/bin.
  2. Runs claude-colorizer install, which adds the statusline and all hooks to ~/.claude/settings.json using the binary's absolute path, so it doesn't matter whether that directory is on your $PATH.

Restart Claude Code, then try it: ask Claude for a palette and the swatches appear under the prompt. The tab or background changes while it works.

Tab colors in Kitty and WezTerm need one extra piece of terminal config. See Terminal support.

What install changes

  • Backup: the previous file is saved as settings.json.bak. If settings.json isn't valid JSON (for example, it has comments), install refuses to touch it.
  • Your existing statusline is kept: it's chained, so its output renders on the line above the swatches. uninstall puts it back exactly.
  • Refresh: the statusline gets "refreshInterval": 2. Permission prompts and idle notifications don't make Claude Code re-run the statusline, so without it the state indicator would lag until the next message.
  • Hooks: they are appended next to any hooks you already have, never replacing them. Running install again doesn't add duplicates.
  • Key order: keys in settings.json are rewritten in alphabetical order.

Preview the changes first with claude-colorizer install --dry-run.

FlagEffect
--dry-runPrint the changes without writing
--no-hooksStatusline only (or use this if you installed the plugin)
--no-statuslineState colors only
--settings PATHEdit another file, e.g. .claude/settings.json in a project

Pass flags through make with make install ARGS="--no-statusline".

Uninstall

make uninstall        # or: claude-colorizer uninstall

This removes only the entries it added, restores a chained statusline, and deletes the binary.

Alternative: as a Claude Code plugin

The repo is also a plugin marketplace. The plugin provides the hooks and inline highlighting (below), but not the statusline, because plugins can't set one:

/plugin marketplace add /path/to/claude-colorizer
/plugin install claude-colorizer@claude-colorizer

Then run claude-colorizer install --no-hooks to add the statusline without registering the hooks twice. To try it for a single session without installing, run claude --plugin-dir /path/to/claude-colorizer.

Inline highlighting

Inline highlighting draws each color literal in Claude's replies on its own color, like nvim-colorizer does in a buffer. Replies without colors are drawn as usual. In a reply with colors, lines holding a color are drawn as plain text, so their bold and inline code markers are dropped.

It only works when the repo is loaded as a plugin. The highlighter is a function-hooks module (hooks/colorize.tsx, with hooks/colors.ts porting internal/colors), and Claude Code loads it only from the plugin's hooks/hooks.json. make install writes plain command hooks to settings.json, which can't load modules, so with make install alone you get state colors and swatches but no inline highlighting.

To turn it on, load the plugin in either of these ways:

  • For one session: claude --plugin-dir /path/to/claude-colorizer.
  • Permanently: install it as shown in Alternative: as a Claude Code plugin. Installed plugins run from a cached copy, so reinstall after changing hooks/.

If you already ran make install, switch its hooks over to the plugin so each event doesn't fire twice:

claude-colorizer uninstall && claude-colorizer install --no-hooks

Highlighting starts in the next session. To check that it loaded, ask Claude to print a hex color such as #ff5733; it should appear on an orange background. Run its tests with claude plugin test ..

What the colors mean

The tab, background tint and title glyph show what the session is doing, so you can tell from another tab or window whether Claude needs you.

ColorGlyphStateMeaning
Slate⚪idleThe session just started (or was cleared) and is waiting for your first prompt.
Blue🔵workingClaude is working: thinking, writing, running tools. Nothing for you to do yet.
Amber🟡attentionClaude is waiting on you: a permission prompt, or it has sat idle waiting for input.
Green🟢doneClaude finished its turn. Read the reply and send the next prompt.
Red🔴errorSomething failed: the API request errored, or a tool call failed.
Your defaultsnoneresetThe session ended.

Red after a failed tool call doesn't always mean the turn is over. Claude often recovers, and the color goes back to blue on its next successful tool call. Red after an API error stays until you send another prompt.

The glyph is a fallback for terminals that can't color their tabs. It appears at the start of the window title. See the Title fallback column in Terminal support.

Hook events behind each state

Hook eventStateDefault tab / tintOSC 7501 state
UserPromptSubmit, PostToolUseworking#3b82f6 / #151b2bworking
Notification (permission, idle)attention#f59e0b / #2a2112blocked
Stopdone#22c55e / #13231adone
StopFailure, PostToolUseFailureerror#ef4444 / #2b1515error
SessionStartidle#94a3b8 / #181b21idle
SessionEndresetterminal defaultsclear

To change any color or glyph, see Configuration.

Configuration

~/.config/claude-colorizer/config.json (or $CLAUDE_COLORIZER_CONFIG). Every key is optional and is layered over the defaults. Run claude-colorizer config to print the effective config.

{
  "tab": true,
  "background": true,
  "title": true,
  "status": true,
  "tmuxBackground": "pane",
  "states": {
    "working": { "tab": "#7c3aed", "background": "#1a1426" },
    "done":    { "background": "#eef9f0" }
  },
  "statusline": {
    "swatches": true,
    "max": 12,
    "sources": ["assistant", "tools", "user"],
    "label": "hex",
    "indicator": "label",
    "prefix": "🎨 ",
    "empty": ""
  }
}

tmuxBackground only matters inside tmux: "pane" (default) tints just Claude's pane, "terminal" tints the outer terminal and keeps its background opacity (see tmux).

statusline.swatches turns the color swatches on (default) or off. With false, the transcript isn't read and the statusline shows only the state indicator. The other swatch keys (max, sources, label, prefix, empty) then have no effect.

statusline.indicator controls the state indicator: "label" (default, ● working), "dot" (just ●), or "none". The dot uses the state's tab color. Hooks record each session's state in a small file under your cache directory (~/.cache/claude-colorizer/sessions on Linux, or $CLAUDE_COLORIZER_STATE_DIR), which is deleted when the session ends.

The default background tints assume a dark theme. On a light theme, set light background values, or set "background": false.

Development

make test     # go vet + unit tests (parser, drivers, detection, transcript)
make cross    # linux/darwin/windows binaries in dist/

See CONTRIBUTING.md for testing on a terminal and adding a new one.

Source 3 files
hooks/colorize.tsx 59 lines
1// Inline color highlighting in Claude's replies, in the spirit of
2// nvim-colorizer: each color literal is drawn on its own color.
3
4import type { Register, RenderChildren } from 'claude-code'
5
6import { contrast, hex } from './colors'
7import { segment, type Segment } from './segment'
8
9export const register: Register = on => {
10  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
11    if (e.props.isSummary) return next(e)
12
13    const segments = segment(e.props.text)
14    if (!segments.some(s => s.kind === 'line')) return next(e)
15
16    const { Box, Text, Markdown } = $.ui.resolve(e)
17
18    const draw = (s: Segment, i: number) => {
19      switch (s.kind) {
20        case 'markdown':
21          return <Markdown key={`md${i}`} text={s.text} />
22        case 'blank':
23          return <Text key={`b${i}`}> </Text>
24        case 'line': {
25          const parts: RenderChildren[] = []
26          let at = 0
27          for (const m of s.matches) {
28            if (m.start > at) parts.push(s.text.slice(at, m.start))
29            parts.push(
30              <Text backgroundColor={hex(m.color)} color={contrast(m.color)}>
31                {m.text}
32              </Text>,
33            )
34            at = m.end
35          }
36          if (at < s.text.length) parts.push(s.text.slice(at))
37          return (
38            <Box key={`l${i}`} paddingLeft={s.code ? 2 : 0}>
39              <Text>{parts.length ? parts : ' '}</Text>
40            </Box>
41          )
42        }
43      }
44    }
45
46    // A tree of our own replaces the whole row, bullet included.
47    return (
48      <Box flexDirection="row">
49        <Box width={2} flexShrink={0}>
50          <Text color="text">{e.props.isFirstOfReply ? '●' : ' '}</Text>
51        </Box>
52        <Box flexDirection="column" flexGrow={1}>
53          {segments.map(draw)}
54        </Box>
55      </Box>
56    )
57  })
58}
59
hooks/colors.ts 155 lines
1// Port of internal/colors (find.go, color.go): finds color literals in text
2// and converts them to 24-bit sRGB. Keep the two in step.
3
4export type RGB = { r: number; g: number; b: number }
5
6// A color literal found in text. start and end are string offsets.
7export type Match = { start: number; end: number; text: string; color: RGB }
8
9const hexRe = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})/g
10// rgb()/rgba()/hsl()/hsla()/oklch() with comma or space syntax and an
11// optional alpha after "," or "/".
12const fnRe =
13  /\b(rgba?|hsla?|oklch)\(\s*([-+.\d]+(?:%|deg)?)\s*[,\s]\s*([-+.\d]+%?)\s*[,\s]\s*([-+.\d]+(?:%|deg)?)\s*(?:[,/]\s*[-+.\d]+%?\s*)?\)/gi
14
15// find returns every color literal in s, in order of appearance.
16export function find(s: string): Match[] {
17  const out: Match[] = []
18  for (const m of s.matchAll(hexRe)) {
19    const start = m.index!
20    const end = start + m[0].length
21    if (!hexBoundary(s, start, end)) continue
22    // "#123" is far more often an issue/PR number than a color, so short
23    // forms must contain at least one hex letter.
24    if (m[0].length <= 5 && !/[a-fA-F]/.test(m[0].slice(1))) continue
25    const color = parseHex(m[0])
26    if (color) out.push({ start, end, text: m[0], color })
27  }
28  for (const m of s.matchAll(fnRe)) {
29    const [, name = '', x = '', y = '', z = ''] = m
30    const color = parseFunc(name.toLowerCase(), [x, y, z])
31    if (!color) continue
32    out.push({ start: m.index!, end: m.index! + m[0].length, text: m[0], color })
33  }
34  return out.sort((a, b) => a.start - b.start)
35}
36
37// hexBoundary rejects hex runs embedded in longer tokens such as URL
38// fragments (#section), HTML entities (&#123;) or longer hex strings.
39function hexBoundary(s: string, start: number, end: number): boolean {
40  const before = s.charAt(start - 1)
41  const after = s.charAt(end)
42  if (before === '&' || isWord(before)) return false
43  if (isWord(after) || after === '-') return false
44  return true
45}
46
47const isWord = (c: string) => /[A-Za-z0-9_]/.test(c)
48
49export function parseHex(s: string): RGB | null {
50  s = s.replace(/^#/, '')
51  if (s.length === 3 || s.length === 4) s = [0, 1, 2].map(i => s.charAt(i).repeat(2)).join('')
52  else if (s.length === 6 || s.length === 8) s = s.slice(0, 6)
53  else return null
54  if (!/^[0-9a-fA-F]{6}$/.test(s)) return null
55  const v = parseInt(s, 16)
56  return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 }
57}
58
59function parseFunc(name: string, a: [string, string, string]): RGB | null {
60  const nx = num(a[0])
61  const ny = num(a[1])
62  const nz = num(a[2])
63  if (!nx || !ny || !nz) return null
64  const [[x, xPct], [y, yPct], [z, zPct]] = [nx, ny, nz]
65  switch (name) {
66    case 'rgb':
67    case 'rgba': {
68      const ch = (f: number, pct: boolean) => clamp8(pct ? (f * 255) / 100 : f)
69      return { r: ch(x, xPct), g: ch(y, yPct), b: ch(z, zPct) }
70    }
71    case 'hsl':
72    case 'hsla':
73      return hslToRGB(x, y / 100, z / 100)
74    case 'oklch': {
75      const l = xPct || x > 1 ? x / 100 : x
76      const c = yPct ? (y * 0.4) / 100 : y
77      return oklchToRGB(l, c, z)
78    }
79  }
80  return null
81}
82
83// num parses "12", "12.5%", "120deg" into [value, has trailing %].
84function num(s: string): [number, boolean] | null {
85  s = s.replace(/deg$/, '')
86  const pct = s.endsWith('%')
87  if (pct) s = s.slice(0, -1)
88  const f = Number(s)
89  return s === '' || Number.isNaN(f) ? null : [f, pct]
90}
91
92function clamp8(f: number): number {
93  if (Number.isNaN(f) || f < 0) return 0
94  if (f > 255) return 255
95  return Math.round(f)
96}
97
98function hslToRGB(h: number, s: number, l: number): RGB {
99  h = ((((h % 360) + 360) % 360) / 360)
100  if (s === 0) {
101    const v = clamp8(l * 255)
102    return { r: v, g: v, b: v }
103  }
104  const q = l < 0.5 ? l * (1 + s) : l + s - l * s
105  const p = 2 * l - q
106  const hue = (t: number) => {
107    if (t < 0) t++
108    if (t > 1) t--
109    if (t < 1 / 6) return p + (q - p) * 6 * t
110    if (t < 1 / 2) return q
111    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
112    return p
113  }
114  return {
115    r: clamp8(hue(h + 1 / 3) * 255),
116    g: clamp8(hue(h) * 255),
117    b: clamp8(hue(h - 1 / 3) * 255),
118  }
119}
120
121// oklchToRGB converts OKLCH (L in [0,1], C, H in degrees) to gamut-clipped sRGB.
122function oklchToRGB(l: number, c: number, h: number): RGB {
123  const hr = (h * Math.PI) / 180
124  const a = c * Math.cos(hr)
125  const b = c * Math.sin(hr)
126
127  const l_ = l + 0.3963377774 * a + 0.2158037573 * b
128  const m_ = l - 0.1055613458 * a - 0.0638541728 * b
129  const s_ = l - 0.0894841775 * a - 1.291485548 * b
130  const l3 = l_ ** 3
131  const m3 = m_ ** 3
132  const s3 = s_ ** 3
133
134  const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3
135  const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3
136  const bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3
137
138  const gamma = (x: number) =>
139    x <= 0.0031308 ? clamp8(12.92 * x * 255) : clamp8((1.055 * x ** (1 / 2.4) - 0.055) * 255)
140  return { r: gamma(r), g: gamma(g), b: gamma(bl) }
141}
142
143export const hex = ({ r, g, b }: RGB) =>
144  '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')
145
146// contrast returns black or white, whichever reads better on c (WCAG).
147export function contrast(c: RGB): string {
148  const lin = (v: number) => {
149    const f = v / 255
150    return f <= 0.04045 ? f / 12.92 : ((f + 0.055) / 1.055) ** 2.4
151  }
152  const lum = 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
153  return lum > 0.179 ? '#000000' : '#ffffff'
154}
155
hooks/segment.ts 71 lines
1// Splits a reply's markdown so lines holding color literals can be drawn as
2// styled text while everything else keeps the engine's markdown renderer.
3
4import { find, type Match } from './colors'
5
6export type Segment =
7  | { kind: 'markdown'; text: string }
8  | { kind: 'blank' }
9  // A line drawn as plain text with each literal on its own color; code is
10  // true for a line from a fenced block.
11  | { kind: 'line'; text: string; matches: Match[]; code: boolean }
12
13const fenceRe = /^\s*(```|~~~)/
14
15export function segment(text: string): Segment[] {
16  const out: Segment[] = []
17  let md: string[] = []
18
19  const flush = () => {
20    // Blank lines at a chunk's edges become spacers, so the gap between a
21    // markdown chunk and a styled line matches the paragraph gap.
22    let lead = 0
23    while (lead < md.length && md[lead]?.trim() === '') lead++
24    let tail = md.length
25    while (tail > lead && md[tail - 1]?.trim() === '') tail--
26    for (let i = 0; i < lead; i++) out.push({ kind: 'blank' })
27    if (tail > lead) out.push({ kind: 'markdown', text: md.slice(lead, tail).join('\n') })
28    for (let i = tail; i < md.length; i++) out.push({ kind: 'blank' })
29    md = []
30  }
31
32  const lines = text.split('\n')
33  for (let i = 0; i < lines.length; i++) {
34    const line = lines[i] ?? ''
35    const fence = line.match(fenceRe)?.[1]
36    if (fence) {
37      let j = i + 1
38      while (j < lines.length && !lines[j]?.trimStart().startsWith(fence)) j++
39      const body = lines.slice(i + 1, j)
40      const block = lines.slice(i, j + 1)
41      i = j
42      if (!body.some(l => find(l).length)) {
43        md.push(...block)
44        continue
45      }
46      flush()
47      for (const l of body) out.push({ kind: 'line', text: l, matches: find(l), code: true })
48      continue
49    }
50
51    if (!find(line).length) {
52      md.push(line)
53      continue
54    }
55    flush()
56    const plain = stripInline(line)
57    out.push({ kind: 'line', text: plain, matches: find(plain), code: false })
58  }
59  flush()
60  return out
61}
62
63// stripInline drops the markdown syntax a styled line would otherwise show
64// raw: emphasis and code markers, heading hashes, list bullets.
65function stripInline(line: string): string {
66  return line
67    .replace(/^(\s*)#{1,6}\s+/, '$1')
68    .replace(/^(\s*)[-*+]\s+/, '$1• ')
69    .replace(/\*\*|__|`/g, '')
70}
71