SLOPSHOPPER

roll-credits

Movie-style closing credits for your Claude Code session. A native Claude Mod with local, deterministic statistics.

newpaneguardcommandtimer
★ 2v0.1.0MITupdated 2026-09-16smukh/roll-credits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · roll-credits
│ ┃ Roll Credits ✕ › fix the failing auth test and add an audit log call │ ┃ ★ ROLL CREDITS ★ │ ┃ Now playing… ⏺ Read(src/auth.ts) │ ┃ [ Still ] [ Replay ] [ Close ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ A SESSION IN CODE ⎿ Added 2 lines, removed 1 line │ ┃ app ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ DIRECTED BY │ ┃ You ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ WRITTEN WITH │ ┃ Claude ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ STARRING › /credits │ ┃ audit.ts │ ┃ 1 successful edit calls │ ┃ │ ┃ SUPPORTING CAST │ ┃ auth.ts · 1 edit │ ┃ cache.ts · 1 edit │ ┃ │ ┃ SPECIAL THANKS │ ┃ Bash · Write · Edit · Grep │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Roll Credits
★ ROLL CREDITS ★ Now playing… [ Still ] [ Replay ] [ Close ] A SESSION IN CODE app DIRECTED BY You WRITTEN WITH Claude STARRING audit.ts 1 successful edit calls SUPPORTING CAST auth.ts · 1 edit cache.ts · 1 edit SPECIAL THANKS Bash · Write · Edit · Grep
README

Roll Credits 🎬

Format: Claude Mod Version License: MIT CI GitHub stars Tested with Claude Code 2.1.272 Status: early access

Subscribe: Web After AI

A Claude Mod that turns your coding session into movie credits. Your most-edited file gets top billing, tools get a thank-you, and failed calls become plot twists.

/credits demo

Credits scroll in a native pane with Still / Play, Replay, and Close controls. Everything runs locally, without model calls or telemetry.

Launch preview

Watch the Roll Credits launch preview

Watch the 38-second video. This is a rendered UI preview using fictional demo data, not a live screen recording.

Install

Requires Claude Code with early-access function hooks enabled. Tested with 2.1.272. The Mods API is experimental.

claude plugin marketplace add smukh/roll-credits
claude plugin install roll-credits@roll-credits-marketplace
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude

Then run /credits demo. Restart Claude after installing. Enable the flag in every session where you want the Mod to load. On PowerShell, set $env:CLAUDE_CODE_ENABLE_FUNCTION_HOOKS="1" before running claude.

Try from source: no build or npm installation needed to use the Mod.

git clone https://github.com/smukh/roll-credits.git
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./roll-credits

If /credits is missing, check claude --version, restart with the flag, and inspect /plugin for load errors. Another plugin already owning /credits takes precedence; Roll Credits leaves that command unchanged.

Commands

CommandResult
/creditsRoll the current session's credits
/credits The Tiny LaunchGive the session a movie title
/credits --stillShow the full credits without animation; scroll to read
/credits --textPrint the complete credits as text
/credits demoTry fictional sample data, without changing your statistics
/credits demo --still The Tiny LaunchA named, motion-free demo
/credits --helpShow usage

Put options before the title. The pane opens only when requested. Use its buttons to switch to a still view, replay, or close; Escape closes it while it has focus. At the end, animation stops automatically. Each invocation takes a snapshot, so edits during the roll don't move the cast around.

Demo

Output from /credits demo --text:

STARRING
App.tsx
8 successful edit calls

SUPPORTING CAST
stars.ts · 3 edits
README.md · 2 edits

SPECIAL THANKS
Read · Edit · Bash · Write

BY THE NUMBERS
42 tool calls · 7 completed turns
12m 34s together · 2 plot twists

POST-CREDITS SCENE
The bugs have signed on for a sequel.

The demo is fictional. Complete CLI output.

How it works

This uses Anthropic's published Mods integration:

  • .claude-plugin/plugin.json identifies the plugin.
  • hooks/hooks.json loads hooks/register.tsx as a function-hooks module.
  • register(on) observes session.start, tool.call, and turn.complete.
  • $.command.register installs /credits; command.run handles it locally.
  • $.ui.open, ui.render, and $.ui.resolve render a native pane using Claude's Box, Text, and Button elements.
  • $.clock.every drives the roll; timers stop on still mode, close, or completion.

Runtime code is in hooks/. See API references and compatibility.

What the credits count

  • Activity observed since the Mod loaded, including subagent tool calls. Reloading or restarting resets it; historical transcripts are not imported.
  • Successful Edit, Write, and NotebookEdit calls determine the cast. These are call counts, not changed-line counts. Edits performed through Bash, external editors, or other tools aren't cast credits.
  • Errors, denied calls, and thrown failures become “plot twists.” They are not a test-failure metric.
  • Completed main-agent answer turns count as turns; aborted, error, and subagent turns don't.
  • Elapsed wall time includes idle time.

Everything stays in memory. Full file paths are used as distinct keys, but only basenames are displayed. No prompts, answers, file contents, or command arguments are saved. Filenames may still be sensitive when you share credits. Up to 512 distinct files and 512 tool names are retained; aggregate call/failure counters continue after that limit. Identical basenames can appear twice. This measures activity, not code quality.

Noninteractive sessions or an unavailable pane fall back to text. The native element trees are tested for terminal, desktop, and mobile surfaces; those tests do not establish that every Claude app exposes Mods.

Develop

Node 22+ is needed for the pinned development tooling. A Claude login is not needed for the tests.

npm ci
npm run types:fetch
npm run check

The type-fetch step downloads a checksum-verified declaration file from a pinned Anthropic commit into gitignored .api/. Anthropic's reference source has its own license; it is not republished under this project's MIT license.

npm test runs claude plugin test at the user tier. Tests cover event dispatch, native rendering, button presses, and timers. npm run validate checks the plugin and marketplace manifests.

See CONTRIBUTING.md. MIT licensed. Independent community project; not affiliated with Anthropic.

Source 2 files
hooks/register.tsx 207 lines
1import type { EngineInterface, Register, Timer } from 'claude-code';
2import { credits, demoStats, fresh, parse, record } from './credits';
3import type { Line } from './credits';
4const ID = 'roll-credits';
5const HELP =
6  'Roll Credits\n/credits [demo] [--text] [--still] [title]\nStill/play, replay, or close using the pane buttons. --still disables animation; --text prints all credits. Demo uses fictional data. Statistics stay in memory and reset when this Mod loads.';
7interface Playback {
8  offset: number;
9  height: number;
10  paused: boolean;
11  opened: boolean;
12  lines: Line[];
13  timer?: Timer;
14}
15function stop(p: Playback): void {
16  p.timer?.cancel();
17  p.timer = undefined;
18}
19function end(p: Playback): number {
20  return Math.max(0, p.lines.length - p.height);
21}
22// Claude's capability auditor requires helpers receiving $ at module scope.
23function animate($: EngineInterface, p: Playback): void {
24  stop(p);
25  if (!p.opened || p.paused || p.offset >= end(p)) return;
26  p.timer = $.clock.every(650, () => {
27    p.offset = Math.min(p.offset + 1, end(p));
28    $.ui.invalidate('ui.render');
29    if (p.offset >= end(p)) stop(p);
30  });
31}
32export const register: Register = (on) => {
33  let stats = fresh(0, 'Untitled project');
34  let active = false,
35    interactive = false;
36  const p: Playback = {
37    offset: 0,
38    height: 12,
39    paused: false,
40    opened: false,
41    lines: [],
42  };
43  on('session.start', async ($, e, next) => {
44    const result = await next(e);
45    stop(p);
46    p.opened = false;
47    active = false;
48    interactive = e.isInteractive;
49    try {
50      stats = fresh(await $.clock.now(), e.cwd);
51      await $.command.register({
52        name: 'credits',
53        description: 'Roll movie credits for your coding session',
54        argumentHint: '[demo] [--text] [--still] [title]',
55      });
56      active = true;
57    } catch {
58      /* A denied capability or command conflict must not break startup. */
59    }
60    return result;
61  });
62  on('tool.call', async ($, e, next) => {
63    try {
64      const result = await next(e);
65      if (active)
66        record(
67          stats,
68          e.tool,
69          'file_path' in e
70            ? e.file_path
71            : 'notebook_path' in e
72              ? e.notebook_path
73              : undefined,
74          result.deny !== undefined || result.isError === true,
75        );
76      return result;
77    } catch (error) {
78      if (active) record(stats, e.tool, undefined, true);
79      throw error;
80    }
81  });
82  on('turn.complete', async ($, e, next) => {
83    const result = await next(e);
84    if (active && !e.agentId && !e.isAborted && e.reason === 'answer')
85      stats.turns++;
86    return result;
87  });
88  on('command.run', { command: 'credits' }, async ($, e, next) => {
89    if (!active) return next(e);
90    const options = parse(e.args);
91    if (options.help) return { text: HELP };
92    const now = await $.clock.now();
93    const snapshot = credits(
94      options.demo ? demoStats(now) : stats,
95      now,
96      options.title,
97      options.demo,
98    );
99    if (options.text || !interactive)
100      return { text: snapshot.map((line) => line.text).join('\n') };
101    stop(p);
102    p.lines = snapshot;
103    p.offset = 0;
104    p.paused = options.still;
105    try {
106      await $.ui.open({
107        id: ID,
108        title: 'Roll Credits',
109        focus: true,
110        closeOnEscape: true,
111        rows: 20,
112      });
113      p.opened = true;
114      $.ui.invalidate('ui.render');
115      animate($, p);
116      return {};
117    } catch {
118      p.opened = false;
119      return { text: snapshot.map((line) => line.text).join('\n') };
120    }
121  });
122  on('ui.close', { id: ID }, async ($, e, next) => {
123    const result = await next(e);
124    if (!('deny' in result)) {
125      p.opened = false;
126      stop(p);
127    }
128    return result;
129  });
130  on('ui.render', { component: 'Pane' }, ($, e, next) => {
131    if (e.requestId !== ID || !p.opened) return next(e);
132    const { Box, Text, Button } = $.ui.resolve(e);
133    p.height = Math.max(1, Math.min(18, e.props.scroll.bodyRows - 5));
134    p.offset = Math.min(p.offset, end(p));
135    const visible = p.paused
136      ? p.lines
137      : p.lines.slice(p.offset, p.offset + p.height);
138    return (
139      <Box flexDirection="column" paddingX={1}>
140        <Text color="yellow" bold>
141          ★ ROLL CREDITS ★
142        </Text>
143        <Text dimColor>
144          {p.paused
145            ? 'Still · scroll to read'
146            : p.offset >= end(p)
147              ? 'That’s a wrap.'
148              : 'Now playing…'}
149        </Text>
150        <Box flexDirection="row" gap={2}>
151          <Button
152            key="pause"
153            label={p.paused ? 'Play' : 'Still'}
154            onPress={() => {
155              p.paused = !p.paused;
156              animate($, p);
157              $.ui.invalidate('ui.render');
158            }}
159          />
160          <Button
161            key="replay"
162            label="Replay"
163            onPress={() => {
164              p.offset = 0;
165              p.paused = false;
166              animate($, p);
167              $.ui.invalidate('ui.render');
168            }}
169          />
170          <Button
171            key="close"
172            label="Close"
173            onPress={() => {
174              void $.ui
175                .close({ id: ID })
176                .then(() => {
177                  p.opened = false;
178                  stop(p);
179                })
180                .catch(() => {});
181            }}
182          />
183        </Box>
184        <Text> </Text>
185        <Box flexDirection="column" minHeight={p.height}>
186          {visible.map((line, index) => (
187            <Box key={`${index}`} justifyContent="center">
188              <Text
189                bold={line.kind === 'title'}
190                color={
191                  line.kind === 'heading' || line.kind === 'title'
192                    ? 'yellow'
193                    : undefined
194                }
195                dimColor={line.kind === 'muted'}
196                wrap="truncate-end"
197              >
198                {line.text || ' '}
199              </Text>
200            </Box>
201          ))}
202        </Box>
203      </Box>
204    );
205  });
206};
207
hooks/credits.ts 171 lines
1/** Deterministic credits; never reads transcript text or file contents. */
2export interface Stats {
3  startedAt: number;
4  project: string;
5  calls: number;
6  failures: number;
7  turns: number;
8  files: Map<string, number>;
9  tools: Map<string, number>;
10}
11export interface Line {
12  text: string;
13  kind: 'title' | 'heading' | 'body' | 'muted';
14}
15export function clean(value: string, limit = 100): string {
16  return value
17    .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
18    .slice(0, limit);
19}
20export function basename(path: string): string {
21  return clean(
22    path.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() ||
23      'Untitled project',
24  );
25}
26export function fresh(now: number, cwd: string): Stats {
27  return {
28    startedAt: now,
29    project: basename(cwd),
30    calls: 0,
31    failures: 0,
32    turns: 0,
33    files: new Map(),
34    tools: new Map(),
35  };
36}
37// Bound cardinality for sessions with thousands of different files.
38function bump(map: Map<string, number>, key: string): void {
39  if (map.has(key) || map.size < 512) map.set(key, (map.get(key) || 0) + 1);
40}
41export function record(
42  stats: Stats,
43  tool: string,
44  path: unknown,
45  failed: boolean,
46): void {
47  stats.calls++;
48  bump(stats.tools, clean(tool));
49  if (failed) {
50    stats.failures++;
51    return;
52  }
53  if (
54    ['Edit', 'Write', 'NotebookEdit'].includes(tool) &&
55    typeof path === 'string'
56  )
57    bump(stats.files, path);
58}
59function ranked(map: Map<string, number>): [string, number][] {
60  return [...map].sort(
61    (a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0),
62  );
63}
64export function credits(
65  stats: Stats,
66  now: number,
67  title?: string,
68  demo = false,
69): Line[] {
70  const lines: Line[] = [];
71  const add = (text: string, kind: Line['kind'] = 'body') =>
72    lines.push({ text, kind });
73  add(demo ? 'DEMO · FICTIONAL SESSION' : 'A SESSION IN CODE', 'muted');
74  add(clean(title || stats.project, 80), 'title');
75  add('');
76  add('DIRECTED BY', 'heading');
77  add('You');
78  add('WRITTEN WITH', 'heading');
79  add('Claude');
80  add('');
81  const cast = ranked(stats.files);
82  add('STARRING', 'heading');
83  add(cast[0] ? basename(cast[0][0]) : 'The blank canvas');
84  add(
85    cast[0]
86      ? `${cast[0][1]} successful edit calls`
87      : 'No successful edits yet.',
88    'muted',
89  );
90  if (cast.length > 1) {
91    add('');
92    add('SUPPORTING CAST', 'heading');
93    for (const [file, count] of cast.slice(1, 6))
94      add(`${basename(file)} · ${count} edit${count === 1 ? '' : 's'}`);
95    if (cast.length > 6) add(`And ${cast.length - 6} more files`, 'muted');
96  }
97  add('');
98  add('SPECIAL THANKS', 'heading');
99  add(
100    ranked(stats.tools)
101      .slice(0, 4)
102      .map(([tool]) => tool)
103      .join(' · ') || 'Your curiosity',
104  );
105  add('');
106  add('BY THE NUMBERS', 'heading');
107  const seconds = Math.floor(Math.max(0, now - stats.startedAt) / 1000);
108  const elapsed =
109    seconds < 60
110      ? `${seconds}s`
111      : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
112  add(`${stats.calls} tool calls · ${stats.turns} completed turns`);
113  add(
114    `${elapsed} together · ${stats.failures} plot twist${stats.failures === 1 ? '' : 's'}`,
115  );
116  add('');
117  add('POST-CREDITS SCENE', 'heading');
118  add(
119    stats.failures
120      ? 'The bugs have signed on for a sequel.'
121      : 'One more tiny change?',
122  );
123  add('');
124  add('THE END', 'title');
125  add('Made with patience. And a few tool calls.', 'muted');
126  add(
127    demo
128      ? 'Sample data only.'
129      : 'Observed since this Mod loaded. Edits via Bash are not counted.',
130    'muted',
131  );
132  return lines;
133}
134export function demoStats(now: number): Stats {
135  const stats = fresh(now - 754000, 'the-little-launch');
136  stats.calls = 42;
137  stats.turns = 7;
138  stats.failures = 2;
139  stats.files = new Map([
140    ['src/App.tsx', 8],
141    ['src/stars.ts', 3],
142    ['README.md', 2],
143  ]);
144  stats.tools = new Map([
145    ['Read', 18],
146    ['Edit', 11],
147    ['Bash', 9],
148    ['Write', 4],
149  ]);
150  return stats;
151}
152export function parse(args: string): {
153  demo: boolean;
154  text: boolean;
155  still: boolean;
156  title?: string;
157  help: boolean;
158} {
159  const words = args.trim().split(/\s+/).filter(Boolean);
160  const flags = new Set<string>();
161  while (words[0] && ['demo', '--text', '--still', '--help'].includes(words[0]))
162    flags.add(words.shift()!);
163  return {
164    demo: flags.has('demo'),
165    text: flags.has('--text'),
166    still: flags.has('--still'),
167    help: flags.has('--help'),
168    title: words.join(' ') || undefined,
169  };
170}
171