SLOPSHOPPER

clear-caption

Shows a plain-language caption for each shell command, subagent and file edit, alongside any skin or mod.

newcommand
★ 2v0.7.0MITupdated 2026-10-20KashifManzer/clear-caption
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clear-caption
› 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 › /caption ⎿ clear-caption: Captions: every caption. Usage: /caption all|risky|off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="assets/banner.svg" alt="clear-caption: subtitles for every command Claude runs" width="100%">

<b>Know what Claude is about to run, in plain English.</b>

<a href=".claude-plugin/plugin.json"><img alt="version" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FKashifManzer%2Fclear-caption%2Fmain%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&color=FFD54A&labelColor=0B0B0E"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-FFD54A?labelColor=0B0B0E"></a> <a href="https://code.claude.com/docs"><img alt="Claude Code mod" src="https://img.shields.io/badge/Claude%20Code-mod-FFD54A?labelColor=0B0B0E"></a> <a href="CONTRIBUTING.md"><img alt="PRs welcome" src="https://img.shields.io/badge/PRs-welcome-FFD54A?labelColor=0B0B0E"></a>

<a href="#install">Install</a> · <a href="#what-you-get">What you get</a> · <a href="#turn-it-up-or-down">Settings</a> · <a href="#good-to-know">Good to know</a> · <a href="CONTRIBUTING.md">Contribute</a>


Why

You approve commands all day. Some of them look like this: find . -name "*.tmp" -exec rm {} \;

clear-caption adds one quiet line above each call that says what it does. Your tool rows stay exactly as they were.

Before

● Bash(find . -name "*.tmp" -exec rm {} \;)
● Bash(git reset --hard HEAD)
● Update(math.js)

After

● clear-caption: Delete all .tmp files
● Bash(find . -name "*.tmp" -exec rm {} \;)
● clear-caption: ⚠ Throw away all local changes
● Bash(git reset --hard HEAD)
● clear-caption: Fix clamp: Math.min should cap at hi, not lo.
● Update(math.js)

Install

Paste this at the Claude Code prompt:

/plugin install clear-caption --marketplace KashifManzer/clear-caption

Answer y to add the marketplace, pick a scope, and you're done.

What you get

  • 💬 A caption on every shell command and subagent. It's the call's own description, shown right above its row.
  • ✏️ A caption on every file edit. One sentence on why the file changes, right above the diff.
  • ⚠️ A warning on risky commands. Recursive deletes, git reset --hard, force-pushes and more get a ⚠, even when the description sounds harmless.
  • 🎨 Works with your skin. Captions are their own lines, so skins and other mods that redraw tool rows can't hide them.
  • 🤫 Quiet by design. Subagents' own calls don't get captions, so a busy subagent won't flood your screen.

The ⚠ shows when the real command:

  • deletes recursively (rm -r, rm --recursive) outside temp folders
  • throws away git work (git reset --hard, git clean -f, git checkout -- … or git checkout ., git restore without --staged, git branch -D, git stash drop or git stash clear)
  • force-pushes
  • deletes by search (find … -delete)
  • uses sudo, pipes curl into a shell, or runs chmod -R

Deleting only under /tmp, /private/tmp, /var/folders, $TMPDIR or a scratchpad folder is not marked. That includes a $VAR the same command sets to one of those, like S=/tmp/run; rm -rf $S.

It's a short list of common spellings, not a guarantee. Text inside quotes or heredocs is read as a command too.

Turn it up or down

/caption all     every caption (default)
/caption risky   only ⚠ captions
/caption off     no captions
/caption         show the current setting

Your choice is saved for every session. Turning edit captions back on with /caption all takes effect after /clear or in a new session.

Good to know

  • The caption is Claude's own words. It can be vague or wrong, so the real command and diff always stay on screen.
  • Edit captions are tested. 57 of 57 edits got their line across Opus, Sonnet and Haiku. On Opus test tasks the average cost was $0.257 with or without them, for about 3% more output tokens.
  • Rows show a moment later. A Bash or Agent row waits until Claude finishes writing the call, about 0.4-0.8 seconds for Bash. The call itself doesn't run any later.
  • Built on an early-access API. Claude Code's mod hooks may change between releases. Tested on Claude Code 2.1.296.
  • How edit captions work. Edit and Write have no description argument, so the mod asks Claude to add one, then strips it out before Claude Code checks the call. The edit runs as usual and the approval dialog shows a clean diff. An edit without the sentence just gets no line. Nothing is refused.
  • Where captions land. Each caption sits right above its own call, even with parallel calls or batched edits. When Claude Code or a skin folds quick read-only calls (like ls and wc) into one row, the first caption sits above that row and the rest under it.
  • Spillover. Twice in about 250 Read calls in testing (both Haiku), Claude added a description to Read too. Read ignores it. Grep and Glob refuse it when they're turned on, so such a call would fail once.
  • Removing the mod mid-session. The note to Claude stays in that session, but nothing strips the extra argument anymore. In testing the next edit failed once, Claude retried without it, and everything after worked. /clear or a new session starts clean.
  • A resumed session (--resume) keeps the note it started with.
  • The line names the mod. Claude Code prefixes every mod's line with its name, so it reads clear-caption: ....
  • Denied calls still get a caption. The line is logged as soon as Claude has written the call.

How it works

Two hooks in hooks/register.tsx do the work. tool.describe asks Claude for a one-line description on Edit and Write. turn.step holds each call until its arguments arrive, logs the caption with $.ui.log, then passes the call on unchanged (edits lose only their description). The pure parts live in hooks/label.ts.

Want to try a local copy without installing?

claude --plugin-dir /path/to/clear-caption

Contributing

Bug reports, ideas and PRs are all welcome. Start with CONTRIBUTING.md. Please follow the Code of Conduct, and report security issues privately as described in SECURITY.md.

License

MIT © KashifManzer

If clear-caption saved you a squint, a ⭐ helps other people find it. <sub>Banner type: Instrument Serif and Fira Code, both under the SIL Open Font License.</sub>

Source 2 files
hooks/register.tsx 121 lines
1import type { Register, TurnStepChunk, TurnStepInputChunk, TurnStepToolChunk } from 'claude-code'
2
3import { captionOf, EDIT_ASK, modeOf } from './label'
4
5const USAGE = 'Usage: /caption all|risky|off'
6const SAYS = { all: 'every caption', risky: 'risky only (⚠ commands)', off: 'off' }
7const EDITS = ['Edit', 'Write']
8
9// Says what a call does in plain words, as a dim transcript line, before it runs.
10// A line of its own, not the tool row: another mod (a skin) may own the row, and no mod owns this.
11export const register: Register = on => {
12  on('session.start', async ($, e, next) => {
13    await $.command.register({
14      name: 'caption',
15      description: 'Show tool captions: all, risky only, or off',
16      argumentHint: 'all|risky|off',
17      immediate: true,
18    })
19
20    return next(e)
21  })
22
23  // The mode lives in $.store, shared by every session and kept across restarts.
24  on('command.run', { command: 'caption' }, async ($, e) => {
25    const current = modeOf(await $.store.get('mode')) ?? 'all'
26
27    if (e.args.trim() === '') {
28      return { text: `Captions: ${SAYS[current]}. ${USAGE}` }
29    }
30
31    const mode = modeOf(e.args)
32
33    if (mode === null) {
34      return { text: `Captions stay ${SAYS[current]}. ${USAGE}` }
35    }
36
37    await $.store.set('mode', mode)
38
39    // Claude Code keeps the tool descriptions a session started with, so the ask for edit lines comes with /clear.
40    const edits = mode === 'all' && current !== 'all' ? '. Edit lines start after /clear if this session began without them' : ''
41
42    return { text: `Captions: ${SAYS[mode]}${edits}` }
43  })
44
45  // Edit and Write have no description field, so under `/caption all` their description asks Claude to add one.
46  // Claude Code keeps the descriptions a session started with, so a switch reaches edits after `/clear` or in a new
47  // session; `$.ui.invalidate('tool.describe')` re-runs this hook but was seen not to change what Claude is sent.
48  on('tool.describe', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
49    const described = await next(e)
50
51    if ((modeOf(await $.store.get('mode')) ?? 'all') !== 'all') {
52      return described
53    }
54
55    return { ...described, description: `${described.description}\n\n${EDIT_ASK}` }
56  })
57
58  // Logged while Claude's reply streams in, right before the call's row is passed on, so the line sits above its
59  // row. The row waits for its arguments, as the description is one of them; the call runs no later, since it
60  // runs only on whole arguments. An edit's description is taken out before Claude Code checks the call, in every
61  // mode and subagent, since Claude Code refuses an argument it doesn't know. Nothing here may throw: a stream hook
62  // that fails loses what it holds.
63  // ponytail: should Edit or Write ever get a real `description` argument, this would take it out too. The step's
64  // result (`toolUses`) still lists the field for hooks above this one; the engine runs and records the chunks.
65  on('turn.step', async function* ($, e, next) {
66    const mode = modeOf(await $.store.get('mode')) ?? 'all'
67    const quiet = e.agentId !== undefined || mode === 'off'
68
69    let tool: TurnStepToolChunk | null = null
70    let pieces: TurnStepInputChunk[] = []
71
72    const release = (call: TurnStepToolChunk) => {
73      let held: TurnStepChunk[] = [call, ...pieces]
74
75      try {
76        const args = JSON.parse(pieces.map(c => c.json).join(''))
77
78        if (EDITS.includes(call.name) && Object.hasOwn(Object(args), 'description')) {
79          const { description: _, ...rest } = args
80          held = [call, { kind: 'input', index: call.index, json: JSON.stringify(rest) }]
81        }
82
83        const caption = quiet ? null : captionOf({ ...args, tool: call.name }, mode)
84
85        if (caption !== null) {
86          $.ui.log(caption)
87        }
88      } catch {
89        // arguments that don't parse: the call can't run either, so it gets no caption
90      }
91
92      tool = null
93      pieces = []
94
95      return held
96    }
97
98    for await (const c of next(e)) {
99      if (tool !== null && c.kind === 'input' && c.index === tool.index) {
100        pieces.push(c)
101        continue
102      }
103
104      if (tool !== null) {
105        yield* release(tool)
106      }
107
108      if (c.kind === 'tool' && (EDITS.includes(c.name) || (!quiet && (c.name === 'Bash' || c.name === 'Agent')))) {
109        tool = c
110        continue
111      }
112
113      yield c
114    }
115
116    if (tool !== null) {
117      yield* release(tool)
118    }
119  })
120}
121
hooks/label.ts 79 lines
1const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
2
3// Model-written text is cut to one line and stripped of control characters (ESC, CR, BEL...)
4// before it reaches the terminal.
5const line = (s: string) => (s.split(/\r\n|\n|\r/)[0] ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()
6
7// ponytail: a short list of destructive spellings; others (git push origin +main) slip through, and text inside
8// quotes or heredocs is read as a command too. Add a pattern when a real miss or false alarm shows up.
9const RISKY = /\bgit\s+push\b[^\n;&|]*\s(-f|--force)\b|\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-zA-Z]*f|\bgit\s+checkout\s+(--\s|\.(\s|$))|\bgit\s+restore\b(?![^\n;&|]*--staged)|\bgit\s+branch\s+-D\b|\bgit\s+stash\s+(drop|clear)\b|\bfind\b[^\n;&|]*\s-delete\b|\bsudo\b|\bcurl\b[^|\n]*\|\s*(ba|z)?sh\b|\bchmod\s+-R\b/
10
11// Temp locations a recursive delete may clear without a ⚠.
12const TEMP = /^(\/tmp\/|\/private\/tmp\/|\/var\/folders\/|\$\{?TMPDIR\}?\/)|\/scratchpad\//
13
14// A recursive `rm` with a target outside temp locations, or none named. Redirections are not targets,
15// and `..` can climb out of temp, so it never counts as one. A `$VAR` the same command sets is read as
16// its value; one set twice keeps a value that is not temp, so it stays flagged.
17function deletesWork(command: string): boolean {
18  const vars = new Map<string, string>()
19
20  for (const [, name = '', value = ''] of command.matchAll(/(?:^|[\s;&])([A-Za-z_]\w*)=["']?([^\s;&"']+)/g)) {
21    const kept = vars.get(name)
22
23    if (kept === undefined || TEMP.test(kept)) {
24      vars.set(name, value)
25    }
26  }
27
28  for (const [, args = ''] of command.matchAll(/\brm\s+([^\n;&|]*)/g)) {
29    const words = args.split(/\s+/).filter(w => w !== '' && !/^\d*[<>]/.test(w))
30    const isRecursive = words.some(w => /^-[a-zA-Z]*[rR]/.test(w) || w === '--recursive')
31    const targets = words
32      .filter(w => !w.startsWith('-'))
33      .map(w => w.replace(/^["']|["']$/g, '').replace(/^\$\{?(\w+)\}?/, (whole, name: string) => vars.get(name) ?? whole))
34
35    if (isRecursive && (targets.length === 0 || targets.some(t => !TEMP.test(t) || t.includes('..')))) {
36      return true
37    }
38  }
39
40  return false
41}
42
43// What `/caption` shows: every caption, only the ⚠ ones, or none.
44export type Mode = 'all' | 'risky' | 'off'
45
46export function modeOf(v: unknown): Mode | null {
47  const mode = str(v).toLowerCase()
48  return mode === 'all' || mode === 'risky' || mode === 'off' ? mode : null
49}
50
51// Plain-language caption for a tool call, or null to say nothing.
52// Bash, Agent, Edit and Write carry the model's `description` (a claim, not proof: the tool row keeps the real
53// command or diff), so a destructive command is marked whatever its description says.
54// A subagent's own calls are skipped so they don't flood the main transcript.
55export function captionOf(e: { tool: string; agentId?: string; description?: unknown; command?: unknown }, mode: Mode = 'all'): string | null {
56  if (mode === 'off' || e.agentId !== undefined || !['Bash', 'Agent', 'Edit', 'Write'].includes(e.tool)) {
57    return null
58  }
59
60  const caption = line(str(e.description))
61
62  if (caption === '') {
63    return null
64  }
65
66  const command = str(e.command)
67
68  if (e.tool === 'Bash' && (RISKY.test(command) || deletesWork(command))) {
69    return `⚠ ${caption}`
70  }
71
72  return mode === 'all' ? caption : null
73}
74
75// Edit and Write have no description field, so their tool description asks Claude to add one; the field is taken
76// out before Claude Code checks the call, as it refuses arguments it doesn't know.
77export const EDIT_ASK =
78  'IMPORTANT: this tool takes one more argument than its schema lists: `description` (string, required). Write it first: one plain sentence of at most 15 words saying why this file changes and what you add or remove, as with Bash. It is shown to the user above the edit and removed before the tool runs. Example: {"description": "Fix clamp: it compared with lo where it needs hi.", "file_path": "...", ...}'
79