SLOPSHOPPER

syntax

Colors the prompt's draft as it is typed: its markdown, the code in its fences, and in shell mode the command.

newprompt
v0.1.0MITupdated 2026-10-05thefuga/claude-x/mods/syntax
A shopper browsing a rack in a slop shop
README

syntax

Colors the draft in Claude Code's prompt box as it is typed, as opencode.vim does: headings, emphasis, code spans, links, lists and quotes, and the code inside a fence by the language it names. In shell mode the draft is a command and is colored as one.

The colors are Claude Code's own theme colors, so they follow whatever /theme picks, light or dark.

There is no tree-sitter in a mod, so the colors come from a scanner of its own (hooks/markdown.ts, hooks/code.ts). A draft longer than 20,000 characters is left plain.

Install

claude plugin marketplace add thefuga/claude-x
claude plugin install syntax@claude-x

It has no options: disable it (claude plugin disable syntax@claude-x) to leave the draft plain.

Works with

  • statusline: the editor's mode is read from it, which is how a shell command is told from markdown. Without it every draft is markdown.
  • vim: a draft it loads back with :e is colored at the next typed key.

Known issues

The draft's colors blink off

The colors disappear from the whole draft when its text changes by anything other than a typed key, and come back at the next typed character. The text itself is never touched.

It happens on:

  • a new line from a key bound to chat:newline in ~/.claude/keybindings.json, such as an Enter rebound to add a line instead of sending;
  • an edit in the vim editor's normal mode: x, dd, p, u;
  • a history recall or a completion;
  • a draft the vim mod loads back with :e.

Typing, pasting, backspace, moving the cursor and a visual selection keep the colors. So does a new line from Ctrl+J, in a terminal that sends it as a plain line feed.

Claude Code lets a mod paint the draft only in its answer to prompt.edit, which it raises for the keys its text field handles itself. The cases above change the draft without raising it, and Claude Code drops the colors it held for the old text. The mod cannot paint again on its own: the one call that paints a whole draft ($.prompt.fill with replace) also moves the cursor to the draft's end, and can overwrite a character typed in the meantime. Seen on Claude Code 2.1.288; the fix is the engine's to make.

Source 6 files
hooks/register.ts 23 lines
1import { atom, read } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { paint } from './paint'
5
6// The editor's mode as the statusline mod reads it off the prompt's hint, where that mod is
7// installed: in shell mode what is typed is a command, and is colored as one. Without it every
8// draft is markdown.
9const mode = atom({ plugin: 'statusline', key: 'mode' } as const, 'INSERT')
10
11export const register: Register = on => {
12  // Every keystroke passes here with the draft it leaves, and the engine paints the runs answered in
13  // the frame it draws the text. It keeps them until the draft changes with no keystroke (a new line
14  // from a key bound to one, an edit in the vim editor's normal mode), and nothing here can paint
15  // again before the next one: the one call that paints a whole draft also moves the cursor to its end.
16  on('prompt.edit', async ($, e, next) => {
17    const edited = await next(e)
18    const runs = paint(edited.text, (await read($, mode)).startsWith('SHELL'))
19
20    return runs.length === 0 ? edited : { ...edited, decorations: [...(edited.decorations ?? []), ...runs] }
21  })
22}
23
hooks/paint.ts 20 lines
1import type { PromptDecoration } from 'claude-code'
2
3import { syntax } from './theme'
4import { lex } from './code'
5import { scan } from './markdown'
6
7// A draft longer than this is left as the engine draws it: every key would have it read again.
8export const MAX_PAINTED = 20_000
9
10// The runs the engine paints over a draft: its markdown, or in shell mode the command it is.
11export const paint = (text: string, isShell: boolean): PromptDecoration[] => {
12  if (text.length > MAX_PAINTED) {
13    return []
14  }
15
16  const spans = isShell ? lex(text, 'sh') : scan(text)
17
18  return spans.map(({ start, end, kind }) => ({ start, end, ...syntax[kind] }))
19}
20
hooks/theme.ts 33 lines
1import type { PromptDecoration } from 'claude-code'
2
3import type { Kind } from './markdown'
4
5type Style = Omit<PromptDecoration, 'start' | 'end'>
6
7// Every color is a key of Claude Code's theme, so the draft follows whatever theme is picked in
8// `/theme`, light or dark. The keys are chosen for their color in the built-in themes as much as for
9// their meaning: a heading in `warning` is yellow, not a warning.
10
11// What each part of a draft's markdown, and of the code in its fences, is painted in. A part inside
12// another is painted after it, so a mark names every style its surroundings may have set.
13export const syntax: Record<Kind, Style> = {
14  heading: { color: 'warning', bold: true },
15  marker: { color: 'subtle', bold: false, italic: false, underline: false },
16  strong: { color: 'claude', bold: true },
17  emphasis: { color: 'autoAccept', italic: true },
18  raw: { color: 'success' },
19  link: { color: 'suggestion' },
20  url: { color: 'suggestion', underline: true },
21  list: { color: 'claude', bold: true },
22  quote: { color: 'subtle', italic: true },
23  tag: { color: 'suggestion' },
24  language: { color: 'autoAccept', italic: true },
25  comment: { color: 'inactive', italic: true },
26  string: { color: 'success' },
27  number: { color: 'permission' },
28  keyword: { color: 'error' },
29  constant: { color: 'permission' },
30  call: { color: 'suggestion' },
31  property: { color: 'suggestion' },
32}
33
hooks/code.ts 281 lines
1// The code a draft holds, colored by the language it is said to be in: comments, strings, numbers,
2// the language's own words and the names that are called. A mod's module has no parser to load (no
3// WebAssembly, no package), so this is one pattern run over the text with no grammar behind it:
4// what it cannot tell apart it leaves uncolored.
5
6export type TokenKind = 'comment' | 'string' | 'number' | 'keyword' | 'constant' | 'call' | 'property'
7
8export type Token = { start: number; end: number; kind: TokenKind }
9
10// A language as far as it is told apart here: what is tried at each place, in this order, then its
11// names against the words it keeps for itself. A name that touches one of `joins` is a part of
12// something longer (a member, a flag, a path) and is no word of the language's, whatever it spells.
13type Language = {
14  comments?: readonly string[]
15  properties?: readonly string[]
16  strings?: readonly string[]
17  variables?: readonly string[]
18  keywords?: string
19  constants?: string
20  joins?: { before: string; after: string }
21  isCaseless?: boolean
22}
23
24type Grammar = {
25  pattern: RegExp
26  keywords: ReadonlySet<string>
27  constants: ReadonlySet<string>
28  joins: { before: string; after: string }
29  isCaseless: boolean
30}
31
32const SLASHES = String.raw`\/\/[^\n]*`
33const BLOCK = String.raw`\/\*[\s\S]*?\*\/`
34// A `#` that starts a word: `$#`, `${#name}` and `a#b` are not comments.
35const HASH = String.raw`(?<=^|\s)#[^\n]*`
36const DASHES = String.raw`--[^\n]*`
37const DOUBLE = String.raw`"(?:\\.|[^"\\\n])*"`
38const SINGLE = String.raw`'(?:\\.|[^'\\\n])*'`
39// Strings that run over lines: a template, a raw string, a triple-quoted one, a shell's quotes.
40const TEMPLATE = String.raw`\x60(?:\\[\s\S]|[^\x60\\])*\x60`
41const RAW = String.raw`\x60[^\x60]*\x60`
42const TRIPLE = String.raw`"""[\s\S]*?"""|'''[\s\S]*?'''`
43const LONG_DOUBLE = String.raw`"(?:\\[\s\S]|[^"\\])*"`
44const LONG_SINGLE = String.raw`'[^']*'`
45// One character between quotes, which is what tells a Rust character from a lifetime.
46const CHARACTER = String.raw`'(?:\\[^'\n]+|[^'\\\n])'`
47const NUMBER = String.raw`\b(?:0[xX][\da-fA-F_]+|0[bB][01_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)\b`
48// A name, and the bracket after it when it is called (`!` first for a Rust macro).
49const NAME = String.raw`(?<word>[A-Za-z_]\w*)(?<call>!?[ \t]*\()?`
50
51const MEMBER = { before: '.$', after: '' }
52const ARGUMENT = { before: '-./$', after: '-./=' }
53
54const group = (name: string, sources: readonly string[] | undefined) =>
55  sources === undefined || sources.length === 0 ? [] : [`(?<${name}>${sources.join('|')})`]
56
57const words = (list: string | undefined) => new Set(list?.split(' ') ?? [])
58
59const grammar = ({ comments, properties, strings, variables, keywords, constants, joins, isCaseless }: Language): Grammar => ({
60  pattern: new RegExp(
61    [
62      ...group('comment', comments),
63      ...group('property', properties),
64      ...group('string', strings),
65      ...group('variable', variables),
66      `(?<number>${NUMBER})`,
67      NAME,
68    ].join('|'),
69    'gm',
70  ),
71  keywords: words(keywords),
72  constants: words(constants),
73  joins: joins ?? MEMBER,
74  isCaseless: isCaseless === true,
75})
76
77const SCRIPT = grammar({
78  comments: [SLASHES, BLOCK],
79  strings: [DOUBLE, SINGLE, TEMPLATE],
80  keywords:
81    'as async await break case catch class const continue debugger declare default delete do else enum export extends finally for from function if implements import in instanceof interface keyof let namespace new of private protected public readonly return satisfies static super switch this throw try type typeof var void while yield',
82  constants: 'true false null undefined NaN Infinity',
83})
84
85const GO = grammar({
86  comments: [SLASHES, BLOCK],
87  strings: [DOUBLE, SINGLE, RAW],
88  keywords:
89    'break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var',
90  constants: 'true false nil iota',
91})
92
93const RUST = grammar({
94  comments: [SLASHES, BLOCK],
95  strings: [LONG_DOUBLE, CHARACTER],
96  keywords:
97    'as async await break const continue crate dyn else enum extern fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait type unsafe use where while',
98  constants: 'true false None Some Ok Err',
99})
100
101const PYTHON = grammar({
102  comments: [String.raw`#[^\n]*`],
103  strings: [TRIPLE, DOUBLE, SINGLE],
104  keywords:
105    'and as assert async await break case class continue def del elif else except finally for from global if import in is lambda match nonlocal not or pass raise return self try while with yield',
106  constants: 'True False None',
107})
108
109const SHELL = grammar({
110  comments: [HASH],
111  strings: [LONG_DOUBLE, LONG_SINGLE],
112  variables: [String.raw`\$\{[^}\n]*\}|\$[A-Za-z_]\w*|\$[0-9@#?*!$-]`],
113  keywords:
114    'alias case declare do done elif else esac exit export fi for function if in local readonly return set source then unset until while',
115  joins: ARGUMENT,
116})
117
118const JSON_LIKE = grammar({
119  comments: [SLASHES, BLOCK],
120  properties: [String.raw`"(?:\\.|[^"\\\n])*"(?=[ \t]*:)`],
121  strings: [DOUBLE],
122  constants: 'true false null',
123})
124
125const YAML = grammar({
126  comments: [HASH],
127  properties: [String.raw`(?<=^[ \t]*(?:-[ \t]+)*)[\w.-]+(?=[ \t]*:(?:[ \t]|$))`],
128  strings: [DOUBLE, SINGLE],
129  constants: 'true false null',
130  joins: ARGUMENT,
131})
132
133const TOML = grammar({
134  comments: [HASH],
135  properties: [String.raw`(?<=^[ \t]*)[\w.-]+(?=[ \t]*=)`],
136  strings: [TRIPLE, DOUBLE, SINGLE],
137  constants: 'true false',
138  joins: ARGUMENT,
139})
140
141const SQL = grammar({
142  comments: [DASHES, BLOCK],
143  strings: [LONG_SINGLE, DOUBLE],
144  keywords:
145    'all alter and as asc begin between by case commit create cross delete desc distinct drop else end exists foreign from full group having in index inner insert into is join key left like limit not offset on or order outer primary references returning right rollback select set table then union update values view when where with',
146  constants: 'true false null',
147  isCaseless: true,
148})
149
150const RUBY = grammar({
151  comments: [HASH],
152  strings: [DOUBLE, SINGLE],
153  keywords:
154    'alias and begin break case class def do else elsif end ensure for if in module next not or redo require rescue retry return self super then undef unless until when while yield',
155  constants: 'true false nil',
156})
157
158const LUA = grammar({
159  comments: [DASHES],
160  strings: [DOUBLE, SINGLE],
161  keywords: 'and break do else elseif end for function goto if in local not or repeat return then until while',
162  constants: 'true false nil',
163})
164
165// C and what is written like it: one list of the words those languages share.
166const BRACES = grammar({
167  comments: [SLASHES, BLOCK],
168  strings: [DOUBLE, SINGLE],
169  keywords:
170    'abstract auto bool break case catch char class const continue default do double else enum extends final finally float for fun func if implements import in int interface let long namespace new override package private protected public return short signed sizeof static struct super switch template this throw throws try typedef typename unsigned using val var virtual void volatile while',
171  constants: 'true false null nullptr nil NULL',
172})
173
174// The names a fence gives a language, each to the grammar that reads it.
175const GRAMMARS: Record<string, Grammar> = {
176  ts: SCRIPT,
177  tsx: SCRIPT,
178  typescript: SCRIPT,
179  js: SCRIPT,
180  jsx: SCRIPT,
181  javascript: SCRIPT,
182  mjs: SCRIPT,
183  cjs: SCRIPT,
184  go: GO,
185  golang: GO,
186  rs: RUST,
187  rust: RUST,
188  py: PYTHON,
189  python: PYTHON,
190  sh: SHELL,
191  bash: SHELL,
192  zsh: SHELL,
193  shell: SHELL,
194  console: SHELL,
195  json: JSON_LIKE,
196  jsonc: JSON_LIKE,
197  json5: JSON_LIKE,
198  yaml: YAML,
199  yml: YAML,
200  toml: TOML,
201  sql: SQL,
202  rb: RUBY,
203  ruby: RUBY,
204  lua: LUA,
205  c: BRACES,
206  h: BRACES,
207  cpp: BRACES,
208  'c++': BRACES,
209  cc: BRACES,
210  java: BRACES,
211  cs: BRACES,
212  csharp: BRACES,
213  kt: BRACES,
214  kotlin: BRACES,
215  swift: BRACES,
216  php: BRACES,
217  dart: BRACES,
218  scala: BRACES,
219}
220
221const touches = (characters: string, neighbor: string) => neighbor !== '' && characters.includes(neighbor)
222
223const named = (known: Grammar, word: string, isJoined: boolean, isCalled: boolean): TokenKind | null => {
224  const name = known.isCaseless ? word.toLowerCase() : word
225
226  if (!isJoined && known.keywords.has(name)) {
227    return 'keyword'
228  }
229
230  if (!isJoined && known.constants.has(name)) {
231    return 'constant'
232  }
233
234  return isCalled ? 'call' : null
235}
236
237// The tokens of `source` in `language`, a fence's name for it; none for a language not known here.
238export const lex = (source: string, language: string): Token[] => {
239  const known = GRAMMARS[language.toLowerCase()]
240
241  if (known === undefined) {
242    return []
243  }
244
245  return [...source.matchAll(known.pattern)].flatMap((match): Token[] => {
246    const { comment, property, string, variable, number, word, call } = match.groups ?? {}
247    const start = match.index
248    const whole = (kind: TokenKind) => [{ start, end: start + match[0].length, kind }]
249
250    if (comment !== undefined) {
251      return whole('comment')
252    }
253
254    if (property !== undefined) {
255      return whole('property')
256    }
257
258    if (string !== undefined) {
259      return whole('string')
260    }
261
262    if (variable !== undefined) {
263      return whole('constant')
264    }
265
266    if (number !== undefined) {
267      return whole('number')
268    }
269
270    if (word === undefined) {
271      return []
272    }
273
274    const end = start + word.length
275    const isJoined = touches(known.joins.before, source.charAt(start - 1)) || touches(known.joins.after, source.charAt(end))
276    const kind = named(known, word, isJoined, call !== undefined)
277
278    return kind === null ? [] : [{ start, end, kind }]
279  })
280}
281
hooks/markdown.ts 200 lines
1// The markdown a draft is written in, as far as it is worth a color: headings, emphasis, code,
2// links, lists, quotes, tags, and inside a fence the code of the language it names. Lines are read
3// one at a time against the handful of forms a prompt uses; the rest of markdown (tables, indented
4// code, reference links, a paragraph that runs into a heading) is left as plain text.
5
6import { lex } from './code'
7import type { TokenKind } from './code'
8
9export type Kind = TokenKind | 'heading' | 'marker' | 'strong' | 'emphasis' | 'raw' | 'link' | 'url' | 'list' | 'quote' | 'tag' | 'language'
10
11// One stretch of the draft and what it is. Where two cover the same text the later is the inner
12// one, and the later run is the one the engine lets win.
13export type Span = { start: number; end: number; kind: Kind }
14
15type Fence = { mark: string; language: string; body: number }
16
17// A line longer than this is a paste, not prose: its blocks are told apart and its inside left alone.
18const MAX_INLINE = 2000
19// How far emphasis is followed into emphasis.
20const MAX_DEPTH = 3
21
22const HEADING = /^( {0,3})(#{1,6})(?:[ \t]+(.*))?$/
23const QUOTE = /^( {0,3}>[ \t]?)(.*)$/
24const ITEM = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)(\[[ xX]\](?=[ \t]|$))?(.*)$/
25const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/
26const OPENING = /^( {0,3})(`{3,}|~{3,})[ \t]*([^\s`]*)(.*)$/
27const CLOSING = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
28
29// Where stars open and close emphasis. Markdown asks less of them, and would set a glob
30// (`src/**/*.ts`), a product (`2 * 3`) and a pair of star arguments in italics: here the stars stand
31// clear of a word and of a path on their outer side, and what they hold starts with neither a
32// space nor a stop and ends with neither a space nor an opening bracket.
33const OPENS = String.raw`(?<![\w*/])`
34const STARTS = String.raw`(?=[^\s*/.,;:!?)\]}])`
35const ENDS = String.raw`[^\s*/(\[{]`
36const CLOSES = String.raw`(?![\w*/])`
37
38// What is told apart inside a line, the first that fits at each place: an escaped mark, a code
39// span, a link, an address, a tag, then strong and plain emphasis. A mark inside a word is a part
40// of the word, and one with a space on its inner side opens or closes nothing.
41const INLINE = new RegExp(
42  [
43    String.raw`(?<escape>\\[!-/:-@[-\x60{-~])`,
44    String.raw`(?<ticks>\x60+)(?!\x60)(?<raw>.*?[^\x60])\k<ticks>(?!\x60)`,
45    String.raw`\[(?<label>[^\]\n]+)\]\((?<target>[^)\s]+)\)`,
46    String.raw`(?<url>https?:\/\/[^\s<>\x60]*[^\s<>\x60.,;:!?'")\]])`,
47    String.raw`(?<tag><\/?[A-Za-z][\w:-]*(?:\s[^<>\n]*)?\/?>)`,
48    String.raw`${OPENS}(?<stars>\*\*)${STARTS}(?<strong>.+?)(?<=${ENDS})\*\*${CLOSES}`,
49    String.raw`(?<![\w_])(?<bars>__)(?=\S)(?<stronger>.+?)(?<=\S)__(?![\w_])`,
50    String.raw`${OPENS}(?<star>\*)${STARTS}(?<emphasis>[^*\n]*?${ENDS})\*${CLOSES}`,
51    String.raw`(?<![\w_])(?<bar>_)(?=[^\s_])(?<emphatic>[^_\n]*?[^\s_])_(?![\w_])`,
52  ].join('|'),
53  'g',
54)
55
56const inline = (text: string, at: number, depth: number, spans: Span[]) => {
57  if (text.length > MAX_INLINE || depth > MAX_DEPTH) {
58    return
59  }
60
61  for (const match of text.matchAll(INLINE)) {
62    const { ticks, raw, label, target, url, tag, stars, strong, bars, stronger, star, emphasis, bar, emphatic } = match.groups ?? {}
63    const start = at + match.index
64    const end = start + match[0].length
65    // Marks of `width` on both sides of what they hold: the whole as `kind`, the marks over it, and
66    // what it holds read again for what is inside.
67    const wrapped = (kind: Kind, width: number, held: string) => {
68      spans.push({ start, end, kind })
69      inline(held, start + width, depth + 1, spans)
70      spans.push({ start, end: start + width, kind: 'marker' }, { start: end - width, end, kind: 'marker' })
71    }
72
73    if (ticks !== undefined && raw !== undefined) {
74      spans.push(
75        { start, end, kind: 'raw' },
76        { start, end: start + ticks.length, kind: 'marker' },
77        { start: end - ticks.length, end, kind: 'marker' },
78      )
79    } else if (label !== undefined && target !== undefined) {
80      const close = start + 1 + label.length
81
82      spans.push({ start, end, kind: 'marker' }, { start: start + 1, end: close, kind: 'link' })
83      inline(label, start + 1, depth + 1, spans)
84      spans.push({ start: close + 2, end: end - 1, kind: 'url' })
85    } else if (url !== undefined) {
86      spans.push({ start, end, kind: 'url' })
87    } else if (tag !== undefined) {
88      spans.push({ start, end, kind: 'tag' })
89    } else if (stars !== undefined && strong !== undefined) {
90      wrapped('strong', 2, strong)
91    } else if (bars !== undefined && stronger !== undefined) {
92      wrapped('strong', 2, stronger)
93    } else if (star !== undefined && emphasis !== undefined) {
94      wrapped('emphasis', 1, emphasis)
95    } else if (bar !== undefined && emphatic !== undefined) {
96      wrapped('emphasis', 1, emphatic)
97    }
98  }
99}
100
101// The fence a line opens, if it opens one. A backtick fence names no backtick after its marks,
102// which is what tells it from a line of code set in three of them.
103const opening = (line: string, at: number): Fence | null => {
104  const [, , mark, language, rest] = OPENING.exec(line) ?? []
105
106  if (mark === undefined || language === undefined || (mark.startsWith('`') && rest?.includes('`') === true)) {
107    return null
108  }
109
110  return { mark, language, body: at + line.length + 1 }
111}
112
113const closes = (line: string, { mark }: Fence) => {
114  const [, found] = CLOSING.exec(line) ?? []
115
116  return found !== undefined && found.startsWith(mark)
117}
118
119// The spans of a line that is in no fence, by the block it is.
120const block = (line: string, at: number, spans: Span[]) => {
121  const end = at + line.length
122  const heading = HEADING.exec(line)
123  const quote = QUOTE.exec(line)
124  const item = ITEM.exec(line)
125
126  if (RULE.test(line)) {
127    spans.push({ start: at, end, kind: 'marker' })
128  } else if (heading !== null) {
129    const [, indent = '', marks = '', title = ''] = heading
130    const from = at + indent.length
131
132    spans.push({ start: from, end, kind: 'heading' }, { start: from, end: from + marks.length, kind: 'marker' })
133    inline(title, end - title.length, 1, spans)
134  } else if (quote !== null) {
135    const [, lead = '', quoted = ''] = quote
136
137    spans.push({ start: at, end, kind: 'quote' }, { start: at, end: at + lead.length, kind: 'marker' })
138    inline(quoted, at + lead.length, 1, spans)
139  } else if (item !== null) {
140    const [, indent = '', bullet = '', gap = '', task, rest = ''] = item
141    const from = at + indent.length
142    const boxed = from + bullet.length + gap.length
143
144    spans.push({ start: from, end: from + bullet.length, kind: 'list' })
145
146    if (task !== undefined) {
147      spans.push({ start: boxed, end: boxed + task.length, kind: 'list' })
148    }
149
150    inline(rest, end - rest.length, 1, spans)
151  } else {
152    inline(line, at, 1, spans)
153  }
154}
155
156// The code between a fence's two lines, which ends one character before `until`, where the line
157// that closes it starts (or would, in a fence left open).
158const fenced = (text: string, { language, body }: Fence, until: number, spans: Span[]) => {
159  const source = text.slice(body, Math.max(body, until - 1))
160
161  for (const { start, end, kind } of lex(source, language)) {
162    spans.push({ start: body + start, end: body + end, kind })
163  }
164}
165
166// The spans of a draft, in the order they are to be painted.
167export const scan = (text: string): Span[] => {
168  const spans: Span[] = []
169  let open: Fence | null = null
170  let at = 0
171
172  for (const line of text.split('\n')) {
173    const end = at + line.length
174
175    if (open === null) {
176      open = opening(line, at)
177
178      if (open === null) {
179        block(line, at, spans)
180      } else {
181        const named = at + line.indexOf(open.mark) + open.mark.length
182
183        spans.push({ start: at, end: named, kind: 'marker' }, ...(named < end ? [{ start: named, end, kind: 'language' } as const] : []))
184      }
185    } else if (closes(line, open)) {
186      fenced(text, open, at, spans)
187      spans.push({ start: at, end, kind: 'marker' })
188      open = null
189    }
190
191    at = end + 1
192  }
193
194  if (open !== null) {
195    fenced(text, open, text.length + 1, spans)
196  }
197
198  return spans
199}
200
types/index.d.ts 13 lines
1// The editor's mode as the statusline mod's badge names it: `INSERT`, `NORMAL`, `SHELL`,
2// `SHELL NORMAL`… In shell mode the draft is colored as a command.
3export type Mode = string
4
5// The value this mod reads from the statusline mod, as that mod declares it. Nothing is kept here.
6declare module 'claude-code' {
7  interface PluginState {
8    statusline: {
9      mode: Mode
10    }
11  }
12}
13