SLOPSHOPPER

bad-apple

Plays Bad Apple!! in a pane or the band above the prompt, with sound

newpanebandcommandtoaststatus
v0.2.0MITupdated 2026-10-05narze/bad-apple-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bad-apple
│ ┃ bad-apple ✕ › fix the failing auth test and add an audit log call │ ┃ Loading frames… │ ⏺ 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 │ │ › /bad-apple │ ⎿ bad-apple: No frames yet: run /plugins/bad-apple/scripts/build-a │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · bad-apple
Loading frames…
README

bad-apple-claude-mod

Plays Bad Apple!! inside Claude Code, as a mod (a plugin of function hooks). The video plays in the band above the prompt, or in a side pane, with the song in sync.

  • Quadrant cells: each cell is a solid quadrant glyph (▘▀▌▙█…) over 2x2 quarters, drawn as one Raster. Black and white by default; with gray on, each cell takes two of 32 gray levels, the best two-tone fit to how much of each quarter is lit, so edges are antialiased
  • 192x144 frames into at most 96x36 cells; smaller sites average bigger pixel blocks, never upscale
  • About 60 repaints a second with $.ui.blit, frame picked from the wall clock so video stays in sync with the song
  • Pause and resume, also for the song (the mod cuts the constant-bitrate MP3 at the right byte)
  • Time and a progress bar beside the video, and in the status line

Use

CommandWhat it does
/bad-applePlay in the band above the prompt (default)
/bad-apple panePlay in a pane (Esc closes it)
/bad-apple pause / resumePause or resume
/bad-apple restartPlay again from 0:00, in the same place
/bad-apple grayTurn grayscale antialiasing on or off (off by default; remembered across sessions)
/bad-apple stopStop

The band has Pause/Resume (p), Restart (r), Gray (g) and Stop (s) buttons; the pane has all but Stop (Esc closes it).

Sound plays through afplay, so only on macOS. Other systems get the video only.

Install

The video frames and the song are not in this repo. Build them on your machine. You need ffmpeg, python3, and yt-dlp (or uvx, which runs the latest yt-dlp).

git clone https://github.com/narze/bad-apple-claude-mod
cd bad-apple-claude-mod
./scripts/download-video.sh   # yt-dlp: YouTube -> work/bad-apple.webm
./scripts/build-assets.sh     # work file -> assets/
claude --plugin-dir "$PWD"

Scripts

ScriptWhat it does
scripts/download-video.sh [url] [--force]Downloads the PV from YouTube with yt-dlp (480p or less) to the working file work/bad-apple.<ext>. Skips the download when the file is there; --force downloads again. Default URL: https://www.youtube.com/watch?v=FtutLA63Cp8
scripts/build-assets.sh [url] [--force]Runs download-video.sh (same arguments), then makes 192x144 1-bit frames at 30 fps in assets/frames-*.bin and a 96 kbit/s mono MP3 in assets/bad-apple.mp3

If YouTube refuses the download (HTTP Error 403), update yt-dlp, or install uv so the script uses uvx yt-dlp@latest. work/ and assets/ are git-ignored.

Develop

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .   # after Claude Code has loaded the mod once (it writes .claude-plugin/types/)
FileRole
hooks/register.tsxCommand, player, band and pane drawing
hooks/frames.tsPure helpers: grid fit, frame encoding, MP3 offset
hooks/bad-apple.test.tsTests (claude plugin test)
types/index.d.ts$.state contract (mode, isPaused, isMono)
scripts/download-video.shDownloads the working video file to work/
scripts/build-assets.shBuilds assets/ from the working file

Credits

Bad Apple!! is a song from Touhou Project by ZUN, arranged by Alstroemeria Records, with the shadow-art PV by Anira. This repo has no part of the video or the song.

License

The code is MIT. The MIT license does not cover the video or the song.

Source 3 files
hooks/register.tsx 296 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { Mode } from '../types'
5import { clockText, encodeFrame, encodeProgress, fitGrid, joinChunks, songFrom, type Grid, type Meta } from './frames.ts'
6
7const PANE = 'bad-apple'
8const RASTER = 'video'
9const PROGRESS = 'progress'
10const BAND_ROWS = 16
11// Cells beside the video in the band: title, time, bar, buttons.
12const INFO_COLUMNS = 20
13// assets/bad-apple.mp3 is constant 96 kbit/s.
14const SONG_BYTES_PER_SECOND = 12000
15
16// Where it plays and whether it is paused; the band and the pane redraw
17// when either changes.
18const mode = atom({ plugin: 'bad-apple', key: 'mode' } as const, 'off' as Mode)
19const isPaused = atom({ plugin: 'bad-apple', key: 'isPaused' } as const, false)
20// Grayscale off (the default): black and white only. Kept across sessions in $.store.
21const isMono = atom({ plugin: 'bad-apple', key: 'isMono' } as const, true)
22
23// Player state lives in the module: a reload drops the timer anyway, so
24// a fresh load with a stopped player is the right state after one.
25let meta: Meta | undefined
26let bits: Uint8Array | undefined
27let song: Uint8Array | undefined
28let timer: Timer | undefined
29let audio: AbortController | undefined
30let frame = 0
31// Song position in ms while paused; `startedAt` maps wall clock to it while playing.
32let position = 0
33let startedAt = 0
34// The Rasters the ticker repaints: set by whichever site drew them last.
35let target: { requestId: string; grid: Grid; bar: number } | undefined
36let lastProgress = ''
37// Mirrors `isMono` for the ticker, which repaints without reading state.
38let mono = true
39
40function silence() {
41  timer?.cancel()
42  timer = undefined
43  audio?.abort()
44  audio = undefined
45}
46
47function halt() {
48  silence()
49  target = undefined
50  lastProgress = ''
51  position = 0
52  frame = 0
53}
54
55type Action = 'band' | 'pane' | 'pause' | 'resume' | 'toggle' | 'restart' | 'gray' | 'stop'
56
57// The player's verbs, made in session.start so a command and a Button press
58// both reach them; set again on every load.
59let player: ((action: Action) => Promise<string>) | undefined
60
61export const register: Register = on => {
62  on('session.start', async ($, e, next) => {
63    await $.command.register({
64      name: 'bad-apple',
65      description: 'Play Bad Apple!! above the prompt (pane, pause, resume, restart, gray, stop)',
66      argumentHint: '[pane|pause|resume|restart|gray|stop]',
67    })
68    mono = (await $.store.get('isMono').catch(() => undefined)) !== false
69    await update($, isMono, () => mono)
70
71    player = async asked => {
72      if (asked === 'gray') {
73        mono = !(await read($, isMono))
74        await update($, isMono, () => mono)
75        await $.store.set('isMono', mono)
76        return mono ? 'Grayscale off.' : 'Grayscale on.'
77      }
78      const where = await read($, mode)
79      const paused = await read($, isPaused)
80      const action = asked === 'toggle'
81        ? (paused ? 'resume' : 'pause')
82        : asked === 'restart' && where === 'off' ? 'band' : asked
83
84      if (action === 'pause') {
85        if (where === 'off' || paused) return 'Bad Apple!! is not playing.'
86        position = (await $.clock.now()) - startedAt
87        silence()
88        await update($, isPaused, () => true)
89        $.ui.status(`⏸ Bad Apple!! ${clockText(position / 1000)}`)
90        return 'Bad Apple!! paused.'
91      }
92
93      if (action === 'resume' && (where === 'off' || !paused)) return 'Bad Apple!! is not paused.'
94
95      // Resume and restart keep playing where it is; the rest pick a site anew.
96      const isSameSite = action === 'resume' || action === 'restart'
97      if (action === 'restart') {
98        silence()
99        position = 0
100        frame = 0
101        lastProgress = ''
102      }
103
104      if (!isSameSite) {
105        // Switch off the old site first, so its close does not stop the new one.
106        halt()
107        await update($, isPaused, () => false)
108        await update($, mode, () => 'off')
109        await $.ui.close({ id: PANE })
110        $.ui.status(undefined)
111        if (action === 'stop') return 'Bad Apple!! stopped.'
112      }
113
114      if (!meta || !bits || !song) {
115        const dir = `${$.plugin.root}/assets`
116        if (!(await $.fs.exists(`${dir}/meta.json`))) {
117          return `No frames yet: run ${$.plugin.root}/scripts/build-assets.sh first.`
118        }
119        const info: Meta = JSON.parse(await $.fs.read(`${dir}/meta.json`))
120        const parts: Uint8Array[] = []
121        for (let i = 0; i < info.chunks; i++) {
122          const { base64 } = await $.fs.read(`${dir}/frames-${i}.bin`, { as: 'bytes' })
123          parts.push(Uint8Array.fromBase64(base64))
124        }
125        const mp3 = await $.fs.read(`${dir}/bad-apple.mp3`, { as: 'bytes' })
126        bits = joinChunks(parts)
127        song = Uint8Array.fromBase64(mp3.base64)
128        meta = info
129      }
130      const video = meta
131      const data = bits
132      const total = clockText(video.frames / video.fps)
133
134      if (!isSameSite) {
135        await update($, mode, () => action)
136        if (action === 'pane') {
137          await $.ui.open({ id: PANE, title: 'Bad Apple!!', focus: true, closeOnEscape: true, rows: 40, columns: 98 })
138        }
139      }
140      await update($, isPaused, () => false)
141
142      audio = new AbortController()
143      const clip = { base64: songFrom(song, position, SONG_BYTES_PER_SECOND).toBase64(), mime: 'audio/mpeg' }
144      void $.audio.play(clip, { signal: audio.signal }).catch(() => {
145        $.ui.toast('bad-apple: no sound here, video only')
146      })
147      startedAt = (await $.clock.now()) - position
148
149      // Frame index comes from the wall clock, so video stays in sync with
150      // the song even when a tick is late.
151      const ticker: Timer = $.clock.every(16, async () => {
152        if (timer !== ticker) return
153        const elapsed = (await $.clock.now()) - startedAt
154        const due = Math.floor((elapsed * video.fps) / 1000)
155        if (due >= video.frames) {
156          halt()
157          await update($, mode, () => 'off')
158          await $.ui.close({ id: PANE })
159          $.ui.status(undefined)
160          $.ui.toast('Bad Apple!! - fin')
161          return
162        }
163        if (due === frame) return
164        frame = due
165        if (frame % video.fps === 0) {
166          $.ui.status(`▶ Bad Apple!! ${clockText(elapsed / 1000)} / ${total}`)
167        }
168        if (target) {
169          const { requestId, grid, bar } = target
170          await $.ui.blit({ requestId, key: RASTER, cells: encodeFrame(video, data, frame, grid, mono) })
171          const progress = encodeProgress(frame / video.fps, video.frames / video.fps, bar)
172          if (progress !== lastProgress) {
173            lastProgress = progress
174            await $.ui.blit({ requestId, key: PROGRESS, cells: progress })
175          }
176        }
177      })
178      timer = ticker
179
180      if (action === 'resume') return 'Bad Apple!! resumed.'
181      if (action === 'restart') return 'Bad Apple!! restarted.'
182      return action === 'band'
183        ? 'Playing Bad Apple!! above the prompt - /bad-apple stop ends it.'
184        : 'Playing Bad Apple!! - Esc closes the pane.'
185    }
186
187    return next(e)
188  })
189
190  on('command.run', { command: 'bad-apple' }, async ($, e) => {
191    const arg = e.args.trim() || 'band'
192    const actions: Action[] = ['band', 'pane', 'pause', 'resume', 'toggle', 'restart', 'gray', 'stop']
193    const action = actions.find(one => one === arg)
194    if (!action) return { text: `Unknown "${arg}". Use /bad-apple [pane|pause|resume|restart|gray|stop].` }
195    if (!player) return { text: 'bad-apple: not ready yet, try again.' }
196    return { text: await player(action) }
197  })
198
199  on('ui.close', async ($, e, next) => {
200    if (e.id === PANE && (await read($, mode)) === 'pane') {
201      halt()
202      await update($, isPaused, () => false)
203      await update($, mode, () => 'off')
204      $.ui.status(undefined)
205    }
206    return next(e)
207  })
208
209  on('session.end', async ($, e, next) => {
210    halt()
211    return next(e)
212  })
213
214  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
215    const where = await read($, mode)
216    if (where !== 'band' || e.props.hasSurvey || e.surface !== 'terminal' || !meta || !bits) {
217      return next(e)
218    }
219    const paused = await read($, isPaused)
220    mono = await read($, isMono)
221    const { Box, Text, Button, Raster } = $.ui.resolve(e)
222    const grid = fitGrid(meta, e.props.bodyColumns - INFO_COLUMNS - 2, Math.min(BAND_ROWS, e.props.maxRows - 1))
223    const progress = encodeProgress(frame / meta.fps, meta.frames / meta.fps, INFO_COLUMNS)
224    target = { requestId: e.requestId, grid, bar: INFO_COLUMNS }
225    lastProgress = progress
226    return (
227      <Box flexDirection="row" gap={2}>
228        <Raster key={RASTER} columns={grid.columns} rows={grid.rows} cells={encodeFrame(meta, bits, frame, grid, mono)} />
229        <Box flexDirection="column">
230          <Text bold>Bad Apple!!</Text>
231          <Text dimColor>{paused ? 'paused' : 'now playing'}</Text>
232          <Text> </Text>
233          <Raster key={PROGRESS} columns={INFO_COLUMNS} rows={2} cells={progress} />
234          <Text> </Text>
235          <Button
236            key="toggle"
237            label={paused ? 'Resume' : 'Pause'}
238            hotkey="p"
239            variant="primary"
240            onPress={() => player?.('toggle')}
241          />
242          <Button
243            key="restart"
244            label="Restart"
245            hotkey="r"
246            onPress={() => player?.('restart')}
247          />
248          <Button
249            key="gray"
250            label={mono ? 'Gray: off' : 'Gray: on'}
251            hotkey="g"
252            onPress={() => player?.('gray')}
253          />
254          <Button
255            key="stop"
256            label="Stop"
257            hotkey="s"
258            onPress={() => player?.('stop')}
259          />
260        </Box>
261      </Box>
262    )
263  })
264
265  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266    if (e.surface !== 'terminal' || !meta || !bits) {
267      const { Text } = $.ui.resolve(e)
268      return <Text dimColor>{meta ? 'Bad Apple!! plays in the terminal only.' : 'Loading frames…'}</Text>
269    }
270    const paused = await read($, isPaused)
271    mono = await read($, isMono)
272    const { Box, Button, Raster } = $.ui.resolve(e)
273    const grid = fitGrid(meta, e.props.bodyColumns, e.props.scroll.bodyRows - 2)
274    const bar = Math.max(10, Math.min(40, grid.columns - 40))
275    const progress = encodeProgress(frame / meta.fps, meta.frames / meta.fps, bar)
276    target = { requestId: PANE, grid, bar }
277    lastProgress = progress
278    return (
279      <Box flexDirection="column">
280        <Raster key={RASTER} columns={grid.columns} rows={grid.rows} cells={encodeFrame(meta, bits, frame, grid, mono)} />
281        <Box flexDirection="row" gap={2}>
282          <Button
283            key="toggle"
284            label={paused ? 'Resume' : 'Pause'}
285            hotkey="p"
286            onPress={() => player?.('toggle')}
287          />
288          <Button key="restart" label="Restart" hotkey="r" onPress={() => player?.('restart')} />
289          <Button key="gray" label={mono ? 'Gray: off' : 'Gray: on'} hotkey="g" onPress={() => player?.('gray')} />
290          <Raster key={PROGRESS} columns={bar} rows={2} cells={progress} />
291        </Box>
292      </Box>
293    )
294  })
295}
296
hooks/frames.ts 181 lines
1// Pure helpers: fit the video into a cell grid and encode one frame as
2// Raster cells. Each cell is a solid quadrant glyph over 2x2 quarters with
3// two gray levels: each quarter's gray is the share of its source pixels
4// lit, and the glyph and grays are the best two-tone fit, so edges come
5// out antialiased.
6
7export type Meta = {
8  width: number
9  height: number
10  fps: number
11  frames: number
12  framesPerChunk: number
13  chunks: number
14}
15
16export type Grid = { columns: number; rows: number }
17
18const WHITE = 0xffffff
19const BLACK = 0x000000
20const DEFAULT = 0x01000000
21const TRACK = 0x3a3a3a
22const FULL = 0x2588
23
24// Quadrant glyph by lit quarters: top-left 8, top-right 4, bottom-left 2, bottom-right 1.
25const QUADRANTS = [
26  0x20, 0x2597, 0x2596, 0x2584, 0x259d, 0x2590, 0x259e, 0x259f,
27  0x2598, 0x259a, 0x258c, 0x2599, 0x2580, 0x259c, 0x259b, 0x2588,
28]
29// Gray steps: 32 levels keep the fg/bg pairs within the Raster's 1024.
30const LEVELS = 31
31
32const PATTERNS = [15, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
33
34function gray(share: number): number {
35  const v = Math.round((Math.round(share * LEVELS) * 255) / LEVELS)
36  return (v << 16) | (v << 8) | v
37}
38
39// Biggest grid that keeps the video aspect inside the given room, never
40// more quarters than the video has pixels for. A cell is about twice as
41// tall as wide, so rows = columns * height / width / 2.
42export function fitGrid(meta: Meta, maxColumns: number, maxRows: number): Grid {
43  let columns = Math.max(1, Math.min(meta.width >> 1, maxColumns))
44  let rows = Math.round((columns * meta.height) / meta.width / 2)
45  if (rows > maxRows) {
46    rows = Math.max(1, maxRows)
47    columns = Math.max(1, Math.round((rows * 2 * meta.width) / meta.height))
48  }
49  return { columns: Math.min(columns, 512), rows: Math.max(1, Math.min(rows, 256)) }
50}
51
52// Source pixel ranges [start, end) for each of `count` target pixels over `size`.
53function spans(count: number, size: number): Array<[number, number]> {
54  return Array.from({ length: count }, (_, i) => {
55    const start = Math.min(size - 1, Math.floor((i * size) / count))
56    return [start, Math.max(start + 1, Math.floor(((i + 1) * size) / count))]
57  })
58}
59
60// One frame from the packed 1-bit data (MSB first, row-major) as base64 cells.
61// At 96x36 cells a 192x144 video gives each quarter a 1x2 pixel block;
62// smaller grids average bigger blocks, so nothing is upscaled. With
63// `isMono` each quarter is cut to black or white: no gray, crisp edges.
64export function encodeFrame(meta: Meta, bits: Uint8Array, frame: number, grid: Grid, isMono = false): string {
65  const { width, height } = meta
66  const base = frame * ((width * height) >> 3)
67  const source = (x: number, y: number) => {
68    const i = y * width + x
69    return ((bits[base + (i >> 3)] ?? 0) >> (7 - (i & 7))) & 1
70  }
71  const xs = spans(grid.columns * 2, width)
72  const ys = spans(grid.rows * 2, height)
73  const share = (qx: number, qy: number) => {
74    const [x0, x1] = xs[qx] ?? [0, 1]
75    const [y0, y1] = ys[qy] ?? [0, 1]
76    let on = 0
77    for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) on += source(x, y)
78    const lit = on / ((x1 - x0) * (y1 - y0))
79    return isMono ? (lit >= 0.5 ? 1 : 0) : lit
80  }
81  const words = new Uint32Array(grid.columns * grid.rows * 3)
82  const quarter = [0, 0, 0, 0]
83  let w = 0
84  for (let r = 0; r < grid.rows; r++) {
85    for (let c = 0; c < grid.columns; c++) {
86      // top-left, top-right, bottom-left, bottom-right: bits 8, 4, 2, 1
87      quarter[0] = share(c * 2, r * 2)
88      quarter[1] = share(c * 2 + 1, r * 2)
89      quarter[2] = share(c * 2, r * 2 + 1)
90      quarter[3] = share(c * 2 + 1, r * 2 + 1)
91      let best = 15
92      let bestError = Infinity
93      let fg = 0
94      let bg = 0
95      // The cell as one tone (15) first, then every split into a lit set and
96      // the rest; a split wins only when it fits strictly better.
97      for (const pattern of PATTERNS) {
98        let litSum = 0
99        let litCount = 0
100        let restSum = 0
101        for (let q = 0; q < 4; q++) {
102          if (pattern & (8 >> q)) {
103            litSum += quarter[q] ?? 0
104            litCount++
105          } else {
106            restSum += quarter[q] ?? 0
107          }
108        }
109        const litMean = litSum / litCount
110        const restMean = litCount === 4 ? litMean : restSum / (4 - litCount)
111        let error = 0
112        for (let q = 0; q < 4; q++) {
113          const mean = pattern & (8 >> q) ? litMean : restMean
114          error += ((quarter[q] ?? 0) - mean) ** 2
115        }
116        if (error < bestError - 1e-9) {
117          best = pattern
118          bestError = error
119          fg = litMean
120          bg = restMean
121        }
122      }
123      // The brighter part is the glyph, so a shape reads the same each way.
124      if (best !== 15 && fg < bg) {
125        best = 15 - best
126        ;[fg, bg] = [bg, fg]
127      }
128      words[w++] = QUADRANTS[best] ?? 0x2588
129      words[w++] = gray(fg)
130      words[w++] = gray(best === 15 ? fg : bg)
131    }
132  }
133  return new Uint8Array(words.buffer).toBase64()
134}
135
136// Two rows of `columns` cells: "m:ss / m:ss", then a bar filled by eighths.
137export function encodeProgress(elapsed: number, total: number, columns: number): string {
138  const words = new Uint32Array(columns * 2 * 3)
139  const text = `${clockText(elapsed)} / ${clockText(total)}`
140  for (let c = 0; c < columns; c++) {
141    words.set([text.codePointAt(c) ?? 0x20, DEFAULT, DEFAULT], c * 3)
142  }
143  const eighths = Math.round(Math.min(1, Math.max(0, elapsed / total)) * columns * 8)
144  for (let c = 0; c < columns; c++) {
145    const lit = Math.min(8, Math.max(0, eighths - c * 8))
146    const glyph = lit === 8 ? FULL : lit === 0 ? 0x20 : 0x2590 - lit
147    words.set([glyph, WHITE, TRACK], (columns + c) * 3)
148  }
149  return new Uint8Array(words.buffer).toBase64()
150}
151
152export function joinChunks(parts: Uint8Array[]): Uint8Array {
153  const all = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
154  let at = 0
155  for (const p of parts) {
156    all.set(p, at)
157    at += p.length
158  }
159  return all
160}
161
162export function clockText(seconds: number): string {
163  const s = Math.max(0, Math.floor(seconds))
164  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
165}
166
167// Where the MP3 frames start: after the ID3v2 tag, whose size is syncsafe.
168export function id3End(bytes: Uint8Array): number {
169  if (bytes[0] !== 0x49 || bytes[1] !== 0x44 || bytes[2] !== 0x33) return 0
170  const size = (((bytes[6] ?? 0) & 0x7f) << 21) | (((bytes[7] ?? 0) & 0x7f) << 14) |
171    (((bytes[8] ?? 0) & 0x7f) << 7) | ((bytes[9] ?? 0) & 0x7f)
172  return 10 + size
173}
174
175// The song from `ms` on: a constant-bitrate MP3 cut at a byte offset; the
176// decoder finds the next frame by itself.
177export function songFrom(bytes: Uint8Array, ms: number, bytesPerSecond: number): Uint8Array {
178  const at = id3End(bytes) + Math.floor((ms / 1000) * bytesPerSecond)
179  return bytes.subarray(Math.min(at, bytes.length))
180}
181
types/index.d.ts 8 lines
1export type Mode = 'off' | 'pane' | 'band'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'bad-apple': { mode: Mode; isPaused: boolean; isMono: boolean }
6  }
7}
8