SLOPSHOPPER

ascii-flipbook

Play a local text animation whose frames are separated by a form feed.

newpanecommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ascii-flipbook
│ ┃ ASCII Flipbook ✕ › fix the failing auth test and add an audit log call │ ┃ ASCII Flipbook │ ┃ ⏺ Read(src/auth.ts) │ ┃ Play a local text animation whose frames are ⎿ Read 6 lines │ ┃ separated by a form feed. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Text animation: animation.txt ⏎ load ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Frame 1 / 2 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ( o.o ) │ ┃ > ^ < ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ p: Play n: Next frame › /ascii-flipbook │ ┃ │ ┃ Built-in two-frame demo. Open a text file to │ ┃ load your own. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ASCII Flipbook
ASCII Flipbook Play a local text animation whose frames are separated by a form feed. Text animation: animation.txt ⏎ load Frame 1 / 2 ( o.o ) > ^ < p: Play n: Next frame Built-in two-frame demo. Open a text file to load your own.
README

ASCII Flipbook

Play a local text animation whose frames are separated by a form feed.

Use it when: Preview ASCII storyboards in a pane without a video decoder.

ASCII Flipbook render preview

Screenshot of this mod's render tree in the fixture preview, with sample data. This is not a capture of the Claude Code application. How previews are made.

Install and open

claude plugin marketplace add whyashthakker/awesome-claude-code-mods
claude plugin install ascii-flipbook@awesome-claude-code-mods

Run /reload-plugins in an existing session, then /ascii-flipbook. Use Tab and Enter to operate controls; Esc closes the pane. Terminal and Desktop Code tab supported; pane UI is unavailable in claude -p and the VS Code chat panel.

Try the source without installing:

claude --plugin-dir ./mods/ascii-flipbook

Example: open /ascii-flipbook and enter animation.txt in its input.

Access and behavior

Reads a chosen text animation (up to 128 KiB), uses a 2 fps timer. No ffmpeg, audio, video decoding, or network.

Module variables reset on hot reload. All display output is bounded; viewers may truncate long content to 9,000 characters. Relative file paths and Git commands use the session working directory.

Verify

Tested with Claude Code 2.1.288. Minimum documented mods version: 2.1.287. The API can change; validate against your installed version.

claude plugin validate mods/ascii-flipbook --strict
claude plugin test mods/ascii-flipbook

Tests cover plugin commands, pane isolation, both UI surfaces, and the behavior described in tests/register.test.ts. Drawing tests validate element trees; they do not verify pixels painted by the native apps. Validation details.

MIT licensed. No runtime packages or build step required.

Source 2 files
hooks/register.js 48 lines
1import { clean, text, code, row, frame, bar, pretty, bounded, entries, safePath } from './ui.js'
2const ID = "ascii-flipbook"
3
4let target='animation.txt',frames=['( o.o )\n > ^ <','( -.- )\n > ^ <'],index=0,playing=false,report='Built-in two-frame demo. Open a text file to load your own.'
5async function load($){
6 try {
7  const p=safePath(target),s=await $.fs.stat(p)
8  if(s.size>131072)throw new Error('Animation must be at most 128 KiB.')
9  const f=(await $.fs.read(p)).split('\f')
10  if(f.length>120||f.some(x=>x.length>4000))throw new Error('Use at most 120 frames of 4,000 characters each.')
11  frames=f;index=0;report=f.length+' frames loaded.'
12 } catch(error){report=error.message}
13 $.ui.invalidate('ui.render')
14}
15
16export function register(on) {
17  on('session.start', async ($, e, next) => {
18    $.clock.every(500,()=>{if(playing){index=(index+1)%frames.length;$.ui.invalidate('ui.render')}})
19    await $.command.register({name:ID, description:"Play a local text animation whose frames are separated by a form feed.", immediate:true, argumentHint:""})
20    return next(e)
21  })
22  on('command.run', {command:"ascii-flipbook"}, async ($, e) => {
23    if(e.args.trim()){target=e.args.trim();await load($)}
24    await $.ui.open({id:ID,title:"ASCII Flipbook",focus:true,closeOnEscape:true})
25    return {}
26  })
27
28  on('ui.render', {component:'Pane'}, async ($, e, next) => {
29    if(e.requestId !== ID) return next(e)
30    const E = $.ui.resolve(e)
31    try {
32      const body = await draw($, E)
33      return frame(E,"ASCII Flipbook","Play a local text animation whose frames are separated by a form feed.",body)
34    } catch(error) {
35      return frame(E,"ASCII Flipbook",'Unable to inspect this data.',[text(E,error.message,{color:'yellow'}),E.Button({key:'refresh',label:'Refresh',onPress:()=>$.ui.invalidate('ui.render')})])
36    }
37  })
38}
39async function draw($, E) {
40  const refresh = E.Button({key:'refresh',label:'Refresh',hotkey:'r',plain:true,onPress:()=>$.ui.invalidate('ui.render')})
41
42return [E.Input({key:'target',label:'Text animation',value:target,submitLabel:'load',onSubmit:async(v)=>{target=v;await load($)}}),
43 text(E,'Frame '+(index+1)+' / '+frames.length,{bold:true}),code(E,frames[index]),
44 row(E,[E.Button({key:'play',label:playing?'Pause':'Play',hotkey:'p',plain:true,onPress:()=>{playing=!playing;$.ui.invalidate('ui.render')}}),
45 E.Button({key:'next',label:'Next frame',hotkey:'n',plain:true,onPress:()=>{index=(index+1)%frames.length;$.ui.invalidate('ui.render')}})]),text(E,report)]
46
47}
48
hooks/ui.js 36 lines
1// Pure UI helpers. No engine access; each plugin contains its own copy.
2export function clean(value, limit = 9000) {
3  return String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, limit)
4}
5export function text(E, value, props = {}) {
6  return E.Text({ ...props, children: [clean(value)] })
7}
8export function code(E, value, language = 'text') {
9  return E.Code({ source: clean(value), language })
10}
11export function row(E, children) {
12  return E.Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children })
13}
14export function frame(E, title, subtitle, children) {
15  return E.Box({ flexDirection: 'column', gap: 1, children: [
16    text(E, title, { bold: true, color: 'cyan' }),
17    text(E, subtitle, { dimColor: true }), ...children,
18  ] })
19}
20export function bar(percent, width = 24) {
21  const p = Math.max(0, Math.min(100, Number(percent) || 0))
22  const n = Math.round(p / 100 * width)
23  return '[' + '#'.repeat(n) + '-'.repeat(width - n) + '] ' + p.toFixed(1) + '%'
24}
25export function pretty(value) { return JSON.stringify(value, null, 2) }
26export function bounded(value, max = 9000) {
27  if (value.length > max) throw new Error('Input exceeds ' + max + ' characters. Use a smaller selection.')
28  return value
29}
30export function entries(value) { return value && typeof value === 'object' ? Object.entries(value) : [] }
31export function safePath(value) {
32  const path = value.trim()
33  if (!path || path.startsWith('-') || /[\x00-\x1f]/.test(path)) throw new Error('Enter a path without control characters or leading flags.')
34  return path
35}
36