SLOPSHOPPER

rec-mode

Recording mode: blurs emails, keys, IP addresses and user names on screen; hover over a blurred spot to reveal it. /rec turns it on and off.

newspinnerrowscommandstatus
v0.1.0no licenseupdated 2026-10-10simplybychris/claude-code-mods/rec-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rec-mode
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rec ⎿ rec-mode: Recording mode on: emails, keys, IP addresses and user names are blurred. Hover over a blurred spot to reveal ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ rec-mode: ● REC · private data blurred · hover to reveal
README

Rec Mode

Tryb nagrywania. Gdy jest włączony, na ekranie zamazuje maile, klucze API, tokeny, hasła zapisane jako password=..., adresy IP i nazwy użytkownika. Najedź kursorem na zamazane miejsce, żeby je podejrzeć. Zmienia się tylko to, co widać: Claude dalej czyta wszystko normalnie, a zapis rozmowy zostaje bez zmian.

Jak używać

  • /rec włącza i wyłącza tryb (albo /rec on, /rec off).
  • Pod polem promptu widać ● REC, dopóki tryb jest włączony. Stan zostaje zapamiętany do następnej sesji.
  • Własne słowa do zamazania (np. nick, nazwa klienta) dopiszesz w /config, w wierszu Extra names to blur, po przecinku.

Dobrze wiedzieć

  • Wykrywanie działa na wzorcach, więc coś może się prześlizgnąć. Przed publikacją nagrania i tak je obejrzyj.
  • Odpowiedzi, prompty i wynik poleceń w powłoce są zamazane szarym paskiem. Wiersze, które rysuje sam Claude Code (wywołania narzędzi, spinner), dostają kropki •••.
  • Nazwę konta bierze z USER/HOME (macOS, Linux) i USERNAME/USERPROFILE (Windows).

Wymaga Claude Code 2.1.287+ w terminalu albo 2.1.286+ w aplikacji Desktop. Brak zależności.

Source 4 files
hooks/register.tsx 125 lines
1// rec-mode: a recording mode for Claude Code. While it is on, emails, keys, IP
2// addresses and user names are blurred on screen, and a pointer over a blurred
3// spot reveals it. Only the drawing changes: Claude reads everything as before
4// and the stored transcript is untouched. /rec turns it on and off.
5//
6// Replies, prompts and shell output are drawn by the mod, with the reveal on
7// hover. Rows the app draws itself (a tool call's input, a file Read, the
8// spinner, a command's output) get the same spots masked with dots instead.
9import type { EngineInterface, Register } from 'claude-code'
10
11import { findSpans, maskDeep, maskText } from './detect'
12import type { Context } from './detect'
13import { renderOutput, renderPrompt, renderReply } from './view'
14
15const COMMAND = 'rec'
16const STATUS = '● REC · private data blurred · hover to reveal'
17const STORE_KEY = 'isOn'
18
19// The module's state: a hot reload starts it over, and session.start reads it back.
20let isOn = false
21let ctx: Context = { users: [], names: [] }
22
23async function turn($: EngineInterface, on: boolean) {
24  isOn = on
25  await $.store.set(STORE_KEY, on).catch(() => {})
26  $.ui.status(on ? STATUS : undefined)
27  $.ui.invalidate('ui.render')
28}
29
30function hasSecret(text: string) {
31  return findSpans(text, ctx).length > 0
32}
33
34export const register: Register = (on, options) => {
35  const names = String(options.names ?? '')
36    .split(',')
37    .map(n => n.trim())
38    .filter(n => n.length > 0)
39  ctx = { users: [], names }
40
41  on('session.start', async ($, e, next) => {
42    const result = await next(e)
43    const user = await $.env.get('USER').catch(() => undefined)
44    const winUser = await $.env.get('USERNAME').catch(() => undefined)
45    const home = await $.env.get('HOME').catch(() => undefined)
46    const winHome = await $.env.get('USERPROFILE').catch(() => undefined)
47    const users = [user, winUser, ...[home, winHome].map(h => h?.split(/[\\/]/).filter(Boolean).pop())].filter(
48      (u): u is string => typeof u === 'string',
49    )
50    ctx = { users, names }
51    await $.command
52      .register({
53        name: COMMAND,
54        description: 'Recording mode: blur emails, keys, IP addresses and user names on screen',
55        argumentHint: '[on|off]',
56      })
57      .catch(() => {})
58    isOn = (await $.store.get(STORE_KEY).catch(() => false)) === true
59    $.ui.status(isOn ? STATUS : undefined)
60    $.ui.invalidate('ui.render')
61    return result
62  })
63
64  on('command.run', { command: COMMAND }, async ($, e) => {
65    const arg = e.args.trim().toLowerCase()
66    const want = arg === 'on' ? true : arg === 'off' ? false : !isOn
67    await turn($, want)
68    return {
69      text: want
70        ? 'Recording mode on: emails, keys, IP addresses and user names are blurred. Hover over a blurred spot to reveal it.'
71        : 'Recording mode off.',
72    }
73  })
74
75  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
76    if (!isOn || !hasSecret(e.props.text)) return next(e)
77    return renderReply($.ui.resolve(e), e.props.text, ctx, e.requestId)
78  })
79
80  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
81    if (!isOn || !hasSecret(e.props.text)) return next(e)
82    const kind = e.props.origin.kind
83    if (kind === 'composer' || kind === 'bridge') return renderPrompt($.ui.resolve(e), e.props.text, ctx, e.requestId)
84    return next({ ...e, props: { ...e.props, text: maskText(e.props.text, ctx) } })
85  })
86
87  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
88    if (!isOn) return next(e)
89    const out = e.props.output
90    if (e.props.tool === 'Bash' && !e.props.isErrored && out !== null && typeof out === 'object') {
91      const { stdout, stderr } = out as { stdout?: unknown; stderr?: unknown }
92      const text = [stdout, stderr].filter((s): s is string => typeof s === 'string' && s.length > 0).join('\n')
93      return hasSecret(text) ? renderOutput($.ui.resolve(e), text, ctx, e.requestId) : next(e)
94    }
95    const output = maskDeep(out, ctx)
96    return output === out ? next(e) : next({ ...e, props: { ...e.props, output } })
97  })
98
99  on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
100    if (!isOn) return next(e)
101    const props = { ...e.props }
102    const input = maskDeep(e.props.input, ctx)
103    if (input !== e.props.input) props.input = input
104    if ('output' in e.props) {
105      const output = maskDeep(e.props.output, ctx)
106      if (output !== e.props.output) props.output = output
107    }
108    return props.input === e.props.input && props.output === e.props.output ? next(e) : next({ ...e, props })
109  })
110
111  on('ui.render', { component: 'CommandOutput' }, ($, e, next) => {
112    if (!isOn || e.props.command === COMMAND) return next(e)
113    const text = maskText(e.props.text, ctx)
114    return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
115  })
116
117  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
118    if (!isOn) return next(e)
119    const word = maskText(e.props.word, ctx)
120    const message = e.props.message === null ? null : maskText(e.props.message, ctx)
121    if (word === e.props.word && message === e.props.message) return next(e)
122    return next({ ...e, props: { ...e.props, word, message } })
123  })
124}
125
hooks/detect.ts 182 lines
1// rec-mode's detector: finds private data in a text (emails, keys, IP addresses,
2// user names) as spans, and masks them. Pure: no `$`, tested on its own.
3
4export type Kind = 'email' | 'key' | 'ip' | 'user'
5export type Span = { start: number; end: number; kind: Kind }
6
7export type Context = {
8  /** Account names, matched as whole words in the case written (the OS user). */
9  users: readonly string[]
10  /** Extra words from the mod's options: any case, and words starting with them. */
11  names: readonly string[]
12}
13
14// A rule matches the whole span, or `(prefix)(span)` when `isGrouped`: group 1
15// is what comes before, group 2 the span, and only lookaheads follow it.
16type Rule = { kind: Kind; re: RegExp; isGrouped?: boolean; isValue?: boolean }
17
18const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
19const HEX = '[0-9A-Fa-f]{1,4}'
20const VALUE = '[^\\s"\'`,;)}\\]]'
21
22const RULES: Rule[] = [
23  // PEM private keys, whole.
24  { kind: 'key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
25  // Provider keys, by their prefixes.
26  {
27    kind: 'key',
28    re: /(?:sk-ant-[\w-]{16,}|sk-(?:proj-|svcacct-|live-|test-)?[\w-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_\w{30,}|xox[abprs]-[\w-]{10,}|AKIA[0-9A-Z]{16}|AIza[\w-]{35}|glpat-[\w-]{20,}|hf_[A-Za-z0-9]{30,}|(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{20,})/g,
29  },
30  // JSON Web Tokens.
31  { kind: 'key', re: /eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}/g },
32  // Authorization: Bearer <token>.
33  { kind: 'key', re: /(\bBearer\s+)([\w.~+/-]{16,}=*)/g, isGrouped: true },
34  // API_KEY=..., "token": "...", password: ...
35  {
36    kind: 'key',
37    re: new RegExp(
38      `([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|passw(?:or)?d|pwd|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_]*["']?\\s*[:=]\\s*["']?)(${VALUE}{6,})`,
39      'gi',
40    ),
41    isGrouped: true,
42    isValue: true,
43  },
44  { kind: 'email', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g },
45  { kind: 'ip', re: new RegExp(`(^|[^\\w.])((?:${OCTET}\\.){3}${OCTET})(?![\\w]|\\.\\d)`, 'g'), isGrouped: true },
46  {
47    kind: 'ip',
48    re: new RegExp(`(^|[^\\w:.])((?:${HEX}:){4,7}${HEX}|(?:${HEX}:){1,6}:(?:${HEX}(?::${HEX}){0,5})?)(?![\\w:])`, 'g'),
49    isGrouped: true,
50  },
51  // The account in a home path: /Users/<name>, /home/<name>, C:\Users\<name>.
52  { kind: 'user', re: /((?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\))([^/\\\s"'`:]+)/g, isGrouped: true },
53  // user=..., "username": "...", login: ..., user.name=...
54  {
55    kind: 'user',
56    re: new RegExp(`(\\b(?:user(?:[_-]?name)?|login|user\\.name|github[_-]?user)["']?\\s*[:=]\\s*["']?)(${VALUE}{2,})`, 'gi'),
57    isGrouped: true,
58    isValue: true,
59  },
60  // @handles, but not a scoped package (@scope/pkg), a decorator call or a doc tag.
61  { kind: 'user', re: /(^|[^\w@./-])(@[A-Za-z0-9_][A-Za-z0-9_.-]*[A-Za-z0-9_])(?![\w/(]|[.-][\w/])/g, isGrouped: true },
62]
63
64// @-words that are code, not people.
65const NOT_HANDLES = new Set(
66  (
67    'param returns return example remarks see type typedef deprecated media import keyframes supports override throws ' +
68    'link todo default template internal public private since version author property container layer tailwind apply ' +
69    'font-face charset namespace page each include mixin extend use forward if else for while function ts-ignore ' +
70    'ts-expect-error ts-nocheck jsx class interface module'
71  )
72    .split(' ')
73    .map(w => '@' + w),
74)
75
76const NOT_SECRET_VALUES = new Set(['true', 'false', 'null', 'undefined', 'none', 'required', 'optional', 'string', 'number'])
77const GENERIC_USERS = new Set(['root', 'user', 'admin', 'runner', 'ubuntu', 'users', 'home'])
78
79function escape(s: string) {
80  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
81}
82
83// The rules for the context's own names, compiled once per context.
84const compiled = new WeakMap<Context, Rule[]>()
85
86function contextRules(ctx: Context): Rule[] {
87  const known = compiled.get(ctx)
88  if (known) return known
89  const rules: Rule[] = []
90  for (const user of new Set(ctx.users)) {
91    if (user.length < 3 || GENERIC_USERS.has(user.toLowerCase())) continue
92    rules.push({ kind: 'user', re: new RegExp(`(^|[^A-Za-z0-9_])(${escape(user)})(?![A-Za-z0-9_])`, 'g'), isGrouped: true })
93  }
94  for (const name of new Set(ctx.names.map(n => n.trim()).filter(n => n.length >= 2))) {
95    rules.push({ kind: 'user', re: new RegExp(`(^|[^\\p{L}\\p{N}_])(${escape(name)}[\\p{L}\\p{N}_]*)`, 'giu'), isGrouped: true })
96  }
97  compiled.set(ctx, rules)
98  return rules
99}
100
101/** Every private span in `text`, in order, none overlapping (the longer wins). */
102export function findSpans(text: string, ctx: Context): Span[] {
103  if (text.length === 0) return []
104  const found: Span[] = []
105  for (const rule of [...RULES, ...contextRules(ctx)]) {
106    rule.re.lastIndex = 0
107    for (let m = rule.re.exec(text); m !== null; m = rule.re.exec(text)) {
108      if (m[0].length === 0) {
109        rule.re.lastIndex += 1
110        continue
111      }
112      const prefix = rule.isGrouped ? (m[1] ?? '') : ''
113      const value = rule.isGrouped ? (m[2] ?? '') : m[0]
114      if (value.length === 0) continue
115      if (rule.isValue && (/^\d+$/.test(value) || NOT_SECRET_VALUES.has(value.toLowerCase()))) continue
116      if (value.startsWith('@') && NOT_HANDLES.has(value.toLowerCase())) continue
117      const start = m.index + prefix.length
118      found.push({ start, end: start + value.length, kind: rule.kind })
119    }
120  }
121  found.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start))
122  const spans: Span[] = []
123  for (const s of found) {
124    const last = spans[spans.length - 1]
125    if (last && s.start < last.end) continue
126    spans.push(s)
127  }
128  return spans
129}
130
131export const MASK = '•'
132
133/** The text with every private character swapped for a dot; line breaks stay. */
134export function maskText(text: string, ctx: Context): string {
135  const spans = findSpans(text, ctx)
136  if (spans.length === 0) return text
137  let out = ''
138  let at = 0
139  for (const s of spans) {
140    out += text.slice(at, s.start) + text.slice(s.start, s.end).replace(/[^\n]/g, MASK)
141    at = s.end
142  }
143  return out + text.slice(at)
144}
145
146/** A value with every string in it masked; the same value back when nothing changed. */
147export function maskDeep<T>(value: T, ctx: Context): T {
148  if (typeof value === 'string') return maskText(value, ctx) as T
149  if (Array.isArray(value)) {
150    const items = value.map(v => maskDeep(v, ctx))
151    return (items.some((v, i) => v !== value[i]) ? items : value) as T
152  }
153  if (value !== null && typeof value === 'object') {
154    let changed = false
155    const out: Record<string, unknown> = {}
156    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
157      const masked = maskDeep(v, ctx)
158      if (masked !== v) changed = true
159      out[k] = masked
160    }
161    return (changed ? out : value) as T
162  }
163  return value
164}
165
166/** One piece of a line as drawn: plain text, or a private span to blur. */
167export type Piece = { text: string; isSecret: boolean }
168
169/** A line cut into plain and private pieces. */
170export function pieces(text: string, ctx: Context): Piece[] {
171  const spans = findSpans(text, ctx)
172  const out: Piece[] = []
173  let at = 0
174  for (const s of spans) {
175    if (s.start > at) out.push({ text: text.slice(at, s.start), isSecret: false })
176    out.push({ text: text.slice(s.start, s.end), isSecret: true })
177    at = s.end
178  }
179  if (at < text.length || out.length === 0) out.push({ text: text.slice(at), isSecret: false })
180  return out
181}
182
hooks/view.tsx 133 lines
1// rec-mode's drawing: text whose private spots are grey bars the length of what
2// they hide; a pointer over a bar reveals it. Each bar has a hover scope of its
3// own, so only the spot under the pointer opens.
4import type { EngineInterface } from 'claude-code'
5
6import { findSpans, pieces } from './detect'
7import type { Context } from './detect'
8import { lineSegments, splitBlocks } from './md'
9import type { Segment, Style } from './md'
10
11type UI = ReturnType<EngineInterface['ui']['resolve']>
12
13export const COLOR = {
14  bar: '#6e7681', // the blur: text and background in one grey
15  revealText: '#f0f6fc',
16  revealBack: '#30363d',
17  code: '#79c0ff',
18}
19
20export const MAX_OUTPUT_LINES = 40
21
22function hash(s: string) {
23  let h = 5381
24  for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
25  return (h >>> 0).toString(36)
26}
27
28/** Hover scopes for one drawing: `rec<instance>-<n>`, well under the 64-character limit. */
29export function scopes(requestId: string) {
30  const base = `rec${hash(requestId)}`
31  let n = 0
32  return () => `${base}-${n++}`
33}
34
35type NextScope = () => string
36
37function styleOf(style: Style) {
38  if (style === 'bold') return { bold: true }
39  if (style === 'code') return { color: COLOR.code }
40  if (style === 'dim') return { dimColor: true }
41  return {}
42}
43
44/** One line: plain text, styled segments, and blurred spots a pointer reveals. */
45function line(ui: UI, segs: Segment[], ctx: Context, scope: NextScope) {
46  const { Text } = ui
47  const children = []
48  for (const seg of segs) {
49    for (const p of pieces(seg.text, ctx)) {
50      if (p.isSecret) {
51        children.push(
52          <Text
53            {...(seg.style === 'bold' ? { bold: true } : {})}
54            color={COLOR.bar}
55            backgroundColor={COLOR.bar}
56            hover={{ scope: scope(), color: COLOR.revealText, backgroundColor: COLOR.revealBack }}
57          >
58            {p.text}
59          </Text>,
60        )
61      } else if (seg.style === 'plain') {
62        children.push(p.text)
63      } else {
64        children.push(<Text {...styleOf(seg.style)}>{p.text}</Text>)
65      }
66    }
67  }
68  return <Text>{children}</Text>
69}
70
71function plainLines(text: string): Segment[][] {
72  return text.split('\n').map(l => [{ text: l.length > 0 ? l : ' ', style: 'plain' as const }])
73}
74
75/** A reply: blocks without private data stay Markdown, the others are drawn line by line. */
76export function renderReply(ui: UI, text: string, ctx: Context, requestId: string) {
77  const { Box, Markdown } = ui
78  const scope = scopes(requestId)
79  const parts = []
80  let clean: string[] = []
81  const flush = () => {
82    if (clean.length > 0) parts.push(<Markdown text={clean.join('\n\n')} />)
83    clean = []
84  }
85  for (const block of splitBlocks(text)) {
86    if (findSpans(block.text, ctx).length === 0) {
87      clean.push(block.text)
88      continue
89    }
90    flush()
91    if (block.kind === 'fence') {
92      parts.push(
93        <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
94          {plainLines(block.body.join('\n')).map(segs => line(ui, segs, ctx, scope))}
95        </Box>,
96      )
97    } else {
98      parts.push(<Box flexDirection="column">{block.text.split('\n').map(l => line(ui, lineSegments(l), ctx, scope))}</Box>)
99    }
100  }
101  flush()
102  return (
103    <Box flexDirection="column" gap={1}>
104      {parts}
105    </Box>
106  )
107}
108
109/** The person's prompt, as typed, in a frame. */
110export function renderPrompt(ui: UI, text: string, ctx: Context, requestId: string) {
111  const { Box } = ui
112  const scope = scopes(requestId)
113  return (
114    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
115      {plainLines(text).map(segs => line(ui, segs, ctx, scope))}
116    </Box>
117  )
118}
119
120/** A shell command's output, its first lines. */
121export function renderOutput(ui: UI, text: string, ctx: Context, requestId: string) {
122  const { Box, Text } = ui
123  const scope = scopes(requestId)
124  const lines = plainLines(text.replace(/\n+$/, ''))
125  const more = lines.length - MAX_OUTPUT_LINES
126  return (
127    <Box flexDirection="column">
128      {lines.slice(0, MAX_OUTPUT_LINES).map(segs => line(ui, segs, ctx, scope))}
129      {more > 0 && <Text dimColor>{`… +${more} lines`}</Text>}
130    </Box>
131  )
132}
133
hooks/md.ts 82 lines
1// rec-mode's Markdown helpers: a reply cut into blocks, so the blocks without
2// private data stay the app's own Markdown, and a line of Markdown read as plain
3// styled segments, for the blocks rec-mode draws itself. Pure: no `$`.
4
5export type Block = { kind: 'text'; text: string } | { kind: 'fence'; text: string; body: string[] }
6
7const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/
8const FENCE_CLOSE = /^\s{0,3}(`{3,}|~{3,})\s*$/
9
10/** Paragraphs, lists and tables split on blank lines; a code fence is one block, blank lines and all. */
11export function splitBlocks(text: string): Block[] {
12  const out: Block[] = []
13  let lines: string[] = []
14  let fence: string | null = null
15  const flush = () => {
16    if (lines.length > 0) out.push({ kind: 'text', text: lines.join('\n') })
17    lines = []
18  }
19  for (const line of text.split('\n')) {
20    if (fence !== null) {
21      lines.push(line)
22      const close = FENCE_CLOSE.exec(line)
23      if (close?.[1] && close[1][0] === fence[0] && close[1].length >= fence.length) {
24        out.push({ kind: 'fence', text: lines.join('\n'), body: lines.slice(1, -1) })
25        lines = []
26        fence = null
27      }
28      continue
29    }
30    const open = FENCE_OPEN.exec(line)
31    if (open?.[1]) {
32      flush()
33      fence = open[1]
34      lines.push(line)
35      continue
36    }
37    if (line.trim() === '') flush()
38    else lines.push(line)
39  }
40  // A fence still open (a reply mid-stream) is a fence too.
41  if (fence !== null) out.push({ kind: 'fence', text: lines.join('\n'), body: lines.slice(1) })
42  else flush()
43  return out
44}
45
46export type Style = 'plain' | 'bold' | 'code' | 'dim'
47export type Segment = { text: string; style: Style }
48
49/** One Markdown line as styled segments: its marker drawn simply; bold, code and link text unwrapped. */
50export function lineSegments(line: string): Segment[] {
51  let rest = line
52  let base: Style = 'plain'
53  const segs: Segment[] = []
54  let m: RegExpExecArray | null
55  if ((m = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line))) {
56    base = 'bold'
57    rest = m[1] ?? ''
58  } else if ((m = /^(\s*)[-*+]\s+(.*)$/.exec(line))) {
59    segs.push({ text: `${m[1] ?? ''}• `, style: 'plain' })
60    rest = m[2] ?? ''
61  } else if ((m = /^(\s*)(\d+[.)])\s+(.*)$/.exec(line))) {
62    segs.push({ text: `${m[1] ?? ''}${m[2] ?? ''} `, style: 'plain' })
63    rest = m[3] ?? ''
64  } else if ((m = /^\s*>\s?(.*)$/.exec(line))) {
65    segs.push({ text: '│ ', style: 'dim' })
66    rest = m[1] ?? ''
67  } else if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(line)) {
68    return [{ text: line, style: 'dim' }] // a table's rule
69  }
70  const inline = /\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\([^)]*\)/g
71  let at = 0
72  for (m = inline.exec(rest); m !== null; m = inline.exec(rest)) {
73    if (m.index > at) segs.push({ text: rest.slice(at, m.index), style: base })
74    if (m[1] !== undefined) segs.push({ text: m[1], style: 'bold' })
75    else if (m[2] !== undefined) segs.push({ text: m[2], style: 'code' })
76    else segs.push({ text: m[3] ?? '', style: base })
77    at = m.index + m[0].length
78  }
79  if (at < rest.length) segs.push({ text: rest.slice(at), style: base })
80  return segs.length > 0 ? segs : [{ text: ' ', style: 'plain' }]
81}
82